From f1baa87e3c7203421fe2fbe9b43f5f4652b5a6a0 Mon Sep 17 00:00:00 2001 From: MinhDung <113178405+d-init-d@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:18:08 +0700 Subject: [PATCH] fix: strip incompatible thinking blocks when switching to Claude Fixes #6418 ## Problem When switching from models like GLM 4.7 or MiniMax to Claude with extended thinking enabled, users get API errors because other models produce thinking blocks with signatures incompatible with Claude. ## Solution 1. Add `isValidClaudeSignature()` helper to detect valid Claude signatures 2. Extract `normalizeClaudeThinkingBlocks()` for better code organization 3. Convert ONLY invalid signatures - preserve valid Claude thinking blocks 4. Use distinct `` tags for reasoning blocks 5. Filter out messages with empty content after processing 6. Preserve devstral support in Mistral check ## Improvements over PR #8958 - Only converts thinking blocks with INVALID signatures (not all) - Uses distinct tags for thinking vs reasoning - Handles empty content edge case - Better type safety (reduced `any` usage) - Extracted helper function for maintainability - More comprehensive test coverage ## Testing 1. Start session with GLM 4.7 or MiniMax 2. Send messages including tool use 3. Switch to Claude with extended thinking 4. Should no longer get signature errors 5. Tool results still work correctly" --- packages/opencode/src/provider/transform.ts | 204 +++++++-- .../test/provider/thinking-blocks.test.ts | 387 ++++++++++++++++++ 2 files changed, 564 insertions(+), 27 deletions(-) create mode 100644 packages/opencode/test/provider/thinking-blocks.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 39eef6c9165..beed6443522 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -15,11 +15,23 @@ function mimeToModality(mime: string): Modality | undefined { return undefined } +/** + * Validates if a thinking block signature is a valid Claude/Anthropic signature. + * Claude signatures typically start with 'ErUB' (base64 encoded protobuf). + * Other models like GLM, MiniMax produce incompatible signatures. + */ +function isValidClaudeSignature(signature: string | undefined): boolean { + if (!signature) return false + // Claude thinking signatures are base64-encoded protobuf that starts with 'ErUB' + return signature.startsWith('ErUB') +} + export namespace ProviderTransform { // Maps npm package to the key the AI SDK expects for providerOptions function sdkKey(npm: string): string | undefined { switch (npm) { case "@ai-sdk/github-copilot": + return "copilot" case "@ai-sdk/openai": case "@ai-sdk/azure": return "openai" @@ -39,6 +51,103 @@ export namespace ProviderTransform { return undefined } + /** + * Normalizes thinking blocks for Claude when switching from other models. + * + * Problem: When switching from models like GLM 4.7 or MiniMax to Claude with + * extended thinking enabled, users get API errors because other models produce + * thinking blocks with signatures that are incompatible with Claude's validation. + * + * Solution: Convert incompatible thinking/reasoning blocks to wrapped text, + * preserving the content while removing invalid signatures. + * + * @see https://github.com/anomalyco/opencode/issues/6418 + */ + function normalizeClaudeThinkingBlocks( + msgs: ModelMessage[], + options: Record, + ): ModelMessage[] { + const thinkingEnabled = (options as { thinking?: { type?: string } })?.thinking?.type === "enabled" + const convertedThinkingMsgIndices = new Set() + + msgs = msgs + .map((msg, msgIdx) => { + if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { + let hadThinkingBlock = false + + const newContent = msg.content + .map((part) => { + const partAny = part as { type: string; thinking?: string; text?: string; signature?: string; toolCallId?: string } + + // Convert thinking blocks with INVALID signatures to wrapped text + // Valid Claude signatures (starting with 'ErUB') are preserved + if (partAny.type === "thinking" && partAny.signature && !isValidClaudeSignature(partAny.signature)) { + const text = partAny.thinking || partAny.text || "" + hadThinkingBlock = true + if (!text) return null + return { + type: "text" as const, + text: `${text}`, + } + } + + // Convert reasoning parts to wrapped text (reasoning has no signature concept) + if (partAny.type === "reasoning") { + const text = partAny.text || "" + if (!text) return null + return { + type: "text" as const, + text: `${text}`, + } + } + + // Normalize tool call IDs for Claude compatibility + if ((partAny.type === "tool-call" || partAny.type === "tool-result") && "toolCallId" in partAny) { + return { + ...part, + toolCallId: partAny.toolCallId!.replace(/[^a-zA-Z0-9_-]/g, "_"), + } + } + + return part + }) + .filter((part): part is NonNullable => part !== null) + + if (hadThinkingBlock) convertedThinkingMsgIndices.add(msgIdx) + + // Filter out messages with empty content after processing + if (newContent.length === 0) return undefined + + return { ...msg, content: newContent } + } + return msg + }) + .filter((msg): msg is ModelMessage => msg !== undefined) + + // When thinking is enabled, Claude requires the last assistant message to start + // with a valid thinking block. If it doesn't (and has no tool calls or converted + // thinking), remove it to let Claude generate fresh thinking. + if (thinkingEnabled) { + const lastAssistantIdx = msgs.findLastIndex((m) => m.role === "assistant") + if (lastAssistantIdx >= 0) { + const lastAssistant = msgs[lastAssistantIdx] + const hadConvertedThinking = convertedThinkingMsgIndices.has(lastAssistantIdx) + + if (Array.isArray(lastAssistant.content) && lastAssistant.content.length > 0 && !hadConvertedThinking) { + const firstPart = lastAssistant.content[0] as { type: string } + const startsWithValidThinking = firstPart.type === "thinking" || firstPart.type === "redacted_thinking" + const hasToolCall = lastAssistant.content.some((p) => (p as { type: string }).type === "tool-call") + + if (!startsWithValidThinking && !hasToolCall) { + msgs = msgs.filter((_, i) => i !== lastAssistantIdx) + } + } + } + } + + return msgs + } + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, @@ -67,22 +176,13 @@ export namespace ProviderTransform { } if (model.api.id.includes("claude")) { - return msgs.map((msg) => { - if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { - msg.content = msg.content.map((part) => { - if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) { - return { - ...part, - toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"), - } - } - return part - }) - } - return msg - }) + return normalizeClaudeThinkingBlocks(msgs, options) } - if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) { + if ( + model.providerID === "mistral" || + model.api.id.toLowerCase().includes("mistral") || + model.api.id.toLocaleLowerCase().includes("devstral") + ) { const result: ModelMessage[] = [] for (let i = 0; i < msgs.length; i++) { const msg = msgs[i] @@ -174,15 +274,19 @@ export namespace ProviderTransform { cacheControl: { type: "ephemeral" }, }, bedrock: { - cachePoint: { type: "ephemeral" }, + cachePoint: { type: "default" }, }, openaiCompatible: { cache_control: { type: "ephemeral" }, }, + copilot: { + copilot_cache_control: { type: "ephemeral" }, + }, } for (const msg of unique([...system, ...final])) { - const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0 + const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock") + const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { const lastContent = msg.content[msg.content.length - 1] @@ -284,8 +388,8 @@ export namespace ProviderTransform { if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 if (id.includes("kimi-k2")) { - // kimi-k2-thinking & kimi-k2.5 - if (id.includes("thinking") || id.includes("k2.")) { + // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 + if (id.includes("thinking") || id.includes("k2.") || id.includes("k2p")) { return 1.0 } return 0.6 @@ -296,7 +400,7 @@ export namespace ProviderTransform { export function topP(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("qwen")) return 1 - if (id.includes("minimax-m2") || id.includes("kimi-k2.5") || id.includes("gemini")) { + if (id.includes("minimax-m2") || id.includes("kimi-k2.5") || id.includes("kimi-k2p5") || id.includes("gemini")) { return 0.95 } return undefined @@ -319,7 +423,14 @@ export namespace ProviderTransform { if (!model.capabilities.reasoning) return {} const id = model.id.toLowerCase() - if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {} + if ( + id.includes("deepseek") || + id.includes("minimax") || + id.includes("glm") || + id.includes("mistral") || + id.includes("kimi") + ) + return {} // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks if (id.includes("grok") && id.includes("grok-3-mini")) { @@ -346,6 +457,15 @@ export namespace ProviderTransform { return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) case "@ai-sdk/github-copilot": + if (model.id.includes("gemini")) { + // currently github copilot only returns thinking + return {} + } + if (model.id.includes("claude")) { + return { + thinking: { thinking_budget: 4000 }, + } + } const copilotEfforts = iife(() => { if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] return WIDELY_SUPPORTED_EFFORTS @@ -428,13 +548,13 @@ export namespace ProviderTransform { high: { thinking: { type: "enabled", - budgetTokens: 16000, + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), }, }, max: { thinking: { type: "enabled", - budgetTokens: 31999, + budgetTokens: Math.min(31_999, model.limit.output - 1), }, }, } @@ -526,6 +646,26 @@ export namespace ProviderTransform { case "@ai-sdk/perplexity": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity return {} + + case "@mymediset/sap-ai-provider": + case "@jerome-benoit/sap-ai-provider-v2": + if (model.api.id.includes("anthropic")) { + return { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) } return {} } @@ -587,9 +727,12 @@ export namespace ProviderTransform { result["reasoningEffort"] = "medium" } + // Only set textVerbosity for non-chat gpt-5.x models + // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity if ( input.model.api.id.includes("gpt-5.") && !input.model.api.id.includes("codex") && + !input.model.api.id.includes("-chat") && input.model.providerID !== "azure" ) { result["textVerbosity"] = "low" @@ -610,11 +753,18 @@ export namespace ProviderTransform { } export function smallOptions(model: Provider.Model) { - if (model.providerID === "openai" || model.api.id.includes("gpt-5")) { - if (model.api.id.includes("5.")) { - return { reasoningEffort: "low" } + if ( + model.providerID === "openai" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/github-copilot" + ) { + if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("5.")) { + return { store: false, reasoningEffort: "low" } + } + return { store: false, reasoningEffort: "minimal" } } - return { reasoningEffort: "minimal" } + return { store: false } } if (model.providerID === "google") { // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget diff --git a/packages/opencode/test/provider/thinking-blocks.test.ts b/packages/opencode/test/provider/thinking-blocks.test.ts new file mode 100644 index 00000000000..9efb4053648 --- /dev/null +++ b/packages/opencode/test/provider/thinking-blocks.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "../../src/provider/transform" + +const claudeModel = { + id: "anthropic/claude-sonnet-4", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-20250514", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude Sonnet 4", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, +} as any + +describe("Thinking Block Structure", () => { + test("Claude thinking blocks have valid signature starting with ErUB", () => { + const claudeThinkingBlock = { + type: "thinking", + thinking: "Let me analyze this...", + signature: "ErUBCkYIAxgCIkDK8Y0dcPmz8BQ4K7W9vN...", + } + + expect(claudeThinkingBlock.type).toBe("thinking") + expect(claudeThinkingBlock.signature).toMatch(/^ErUB/) + }) + + test("GLM thinking blocks have different signature format (not ErUB)", () => { + const glmThinkingBlock = { + type: "thinking", + thinking: "让我思考一下这个问题...", + signature: "glm_sig_abc123...", + } + + expect(glmThinkingBlock.signature).not.toMatch(/^ErUB/) + }) + + test("GLM reasoning blocks have no signature", () => { + const glmReasoningBlock = { + type: "reasoning", + text: "Let me think about this step by step...", + } + + expect(glmReasoningBlock.type).toBe("reasoning") + expect((glmReasoningBlock as any).signature).toBeUndefined() + }) +}) + +describe("Model Switch - Thinking Blocks", () => { + test("thinking blocks with INVALID signatures are converted to wrapped text", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "User is greeting me, I should respond warmly.", + signature: "glm_invalid_signature_12345", // Invalid - not starting with ErUB + }, + { type: "text", text: "Hello! How can I help you today?" }, + ], + }, + { role: "user", content: "What is 2+2?" }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + + // Thinking block should be converted + const thinkingPart = (assistantMsg?.content as any[])?.find((p: any) => p.type === "thinking") + expect(thinkingPart).toBeUndefined() + + // Should be wrapped in tags + const convertedText = (assistantMsg?.content as any[])?.find( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(convertedText).toBeDefined() + expect(convertedText.text).toContain("User is greeting me") + }) + + test("thinking blocks with VALID Claude signatures are preserved", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Let me think about this...", + signature: "ErUBCkYIAxgCIkDK8Y0dcPmz8BQ4K7W9vN...", // Valid Claude signature + }, + { type: "text", text: "Hello! How can I help you today?" }, + ], + }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + + // Valid thinking block should be preserved + const thinkingPart = (assistantMsg?.content as any[])?.find((p: any) => p.type === "thinking") + expect(thinkingPart).toBeDefined() + expect(thinkingPart.signature).toMatch(/^ErUB/) + }) + + test("last assistant without thinking is removed when thinking enabled (no tool calls)", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: "Hello! How can I help you today?" }], + }, + { role: "user", content: "What is 2+2?" }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantMsgs = transformed.filter((m) => m.role === "assistant") + expect(assistantMsgs.length).toBe(0) + }) + + test("reasoning blocks are converted to wrapped text with distinct tags", () => { + const messages = [ + { role: "user", content: "Solve this: 2+2" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me calculate: 2 + 2 = 4" }, + { type: "text", text: "The answer is 4." }, + ], + }, + ] as any[] + + const transformed = ProviderTransform.message(messages, claudeModel, {}) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + + // Reasoning part should be converted + const reasoningPart = (assistantMsg?.content as any[])?.find((p: any) => p.type === "reasoning") + expect(reasoningPart).toBeUndefined() + + // Should use tags (distinct from thinking) + const convertedText = (assistantMsg?.content as any[])?.find( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(convertedText).toBeDefined() + expect(convertedText.text).toContain("Let me calculate") + }) + + test("empty thinking blocks are filtered out", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "", // Empty thinking + signature: "glm_sig", + }, + { type: "text", text: "Hi there!" }, + ], + }, + ] as any[] + + const transformed = ProviderTransform.message(messages, claudeModel, {}) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + + // Should only have the text part, no empty thinking wrapper + const content = assistantMsg?.content as any[] + expect(content.length).toBe(1) + expect(content[0].text).toBe("Hi there!") + }) + + test("messages with only empty thinking are filtered out entirely", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "", + signature: "glm_sig", + }, + ], + }, + { role: "user", content: "World" }, + ] as any[] + + const transformed = ProviderTransform.message(messages, claudeModel, {}) + + // Assistant message with only empty thinking should be removed + const assistantMsgs = transformed.filter((m) => m.role === "assistant") + expect(assistantMsgs.length).toBe(0) + }) + + test("multiple thinking blocks in single message are all converted", () => { + const messages = [ + { role: "user", content: "Complex question" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "First thought...", + signature: "glm_sig_1", + }, + { + type: "thinking", + thinking: "Second thought...", + signature: "glm_sig_2", + }, + { type: "text", text: "Here's my answer." }, + ], + }, + ] as any[] + + const transformed = ProviderTransform.message(messages, claudeModel, {}) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + const content = assistantMsg?.content as any[] + + // No thinking blocks should remain + const thinkingParts = content.filter((p: any) => p.type === "thinking") + expect(thinkingParts.length).toBe(0) + + // Both should be converted to wrapped text + const wrappedParts = content.filter( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(wrappedParts.length).toBe(2) + }) +}) + +describe("Tool Pairing Preservation", () => { + test("thinking blocks converted but tool pairing preserved", () => { + const messages = [ + { role: "user", content: "Read the file test.txt" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "I need to read this file...", + signature: "glm_invalid_sig", + }, + { + type: "tool-call", + toolCallId: "tool_123", + toolName: "read", + args: { path: "test.txt" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tool_123", + result: "File contents here", + }, + ], + }, + { role: "user", content: "Thanks! Now what?" }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + // Tool call and result should both exist + const assistantWithTool = transformed.find( + (m) => m.role === "assistant" && Array.isArray(m.content) && m.content.some((p: any) => p.type === "tool-call"), + ) + const toolResult = transformed.find( + (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some((p: any) => p.type === "tool-result"), + ) + + expect(assistantWithTool).toBeDefined() + expect(toolResult).toBeDefined() + + // Tool IDs should match (normalized) + const toolCall = (assistantWithTool!.content as any[]).find((p: any) => p.type === "tool-call") + const toolResultPart = (toolResult!.content as any[]).find((p: any) => p.type === "tool-result") + expect(toolCall.toolCallId).toBe(toolResultPart.toolCallId) + + // Thinking should be converted + const thinkingPart = (assistantWithTool!.content as any[]).find((p: any) => p.type === "thinking") + expect(thinkingPart).toBeUndefined() + + const convertedThinking = (assistantWithTool!.content as any[]).find( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(convertedThinking).toBeDefined() + }) + + test("assistant with tool calls is NOT removed even without thinking", () => { + const messages = [ + { role: "user", content: "Read test.txt" }, + { + role: "assistant", + content: [ + { type: "text", text: "Reading the file..." }, + { type: "tool-call", toolCallId: "tool_456", toolName: "read", args: { path: "test.txt" } }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "tool_456", result: "File contents" }], + }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + // Assistant with tool call should NOT be removed + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + }) +}) + +describe("Tool Call ID Normalization", () => { + test("tool call IDs are normalized for Claude compatibility", () => { + const messages = [ + { role: "user", content: "Do something" }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call:abc@123#def", // Special characters + toolName: "test", + args: {}, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call:abc@123#def", + result: "done", + }, + ], + }, + ] as any[] + + const transformed = ProviderTransform.message(messages, claudeModel, {}) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + const toolMsg = transformed.find((m) => m.role === "tool") + + const toolCall = (assistantMsg?.content as any[])[0] + const toolResult = (toolMsg?.content as any[])[0] + + // Both should be normalized to same value + expect(toolCall.toolCallId).toBe(toolResult.toolCallId) + // Should only contain allowed characters + expect(toolCall.toolCallId).toMatch(/^[a-zA-Z0-9_-]+$/) + }) +})