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 }