Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <think>…</think> 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(/<think>[\s\S]*?<\/think>\s*/g, "").trimStart()
}
{
const end = Date.now()
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
Expand Down
Loading