Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
@@ -385,3 +385,5 @@ wutongyuonce pr
|
||||
Terminator666666 pr
|
||||
|
||||
y-nk pr
|
||||
|
||||
edverma pr
|
||||
|
||||
Generated
+28
-5
@@ -52,12 +52,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
|
||||
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz",
|
||||
"integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
@@ -2004,6 +2005,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -2970,6 +2977,12 @@
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
@@ -4702,6 +4715,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
@@ -5339,7 +5362,7 @@
|
||||
"version": "0.84.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@anthropic-ai/sdk": "0.123.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@earendil-works/chord": "^0.84.4",
|
||||
"@earendil-works/pi-telemetry": "^0.84.4",
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed proxied assistant responses dropping persisted provider-native thinking levels.
|
||||
- Fixed the write tool reporting UTF-16 code-unit counts as byte counts by removing the misleading count ([#8979](https://github.com/earendil-works/pi/issues/8979)).
|
||||
|
||||
## [0.84.4] - 2026-08-28
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -33,7 +33,7 @@ export function createWriteTool<TContext extends ExecutionToolContext = Executio
|
||||
getOrThrow(await env.writeFile(absolutePath, content, context));
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
content: [{ type: "text", text: `Successfully wrote to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
+42
-10
@@ -48,12 +48,14 @@ export type ProxyAssistantMessageEvent =
|
||||
type: "done";
|
||||
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
|
||||
usage: AssistantMessage["usage"];
|
||||
providerThinkingLevel?: string;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
reason: Extract<StopReason, "aborted" | "error">;
|
||||
errorMessage?: string;
|
||||
usage: AssistantMessage["usage"];
|
||||
providerThinkingLevel?: string;
|
||||
};
|
||||
|
||||
type ProxySerializableStreamOptions = Pick<
|
||||
@@ -181,6 +183,19 @@ export function streamProxy(model: Model<any>, context: Context, options: ProxyS
|
||||
reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let sawTerminalEvent = false;
|
||||
|
||||
const processLine = (line: string): void => {
|
||||
if (!line.startsWith("data: ")) return;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data) return;
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
const event = processProxyEvent(proxyEvent, partial);
|
||||
if (event) {
|
||||
if (event.type === "done" || event.type === "error") sawTerminalEvent = true;
|
||||
stream.push(event);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -195,16 +210,7 @@ export function streamProxy(model: Model<any>, context: Context, options: ProxyS
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data) {
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
const event = processProxyEvent(proxyEvent, partial);
|
||||
if (event) {
|
||||
stream.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +218,26 @@ export function streamProxy(model: Model<any>, context: Context, options: ProxyS
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
// The final event may not be newline-terminated; flush the decoder and
|
||||
// process whatever is left in the buffer.
|
||||
buffer += decoder.decode();
|
||||
if (buffer) {
|
||||
processLine(buffer);
|
||||
}
|
||||
|
||||
if (!sawTerminalEvent) {
|
||||
// A clean EOF without a done/error event means the server dropped the
|
||||
// response mid-stream. Surface it as an error instead of leaving
|
||||
// consumers waiting on a result that never arrives.
|
||||
partial.stopReason = "error";
|
||||
partial.errorMessage = "Connection closed by proxy server before the response completed";
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: partial,
|
||||
});
|
||||
}
|
||||
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -353,12 +379,18 @@ function processProxyEvent(
|
||||
case "done":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.usage = proxyEvent.usage;
|
||||
if (proxyEvent.providerThinkingLevel !== undefined) {
|
||||
partial.providerThinkingLevel = proxyEvent.providerThinkingLevel;
|
||||
}
|
||||
return { type: "done", reason: proxyEvent.reason, message: partial };
|
||||
|
||||
case "error":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.errorMessage = proxyEvent.errorMessage;
|
||||
partial.usage = proxyEvent.usage;
|
||||
if (proxyEvent.providerThinkingLevel !== undefined) {
|
||||
partial.providerThinkingLevel = proxyEvent.providerThinkingLevel;
|
||||
}
|
||||
return { type: "error", reason: proxyEvent.reason, error: partial };
|
||||
|
||||
default: {
|
||||
|
||||
@@ -352,7 +352,7 @@ describe("AgentHarness tools", () => {
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt");
|
||||
expect(textOutput(result)).toBe("Successfully wrote to nested/dir/file.txt");
|
||||
expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt", BACKGROUND_CONTEXT))).toBe("hello");
|
||||
});
|
||||
|
||||
|
||||
@@ -76,4 +76,54 @@ describe("streamProxy", () => {
|
||||
namespace: "dynamic_tools",
|
||||
});
|
||||
});
|
||||
|
||||
// Regression tests for https://github.com/earendil-works/pi/issues/8996
|
||||
it("processes terminal metadata when the event is not newline-terminated", async () => {
|
||||
const start = `data: ${JSON.stringify({ type: "start" })}\n\n`;
|
||||
const done = `data: ${JSON.stringify({ type: "done", reason: "stop", usage, providerThinkingLevel: "high" })}`;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(start + done, { status: 200 })),
|
||||
);
|
||||
|
||||
const stream = streamProxy(
|
||||
model,
|
||||
{ systemPrompt: "", messages: [] },
|
||||
{
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
},
|
||||
);
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["start", "done"]);
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.providerThinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("emits an error instead of hanging when the stream ends without a terminal event", async () => {
|
||||
const body = `data: ${JSON.stringify({ type: "start" })}\n\n`;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(body, { status: 200 })),
|
||||
);
|
||||
|
||||
const stream = streamProxy(
|
||||
model,
|
||||
{ systemPrompt: "", messages: [] },
|
||||
{
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
},
|
||||
);
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["start", "error"]);
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("Connection closed by proxy server");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Anthropic per-turn effort persistence, deterministic historical effort markers, and signed-thinking mismatch recovery for supported Claude models across Anthropic Messages transports, including OpenRouter.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed GitHub Copilot Claude Fable 5 requests to use the Anthropic Messages adapter so selected reasoning levels are sent ([#8961](https://github.com/earendil-works/pi/issues/8961)).
|
||||
|
||||
## [0.84.4] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@anthropic-ai/sdk": "0.123.0",
|
||||
"@earendil-works/chord": "^0.84.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@earendil-works/pi-telemetry": "^0.84.4",
|
||||
|
||||
@@ -559,6 +559,16 @@ function isGoogleThinkingApi(model: Model<any>): boolean {
|
||||
return model.api === "google-generative-ai" || model.api === "google-vertex";
|
||||
}
|
||||
|
||||
const VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS = new Set(["anthropic", "openrouter"]);
|
||||
|
||||
function supportsAnthropicMidConvoEffort(modelId: string): boolean {
|
||||
const id = modelId.toLowerCase().replace(/^~?anthropic\//, "");
|
||||
return (
|
||||
/^claude-opus-5(?:-\d{8})?$/.test(id) ||
|
||||
/^claude-(?:fable|mythos)-5(?:[.-]1)(?:-\d{8})?$/.test(id)
|
||||
);
|
||||
}
|
||||
|
||||
function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
|
||||
return (
|
||||
modelId.includes("opus-4-6") ||
|
||||
@@ -573,7 +583,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
|
||||
modelId.includes("sonnet-4.6") ||
|
||||
modelId.includes("sonnet-5") ||
|
||||
modelId.includes("sonnet.5") ||
|
||||
modelId.includes("fable-5")
|
||||
modelId.includes("fable-5") ||
|
||||
modelId.includes("mythos-5")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -757,6 +768,7 @@ function applyAnthropicMessagesCompatMetadata(model: Model<Api>): void {
|
||||
const compat = getAnthropicMessagesCompat(model.provider, model.id);
|
||||
if (compat) {
|
||||
mergeAnthropicMessagesCompat(model, compat);
|
||||
if (compat.supportsMidConvoEffort) mergeThinkingLevelMap(model, { off: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +786,10 @@ function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anth
|
||||
const model = modelsById.get(modelId);
|
||||
if (!model) continue;
|
||||
|
||||
const allowedFallbackModels = fallbackModelIds.flatMap((fallbackModelId) => {
|
||||
const compatibleFallbackModelIds = model.compat?.supportsMidConvoEffort
|
||||
? fallbackModelIds.filter(supportsAnthropicMidConvoEffort)
|
||||
: fallbackModelIds;
|
||||
const allowedFallbackModels = compatibleFallbackModelIds.flatMap((fallbackModelId) => {
|
||||
const fallbackModel = modelsById.get(fallbackModelId);
|
||||
return fallbackModel
|
||||
? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }]
|
||||
@@ -1000,6 +1015,12 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
|
||||
|
||||
function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined {
|
||||
const compat: AnthropicMessagesCompat = {};
|
||||
if (
|
||||
VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS.has(provider) &&
|
||||
supportsAnthropicMidConvoEffort(modelId)
|
||||
) {
|
||||
compat.supportsMidConvoEffort = true;
|
||||
}
|
||||
if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) {
|
||||
compat.supportsEagerToolInputStreaming = false;
|
||||
}
|
||||
@@ -1103,11 +1124,12 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
||||
const contextWindow = model.top_provider?.context_length || model.context_length || 4096;
|
||||
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
|
||||
|
||||
const useAnthropicMessages = /^anthropic\//.test(modelKey) && !modelKey.endsWith(":batch");
|
||||
const normalizedModel: Model<any> = {
|
||||
id: modelKey,
|
||||
name: model.name,
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",
|
||||
baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",
|
||||
provider,
|
||||
reasoning: model.supported_parameters?.includes("reasoning") || false,
|
||||
...(thinkingLevelMap && { thinkingLevelMap }),
|
||||
@@ -1392,7 +1414,7 @@ function processFireworksModels(provider: ModelsDevProvider | undefined): Model<
|
||||
maxTokens: model.limit?.output || 4096,
|
||||
};
|
||||
|
||||
if (modelId.includes("glm-5p2")) {
|
||||
if (modelId.includes("glm-")) {
|
||||
models.push({
|
||||
...common,
|
||||
api: "openai-completions",
|
||||
@@ -2059,7 +2081,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
if (m.status === "deprecated") continue;
|
||||
|
||||
// Claude 4.x and 5.x models route to Anthropic Messages API
|
||||
const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId);
|
||||
const isCopilotClaude = /^claude-(haiku|sonnet|opus|fable)-[45]([.\-]|$)/.test(modelId);
|
||||
// Grok, gpt-5, oswe, and MAI-Code models are only served through
|
||||
// the Copilot /responses endpoint.
|
||||
const needsResponsesApi =
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type {
|
||||
CacheControlEphemeral,
|
||||
ContentBlockParam,
|
||||
BetaStopReason,
|
||||
BetaThinkingDroppedInputTransformation,
|
||||
BetaTool,
|
||||
BetaCacheControlEphemeral as CacheControlEphemeral,
|
||||
BetaContentBlockParam as ContentBlockParam,
|
||||
MessageCreateParamsStreaming,
|
||||
MessageParam,
|
||||
RawMessageStreamEvent,
|
||||
RefusalStopDetails,
|
||||
} from "@anthropic-ai/sdk/resources/messages.js";
|
||||
BetaMessageParam as MessageParam,
|
||||
BetaRawMessageStreamEvent as RawMessageStreamEvent,
|
||||
BetaRefusalStopDetails as RefusalStopDetails,
|
||||
} from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
|
||||
import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
AnthropicMessagesCompat,
|
||||
@@ -30,6 +33,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
} from "../types.ts";
|
||||
import { splitDeferredTools } from "../utils/deferred-tools.ts";
|
||||
import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
|
||||
@@ -168,13 +172,11 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
export type AnthropicThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
type MessageCreateParamsStreamingWithFallbacks = MessageCreateParamsStreaming & {
|
||||
fallbacks?: readonly { model: string }[];
|
||||
};
|
||||
|
||||
const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
|
||||
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
||||
const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
||||
const MID_CONVERSATION_OUTPUT_CONFIG_BETA = "mid-conversation-output-config-2026-07-01";
|
||||
const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01";
|
||||
|
||||
function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): boolean {
|
||||
return (model.compat?.allowedFallbackModels?.length ?? 0) > 0;
|
||||
@@ -182,7 +184,9 @@ function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): bo
|
||||
|
||||
function getAnthropicCompat(
|
||||
model: Model<"anthropic-messages">,
|
||||
): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking" | "allowedFallbackModels">> {
|
||||
): Required<
|
||||
Omit<AnthropicMessagesCompat, "forceAdaptiveThinking" | "allowedFallbackModels" | "supportsMidConvoEffort">
|
||||
> {
|
||||
return {
|
||||
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,
|
||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||
@@ -506,12 +510,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const providerThinkingLevel = model.compat?.supportsMidConvoEffort ? (options?.effort ?? "high") : undefined;
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api as Api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
...(providerThinkingLevel === undefined ? {} : { providerThinkingLevel }),
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
@@ -528,6 +534,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
let client: Anthropic;
|
||||
let isOAuth: boolean;
|
||||
let usageModel = model;
|
||||
let inputTransformations: BetaThinkingDroppedInputTransformation[] | undefined;
|
||||
|
||||
if (options?.client) {
|
||||
client = options.client;
|
||||
@@ -551,9 +558,6 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
const created = createClient(
|
||||
model,
|
||||
apiKey,
|
||||
options?.interleavedThinking ?? true,
|
||||
shouldUseFineGrainedToolStreamingBeta(model, context),
|
||||
shouldUseServerSideFallbackBeta(model),
|
||||
options?.headers,
|
||||
options?.fetch,
|
||||
copilotDynamicHeaders,
|
||||
@@ -565,7 +569,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
let params = buildParams(model, context, isOAuth, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as MessageCreateParamsStreaming;
|
||||
params = { ...(nextParams as MessageCreateParamsStreaming), stream: true };
|
||||
}
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
@@ -573,7 +577,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
maxRetries: 0,
|
||||
};
|
||||
const response = await retryProviderRequest(
|
||||
() => client.messages.create({ ...params, stream: true }, requestOptions).asResponse(),
|
||||
() => client.beta.messages.create(params, requestOptions).asResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
@@ -589,6 +593,8 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
for await (const event of iterateAnthropicEvents(response, options?.signal)) {
|
||||
if (event.type === "message_start") {
|
||||
output.responseId = event.message.id;
|
||||
const transformations = event.message.input_transformations;
|
||||
if (Array.isArray(transformations)) inputTransformations = transformations;
|
||||
output.model = event.message.model;
|
||||
const fallbackCost =
|
||||
output.model === model.id
|
||||
@@ -609,6 +615,12 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(usageModel, output.usage);
|
||||
} else if (event.type === "content_block_start") {
|
||||
if (event.content_block.type === "fallback") {
|
||||
if (output.content.length > 0) {
|
||||
throw new Error("Anthropic performed an unsupported mid-output model fallback");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (event.content_block.type === "text") {
|
||||
const block: Block = {
|
||||
type: "text",
|
||||
@@ -729,6 +741,8 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
}
|
||||
}
|
||||
} else if (event.type === "message_delta") {
|
||||
const transformations = event.input_transformations;
|
||||
if (Array.isArray(transformations)) inputTransformations = transformations;
|
||||
if (event.delta.stop_reason) {
|
||||
output.rawStopReason = event.delta.stop_reason;
|
||||
const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);
|
||||
@@ -752,11 +766,8 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
if (event.usage.cache_creation_input_tokens != null) {
|
||||
output.usage.cacheWrite = event.usage.cache_creation_input_tokens;
|
||||
}
|
||||
// Anthropic reports reasoning tokens in `output_tokens_details.thinking_tokens` on the
|
||||
// final message_delta usage (a subset of output_tokens). SDK 0.91.1 omits the field from
|
||||
// its Usage type, so read it through a narrow cast. Verified against the live API.
|
||||
const thinkingTokens = (event.usage as { output_tokens_details?: { thinking_tokens?: number } })
|
||||
.output_tokens_details?.thinking_tokens;
|
||||
// Anthropic reports reasoning tokens as a subset of output tokens.
|
||||
const thinkingTokens = event.usage.output_tokens_details?.thinking_tokens;
|
||||
if (thinkingTokens != null) {
|
||||
output.usage.reasoning = thinkingTokens;
|
||||
}
|
||||
@@ -778,6 +789,19 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage || "An unknown error occurred");
|
||||
}
|
||||
if (inputTransformations && inputTransformations.length > 0) {
|
||||
appendAssistantMessageDiagnostic(output, {
|
||||
type: "anthropic_input_transformations",
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
transformations: inputTransformations.map((transformation) => ({
|
||||
type: transformation.type ?? undefined,
|
||||
path: transformation.path ?? undefined,
|
||||
reason: transformation.reason ?? undefined,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
@@ -875,28 +899,12 @@ function isOAuthToken(apiKey: string): boolean {
|
||||
function createClient(
|
||||
model: Model<"anthropic-messages">,
|
||||
apiKey: string | undefined,
|
||||
interleavedThinking: boolean,
|
||||
useFineGrainedToolStreamingBeta: boolean,
|
||||
useServerSideFallbackBeta: boolean,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
dynamicHeaders?: Record<string, string>,
|
||||
sessionId?: string,
|
||||
): { client: Anthropic; isOAuthToken: boolean } {
|
||||
// Adaptive thinking models have interleaved thinking built in, so skip the beta header.
|
||||
const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true;
|
||||
const betaFeatures: string[] = [];
|
||||
if (useFineGrainedToolStreamingBeta) {
|
||||
betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA);
|
||||
}
|
||||
if (needsInterleavedBeta) {
|
||||
betaFeatures.push(INTERLEAVED_THINKING_BETA);
|
||||
}
|
||||
if (useServerSideFallbackBeta) {
|
||||
betaFeatures.push(SERVER_SIDE_FALLBACK_BETA);
|
||||
}
|
||||
|
||||
// Copilot: Bearer auth, selective betas.
|
||||
// Copilot: Bearer auth.
|
||||
if (model.provider === "github-copilot") {
|
||||
const client = new Anthropic({
|
||||
apiKey: null,
|
||||
@@ -908,7 +916,6 @@ function createClient(
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
|
||||
},
|
||||
model.headers,
|
||||
dynamicHeaders,
|
||||
@@ -931,7 +938,6 @@ function createClient(
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-beta": ["claude-code-20250219", "oauth-2025-04-20", ...betaFeatures].join(","),
|
||||
"user-agent": `claude-cli/${claudeCodeVersion}`,
|
||||
"x-app": "cli",
|
||||
},
|
||||
@@ -950,7 +956,6 @@ function createClient(
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
|
||||
},
|
||||
sessionAffinityHeaders,
|
||||
model.headers,
|
||||
@@ -968,12 +973,54 @@ function createClient(
|
||||
return { client, isOAuthToken: false };
|
||||
}
|
||||
|
||||
function getBetaFeatures(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
isOAuthToken: boolean,
|
||||
options?: AnthropicOptions,
|
||||
): NonNullable<MessageCreateParamsStreaming["betas"]> {
|
||||
let configuredFeatures: string | null | undefined;
|
||||
for (const headers of [model.headers, options?.headers]) {
|
||||
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||
if (name.toLowerCase() === "anthropic-beta") configuredFeatures = value;
|
||||
}
|
||||
}
|
||||
if (configuredFeatures === null) return [];
|
||||
if (configuredFeatures !== undefined) {
|
||||
return [
|
||||
...new Set(
|
||||
configuredFeatures
|
||||
.split(",")
|
||||
.map((feature) => feature.trim())
|
||||
.filter((feature) => feature.length > 0),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const features: NonNullable<MessageCreateParamsStreaming["betas"]> = [];
|
||||
if (isOAuthToken) features.push("claude-code-20250219", "oauth-2025-04-20");
|
||||
if (shouldUseFineGrainedToolStreamingBeta(model, context)) features.push(FINE_GRAINED_TOOL_STREAMING_BETA);
|
||||
if (
|
||||
model.reasoning &&
|
||||
options?.thinkingEnabled === true &&
|
||||
(options.interleavedThinking ?? true) &&
|
||||
model.compat?.forceAdaptiveThinking !== true
|
||||
) {
|
||||
features.push(INTERLEAVED_THINKING_BETA);
|
||||
}
|
||||
if (shouldUseServerSideFallbackBeta(model)) features.push(SERVER_SIDE_FALLBACK_BETA);
|
||||
if (model.compat?.supportsMidConvoEffort === true) {
|
||||
features.push(MID_CONVERSATION_OUTPUT_CONFIG_BETA, THINKING_BINDING_CONTROLS_BETA);
|
||||
}
|
||||
return [...new Set(features)];
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
isOAuthToken: boolean,
|
||||
options?: AnthropicOptions,
|
||||
): MessageCreateParamsStreamingWithFallbacks {
|
||||
): MessageCreateParamsStreaming {
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
|
||||
const compat = getAnthropicCompat(model);
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
@@ -990,18 +1037,26 @@ function buildParams(
|
||||
deferredTools = [];
|
||||
}
|
||||
const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name)));
|
||||
const params: MessageCreateParamsStreamingWithFallbacks = {
|
||||
const converted = convertMessages(
|
||||
transformedMessages,
|
||||
isOAuthToken,
|
||||
cacheControl,
|
||||
compat.allowEmptySignature,
|
||||
deferredToolNames,
|
||||
normalizeToolName,
|
||||
model.compat?.supportsMidConvoEffort === true ? model.provider : undefined,
|
||||
);
|
||||
const activeEffort = options?.effort ?? "high";
|
||||
const betaFeatures = getBetaFeatures(model, context, isOAuthToken, options);
|
||||
const params: MessageCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
messages: convertMessages(
|
||||
transformedMessages,
|
||||
isOAuthToken,
|
||||
cacheControl,
|
||||
compat.allowEmptySignature,
|
||||
deferredToolNames,
|
||||
normalizeToolName,
|
||||
),
|
||||
messages:
|
||||
model.compat?.supportsMidConvoEffort === true
|
||||
? insertThinkingLevelMessages(converted, activeEffort)
|
||||
: converted.messages,
|
||||
max_tokens: options?.maxTokens ?? model.maxTokens,
|
||||
stream: true,
|
||||
...(betaFeatures.length > 0 ? { betas: betaFeatures } : {}),
|
||||
};
|
||||
|
||||
// For OAuth tokens, we MUST include Claude Code identity
|
||||
@@ -1032,7 +1087,12 @@ function buildParams(
|
||||
}
|
||||
|
||||
// Temperature is incompatible with extended thinking and unsupported on Claude Opus 4.7+.
|
||||
if (options?.temperature !== undefined && !options?.thinkingEnabled && compat.supportsTemperature) {
|
||||
if (
|
||||
options?.temperature !== undefined &&
|
||||
!options?.thinkingEnabled &&
|
||||
model.compat?.supportsMidConvoEffort !== true &&
|
||||
compat.supportsTemperature
|
||||
) {
|
||||
params.temperature = options.temperature;
|
||||
}
|
||||
|
||||
@@ -1056,8 +1116,16 @@ function buildParams(
|
||||
];
|
||||
}
|
||||
|
||||
// Configure thinking mode: adaptive, budget-based, or explicitly disabled.
|
||||
if (model.reasoning) {
|
||||
// Managed effort models always use adaptive thinking so prefix mismatches can
|
||||
// be dropped instead of surfacing as persistent 400 responses.
|
||||
if (model.compat?.supportsMidConvoEffort === true) {
|
||||
params.thinking = {
|
||||
type: "adaptive",
|
||||
display: options?.thinkingDisplay ?? "summarized",
|
||||
block_binding: { prefix_mismatch_behavior: "drop_block" },
|
||||
};
|
||||
params.output_config = { effort: "high" };
|
||||
} else if (model.reasoning) {
|
||||
if (options?.thinkingEnabled) {
|
||||
// Default to "summarized" so Opus 4.7 and Mythos Preview behave like
|
||||
// older Claude 4 models (whose API default is also "summarized").
|
||||
@@ -1066,13 +1134,7 @@ function buildParams(
|
||||
// Adaptive thinking: Claude decides when and how much to think.
|
||||
params.thinking = { type: "adaptive", display };
|
||||
if (options.effort) {
|
||||
// The Anthropic SDK types can lag newly supported effort values such as "xhigh".
|
||||
params.output_config =
|
||||
options.effort === "xhigh"
|
||||
? ({ effort: options.effort } as unknown as NonNullable<
|
||||
MessageCreateParamsStreaming["output_config"]
|
||||
>)
|
||||
: { effort: options.effort };
|
||||
params.output_config = { effort: options.effort };
|
||||
}
|
||||
} else {
|
||||
// Budget-based thinking for older models
|
||||
@@ -1150,6 +1212,11 @@ function convertToolResult(
|
||||
};
|
||||
}
|
||||
|
||||
interface ConvertedAnthropicMessages {
|
||||
messages: MessageParam[];
|
||||
assistantLevels: Map<number, AnthropicEffort>;
|
||||
}
|
||||
|
||||
function convertMessages(
|
||||
transformedMessages: Message[],
|
||||
isOAuthToken: boolean,
|
||||
@@ -1157,8 +1224,10 @@ function convertMessages(
|
||||
allowEmptySignature = false,
|
||||
deferredToolNames: ReadonlySet<string> = new Set(),
|
||||
normalizeToolName: (name: string) => string = (name) => name,
|
||||
): MessageParam[] {
|
||||
managedProvider?: string,
|
||||
): ConvertedAnthropicMessages {
|
||||
const params: MessageParam[] = [];
|
||||
const assistantLevels = new Map<number, AnthropicEffort>();
|
||||
const loadedToolNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < transformedMessages.length; i++) {
|
||||
@@ -1257,10 +1326,19 @@ function convertMessages(
|
||||
}
|
||||
}
|
||||
if (blocks.length === 0) continue;
|
||||
const messageIndex = params.length;
|
||||
params.push({
|
||||
role: "assistant",
|
||||
content: blocks,
|
||||
});
|
||||
if (
|
||||
managedProvider !== undefined &&
|
||||
msg.api === "anthropic-messages" &&
|
||||
msg.provider === managedProvider &&
|
||||
isAnthropicEffort(msg.providerThinkingLevel)
|
||||
) {
|
||||
assistantLevels.set(messageIndex, msg.providerThinkingLevel);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.
|
||||
const toolResults: ContentBlockParam[] = [];
|
||||
@@ -1314,7 +1392,27 @@ function convertMessages(
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
return { messages: params, assistantLevels };
|
||||
}
|
||||
|
||||
function isAnthropicEffort(value: unknown): value is AnthropicEffort {
|
||||
return value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max";
|
||||
}
|
||||
|
||||
function insertThinkingLevelMessages(
|
||||
converted: ConvertedAnthropicMessages,
|
||||
activeEffort: AnthropicEffort,
|
||||
): MessageParam[] {
|
||||
const messages: MessageParam[] = [];
|
||||
for (let index = 0; index < converted.messages.length; index++) {
|
||||
const historicalEffort = converted.assistantLevels.get(index);
|
||||
if (historicalEffort !== undefined) {
|
||||
messages.push({ role: "system", content: [], output_config: { effort: historicalEffort } });
|
||||
}
|
||||
messages.push(converted.messages[index]);
|
||||
}
|
||||
messages.push({ role: "system", content: [], output_config: { effort: activeEffort } });
|
||||
return messages;
|
||||
}
|
||||
|
||||
function shouldUseFineGrainedToolStreamingBeta(model: Model<"anthropic-messages">, context: Context): boolean {
|
||||
@@ -1328,7 +1426,7 @@ function convertTools(
|
||||
supportsStrictTools: boolean,
|
||||
cacheControl?: CacheControlEphemeral,
|
||||
deferLoading = false,
|
||||
): Anthropic.Messages.Tool[] {
|
||||
): BetaTool[] {
|
||||
if (!tools) return [];
|
||||
|
||||
return tools.map((tool, index) => {
|
||||
@@ -1361,7 +1459,7 @@ function convertTools(
|
||||
}
|
||||
|
||||
function mapStopReason(
|
||||
reason: Anthropic.Messages.StopReason | string,
|
||||
reason: BetaStopReason | string,
|
||||
stopDetails?: RefusalStopDetails | null,
|
||||
): { stopReason: StopReason; errorMessage?: string } {
|
||||
switch (reason) {
|
||||
|
||||
@@ -178,12 +178,17 @@ interface OpenAICompatCacheControl {
|
||||
|
||||
type ResolvedOpenAICompletionsCompat = Omit<
|
||||
Required<OpenAICompletionsCompat>,
|
||||
"cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField"
|
||||
| "cacheControlFormat"
|
||||
| "deferredToolsMode"
|
||||
| "supportsThinkingTokenBudget"
|
||||
| "thinkingTokenBudgetField"
|
||||
| "vllmPriority"
|
||||
> & {
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"];
|
||||
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
|
||||
vllmPriority?: OpenAICompletionsCompat["vllmPriority"];
|
||||
};
|
||||
|
||||
type ResolvedChatTemplateKwargValue = string | number | boolean | null;
|
||||
@@ -849,6 +854,10 @@ function buildParams(
|
||||
params.tool_choice = options.toolChoice;
|
||||
}
|
||||
|
||||
if (compat.vllmPriority !== undefined) {
|
||||
(params as any).priority = compat.vllmPriority;
|
||||
}
|
||||
|
||||
const thinkingTokenBudgetField = resolveThinkingTokenBudgetField(compat);
|
||||
const thinkingBudget = resolveClampedThinkingBudget(model, options, params);
|
||||
|
||||
@@ -1701,5 +1710,6 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
|
||||
deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode,
|
||||
sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat,
|
||||
supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention,
|
||||
vllmPriority: model.compat.vllmPriority,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export type PiMessagesEvent =
|
||||
reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;
|
||||
usage: PiMessagesUsage;
|
||||
responseId?: string;
|
||||
providerThinkingLevel?: string;
|
||||
rewrite?: PiMessagesRewriteImpact;
|
||||
}
|
||||
| {
|
||||
@@ -79,6 +80,7 @@ export type PiMessagesEvent =
|
||||
usage: PiMessagesUsage;
|
||||
errorMessage?: string;
|
||||
responseId?: string;
|
||||
providerThinkingLevel?: string;
|
||||
rewrite?: PiMessagesRewriteImpact;
|
||||
};
|
||||
|
||||
@@ -194,6 +196,9 @@ function createEventConverter(model: Model<"pi-messages">) {
|
||||
usage: event.usage,
|
||||
responseId: event.responseId,
|
||||
});
|
||||
if (event.providerThinkingLevel !== undefined) {
|
||||
partial.providerThinkingLevel = event.providerThinkingLevel;
|
||||
}
|
||||
appendRewriteDiagnostic(partial, event.rewrite);
|
||||
return { type: "done", reason: event.reason, message: partial };
|
||||
case "error":
|
||||
@@ -203,6 +208,9 @@ function createEventConverter(model: Model<"pi-messages">) {
|
||||
errorMessage: event.errorMessage,
|
||||
responseId: event.responseId,
|
||||
});
|
||||
if (event.providerThinkingLevel !== undefined) {
|
||||
partial.providerThinkingLevel = event.providerThinkingLevel;
|
||||
}
|
||||
appendRewriteDiagnostic(partial, event.rewrite);
|
||||
return { type: "error", reason: event.reason, error: partial };
|
||||
case "start":
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { OPENROUTER_MODELS } from "./openrouter.models.ts";
|
||||
|
||||
export function openrouterProvider(): Provider<"openai-completions"> {
|
||||
export function openrouterProvider(): Provider<"anthropic-messages" | "openai-completions"> {
|
||||
return createProvider({
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
@@ -18,6 +19,9 @@ export function openrouterProvider(): Provider<"openai-completions"> {
|
||||
}),
|
||||
},
|
||||
models: Object.values(OPENROUTER_MODELS),
|
||||
api: openAICompletionsApi(),
|
||||
api: {
|
||||
"anthropic-messages": anthropicMessagesApi(),
|
||||
"openai-completions": openAICompletionsApi(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,6 +433,8 @@ export interface AssistantMessage {
|
||||
model: string;
|
||||
responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`)
|
||||
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
|
||||
/** Exact provider-native effort level used for this response. Absent for legacy or unmanaged responses. */
|
||||
providerThinkingLevel?: string;
|
||||
diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries.
|
||||
usage: Usage;
|
||||
stopReason: StopReason;
|
||||
@@ -632,6 +634,13 @@ export interface OpenAICompletionsCompat {
|
||||
sessionAffinityFormat?: SessionAffinityFormat;
|
||||
/** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */
|
||||
supportsLongCacheRetention?: boolean;
|
||||
/**
|
||||
* vLLM scheduler priority sent as the top-level `priority` request field (lower values are
|
||||
* handled earlier; server default 0). Only meaningful when vLLM runs with
|
||||
* `--scheduling-policy priority`; useful for keeping background/batch work from stalling
|
||||
* interactive sessions. Off by default; not set on the generated catalog.
|
||||
*/
|
||||
vllmPriority?: number;
|
||||
}
|
||||
|
||||
/** Compatibility settings for OpenAI Responses APIs. */
|
||||
@@ -704,6 +713,8 @@ export interface AnthropicMessagesCompat {
|
||||
allowEmptySignature?: boolean;
|
||||
/** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */
|
||||
supportsStrictTools?: boolean;
|
||||
/** Whether the exact model transport supports effort-only system messages and thinking binding controls. Default: false. */
|
||||
supportsMidConvoEffort?: boolean;
|
||||
/**
|
||||
* Models Anthropic accepts in `fallbacks` for server-side refusal fallback,
|
||||
* with local pricing metadata for returned fallback responses. When absent or
|
||||
|
||||
@@ -39,12 +39,14 @@ vi.mock("@anthropic-ai/sdk", () => {
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
mockState.constructorOpts = opts;
|
||||
}
|
||||
messages = {
|
||||
create: (params: Record<string, unknown>) => {
|
||||
mockState.createParams = params;
|
||||
return {
|
||||
asResponse: async () => createSseResponse(),
|
||||
};
|
||||
beta = {
|
||||
messages: {
|
||||
create: (params: Record<string, unknown>) => {
|
||||
mockState.createParams = params;
|
||||
return {
|
||||
asResponse: async () => createSseResponse(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -138,7 +140,7 @@ describe("Anthropic auth token env", () => {
|
||||
expect(mockState.constructorOpts?.authToken).toBeNull();
|
||||
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string | null>;
|
||||
expect(headers.Authorization).toBe("Bearer gateway-token");
|
||||
expect(headers["anthropic-beta"] ?? "").not.toContain("oauth-2025-04-20");
|
||||
expect(mockState.createParams?.betas ?? []).not.toContain("oauth-2025-04-20");
|
||||
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
|
||||
});
|
||||
|
||||
@@ -157,7 +159,7 @@ describe("Anthropic auth token env", () => {
|
||||
expect(mockState.constructorOpts?.authToken).toBeNull();
|
||||
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer ctx-token");
|
||||
expect(headers["anthropic-beta"] ?? "").not.toContain("oauth-2025-04-20");
|
||||
expect(mockState.createParams?.betas ?? []).not.toContain("oauth-2025-04-20");
|
||||
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
|
||||
});
|
||||
|
||||
@@ -174,8 +176,7 @@ describe("Anthropic auth token env", () => {
|
||||
|
||||
expect(mockState.constructorOpts?.apiKey).toBeNull();
|
||||
expect(mockState.constructorOpts?.authToken).toBe("sk-ant-oat-test");
|
||||
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
|
||||
expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20");
|
||||
expect(mockState.createParams?.betas).toContain("oauth-2025-04-20");
|
||||
});
|
||||
|
||||
it("lets explicit request headers override ANTHROPIC_AUTH_TOKEN", async () => {
|
||||
@@ -213,4 +214,22 @@ describe("Anthropic-compatible user agents", () => {
|
||||
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
|
||||
expect(headers["User-Agent"]).toBe("custom-client");
|
||||
});
|
||||
|
||||
it("preserves explicit Anthropic beta header replacement", async () => {
|
||||
await streamAnthropic(anthropicModel, context, {
|
||||
apiKey: "anthropic-key",
|
||||
headers: { "anthropic-beta": "custom-beta" },
|
||||
}).result();
|
||||
|
||||
expect(mockState.createParams?.betas).toEqual(["custom-beta"]);
|
||||
});
|
||||
|
||||
it("preserves explicit Anthropic beta header suppression", async () => {
|
||||
await streamAnthropic(anthropicModel, context, {
|
||||
apiKey: "anthropic-key",
|
||||
headers: { "anthropic-beta": null },
|
||||
}).result();
|
||||
|
||||
expect(mockState.createParams?.betas).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ function createSseResponse(events: Array<{ event: string; data: string }>): Resp
|
||||
|
||||
function createFakeAnthropicClient(response: Response): Anthropic {
|
||||
return {
|
||||
messages: { create: () => ({ asResponse: async () => response }) },
|
||||
beta: { messages: { create: () => ({ asResponse: async () => response }) } },
|
||||
} as unknown as Anthropic;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
||||
|
||||
interface WireMessage {
|
||||
role: string;
|
||||
content: unknown;
|
||||
output_config?: { effort?: string };
|
||||
}
|
||||
|
||||
interface CapturedPayload {
|
||||
messages: WireMessage[];
|
||||
thinking?: {
|
||||
type: string;
|
||||
display?: string;
|
||||
block_binding?: { prefix_mismatch_behavior?: string };
|
||||
};
|
||||
output_config?: { effort?: string };
|
||||
fallbacks?: Array<{ model: string }>;
|
||||
}
|
||||
|
||||
function managedModel(provider = "anthropic"): Model<"anthropic-messages"> {
|
||||
return {
|
||||
id: "claude-fable-5-1",
|
||||
name: "Claude Fable 5.1",
|
||||
api: "anthropic-messages",
|
||||
provider,
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { off: null, minimal: "low", low: "low", medium: "medium", high: "high", max: "max" },
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 32000,
|
||||
compat: { forceAdaptiveThinking: true, supportsMidConvoEffort: true },
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(model: Model<"anthropic-messages">, level?: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "reasoning", thinkingSignature: "signature" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
...(level === undefined ? {} : { providerThinkingLevel: level }),
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function capture(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
effort?: "low" | "medium" | "high" | "xhigh" | "max",
|
||||
): Promise<{ payload: CapturedPayload; message: AssistantMessage }> {
|
||||
let payload: CapturedPayload | undefined;
|
||||
const result = stream(model, context, {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
thinkingEnabled: true,
|
||||
effort,
|
||||
onPayload: (value) => {
|
||||
payload = value as CapturedPayload;
|
||||
throw new Error("payload captured");
|
||||
},
|
||||
});
|
||||
const message = await result.result();
|
||||
if (!payload) throw new Error("Expected payload capture");
|
||||
return { payload, message };
|
||||
}
|
||||
|
||||
const user = (text: string, timestamp: number) => ({ role: "user" as const, content: text, timestamp });
|
||||
|
||||
function effortMessages(payload: CapturedPayload): WireMessage[] {
|
||||
return payload.messages.filter((message) => message.role === "system");
|
||||
}
|
||||
|
||||
describe("Anthropic mid-conversation effort", () => {
|
||||
it("reconstructs an exact historical marker prefix and appends the current marker", async () => {
|
||||
const model = managedModel();
|
||||
const first = await capture(model, { messages: [user("one", 1)] }, "low");
|
||||
const second = await capture(
|
||||
model,
|
||||
{ messages: [user("one", 1), assistant(model, "low"), user("two", 2)] },
|
||||
"high",
|
||||
);
|
||||
|
||||
expect(first.payload.messages).toEqual([
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
]);
|
||||
expect(second.payload.messages.slice(0, first.payload.messages.length)).toEqual(first.payload.messages);
|
||||
expect(second.payload.messages.at(-1)).toEqual({
|
||||
role: "system",
|
||||
content: [],
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
expect(first.payload.output_config).toEqual({ effort: "high" });
|
||||
expect(second.payload.output_config).toEqual({ effort: "high" });
|
||||
expect(second.payload.thinking).toEqual({
|
||||
type: "adaptive",
|
||||
display: "summarized",
|
||||
block_binding: { prefix_mismatch_behavior: "drop_block" },
|
||||
});
|
||||
expect(first.message.providerThinkingLevel).toBe("low");
|
||||
});
|
||||
|
||||
it.each(["low", "medium", "high", "xhigh", "max"] as const)("preserves native effort %s", async (effort) => {
|
||||
const model = managedModel();
|
||||
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, effort);
|
||||
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort } }]);
|
||||
expect(message.providerThinkingLevel).toBe(effort);
|
||||
});
|
||||
|
||||
it("defaults omitted effort to high and still enables drop_block", async () => {
|
||||
const { payload, message } = await capture(managedModel(), { messages: [user("one", 1)] });
|
||||
expect(payload.messages.at(-1)).toEqual({
|
||||
role: "system",
|
||||
content: [],
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
expect(payload.thinking?.block_binding?.prefix_mismatch_behavior).toBe("drop_block");
|
||||
expect(message.providerThinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("does not invent markers for legacy or other-provider assistants", async () => {
|
||||
const model = managedModel();
|
||||
const legacy = assistant(model);
|
||||
const otherProvider = { ...assistant(model, "low"), provider: "other-provider" };
|
||||
const { payload } = await capture(
|
||||
model,
|
||||
{ messages: [user("one", 1), legacy, user("two", 2), otherProvider, user("three", 3)] },
|
||||
"medium",
|
||||
);
|
||||
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "medium" } }]);
|
||||
});
|
||||
|
||||
it("leaves unsupported models on top-level effort", async () => {
|
||||
const model = managedModel();
|
||||
model.compat = { forceAdaptiveThinking: true };
|
||||
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, "low");
|
||||
expect(payload.messages).toEqual([{ role: "user", content: "one" }]);
|
||||
expect(payload.output_config).toEqual({ effort: "low" });
|
||||
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
||||
expect(message.providerThinkingLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the effort and binding beta headers", async () => {
|
||||
let betaHeader: string | null = null;
|
||||
const events = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_test",
|
||||
model: "claude-fable-5-1",
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
const body = events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join("");
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
betaHeader = request.headers.get("anthropic-beta");
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
};
|
||||
const result = await stream(
|
||||
managedModel(),
|
||||
{ messages: [user("one", 1)] },
|
||||
{
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
fetch: fetchImpl,
|
||||
},
|
||||
).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(betaHeader).toContain("mid-conversation-output-config-2026-07-01");
|
||||
expect(betaHeader).toContain("thinking-binding-controls-2026-08-01");
|
||||
});
|
||||
|
||||
it("generates exact model and transport gates", () => {
|
||||
const direct = getModel("anthropic", "claude-fable-5-1");
|
||||
const openRouter = getModel("openrouter", "anthropic/claude-fable-5.1");
|
||||
const unsupported = getModel("anthropic", "claude-opus-4-8");
|
||||
expect(direct.compat?.supportsMidConvoEffort).toBe(true);
|
||||
expect(direct.thinkingLevelMap?.off).toBeNull();
|
||||
expect(openRouter.api).toBe("anthropic-messages");
|
||||
expect(openRouter.baseUrl).toBe("https://openrouter.ai/api");
|
||||
expect(openRouter.compat?.supportsMidConvoEffort).toBe(true);
|
||||
expect(unsupported.compat?.supportsMidConvoEffort).toBeUndefined();
|
||||
expect(getModel("anthropic", "claude-opus-5").compat?.allowedFallbackModels).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -70,15 +70,176 @@ const minimalAnthropicEvents = [
|
||||
|
||||
function createFakeAnthropicClient(response: Response): Anthropic {
|
||||
return {
|
||||
messages: {
|
||||
create: () => ({
|
||||
asResponse: async () => response,
|
||||
}),
|
||||
beta: {
|
||||
messages: {
|
||||
create: () => ({
|
||||
asResponse: async () => response,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Anthropic;
|
||||
}
|
||||
|
||||
describe("Anthropic raw SSE parsing", () => {
|
||||
it("fails safely when Anthropic falls back after output begins", async () => {
|
||||
const model = getModel("anthropic", "claude-opus-5");
|
||||
const response = createSseResponse([
|
||||
{
|
||||
event: "message_start",
|
||||
data: JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_fallback",
|
||||
model: "claude-opus-5",
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
event: "content_block_start",
|
||||
data: JSON.stringify({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "partial" },
|
||||
}),
|
||||
},
|
||||
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
|
||||
{
|
||||
event: "content_block_start",
|
||||
data: JSON.stringify({
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "fallback",
|
||||
from: { model: "claude-opus-5" },
|
||||
to: { model: "claude-opus-4-8" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAnthropic(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
{ client: createFakeAnthropicClient(response) },
|
||||
).result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("unsupported mid-output model fallback");
|
||||
});
|
||||
|
||||
it("forces streaming after an onPayload replacement", async () => {
|
||||
let streaming: boolean | undefined;
|
||||
const client = {
|
||||
beta: {
|
||||
messages: {
|
||||
create: (params: { stream?: boolean }) => {
|
||||
streaming = params.stream;
|
||||
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Anthropic;
|
||||
|
||||
await streamAnthropic(
|
||||
getModel("anthropic", "claude-fable-5-1"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
{
|
||||
client,
|
||||
onPayload: (payload) => ({ ...(payload as Record<string, unknown>), stream: false }),
|
||||
},
|
||||
).result();
|
||||
|
||||
expect(streaming).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the interleaved-thinking beta when thinking is disabled", async () => {
|
||||
let betaFeatures: string[] | undefined;
|
||||
const client = {
|
||||
beta: {
|
||||
messages: {
|
||||
create: (params: { betas?: string[] }) => {
|
||||
betaFeatures = params.betas;
|
||||
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Anthropic;
|
||||
|
||||
await streamAnthropic(
|
||||
getModel("openrouter", "anthropic/claude-3-haiku"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
{ client, thinkingEnabled: false },
|
||||
).result();
|
||||
|
||||
expect(betaFeatures ?? []).not.toContain("interleaved-thinking-2025-05-14");
|
||||
});
|
||||
|
||||
it("passes managed beta features to injected clients", async () => {
|
||||
let betaFeatures: string[] | undefined;
|
||||
const client = {
|
||||
beta: {
|
||||
messages: {
|
||||
create: (params: { betas?: string[] }) => {
|
||||
betaFeatures = params.betas;
|
||||
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Anthropic;
|
||||
|
||||
const result = await streamAnthropic(
|
||||
getModel("anthropic", "claude-fable-5-1"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
{ client },
|
||||
).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(betaFeatures).toContain("mid-conversation-output-config-2026-07-01");
|
||||
expect(betaFeatures).toContain("thinking-binding-controls-2026-08-01");
|
||||
});
|
||||
|
||||
it("uses the serving model input transformations from the final stream event", async () => {
|
||||
const events = minimalAnthropicEvents.map((event) => ({ ...event }));
|
||||
events[0].data = JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_transformations",
|
||||
model: "claude-fable-5-1",
|
||||
usage: { input_tokens: 12, output_tokens: 0 },
|
||||
input_transformations: [
|
||||
{ type: "thinking_dropped", path: "messages.1.content.0", reason: "prefix_binding_mismatch" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const delta = JSON.parse(events[4].data) as Record<string, unknown>;
|
||||
delta.input_transformations = [
|
||||
{ type: "thinking_dropped", path: "messages.3.content.0", reason: "model_binding_mismatch" },
|
||||
];
|
||||
events[4].data = JSON.stringify(delta);
|
||||
|
||||
const result = await streamAnthropic(
|
||||
getModel("anthropic", "claude-fable-5-1"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
{ client: createFakeAnthropicClient(createSseResponse(events)) },
|
||||
).result();
|
||||
|
||||
expect(result.diagnostics).toEqual([
|
||||
{
|
||||
type: "anthropic_input_transformations",
|
||||
timestamp: expect.any(Number),
|
||||
details: {
|
||||
transformations: [
|
||||
{
|
||||
type: "thinking_dropped",
|
||||
path: "messages.3.content.0",
|
||||
reason: "model_binding_mismatch",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("repairs malformed SSE JSON and malformed streamed tool JSON", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context } from "../src/types.ts";
|
||||
|
||||
const enabled = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
const model = getModel("anthropic", "claude-fable-5-1");
|
||||
const user = (content: string, timestamp: number) => ({ role: "user" as const, content, timestamp });
|
||||
|
||||
function strictBinding(payload: unknown): unknown {
|
||||
const params = payload as {
|
||||
thinking?: {
|
||||
block_binding?: { prefix_mismatch_behavior?: "drop_block" | "error" };
|
||||
};
|
||||
};
|
||||
if (params.thinking?.block_binding) {
|
||||
params.thinking.block_binding.prefix_mismatch_behavior = "error";
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function request(context: Context, effort: "low" | "high"): Promise<AssistantMessage> {
|
||||
return stream(model, context, {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
cacheRetention: "none",
|
||||
maxTokens: 1536,
|
||||
thinkingEnabled: true,
|
||||
thinkingDisplay: "summarized",
|
||||
effort,
|
||||
onPayload: strictBinding,
|
||||
}).result();
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)("Anthropic thinking binding conformance", () => {
|
||||
it("replays managed effort markers required by signed Fable thinking", { timeout: 120000 }, async () => {
|
||||
const firstUser = user("Compute 982451653 multiplied by 961748941. Return only the integer.", 1);
|
||||
const first = await request({ messages: [firstUser] }, "low");
|
||||
expect(first.stopReason, first.errorMessage).toBe("stop");
|
||||
expect(
|
||||
first.content.some(
|
||||
(block) =>
|
||||
block.type === "thinking" &&
|
||||
typeof block.thinkingSignature === "string" &&
|
||||
block.thinkingSignature.length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(first.providerThinkingLevel).toBe("low");
|
||||
|
||||
const secondUser = user("Reply with exactly: ok", 2);
|
||||
const exact = await request({ messages: [firstUser, first, secondUser] }, "high");
|
||||
expect(exact.stopReason, exact.errorMessage).toBe("stop");
|
||||
|
||||
const unmanagedHistory: AssistantMessage = { ...first };
|
||||
delete unmanagedHistory.providerThinkingLevel;
|
||||
const missingMarker = await request({ messages: [firstUser, unmanagedHistory, secondUser] }, "high");
|
||||
expect(missingMarker.stopReason).toBe("error");
|
||||
expect(missingMarker.errorMessage).toContain("Invalid `signature`");
|
||||
});
|
||||
});
|
||||
@@ -36,12 +36,14 @@ vi.mock("@anthropic-ai/sdk", () => {
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
mockState.constructorOpts = opts;
|
||||
}
|
||||
messages = {
|
||||
create: (params: Record<string, unknown>) => {
|
||||
mockState.createParams = params;
|
||||
return {
|
||||
asResponse: async () => createSseResponse(),
|
||||
};
|
||||
beta = {
|
||||
messages: {
|
||||
create: (params: Record<string, unknown>) => {
|
||||
mockState.createParams = params;
|
||||
return {
|
||||
asResponse: async () => createSseResponse(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -99,12 +101,9 @@ describe("Copilot Claude via Anthropic Messages", () => {
|
||||
expect(headers["X-Initiator"]).toBe("user");
|
||||
expect(headers["Openai-Intent"]).toBe("conversation-edits");
|
||||
|
||||
// No fine-grained-tool-streaming (Copilot doesn't support it)
|
||||
const beta = headers["anthropic-beta"] ?? "";
|
||||
expect(beta).not.toContain("fine-grained-tool-streaming");
|
||||
|
||||
// Payload is valid Anthropic Messages format
|
||||
const params = mockState.createParams!;
|
||||
expect(params.betas ?? []).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
expect(params.model).toBe("claude-sonnet-4.6");
|
||||
expect(params.stream).toBe(true);
|
||||
expect(params.max_tokens).toBe(model.maxTokens);
|
||||
@@ -121,7 +120,6 @@ describe("Copilot Claude via Anthropic Messages", () => {
|
||||
if (event.type === "error") break;
|
||||
}
|
||||
|
||||
const headers = mockState.constructorOpts!.defaultHeaders as Record<string, string>;
|
||||
expect(headers["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14");
|
||||
expect(mockState.createParams?.betas ?? []).not.toContain("interleaved-thinking-2025-05-14");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,14 +153,14 @@ describe("openai-completions cacheControlFormat", () => {
|
||||
expectAnthropicCacheMarkers(params);
|
||||
});
|
||||
|
||||
it("preserves Anthropic-style cache markers for OpenRouter Anthropic models", async () => {
|
||||
const model = getModel("openrouter", "anthropic/claude-sonnet-4");
|
||||
it("preserves Anthropic-style cache markers for OpenRouter Anthropic batch aliases", async () => {
|
||||
const model = getModel("openrouter", "anthropic/claude-fable-5.1:batch");
|
||||
const params = await capturePayload(model);
|
||||
expectAnthropicCacheMarkers(params);
|
||||
});
|
||||
|
||||
it("moves the conversation cache marker to a tool result", async () => {
|
||||
const model = getModel("openrouter", "anthropic/claude-sonnet-4");
|
||||
const model = getModel("openrouter", "anthropic/claude-fable-5.1:batch");
|
||||
const timestamp = Date.now();
|
||||
const params = await capturePayload(model, undefined, [
|
||||
{ role: "user", content: "Read the file", timestamp },
|
||||
|
||||
@@ -48,7 +48,7 @@ const compat = {
|
||||
supportsLongCacheRetention: true,
|
||||
} satisfies Omit<
|
||||
Required<OpenAICompletionsCompat>,
|
||||
"cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField"
|
||||
"cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
|
||||
> & {
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
|
||||
@@ -1096,10 +1096,10 @@ describe("openai-completions tool_choice", () => {
|
||||
expect(params.messages?.[0]?.role).toBe("system");
|
||||
});
|
||||
|
||||
it("keeps developer messages for OpenAI and Anthropic OpenRouter reasoning model instructions", async () => {
|
||||
it("keeps developer messages for OpenAI and Anthropic OpenRouter batch instructions", async () => {
|
||||
for (const model of [
|
||||
getModel("openrouter", "openai/gpt-5.2-codex"),
|
||||
getModel("openrouter", "anthropic/claude-sonnet-4.5"),
|
||||
getModel("openrouter", "anthropic/claude-fable-5.1:batch"),
|
||||
]) {
|
||||
expect(model).toBeDefined();
|
||||
let payload: unknown;
|
||||
|
||||
@@ -19,7 +19,10 @@ const emptyUsage: Usage = {
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
const compat: Omit<Required<OpenAICompletionsCompat>, "deferredToolsMode" | "thinkingTokenBudgetField"> & {
|
||||
const compat: Omit<
|
||||
Required<OpenAICompletionsCompat>,
|
||||
"deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
|
||||
> & {
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
|
||||
} = {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
interface CapturedCompletionsPayload {
|
||||
priority?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
lastParams: undefined as CapturedCompletionsPayload | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: (params: CapturedCompletionsPayload) => {
|
||||
mockState.lastParams = params;
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
|
||||
withResponse: () => Promise<{
|
||||
data: typeof stream;
|
||||
response: { status: number; headers: Headers };
|
||||
}>;
|
||||
};
|
||||
promise.withResponse = async () => ({
|
||||
data: stream,
|
||||
response: { status: 200, headers: new Headers() },
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { default: FakeOpenAI };
|
||||
});
|
||||
|
||||
function createModel(overrides: Partial<Model<"openai-completions">> = {}): Model<"openai-completions"> {
|
||||
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
||||
return {
|
||||
...(baseModel as Omit<Model<"openai-completions">, "api">),
|
||||
api: "openai-completions",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureRequest(model: Model<"openai-completions">) {
|
||||
await streamOpenAICompletions(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
{ apiKey: "test-key" },
|
||||
).result();
|
||||
|
||||
return mockState.lastParams;
|
||||
}
|
||||
|
||||
describe("openai-completions vllm priority", () => {
|
||||
beforeEach(() => {
|
||||
mockState.lastParams = undefined;
|
||||
});
|
||||
|
||||
it("sends compat.vllmPriority as the top-level priority request field", async () => {
|
||||
const payload = await captureRequest(
|
||||
createModel({ compat: { vllmPriority: 10 } as Model<"openai-completions">["compat"] }),
|
||||
);
|
||||
|
||||
expect(payload?.priority).toBe(10);
|
||||
});
|
||||
|
||||
it("omits priority when vllmPriority is not set", async () => {
|
||||
const payload = await captureRequest(createModel());
|
||||
|
||||
expect(payload?.priority).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,11 @@ const OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS = [
|
||||
"~anthropic/claude-sonnet-latest",
|
||||
] as const;
|
||||
|
||||
describe("OpenRouter Anthropic cache control metadata", () => {
|
||||
it.each(OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS)("enables cache control for %s", (modelId) => {
|
||||
expect(getModel("openrouter", modelId).compat?.cacheControlFormat).toBe("anthropic");
|
||||
describe("OpenRouter Anthropic latest alias metadata", () => {
|
||||
it.each(OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS)("keeps completions cache control for %s", (modelId) => {
|
||||
const model = getModel("openrouter", modelId);
|
||||
expect(model.api).toBe("openai-completions");
|
||||
if (model.api !== "openai-completions") throw new Error(`Unexpected API for ${modelId}`);
|
||||
expect(model.compat?.cacheControlFormat).toBe("anthropic");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,13 @@ describe("pi-messages", () => {
|
||||
contentIndex: 1,
|
||||
toolCall: { type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } },
|
||||
},
|
||||
{ type: "done", reason: "toolUse", usage, responseId: "resp_1" },
|
||||
{
|
||||
type: "done",
|
||||
reason: "toolUse",
|
||||
usage,
|
||||
responseId: "resp_1",
|
||||
providerThinkingLevel: "high",
|
||||
},
|
||||
],
|
||||
});
|
||||
const model = createModel(baseUrl);
|
||||
@@ -136,6 +142,7 @@ describe("pi-messages", () => {
|
||||
expect(message.stopReason).toBe("toolUse");
|
||||
expect(message.usage).toEqual(usage);
|
||||
expect(message.responseId).toBe("resp_1");
|
||||
expect(message.providerThinkingLevel).toBe("high");
|
||||
expect(message.model).toBe("auto");
|
||||
expect(message.provider).toBe("radius");
|
||||
expect(message.content).toEqual([
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the write tool reporting UTF-16 code-unit counts as byte counts by removing the misleading count ([#8979](https://github.com/earendil-works/pi/issues/8979)).
|
||||
- Fixed proxied plain-HTTP provider requests hanging after a tool call by tunneling them with CONNECT ([#8134](https://github.com/earendil-works/pi/issues/8134)).
|
||||
- Fixed RPC `abort` reporting success without cancelling an in-progress manual compaction ([#8920](https://github.com/earendil-works/pi/issues/8920)).
|
||||
|
||||
## [0.84.4] - 2026-08-28
|
||||
|
||||
@@ -18,6 +20,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added `supportsMidConvoEffort` to custom Anthropic Messages model compatibility settings.
|
||||
- Added transcript notices for Anthropic thinking blocks dropped during provider recovery when cache miss notices are enabled.
|
||||
- Added `ui_prompt_start` and `ui_prompt_end` extension events so host integrations can distinguish active agent work from waiting on user-facing `ctx.ui` prompts ([#8355](https://github.com/earendil-works/pi/pull/8355) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||
- Added `detectSupportedImageMimeTypeFromFile()` to the public library exports ([#8600](https://github.com/earendil-works/pi/pull/8600) by [@xl0](https://github.com/xl0)).
|
||||
- Added inherited experimental vision-capable `deepseek-v4-flash-vision-exp` model support.
|
||||
|
||||
@@ -1703,7 +1703,7 @@ Typical `sourceInfo.source` values:
|
||||
|
||||
### pi.setModel(model)
|
||||
|
||||
Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models.
|
||||
Set the model for the current session. The change is recorded in session history and restored when that session is resumed, but it does not change the configured `defaultProvider` or `defaultModel` used by new sessions. Returns `false` if authentication is not configured for the model's provider. See [models.md](models.md) for configuring custom models.
|
||||
|
||||
```typescript
|
||||
const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
|
||||
@@ -1717,7 +1717,9 @@ if (model) {
|
||||
|
||||
### pi.getThinkingLevel() / pi.setThinkingLevel(level)
|
||||
|
||||
Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). Changes emit `thinking_level_select`.
|
||||
Get the current thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). Changes emit `thinking_level_select`.
|
||||
|
||||
`pi.setThinkingLevel()` changes the thinking level for the current session. The change is recorded in session history and restored when that session is resumed, but it does not change the configured default used by new sessions.
|
||||
|
||||
```typescript
|
||||
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
|
||||
|
||||
@@ -394,6 +394,8 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro
|
||||
|
||||
Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`.
|
||||
|
||||
Claude models with per-turn effort support use `supportsMidConvoEffort`. Pi then persists each response's provider effort, reconstructs effort-only system messages on later requests, and sends thinking binding controls with `prefix_mismatch_behavior: "drop_block"` to avoid stale signed-thinking prefixes causing persistent 400 responses. Set this only for the exact supported Claude model on a faithful Anthropic Messages transport; do not enable it for APIs that merely imitate the Messages shape.
|
||||
|
||||
Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures.
|
||||
|
||||
Built-in Anthropic models enable `supportsStrictTools` in their model metadata. Custom Anthropic-compatible models must set it to `true` when their endpoint accepts strict JSON-schema tool definitions.
|
||||
@@ -430,6 +432,7 @@ Built-in Anthropic models enable `supportsStrictTools` in their model metadata.
|
||||
| `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. |
|
||||
| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. |
|
||||
| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. |
|
||||
| `supportsMidConvoEffort` | Whether the exact Claude model transport supports per-turn effort system messages and thinking binding controls. Pi persists native effort levels and always sends `drop_block` when enabled. Default: `false`. |
|
||||
| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. |
|
||||
| `supportsStrictTools` | Whether the provider accepts strict JSON-schema tool definitions. Default: `false`; built-in Anthropic models enable it in generated metadata. |
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ See [set_follow_up_mode](#set_follow_up_mode) for controlling how follow-up mess
|
||||
|
||||
#### abort
|
||||
|
||||
Abort the current agent operation.
|
||||
Abort the current operation and wait for the session to become idle before responding.
|
||||
|
||||
```json
|
||||
{"type": "abort"}
|
||||
|
||||
@@ -32,7 +32,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
|
||||
| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
|
||||
| `modelThinkingLevels` | object | - | Per-model startup thinking levels keyed by `"provider/modelId"`; configure from `/settings` → Default thinking level per model or edit manually |
|
||||
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
|
||||
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage |
|
||||
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses, compaction or branch-summary usage, and provider recovery diagnostics such as dropped Anthropic thinking blocks |
|
||||
| `thinkingBudgets` | object | - | Custom token budgets per thinking level. Anthropic, Google, and Bedrock use these natively. OpenAI-compatible models use them when `compat.thinkingTokenBudgetField` (or `supportsThinkingTokenBudget`) is set. |
|
||||
|
||||
#### thinkingBudgets
|
||||
|
||||
+28
-5
@@ -15,12 +15,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
|
||||
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz",
|
||||
"integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
@@ -482,7 +483,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.4.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@anthropic-ai/sdk": "0.123.0",
|
||||
"@earendil-works/chord": "^0.84.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@earendil-works/pi-telemetry": "^0.84.4",
|
||||
@@ -1396,6 +1397,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
@@ -1593,6 +1600,12 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
@@ -2124,6 +2137,16 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||
|
||||
+28
-5
@@ -42,12 +42,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
|
||||
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz",
|
||||
"integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
@@ -509,7 +510,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.4.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@anthropic-ai/sdk": "0.123.0",
|
||||
"@earendil-works/chord": "^0.84.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@earendil-works/pi-telemetry": "^0.84.4",
|
||||
@@ -1386,6 +1387,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
@@ -1583,6 +1590,12 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
@@ -2114,6 +2127,16 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||
|
||||
@@ -182,7 +182,6 @@ export type AgentSessionEvent =
|
||||
reason: "manual" | "threshold" | "overflow";
|
||||
}
|
||||
| { type: "summarization_retry_finished" }
|
||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||||
| { type: "bash_execution_update"; id?: string; delta: string };
|
||||
|
||||
/** Listener function for agent session events */
|
||||
@@ -618,7 +617,7 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
private _resolveIdleWaitIfIdle(): void {
|
||||
if (this._isAgentRunActive || !this._resolveIdleWait) {
|
||||
if (!this.isIdle || !this._resolveIdleWait) {
|
||||
return;
|
||||
}
|
||||
const resolve = this._resolveIdleWait;
|
||||
@@ -922,9 +921,9 @@ export class AgentSession {
|
||||
return this._isAgentRunActive;
|
||||
}
|
||||
|
||||
/** Whether the session has no active agent run, retry, auto-compaction, or queued continuation. */
|
||||
/** Whether the session has no active agent run, compaction, branch summary, retry, or queued continuation. */
|
||||
get isIdle(): boolean {
|
||||
return !this._isAgentRunActive;
|
||||
return !this._isAgentRunActive && !this.isCompacting;
|
||||
}
|
||||
|
||||
/** Current effective system prompt (includes any per-turn extension modifications) */
|
||||
@@ -1619,6 +1618,8 @@ export class AgentSession {
|
||||
*/
|
||||
async abort(): Promise<void> {
|
||||
this.abortRetry();
|
||||
this.abortCompaction();
|
||||
this.abortBranchSummary();
|
||||
this.agent.abort();
|
||||
await this.waitForIdle();
|
||||
}
|
||||
@@ -1922,6 +1923,11 @@ export class AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private _clearManualCompactionState(): void {
|
||||
this._compactionAbortController = undefined;
|
||||
this._resolveIdleWaitIfIdle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually compact the session context.
|
||||
*
|
||||
@@ -2052,7 +2058,7 @@ export class AgentSession {
|
||||
details,
|
||||
};
|
||||
// compaction_end listeners may submit queued prompts, so expose idle state before notifying them.
|
||||
this._compactionAbortController = undefined;
|
||||
this._clearManualCompactionState();
|
||||
this._emit({
|
||||
type: "compaction_end",
|
||||
reason: "manual",
|
||||
@@ -2065,7 +2071,7 @@ export class AgentSession {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
|
||||
const errorMessage = aborted ? undefined : `Compaction failed: ${message}`;
|
||||
this._compactionAbortController = undefined;
|
||||
this._clearManualCompactionState();
|
||||
this._emit({
|
||||
type: "compaction_end",
|
||||
reason: "manual",
|
||||
@@ -2083,7 +2089,7 @@ export class AgentSession {
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
this._compactionAbortController = undefined;
|
||||
this._clearManualCompactionState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2420,6 +2426,7 @@ export class AgentSession {
|
||||
return false;
|
||||
} finally {
|
||||
this._autoCompactionAbortController = undefined;
|
||||
this._resolveIdleWaitIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3292,6 +3299,7 @@ export class AgentSession {
|
||||
return { editorText, cancelled: false, summaryEntry };
|
||||
} finally {
|
||||
this._branchSummaryAbortController = undefined;
|
||||
this._resolveIdleWaitIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1412,13 +1412,19 @@ export interface ExtensionAPI {
|
||||
// Model and Thinking Level
|
||||
// =========================================================================
|
||||
|
||||
/** Set the current model. Returns false if no API key available. */
|
||||
/**
|
||||
* Set the model for the current session without changing the configured default for new sessions.
|
||||
* Returns false if authentication is not configured for the model's provider.
|
||||
*/
|
||||
setModel(model: Model<any>): Promise<boolean>;
|
||||
|
||||
/** Get current thinking level. */
|
||||
getThinkingLevel(): ThinkingLevel;
|
||||
|
||||
/** Set thinking level (clamped to model capabilities). */
|
||||
/**
|
||||
* Set the thinking level (clamped to model capabilities) for the current session without changing the configured default
|
||||
* for new sessions.
|
||||
*/
|
||||
setThinkingLevel(level: ThinkingLevel): void;
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -109,6 +109,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
||||
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
|
||||
),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
vllmPriority: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
const OpenAIResponsesCompatSchema = Type.Object({
|
||||
@@ -133,6 +134,7 @@ const AnthropicMessagesCompatSchema = Type.Object({
|
||||
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
|
||||
allowEmptySignature: Type.Optional(Type.Boolean()),
|
||||
supportsStrictTools: Type.Optional(Type.Boolean()),
|
||||
supportsMidConvoEffort: Type.Optional(Type.Boolean()),
|
||||
supportsToolReferences: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
|
||||
@@ -165,6 +165,15 @@ function modelFromJson(
|
||||
};
|
||||
}
|
||||
|
||||
function findModelDefaults(models: readonly Model<Api>[], modelId: string, api?: Api): Model<Api> | undefined {
|
||||
return (
|
||||
models.find((model) => model.id === modelId) ??
|
||||
(api ? models.find((model) => model.api === api) : undefined) ??
|
||||
models.find((model) => model.api === "openai-completions") ??
|
||||
models[0]
|
||||
);
|
||||
}
|
||||
|
||||
function applyModelsJson(
|
||||
providerId: string,
|
||||
baseModels: readonly Model<Api>[],
|
||||
@@ -197,7 +206,7 @@ function applyModelsJson(
|
||||
}));
|
||||
for (const definition of config.models ?? []) {
|
||||
const existingIndex = models.findIndex((model) => model.id === definition.id);
|
||||
const defaults = existingIndex >= 0 ? models[existingIndex] : models[0];
|
||||
const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api);
|
||||
const model = modelFromJson(providerId, definition, config, defaults);
|
||||
if (existingIndex >= 0) models[existingIndex] = model;
|
||||
else models.push(model);
|
||||
@@ -215,7 +224,7 @@ function applyExtension(
|
||||
return config.baseUrl ? models.map((model) => ({ ...model, baseUrl: config.baseUrl! })) : [...models];
|
||||
}
|
||||
return config.models.map((definition) => {
|
||||
const defaults = models.find((model) => model.id === definition.id) ?? models[0];
|
||||
const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api);
|
||||
const api = definition.api ?? config.api ?? defaults?.api;
|
||||
if (!api) {
|
||||
throw new Error(
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface Settings {
|
||||
branchSummary?: BranchSummarySettings;
|
||||
retry?: RetrySettings;
|
||||
hideThinkingBlock?: boolean;
|
||||
showCacheMissNotices?: boolean; // default: false - show prompt-cache miss and compaction cost notices
|
||||
showCacheMissNotices?: boolean; // default: false - show cache cost and provider recovery notices
|
||||
externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion
|
||||
quietStartup?: boolean;
|
||||
|
||||
@@ -84,7 +84,7 @@ export function createWriteToolDefinition(
|
||||
throwIfAborted();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
content: [{ type: "text", text: `Successfully wrote to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ export class SettingsSelectorComponent extends Container {
|
||||
{
|
||||
id: "cache-miss-notices",
|
||||
label: "Cache miss notices",
|
||||
description: "Show transcript notices for significant prompt-cache misses and compaction costs",
|
||||
description: "Show transcript notices for cache costs and provider recovery diagnostics",
|
||||
currentValue: config.showCacheMissNotices ? "true" : "false",
|
||||
values: ["true", "false"],
|
||||
},
|
||||
|
||||
@@ -3257,6 +3257,7 @@ export class InteractiveMode {
|
||||
for (const [, component] of this.pendingTools.entries()) {
|
||||
component.setArgsComplete();
|
||||
}
|
||||
this.maybeShowAssistantDiagnostics(this.streamingMessage);
|
||||
this.maybeShowCacheMissNotice(this.streamingMessage);
|
||||
}
|
||||
this.streamingComponent = undefined;
|
||||
@@ -3699,6 +3700,7 @@ export class InteractiveMode {
|
||||
}
|
||||
}
|
||||
if (message.stopReason !== "aborted" && message.stopReason !== "error") {
|
||||
this.maybeShowAssistantDiagnostics(message);
|
||||
const miss = cacheMisses.get(message);
|
||||
if (miss) this.addCacheMissNotice(miss);
|
||||
}
|
||||
@@ -3761,6 +3763,32 @@ export class InteractiveMode {
|
||||
);
|
||||
}
|
||||
|
||||
private maybeShowAssistantDiagnostics(message: AssistantMessage): void {
|
||||
if (!this.settingsManager.getShowCacheMissNotices()) return;
|
||||
|
||||
for (const diagnostic of message.diagnostics ?? []) {
|
||||
if (diagnostic.type !== "anthropic_input_transformations") continue;
|
||||
const transformations = diagnostic.details?.transformations;
|
||||
if (!Array.isArray(transformations)) continue;
|
||||
|
||||
const dropped = transformations.flatMap((transformation): string[] => {
|
||||
if (typeof transformation !== "object" || transformation === null) return [];
|
||||
const details = transformation as Record<string, unknown>;
|
||||
if (details.type !== "thinking_dropped") return [];
|
||||
const reason = typeof details.reason === "string" ? details.reason : "unknown reason";
|
||||
const location = typeof details.path === "string" ? ` at ${details.path}` : "";
|
||||
return [`${reason}${location}`];
|
||||
});
|
||||
if (dropped.length === 0) continue;
|
||||
|
||||
const noun = dropped.length === 1 ? "thinking block" : `${dropped.length} thinking blocks`;
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("warning", `Anthropic dropped ${noun}: ${dropped.join("; ")}`), 1, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a transcript notice when a completed assistant message paid for a
|
||||
* significant cache miss. Only states observable facts: the miss itself,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { Container } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "survived" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5-1",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
diagnostics: [
|
||||
{
|
||||
type: "anthropic_input_transformations",
|
||||
timestamp: 1,
|
||||
details: {
|
||||
transformations: [
|
||||
{
|
||||
type: "thinking_dropped",
|
||||
path: "messages.2.content.0",
|
||||
reason: "prefix_binding_mismatch",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("InteractiveMode assistant diagnostics", () => {
|
||||
test("shows Anthropic thinking drops when cache miss notices are enabled", () => {
|
||||
const maybeShowAssistantDiagnostics = Reflect.get(InteractiveMode.prototype, "maybeShowAssistantDiagnostics") as (
|
||||
this: {
|
||||
chatContainer: Container;
|
||||
settingsManager: { getShowCacheMissNotices(): boolean };
|
||||
},
|
||||
message: AssistantMessage,
|
||||
) => void;
|
||||
|
||||
initTheme("dark");
|
||||
const enabled = {
|
||||
chatContainer: new Container(),
|
||||
settingsManager: { getShowCacheMissNotices: () => true },
|
||||
};
|
||||
maybeShowAssistantDiagnostics.call(enabled, message);
|
||||
const output = stripAnsi(enabled.chatContainer.render(120).join("\n"));
|
||||
expect(output).toContain("Anthropic dropped thinking block: prefix_binding_mismatch at messages.2.content.0");
|
||||
|
||||
const disabled = {
|
||||
chatContainer: new Container(),
|
||||
settingsManager: { getShowCacheMissNotices: () => false },
|
||||
};
|
||||
maybeShowAssistantDiagnostics.call(disabled, message);
|
||||
expect(disabled.chatContainer.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -156,9 +156,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs:
|
||||
runtimeHost,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
if (session.isStreaming) {
|
||||
await session.abort();
|
||||
}
|
||||
await session.abort();
|
||||
} catch {
|
||||
// ignore test cleanup failures
|
||||
}
|
||||
|
||||
@@ -95,6 +95,31 @@ function seedCompactableSession(harness: Harness): void {
|
||||
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
|
||||
async function createAbortableCompactionHarness(): Promise<{
|
||||
harness: Harness;
|
||||
compactionStarted: Promise<void>;
|
||||
}> {
|
||||
let markCompactionStarted = () => {};
|
||||
const compactionStarted = new Promise<void>((resolve) => {
|
||||
markCompactionStarted = resolve;
|
||||
});
|
||||
const harness = await createHarness({
|
||||
settings: { compaction: { keepRecentTokens: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
return await new Promise<{ cancel: true }>((resolve) => {
|
||||
event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true });
|
||||
markCompactionStarted();
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
seedCompactableSession(harness);
|
||||
return { harness, compactionStarted };
|
||||
}
|
||||
|
||||
describe("AgentSession compaction characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
@@ -637,28 +662,38 @@ describe("AgentSession compaction characterization", () => {
|
||||
});
|
||||
|
||||
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
|
||||
const harness = await createHarness({
|
||||
settings: { compaction: { keepRecentTokens: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
return await new Promise<{ cancel: true }>((resolve) => {
|
||||
event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true });
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
const { harness, compactionStarted } = await createAbortableCompactionHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.prompt("one");
|
||||
await harness.session.prompt("two");
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const compactExpectation = expect(compactPromise).rejects.toThrow("Compaction cancelled");
|
||||
await compactionStarted;
|
||||
harness.session.abortCompaction();
|
||||
|
||||
await expect(compactPromise).rejects.toThrow("Compaction cancelled");
|
||||
await compactExpectation;
|
||||
});
|
||||
|
||||
// Regression test for #8920.
|
||||
it("aborts an in-progress manual compaction and waits until the session is idle", async () => {
|
||||
const { harness, compactionStarted } = await createAbortableCompactionHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("continued")]);
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
const compactExpectation = expect(compactPromise).rejects.toThrow("Compaction cancelled");
|
||||
await compactionStarted;
|
||||
await harness.session.abort();
|
||||
await compactExpectation;
|
||||
|
||||
expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({
|
||||
reason: "manual",
|
||||
aborted: true,
|
||||
});
|
||||
expect(harness.session.isCompacting).toBe(false);
|
||||
expect(harness.session.isIdle).toBe(true);
|
||||
|
||||
await expect(harness.session.prompt("next prompt")).resolves.toBeUndefined();
|
||||
expect(harness.session.getLastAssistantText()).toBe("continued");
|
||||
});
|
||||
|
||||
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
|
||||
|
||||
@@ -256,8 +256,7 @@ describe("Coding Agent Tools", () => {
|
||||
|
||||
const result = await writeTool.execute("test-call-3", { path: testFile, content });
|
||||
|
||||
expect(getTextOutput(result)).toContain("Successfully wrote");
|
||||
expect(getTextOutput(result)).toContain(testFile);
|
||||
expect(getTextOutput(result)).toBe(`Successfully wrote to ${testFile}`);
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user