From a9c2b04fc292123e3a7cc1587a9618a04fcdefde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B2=92=E7=B2=92=E6=A9=99?= Date: Sun, 22 Feb 2026 02:48:11 +0800 Subject: [PATCH 1/2] fix: support interleaved reasoning_content for anthropic protocol non-Claude models Models like kimi-k2.5 on ZenMux use @ai-sdk/anthropic but expect reasoning_content on assistant messages. The SDK requires a signature for thinking blocks which non-Claude models lack. Inject a placeholder signature in transform so the SDK emits thinking blocks, then convert them to reasoning_content in the fetch wrapper at wire level. --- packages/opencode/src/provider/provider.ts | 23 +++++++++ packages/opencode/src/provider/transform.ts | 54 ++++++++++++--------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 022ec3167956..41017b542048 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1085,6 +1085,29 @@ export namespace Provider { opts.signal = combined } + // For anthropic protocol non-Claude models with interleaved reasoning, + // convert thinking blocks to reasoning_content on assistant messages + if ( + typeof model.capabilities.interleaved === "object" && + model.capabilities.interleaved.field && + (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") && + opts.body && + opts.method === "POST" + ) { + const body = JSON.parse(opts.body as string) + if (Array.isArray(body.messages)) { + const field = model.capabilities.interleaved.field + for (const msg of body.messages) { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue + const thinking = msg.content.filter((b: any) => b.type === "thinking") + const rest = msg.content.filter((b: any) => b.type !== "thinking") + msg[field] = thinking.map((b: any) => b.thinking).join("") || "" + msg.content = rest + } + opts.body = JSON.stringify(body) + } + } + // Strip openai itemId metadata following what codex does // Codex uses #[serde(skip_serializing)] on id fields for all item types: // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b659799c1b6f..60df584f9f68 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,36 +135,42 @@ export namespace ProviderTransform { if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { const field = model.capabilities.interleaved.field + const isAnthropic = model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic" return msgs.map((msg) => { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") - const reasoningText = reasoningParts.map((part: any) => part.text).join("") + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg - // Filter out reasoning parts from content - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") + const reasoningText = reasoningParts.map((part: any) => part.text).join("") + const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - // Include reasoning_content | reasoning_details directly on the message for all assistant messages - if (reasoningText) { - return { - ...msg, - content: filteredContent, - providerOptions: { - ...msg.providerOptions, - openaiCompatible: { - ...(msg.providerOptions as any)?.openaiCompatible, - [field]: reasoningText, + // For anthropic protocol non-Claude models, inject a fake signature so the SDK + // emits thinking blocks. The fetch wrapper converts them to reasoning_content. + if (isAnthropic) { + const parts = reasoningText + ? [ + { + type: "reasoning" as const, + text: reasoningText, + providerOptions: { anthropic: { signature: "interleaved-placeholder" } }, }, - }, - } - } - - return { - ...msg, - content: filteredContent, - } + ...filteredContent, + ] + : msg.content + return { ...msg, content: parts } } - return msg + if (!reasoningText) return { ...msg, content: filteredContent } + return { + ...msg, + content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...(msg.providerOptions as any)?.openaiCompatible, + [field]: reasoningText, + }, + }, + } }) } From 501222b35c1f36d128e517e090dd91f1747406dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B2=92=E7=B2=92=E6=A9=99?= Date: Sun, 22 Feb 2026 12:55:42 +0800 Subject: [PATCH 2/2] refactor: simplify interleaved reasoning transform with early return --- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/provider/transform.ts | 35 +++++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 41017b542048..eeb99dd8c325 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1101,7 +1101,7 @@ export namespace Provider { if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue const thinking = msg.content.filter((b: any) => b.type === "thinking") const rest = msg.content.filter((b: any) => b.type !== "thinking") - msg[field] = thinking.map((b: any) => b.thinking).join("") || "" + msg[field] = thinking.map((b: any) => b.thinking).join("") msg.content = rest } opts.body = JSON.stringify(body) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 60df584f9f68..bd60250df159 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -139,30 +139,33 @@ export namespace ProviderTransform { return msgs.map((msg) => { if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg - const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") - const reasoningText = reasoningParts.map((part: any) => part.text).join("") - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + const reasoningText = msg.content + .filter((part: any) => part.type === "reasoning") + .map((part: any) => part.text) + .join("") + if (!reasoningText) return msg + + const filtered = msg.content.filter((part: any) => part.type !== "reasoning") // For anthropic protocol non-Claude models, inject a fake signature so the SDK // emits thinking blocks. The fetch wrapper converts them to reasoning_content. if (isAnthropic) { - const parts = reasoningText - ? [ - { - type: "reasoning" as const, - text: reasoningText, - providerOptions: { anthropic: { signature: "interleaved-placeholder" } }, - }, - ...filteredContent, - ] - : msg.content - return { ...msg, content: parts } + return { + ...msg, + content: [ + { + type: "reasoning" as const, + text: reasoningText, + providerOptions: { anthropic: { signature: "interleaved-placeholder" } }, + }, + ...filtered, + ], + } } - if (!reasoningText) return { ...msg, content: filteredContent } return { ...msg, - content: filteredContent, + content: filtered, providerOptions: { ...msg.providerOptions, openaiCompatible: {