From 4e69b0c28060f0f02fbe38bfa7c21a2e2eb25057 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 2 Sep 2026 21:39:29 +0200 Subject: [PATCH] feat(ai): preserve Anthropic per-turn thinking effort Route eligible OpenRouter Claude models through the native Messages API and surface provider thinking-drop diagnostics. --- package-lock.json | 33 ++- packages/agent/CHANGELOG.md | 1 + packages/agent/src/proxy.ts | 8 + packages/agent/test/proxy.test.ts | 5 +- packages/ai/CHANGELOG.md | 4 + packages/ai/package.json | 2 +- packages/ai/scripts/generate-models.ts | 30 ++- packages/ai/src/api/anthropic-messages.ts | 228 +++++++++++++----- packages/ai/src/api/pi-messages.ts | 8 + packages/ai/src/providers/openrouter.ts | 8 +- packages/ai/src/types.ts | 4 + packages/ai/test/anthropic-auth-token.test.ts | 39 ++- .../anthropic-cache-write-1h-cost.test.ts | 2 +- .../anthropic-mid-conversation-effort.test.ts | 212 ++++++++++++++++ .../ai/test/anthropic-sse-parsing.test.ts | 169 ++++++++++++- .../anthropic-thinking-binding-e2e.test.ts | 59 +++++ .../ai/test/github-copilot-anthropic.test.ts | 22 +- ...i-completions-cache-control-format.test.ts | 6 +- .../openai-completions-tool-choice.test.ts | 4 +- .../openrouter-cache-control-models.test.ts | 9 +- packages/ai/test/pi-messages.test.ts | 9 +- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/docs/models.md | 3 + packages/coding-agent/docs/settings.md | 2 +- .../install-lock/package-lock.json | 33 ++- packages/coding-agent/npm-shrinkwrap.json | 33 ++- .../coding-agent/src/core/model-config.ts | 1 + .../src/core/provider-composer.ts | 13 +- .../coding-agent/src/core/settings-manager.ts | 2 +- .../components/settings-selector.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 28 +++ ...ractive-mode-assistant-diagnostics.test.ts | 67 +++++ packages/server/src/protocol.ts | 1 + 33 files changed, 919 insertions(+), 130 deletions(-) create mode 100644 packages/ai/test/anthropic-mid-conversation-effort.test.ts create mode 100644 packages/ai/test/anthropic-thinking-binding-e2e.test.ts create mode 100644 packages/coding-agent/test/interactive-mode-assistant-diagnostics.test.ts diff --git a/package-lock.json b/package-lock.json index cae7da671..80b633fc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" @@ -2026,6 +2027,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", @@ -2993,6 +3000,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", @@ -4725,6 +4738,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", @@ -5361,7 +5384,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/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 9a9cc8cd4..9c7150702 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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 diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5331b095a..26192e665 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -48,12 +48,14 @@ export type ProxyAssistantMessageEvent = type: "done"; reason: Extract; usage: AssistantMessage["usage"]; + providerThinkingLevel?: string; } | { type: "error"; reason: Extract; errorMessage?: string; usage: AssistantMessage["usage"]; + providerThinkingLevel?: string; }; type ProxySerializableStreamOptions = Pick< @@ -377,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: { diff --git a/packages/agent/test/proxy.test.ts b/packages/agent/test/proxy.test.ts index bddf64902..f2ef98258 100644 --- a/packages/agent/test/proxy.test.ts +++ b/packages/agent/test/proxy.test.ts @@ -78,9 +78,9 @@ describe("streamProxy", () => { }); // Regression tests for https://github.com/earendil-works/pi/issues/8996 - it("processes a terminal event that is not newline-terminated", async () => { + 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 })}`; + const done = `data: ${JSON.stringify({ type: "done", reason: "stop", usage, providerThinkingLevel: "high" })}`; vi.stubGlobal( "fetch", vi.fn(async () => new Response(start + done, { status: 200 })), @@ -100,6 +100,7 @@ describe("streamProxy", () => { 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 () => { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 58f25936b..ff66ffbeb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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)). diff --git a/packages/ai/package.json b/packages/ai/package.json index b6e7336e4..ac7fa2093 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -60,7 +60,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@anthropic-ai/sdk": "0.91.1", + "@anthropic-ai/sdk": "0.123.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 613fa4944..b4a716397 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -559,6 +559,16 @@ function isGoogleThinkingApi(model: Model): 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): 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): 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[]> { 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 = { 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 }), diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 06441fa7b..de3a51cf4 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -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> { +): Required< + Omit +> { 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(); @@ -877,28 +901,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, 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, @@ -910,7 +918,6 @@ function createClient( { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", - ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), }, model.headers, dynamicHeaders, @@ -933,7 +940,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", }, @@ -952,7 +958,6 @@ function createClient( { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", - ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), }, sessionAffinityHeaders, model.headers, @@ -970,12 +975,54 @@ function createClient( return { client, isOAuthToken: false }; } +function getBetaFeatures( + model: Model<"anthropic-messages">, + context: Context, + isOAuthToken: boolean, + options?: AnthropicOptions, +): NonNullable { + 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 = []; + 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); @@ -992,18 +1039,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 @@ -1034,7 +1089,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; } @@ -1058,8 +1118,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"). @@ -1068,13 +1136,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 @@ -1152,6 +1214,11 @@ function convertToolResult( }; } +interface ConvertedAnthropicMessages { + messages: MessageParam[]; + assistantLevels: Map; +} + function convertMessages( transformedMessages: Message[], isOAuthToken: boolean, @@ -1159,8 +1226,10 @@ function convertMessages( allowEmptySignature = false, deferredToolNames: ReadonlySet = new Set(), normalizeToolName: (name: string) => string = (name) => name, -): MessageParam[] { + managedProvider?: string, +): ConvertedAnthropicMessages { const params: MessageParam[] = []; + const assistantLevels = new Map(); const loadedToolNames = new Set(); for (let i = 0; i < transformedMessages.length; i++) { @@ -1259,10 +1328,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[] = []; @@ -1316,7 +1394,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 { @@ -1330,7 +1428,7 @@ function convertTools( supportsStrictTools: boolean, cacheControl?: CacheControlEphemeral, deferLoading = false, -): Anthropic.Messages.Tool[] { +): BetaTool[] { if (!tools) return []; return tools.map((tool, index) => { @@ -1363,7 +1461,7 @@ function convertTools( } function mapStopReason( - reason: Anthropic.Messages.StopReason | string, + reason: BetaStopReason | string, stopDetails?: RefusalStopDetails | null, ): { stopReason: StopReason; errorMessage?: string } { switch (reason) { diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 6bedc6674..253c2a5c3 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -71,6 +71,7 @@ export type PiMessagesEvent = reason: Extract; 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": diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 2a6db8d03..382a5f1a4 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -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(), + }, }); } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index eb0dc091f..a2724d20b 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -432,6 +432,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; @@ -701,6 +703,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 diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index 2bad50181..0fa3d376d 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -39,12 +39,14 @@ vi.mock("@anthropic-ai/sdk", () => { constructor(opts: Record) { mockState.constructorOpts = opts; } - messages = { - create: (params: Record) => { - mockState.createParams = params; - return { - asResponse: async () => createSseResponse(), - }; + beta = { + messages: { + create: (params: Record) => { + 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; 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; 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; - 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; 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(); + }); }); diff --git a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts index 13745e9b9..967a58b18 100644 --- a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts +++ b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts @@ -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; } diff --git a/packages/ai/test/anthropic-mid-conversation-effort.test.ts b/packages/ai/test/anthropic-mid-conversation-effort.test.ts new file mode 100644 index 000000000..10461f859 --- /dev/null +++ b/packages/ai/test/anthropic-mid-conversation-effort.test.ts @@ -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(); + }); +}); diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 4f111a61d..9982ae82e 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -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), 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; + 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 = { diff --git a/packages/ai/test/anthropic-thinking-binding-e2e.test.ts b/packages/ai/test/anthropic-thinking-binding-e2e.test.ts new file mode 100644 index 000000000..07c988577 --- /dev/null +++ b/packages/ai/test/anthropic-thinking-binding-e2e.test.ts @@ -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 { + 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`"); + }); +}); diff --git a/packages/ai/test/github-copilot-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts index 32e2d0af5..e2894037f 100644 --- a/packages/ai/test/github-copilot-anthropic.test.ts +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -36,12 +36,14 @@ vi.mock("@anthropic-ai/sdk", () => { constructor(opts: Record) { mockState.constructorOpts = opts; } - messages = { - create: (params: Record) => { - mockState.createParams = params; - return { - asResponse: async () => createSseResponse(), - }; + beta = { + messages: { + create: (params: Record) => { + 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; - expect(headers["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); + expect(mockState.createParams?.betas ?? []).not.toContain("interleaved-thinking-2025-05-14"); }); }); diff --git a/packages/ai/test/openai-completions-cache-control-format.test.ts b/packages/ai/test/openai-completions-cache-control-format.test.ts index b9f5e69dd..4a26f055c 100644 --- a/packages/ai/test/openai-completions-cache-control-format.test.ts +++ b/packages/ai/test/openai-completions-cache-control-format.test.ts @@ -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 }, diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c8e03b6df..e3fdf7b21 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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; diff --git a/packages/ai/test/openrouter-cache-control-models.test.ts b/packages/ai/test/openrouter-cache-control-models.test.ts index 2c210444f..f8db1794f 100644 --- a/packages/ai/test/openrouter-cache-control-models.test.ts +++ b/packages/ai/test/openrouter-cache-control-models.test.ts @@ -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"); }); }); diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts index 62f7fae43..041929ada 100644 --- a/packages/ai/test/pi-messages.test.ts +++ b/packages/ai/test/pi-messages.test.ts @@ -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([ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5051bd151..1e84bd1e2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,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. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 76a5bf291..c8ad2042d 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -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. | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 649f031a5..19cece87d 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 4c48ca4c7..45ea10fad 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -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" @@ -470,7 +471,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", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", @@ -964,6 +965,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", @@ -1120,6 +1127,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", @@ -1651,6 +1664,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", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index cb2f59077..04e68d6b7 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -41,12 +41,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" @@ -496,7 +497,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", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", @@ -954,6 +955,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", @@ -1110,6 +1117,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", @@ -1641,6 +1654,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", diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 042e89652..bf5a8467c 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -134,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()), }); diff --git a/packages/coding-agent/src/core/provider-composer.ts b/packages/coding-agent/src/core/provider-composer.ts index 1be7fcdb5..a36c9ba41 100644 --- a/packages/coding-agent/src/core/provider-composer.ts +++ b/packages/coding-agent/src/core/provider-composer.ts @@ -165,6 +165,15 @@ function modelFromJson( }; } +function findModelDefaults(models: readonly Model[], modelId: string, api?: Api): Model | 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[], @@ -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( diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 3d765ca97..36130faca 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index cd0e8e869..3e8363d91 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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"], }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index aee28d448..f6c1a916e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3321,6 +3321,7 @@ export class InteractiveMode { for (const [, component] of this.pendingTools.entries()) { component.setArgsComplete(); } + this.maybeShowAssistantDiagnostics(this.streamingMessage); this.maybeShowCacheMissNotice(this.streamingMessage); } this.streamingComponent = undefined; @@ -3763,6 +3764,7 @@ export class InteractiveMode { } } if (message.stopReason !== "aborted" && message.stopReason !== "error") { + this.maybeShowAssistantDiagnostics(message); const miss = cacheMisses.get(message); if (miss) this.addCacheMissNotice(miss); } @@ -3825,6 +3827,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; + 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, diff --git a/packages/coding-agent/test/interactive-mode-assistant-diagnostics.test.ts b/packages/coding-agent/test/interactive-mode-assistant-diagnostics.test.ts new file mode 100644 index 000000000..30a13efa4 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-assistant-diagnostics.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 069828e59..c990b50f1 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -88,6 +88,7 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "model" | "responseModel" | "responseId" + | "providerThinkingLevel" | "diagnostics" | "usage" | "stopReason"