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_-]+$/) + }) +})