Skip to content

Commit 4bf976e

Browse files
committed
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
1 parent c99a0a2 commit 4bf976e

2 files changed

Lines changed: 25 additions & 2 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ function normalizeMessages(
8181
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
8282
}
8383

84+
// Strip reasoning parts that have no valid provider signature — they cannot be sent
85+
// as thinking blocks to Anthropic without a signature and would cause a 400 error.
86+
// This is a defence-in-depth guard; the primary prevention is in message-v2.ts where
87+
// reasoning parts from a different model are skipped before reaching this point.
88+
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
89+
msgs = msgs
90+
.map((msg) => {
91+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg
92+
const filtered = msg.content.filter((part) => {
93+
if ((part as any).type !== "reasoning") return true
94+
const opts = (part as any).providerOptions?.anthropic
95+
return opts?.signature != null || opts?.redactedData != null
96+
})
97+
if (filtered.length === 0) return undefined
98+
return { ...msg, content: filtered }
99+
})
100+
.filter((msg): msg is ModelMessage => msg !== undefined)
101+
}
102+
84103
if (model.api.id.includes("claude")) {
85104
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
86105
msgs = msgs.map((msg) => {

packages/opencode/src/session/message-v2.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -786,11 +786,15 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
786786
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
787787
})
788788
}
789-
if (part.type === "reasoning") {
789+
// Reasoning parts carry a provider-specific signature that is cryptographically
790+
// bound to the model that generated them. They cannot be replayed to a different
791+
// model — doing so causes Anthropic to return "thinking.signature: Field required".
792+
// Skip them entirely when the historical message came from a different model.
793+
if (part.type === "reasoning" && !differentModel) {
790794
assistantMessage.parts.push({
791795
type: "reasoning",
792796
text: part.text,
793-
...(differentModel ? {} : { providerMetadata: part.metadata }),
797+
providerMetadata: part.metadata,
794798
})
795799
}
796800
}

0 commit comments

Comments
 (0)