From 48ac948493bf6b35de93bf873c205a7492ac4c06 Mon Sep 17 00:00:00 2001 From: kunalshaw79 Date: Thu, 30 Apr 2026 01:12:32 +0530 Subject: [PATCH] fix: preserve Anthropic thinking block signatures when switching Claude models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: Switching from one Claude model to another (e.g. Sonnet → Opus, or any two distinct claude-* models) caused the session to drop the providerMetadata from reasoning/thinking parts. The Anthropic API requires the 'signature' field on every thinking block echoed back in subsequent turns; without it the request is rejected with: messages.N.content.0.thinking.signature: Field required Root cause (message-v2.ts line 840): const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` This is true whenever the model ID changes. Line 963 then drops providerMetadata for reasoning blocks when differentModel is true: ...(differentModel ? {} : { providerMetadata: part.metadata }) The signature is a cryptographic token that Anthropic issues and expects back — it is valid across all Claude models and must be preserved even when switching between them. Fix 1 (message-v2.ts): Introduce keepReasoningMetadata alongside differentModel. It is true when both the previous and current model are Anthropic-family (providerID is 'anthropic', 'amazon-bedrock', or modelID contains 'claude'). Use keepReasoningMetadata for reasoning parts instead of !differentModel so the signature survives intra-Anthropic model switches. Fix 2 (transform.ts — belt and suspenders): In normalizeMessages for @ai-sdk/anthropic and @ai-sdk/amazon-bedrock, filter out any reasoning part whose providerMetadata.anthropic.signature is missing or empty. This handles sessions created before the signature was stored (pre-fix history) and any edge case where a reasoning block arrives without a valid signature. A block without a signature cannot be echoed back safely; dropping it is better than crashing the session. Reproduction: 1. Start a session with a Claude model that has reasoning (e.g. Opus). 2. Switch the session to a different Claude model. 3. Send a new message. Before fix: 'thinking.signature: Field required'. After fix: conversation continues normally. --- packages/opencode/src/provider/transform.ts | 27 +++++++++++++++++++++ packages/opencode/src/session/message-v2.ts | 25 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index a8f2fcf30857..e9f2a6ba0536 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -93,6 +93,33 @@ function normalizeMessages( .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } + // For Anthropic-family providers (both direct and via Bedrock), strip any + // reasoning/thinking parts whose providerMetadata is missing the signature. + // The Anthropic API requires the signature field on every thinking block in + // subsequent turns. A missing signature means the block was produced by a + // different provider, the metadata was lost, or the session predates + // signature capture — in all cases the safest action is to drop the block. + // A reasoning block without a signature cannot be echoed back and causes: + // "messages.N.content.0.thinking.signature: Field required" + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if (part.type !== "reasoning") return true + // Keep the block only if providerMetadata contains a non-empty signature. + const sig = (part as any).providerMetadata?.anthropic?.signature + if (sig && typeof sig === "string" && sig.length > 0) return true + // No valid signature — drop the block to prevent API rejection. + return false + }) + // If we dropped all content blocks, replace with an empty text block + // so the message remains structurally valid (empty array → API error). + if (filtered.length === 0 && Array.isArray(msg.content) && msg.content.length > 0) + return { ...msg, content: [{ type: "text" as const, text: "" }] } + return { ...msg, content: filtered } + }) + } + if (model.api.id.includes("claude")) { const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") msgs = msgs.map((msg) => { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 911f58efd0b9..3c6ca0ee972e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -838,6 +838,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` + + // When switching between Anthropic-family models (e.g. Sonnet → Opus, + // or amazon-bedrock/claude-* → anthropic/claude-*), reasoning blocks + // MUST still carry their providerMetadata so the signature field is + // echoed back to the API. Without the signature Anthropic rejects the + // request with: + // "messages.N.content.0.thinking.signature: Field required" + // + // The signature is a cryptographic token that is valid across all + // Claude models; it only needs to be dropped when switching to a + // non-Anthropic provider that doesn't understand the thinking format. + const isAnthropicFamily = (providerID: string, modelID: string) => + providerID === "anthropic" || + providerID === "amazon-bedrock" || + modelID.includes("claude") + const keepReasoningMetadata = + !differentModel || + (isAnthropicFamily(model.providerID, model.id) && + isAnthropicFamily(msg.info.providerID ?? "", msg.info.modelID ?? "")) const media: Array<{ mime: string; url: string }> = [] if ( @@ -941,7 +960,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + // Use keepReasoningMetadata (not differentModel) so the Anthropic + // signature is preserved when switching between Claude models. + // The signature is required by the API on every subsequent turn; + // dropping it causes "thinking.signature: Field required". + ...(keepReasoningMetadata ? { providerMetadata: part.metadata } : {}), }) } }