From 4bf976ed0d1da6008cfa5c6999a9fb84f9b8a678 Mon Sep 17 00:00:00 2001 From: Luke Garceau Date: Mon, 27 Apr 2026 15:07:47 -0400 Subject: [PATCH 1/3] fix: skip reasoning parts from different models to prevent Anthropic signature errors When switching models mid-conversation, Anthropic reasoning (thinking) blocks carry a cryptographic signature bound to the model that generated them. Replaying those parts to a different model causes a 400 error: 'thinking.signature: Field required'. - message-v2.ts: skip reasoning parts entirely when the historical message came from a different model (differentModel flag already computed upstream) - transform.ts: defence-in-depth guard that strips unsigned reasoning parts before they reach the Anthropic/Bedrock SDK, preventing the 400 even if message-v2 reconstruction is bypassed --- packages/opencode/src/provider/transform.ts | 19 +++++++++++++++++++ packages/opencode/src/session/message-v2.ts | 8 ++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d19159cdee75..917e27c03758 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -81,6 +81,25 @@ function normalizeMessages( .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } + // Strip reasoning parts that have no valid provider signature — they cannot be sent + // as thinking blocks to Anthropic without a signature and would cause a 400 error. + // This is a defence-in-depth guard; the primary prevention is in message-v2.ts where + // reasoning parts from a different model are skipped before reaching this point. + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { + msgs = msgs + .map((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if ((part as any).type !== "reasoning") return true + const opts = (part as any).providerOptions?.anthropic + return opts?.signature != null || opts?.redactedData != null + }) + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => msg !== undefined) + } + 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 20528763b8b1..b67d7e9beeb1 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -786,11 +786,15 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } - if (part.type === "reasoning") { + // Reasoning parts carry a provider-specific signature that is cryptographically + // bound to the model that generated them. They cannot be replayed to a different + // model — doing so causes Anthropic to return "thinking.signature: Field required". + // Skip them entirely when the historical message came from a different model. + if (part.type === "reasoning" && !differentModel) { assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } From 7dc363322f8bf7bfcbd1ebe2412ab1e7810b6ac8 Mon Sep 17 00:00:00 2001 From: Luke Garceau Date: Mon, 27 Apr 2026 15:10:51 -0400 Subject: [PATCH 2/3] fix: disable tools for deepseek-reasoner and strip tags from text parts DeepSeek R1 (deepseek-reasoner) does not honour the tools parameter on the standard api.deepseek.com endpoint despite models.dev reporting tool_call: true. When tools are sent, R1 ignores them and writes the invocation as markdown text, causing tool calls to appear as plain text instead of being executed. - llm.ts: disable canTool for deepseek-reasoner so tools are never sent to it, avoiding the markdown-text fallback entirely - processor.ts: strip ... blocks from text parts for reasoning models that emit chain-of-thought inline rather than as dedicated reasoning events, preventing raw thinking tokens from appearing in the UI --- packages/opencode/src/session/llm.ts | 10 +++++++++- packages/opencode/src/session/processor.ts | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 3e96e51593d0..4691e5ebbbb3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -193,7 +193,15 @@ const live: Layer.Layer< }, ) - const canTool = input.model.capabilities.toolcall + // DeepSeek R1 (deepseek-reasoner) does not honour the `tools` parameter on the + // standard api.deepseek.com endpoint despite models.dev reporting tool_call: true. + // When tools are sent, R1 ignores the definitions and writes the invocation as + // markdown text inside its response — exactly the wrong behaviour. Disable tools + // for it so the model falls back to conversational output instead. + const isDeepSeekR1 = + input.model.providerID === "deepseek" && + input.model.api.id.toLowerCase().includes("reasoner") + const canTool = input.model.capabilities.toolcall && !isDeepSeekR1 const tools = canTool ? resolveTools(input) : {} // LiteLLM and some Anthropic proxies require the tools parameter to be present diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 21f9329c6fce..a3cd0ff89ee5 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -442,6 +442,13 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + // Some reasoning models (e.g. DeepSeek R1) emit chain-of-thought inside + // tags in the text stream rather than as dedicated + // reasoning events. Strip those blocks so they don't appear as visible + // text in the UI — they are stored separately via reasoning-start/delta. + if (ctx.model.capabilities.reasoning) { + ctx.currentText.text = ctx.currentText.text.replace(/[\s\S]*?<\/think>\s*/g, "").trimStart() + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } From e0b3ee6fd21760556ce9b8e979d232063c40a12f Mon Sep 17 00:00:00 2001 From: Luke Garceau Date: Mon, 27 Apr 2026 15:21:27 -0400 Subject: [PATCH 3/3] fix: extend isDeepSeekR1 guard to cover Bedrock-hosted R1 via family field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard checked providerID === 'deepseek' and api.id containing 'reasoner', which only matched the direct DeepSeek API. Bedrock-hosted R1 has providerID 'amazon-bedrock' and api.id 'deepseek.r1-v1:0' — neither condition fired, so tools were still being sent to Bedrock R1. Broaden the guard to use the provider-agnostic family field ('deepseek-thinking') which is set for all DeepSeek reasoning variants regardless of hosting provider. Fall back to api.id substring matching ('deepseek.r1') as an additional safety net for cases where family may be absent. --- packages/opencode/src/session/llm.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4691e5ebbbb3..f88448e6a50d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -193,14 +193,19 @@ const live: Layer.Layer< }, ) - // DeepSeek R1 (deepseek-reasoner) does not honour the `tools` parameter on the - // standard api.deepseek.com endpoint despite models.dev reporting tool_call: true. - // When tools are sent, R1 ignores the definitions and writes the invocation as - // markdown text inside its response — exactly the wrong behaviour. Disable tools - // for it so the model falls back to conversational output instead. + // DeepSeek R1 does not honour the `tools` parameter regardless of which provider + // hosts it — neither the direct api.deepseek.com endpoint (model id: deepseek-reasoner, + // providerID: deepseek) nor the AWS Bedrock endpoint (model id: deepseek.r1-v1:0, + // providerID: amazon-bedrock) support function calling, even though models.dev + // incorrectly reports tool_call: true for both. When tools are sent, R1 ignores + // the definitions and writes the invocation as markdown text instead. + // Detect R1 via the provider-agnostic `family` field ("deepseek-thinking") which + // is set for all DeepSeek reasoning variants, or fall back to matching the known + // model IDs directly for cases where family is absent. const isDeepSeekR1 = - input.model.providerID === "deepseek" && - input.model.api.id.toLowerCase().includes("reasoner") + input.model.family === "deepseek-thinking" || + (input.model.providerID === "deepseek" && input.model.api.id.toLowerCase().includes("reasoner")) || + input.model.api.id.toLowerCase().includes("deepseek.r1") const canTool = input.model.capabilities.toolcall && !isDeepSeekR1 const tools = canTool ? resolveTools(input) : {}