diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c703a57e1cc..09dfd69a317 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -63,6 +63,46 @@ export namespace ProviderTransform { return result } + // DeepSeek: Handle reasoning_content for tool call continuations + // - With tool calls: Include reasoning_content in providerOptions so model can continue reasoning + // - Without tool calls: Strip reasoning (new turn doesn't need previous reasoning) + // See: https://api-docs.deepseek.com/guides/thinking_mode + if (model.providerID === "deepseek" || model.api.id.toLowerCase().includes("deepseek")) { + return msgs.map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") + const hasToolCalls = msg.content.some((part: any) => part.type === "tool-call") + const reasoningText = reasoningParts.map((part: any) => part.text).join("") + + // Filter out reasoning parts from content + const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + + // If this message has tool calls and reasoning, include reasoning_content + // so DeepSeek can continue reasoning after tool execution + if (hasToolCalls && reasoningText) { + return { + ...msg, + content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...(msg.providerOptions as any)?.openaiCompatible, + reasoning_content: reasoningText, + }, + }, + } + } + + // For final answers (no tool calls), just strip reasoning + return { + ...msg, + content: filteredContent, + } + } + return msg + }) + } + return msgs } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e6080d54c68..648f108bd66 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -96,3 +96,210 @@ describe("ProviderTransform.maxOutputTokens", () => { }) }) }) + +describe("ProviderTransform.message - DeepSeek reasoning content", () => { + test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "echo hello" }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, { + id: "deepseek/deepseek-chat", + providerID: "deepseek", + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "DeepSeek Chat", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "echo hello" }, + }, + ]) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...") + }) + + test("DeepSeek without tool calls strips reasoning from content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { type: "text", text: "Final answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, { + id: "deepseek/deepseek-chat", + providerID: "deepseek", + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "DeepSeek Chat", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "Final answer" }]) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() + }) + + test("DeepSeek model ID containing 'deepseek' matches (case insensitive)", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { + type: "tool-call", + toolCallId: "test", + toolName: "get_weather", + input: { location: "Hangzhou" }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, { + id: "someprovider/deepseek-reasoner", + providerID: "someprovider", + api: { + id: "deepseek-reasoner", + url: "https://api.someprovider.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "SomeProvider DeepSeek Reasoner", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) + + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Thinking...") + }) + + test("Non-DeepSeek providers leave reasoning content unchanged", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Should not be processed" }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, { + id: "openai/gpt-4", + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + name: "GPT-4", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.03, + output: 0.06, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + }) + + expect(result[0].content).toEqual([ + { type: "reasoning", text: "Should not be processed" }, + { type: "text", text: "Answer" }, + ]) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() + }) +})