فهرست منبع

feat: Enhance DeepSeek reasoning content handling (#4975)

Co-authored-by: Aiden Cline <[email protected]>
Jakub Matjanowski 2 ماه پیش
والد
کامیت
46790e57e9
2فایلهای تغییر یافته به همراه247 افزوده شده و 0 حذف شده
  1. 40 0
      packages/opencode/src/provider/transform.ts
  2. 207 0
      packages/opencode/test/provider/transform.test.ts

+ 40 - 0
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
   }
 

+ 207 - 0
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()
+  })
+})