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/llm.ts b/packages/opencode/src/session/llm.ts index 3e96e51593d0..f88448e6a50d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -193,7 +193,20 @@ const live: Layer.Layer< }, ) - const canTool = input.model.capabilities.toolcall + // 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.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) : {} // LiteLLM and some Anthropic proxies require the tools parameter to be present 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, }) } } 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 }