Просмотр исходного кода

feat: migrate DeepSeek to @ai-sdk/deepseek + fix AI SDK tool streaming (#11079)

Daniel 2 недель назад
Родитель
Сommit
4b1d78fe0a

+ 16 - 1
pnpm-lock.yaml

@@ -743,6 +743,9 @@ importers:
 
 
   src:
   src:
     dependencies:
     dependencies:
+      '@ai-sdk/deepseek':
+        specifier: ^2.0.14
+        version: 2.0.14([email protected])
       '@anthropic-ai/bedrock-sdk':
       '@anthropic-ai/bedrock-sdk':
         specifier: ^0.10.2
         specifier: ^0.10.2
         version: 0.10.4
         version: 0.10.4
@@ -1387,6 +1390,12 @@ packages:
   '@adobe/[email protected]':
   '@adobe/[email protected]':
     resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
     resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
 
 
+  '@ai-sdk/[email protected]':
+    resolution: {integrity: sha512-1vXh8sVwRJYd1JO57qdy1rACucaNLDoBRCwOER3EbPgSF2vNVPcdJywGutA01Bhn7Cta+UJQ+k5y/yzMAIpP2w==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      zod: 3.25.76
+
   '@ai-sdk/[email protected]':
   '@ai-sdk/[email protected]':
     resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==}
     resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -10810,6 +10819,12 @@ snapshots:
 
 
   '@adobe/[email protected]': {}
   '@adobe/[email protected]': {}
 
 
+  '@ai-sdk/[email protected]([email protected])':
+    dependencies:
+      '@ai-sdk/provider': 3.0.5
+      '@ai-sdk/provider-utils': 4.0.10([email protected])
+      zod: 3.25.76
+
   '@ai-sdk/[email protected]([email protected])':
   '@ai-sdk/[email protected]([email protected])':
     dependencies:
     dependencies:
       '@ai-sdk/provider': 3.0.5
       '@ai-sdk/provider': 3.0.5
@@ -14671,7 +14686,7 @@ snapshots:
       sirv: 3.0.1
       sirv: 3.0.1
       tinyglobby: 0.2.14
       tinyglobby: 0.2.14
       tinyrainbow: 2.0.0
       tinyrainbow: 2.0.0
-      vitest: 3.2.4(@types/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@20.17.50)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
 
   '@vitest/[email protected]':
   '@vitest/[email protected]':
     dependencies:
     dependencies:

+ 483 - 197
src/api/providers/__tests__/deepseek.spec.ts

@@ -1,125 +1,28 @@
-// Mocks must come first, before imports
-const mockCreate = vi.fn()
-vi.mock("openai", () => {
+// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
+const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
+	mockStreamText: vi.fn(),
+	mockGenerateText: vi.fn(),
+}))
+
+vi.mock("ai", async (importOriginal) => {
+	const actual = await importOriginal<typeof import("ai")>()
 	return {
 	return {
-		__esModule: true,
-		default: vi.fn().mockImplementation(() => ({
-			chat: {
-				completions: {
-					create: mockCreate.mockImplementation(async (options) => {
-						if (!options.stream) {
-							return {
-								id: "test-completion",
-								choices: [
-									{
-										message: { role: "assistant", content: "Test response", refusal: null },
-										finish_reason: "stop",
-										index: 0,
-									},
-								],
-								usage: {
-									prompt_tokens: 10,
-									completion_tokens: 5,
-									total_tokens: 15,
-									prompt_tokens_details: {
-										cache_miss_tokens: 8,
-										cached_tokens: 2,
-									},
-								},
-							}
-						}
-
-						// Check if this is a reasoning_content test by looking at model
-						const isReasonerModel = options.model?.includes("deepseek-reasoner")
-						const isToolCallTest = options.tools?.length > 0
-
-						// Return async iterator for streaming
-						return {
-							[Symbol.asyncIterator]: async function* () {
-								// For reasoner models, emit reasoning_content first
-								if (isReasonerModel) {
-									yield {
-										choices: [
-											{
-												delta: { reasoning_content: "Let me think about this..." },
-												index: 0,
-											},
-										],
-										usage: null,
-									}
-									yield {
-										choices: [
-											{
-												delta: { reasoning_content: " I'll analyze step by step." },
-												index: 0,
-											},
-										],
-										usage: null,
-									}
-								}
-
-								// For tool call tests with reasoner, emit tool call
-								if (isReasonerModel && isToolCallTest) {
-									yield {
-										choices: [
-											{
-												delta: {
-													tool_calls: [
-														{
-															index: 0,
-															id: "call_123",
-															function: {
-																name: "get_weather",
-																arguments: '{"location":"SF"}',
-															},
-														},
-													],
-												},
-												index: 0,
-											},
-										],
-										usage: null,
-									}
-								} else {
-									yield {
-										choices: [
-											{
-												delta: { content: "Test response" },
-												index: 0,
-											},
-										],
-										usage: null,
-									}
-								}
-
-								yield {
-									choices: [
-										{
-											delta: {},
-											index: 0,
-											finish_reason: isToolCallTest ? "tool_calls" : "stop",
-										},
-									],
-									usage: {
-										prompt_tokens: 10,
-										completion_tokens: 5,
-										total_tokens: 15,
-										prompt_tokens_details: {
-											cache_miss_tokens: 8,
-											cached_tokens: 2,
-										},
-									},
-								}
-							},
-						}
-					}),
-				},
-			},
-		})),
+		...actual,
+		streamText: mockStreamText,
+		generateText: mockGenerateText,
 	}
 	}
 })
 })
 
 
-import OpenAI from "openai"
+vi.mock("@ai-sdk/deepseek", () => ({
+	createDeepSeek: vi.fn(() => {
+		// Return a function that returns a mock language model
+		return vi.fn(() => ({
+			modelId: "deepseek-chat",
+			provider: "deepseek",
+		}))
+	}),
+}))
+
 import type { Anthropic } from "@anthropic-ai/sdk"
 import type { Anthropic } from "@anthropic-ai/sdk"
 
 
 import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types"
 import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types"
@@ -148,15 +51,6 @@ describe("DeepSeekHandler", () => {
 			expect(handler.getModel().id).toBe(mockOptions.apiModelId)
 			expect(handler.getModel().id).toBe(mockOptions.apiModelId)
 		})
 		})
 
 
-		it.skip("should throw error if API key is missing", () => {
-			expect(() => {
-				new DeepSeekHandler({
-					...mockOptions,
-					deepSeekApiKey: undefined,
-				})
-			}).toThrow("DeepSeek API key is required")
-		})
-
 		it("should use default model ID if not provided", () => {
 		it("should use default model ID if not provided", () => {
 			const handlerWithoutModel = new DeepSeekHandler({
 			const handlerWithoutModel = new DeepSeekHandler({
 				...mockOptions,
 				...mockOptions,
@@ -171,12 +65,6 @@ describe("DeepSeekHandler", () => {
 				deepSeekBaseUrl: undefined,
 				deepSeekBaseUrl: undefined,
 			})
 			})
 			expect(handlerWithoutBaseUrl).toBeInstanceOf(DeepSeekHandler)
 			expect(handlerWithoutBaseUrl).toBeInstanceOf(DeepSeekHandler)
-			// The base URL is passed to OpenAI client internally
-			expect(OpenAI).toHaveBeenCalledWith(
-				expect.objectContaining({
-					baseURL: "https://api.deepseek.com",
-				}),
-			)
 		})
 		})
 
 
 		it("should use custom base URL if provided", () => {
 		it("should use custom base URL if provided", () => {
@@ -186,18 +74,6 @@ describe("DeepSeekHandler", () => {
 				deepSeekBaseUrl: customBaseUrl,
 				deepSeekBaseUrl: customBaseUrl,
 			})
 			})
 			expect(handlerWithCustomUrl).toBeInstanceOf(DeepSeekHandler)
 			expect(handlerWithCustomUrl).toBeInstanceOf(DeepSeekHandler)
-			// The custom base URL is passed to OpenAI client
-			expect(OpenAI).toHaveBeenCalledWith(
-				expect.objectContaining({
-					baseURL: customBaseUrl,
-				}),
-			)
-		})
-
-		it("should set includeMaxTokens to true", () => {
-			// Create a new handler and verify OpenAI client was called with includeMaxTokens
-			const _handler = new DeepSeekHandler(mockOptions)
-			expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.deepSeekApiKey }))
 		})
 		})
 	})
 	})
 
 
@@ -296,6 +172,31 @@ describe("DeepSeekHandler", () => {
 		]
 		]
 
 
 		it("should handle streaming responses", async () => {
 		it("should handle streaming responses", async () => {
+			// Mock the fullStream async generator
+			// Note: processAiSdkStreamPart expects 'text' property for text-delta type
+			async function* mockFullStream() {
+				yield { type: "text-delta", text: "Test response" }
+			}
+
+			// Mock usage and providerMetadata promises
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+
+			const mockProviderMetadata = Promise.resolve({
+				deepseek: {
+					promptCacheHitTokens: 2,
+					promptCacheMissTokens: 8,
+				},
+			})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
 			const stream = handler.createMessage(systemPrompt, messages)
 			const stream = handler.createMessage(systemPrompt, messages)
 			const chunks: any[] = []
 			const chunks: any[] = []
 			for await (const chunk of stream) {
 			for await (const chunk of stream) {
@@ -309,6 +210,28 @@ describe("DeepSeekHandler", () => {
 		})
 		})
 
 
 		it("should include usage information", async () => {
 		it("should include usage information", async () => {
+			async function* mockFullStream() {
+				yield { type: "text-delta", text: "Test response" }
+			}
+
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+
+			const mockProviderMetadata = Promise.resolve({
+				deepseek: {
+					promptCacheHitTokens: 2,
+					promptCacheMissTokens: 8,
+				},
+			})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
 			const stream = handler.createMessage(systemPrompt, messages)
 			const stream = handler.createMessage(systemPrompt, messages)
 			const chunks: any[] = []
 			const chunks: any[] = []
 			for await (const chunk of stream) {
 			for await (const chunk of stream) {
@@ -321,7 +244,30 @@ describe("DeepSeekHandler", () => {
 			expect(usageChunks[0].outputTokens).toBe(5)
 			expect(usageChunks[0].outputTokens).toBe(5)
 		})
 		})
 
 
-		it("should include cache metrics in usage information", async () => {
+		it("should include cache metrics in usage information from providerMetadata", async () => {
+			async function* mockFullStream() {
+				yield { type: "text-delta", text: "Test response" }
+			}
+
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+
+			// DeepSeek provides cache metrics via providerMetadata
+			const mockProviderMetadata = Promise.resolve({
+				deepseek: {
+					promptCacheHitTokens: 2,
+					promptCacheMissTokens: 8,
+				},
+			})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
 			const stream = handler.createMessage(systemPrompt, messages)
 			const stream = handler.createMessage(systemPrompt, messages)
 			const chunks: any[] = []
 			const chunks: any[] = []
 			for await (const chunk of stream) {
 			for await (const chunk of stream) {
@@ -330,29 +276,76 @@ describe("DeepSeekHandler", () => {
 
 
 			const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
 			const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
 			expect(usageChunks.length).toBeGreaterThan(0)
 			expect(usageChunks.length).toBeGreaterThan(0)
-			expect(usageChunks[0].cacheWriteTokens).toBe(8)
-			expect(usageChunks[0].cacheReadTokens).toBe(2)
+			expect(usageChunks[0].cacheWriteTokens).toBe(8) // promptCacheMissTokens
+			expect(usageChunks[0].cacheReadTokens).toBe(2) // promptCacheHitTokens
+		})
+	})
+
+	describe("completePrompt", () => {
+		it("should complete a prompt using generateText", async () => {
+			mockGenerateText.mockResolvedValue({
+				text: "Test completion",
+			})
+
+			const result = await handler.completePrompt("Test prompt")
+
+			expect(result).toBe("Test completion")
+			expect(mockGenerateText).toHaveBeenCalledWith(
+				expect.objectContaining({
+					prompt: "Test prompt",
+				}),
+			)
 		})
 		})
 	})
 	})
 
 
 	describe("processUsageMetrics", () => {
 	describe("processUsageMetrics", () => {
-		it("should correctly process usage metrics including cache information", () => {
+		it("should correctly process usage metrics including cache information from providerMetadata", () => {
 			// We need to access the protected method, so we'll create a test subclass
 			// We need to access the protected method, so we'll create a test subclass
 			class TestDeepSeekHandler extends DeepSeekHandler {
 			class TestDeepSeekHandler extends DeepSeekHandler {
-				public testProcessUsageMetrics(usage: any) {
-					return this.processUsageMetrics(usage)
+				public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
+					return this.processUsageMetrics(usage, providerMetadata)
 				}
 				}
 			}
 			}
 
 
 			const testHandler = new TestDeepSeekHandler(mockOptions)
 			const testHandler = new TestDeepSeekHandler(mockOptions)
 
 
 			const usage = {
 			const usage = {
-				prompt_tokens: 100,
-				completion_tokens: 50,
-				total_tokens: 150,
-				prompt_tokens_details: {
-					cache_miss_tokens: 80,
-					cached_tokens: 20,
+				inputTokens: 100,
+				outputTokens: 50,
+			}
+
+			// DeepSeek provides cache metrics via providerMetadata
+			const providerMetadata = {
+				deepseek: {
+					promptCacheHitTokens: 20,
+					promptCacheMissTokens: 80,
+				},
+			}
+
+			const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
+
+			expect(result.type).toBe("usage")
+			expect(result.inputTokens).toBe(100)
+			expect(result.outputTokens).toBe(50)
+			expect(result.cacheWriteTokens).toBe(80) // promptCacheMissTokens
+			expect(result.cacheReadTokens).toBe(20) // promptCacheHitTokens
+		})
+
+		it("should handle usage with details.cachedInputTokens when providerMetadata is not available", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
+					return this.processUsageMetrics(usage, providerMetadata)
+				}
+			}
+
+			const testHandler = new TestDeepSeekHandler(mockOptions)
+
+			const usage = {
+				inputTokens: 100,
+				outputTokens: 50,
+				details: {
+					cachedInputTokens: 25,
+					reasoningTokens: 30,
 				},
 				},
 			}
 			}
 
 
@@ -361,24 +354,24 @@ describe("DeepSeekHandler", () => {
 			expect(result.type).toBe("usage")
 			expect(result.type).toBe("usage")
 			expect(result.inputTokens).toBe(100)
 			expect(result.inputTokens).toBe(100)
 			expect(result.outputTokens).toBe(50)
 			expect(result.outputTokens).toBe(50)
-			expect(result.cacheWriteTokens).toBe(80)
-			expect(result.cacheReadTokens).toBe(20)
+			expect(result.cacheReadTokens).toBe(25) // from details.cachedInputTokens
+			expect(result.cacheWriteTokens).toBeUndefined()
+			expect(result.reasoningTokens).toBe(30)
 		})
 		})
 
 
 		it("should handle missing cache metrics gracefully", () => {
 		it("should handle missing cache metrics gracefully", () => {
 			class TestDeepSeekHandler extends DeepSeekHandler {
 			class TestDeepSeekHandler extends DeepSeekHandler {
-				public testProcessUsageMetrics(usage: any) {
-					return this.processUsageMetrics(usage)
+				public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
+					return this.processUsageMetrics(usage, providerMetadata)
 				}
 				}
 			}
 			}
 
 
 			const testHandler = new TestDeepSeekHandler(mockOptions)
 			const testHandler = new TestDeepSeekHandler(mockOptions)
 
 
 			const usage = {
 			const usage = {
-				prompt_tokens: 100,
-				completion_tokens: 50,
-				total_tokens: 150,
-				// No prompt_tokens_details
+				inputTokens: 100,
+				outputTokens: 50,
+				// No details or providerMetadata
 			}
 			}
 
 
 			const result = testHandler.testProcessUsageMetrics(usage)
 			const result = testHandler.testProcessUsageMetrics(usage)
@@ -391,7 +384,7 @@ describe("DeepSeekHandler", () => {
 		})
 		})
 	})
 	})
 
 
-	describe("interleaved thinking mode", () => {
+	describe("reasoning content with deepseek-reasoner", () => {
 		const systemPrompt = "You are a helpful assistant."
 		const systemPrompt = "You are a helpful assistant."
 		const messages: Anthropic.Messages.MessageParam[] = [
 		const messages: Anthropic.Messages.MessageParam[] = [
 			{
 			{
@@ -405,12 +398,41 @@ describe("DeepSeekHandler", () => {
 			},
 			},
 		]
 		]
 
 
-		it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => {
+		it("should handle reasoning content in streaming responses for deepseek-reasoner", async () => {
 			const reasonerHandler = new DeepSeekHandler({
 			const reasonerHandler = new DeepSeekHandler({
 				...mockOptions,
 				...mockOptions,
 				apiModelId: "deepseek-reasoner",
 				apiModelId: "deepseek-reasoner",
 			})
 			})
 
 
+			// Mock the fullStream async generator with reasoning content
+			// Note: processAiSdkStreamPart expects 'text' property for reasoning type
+			async function* mockFullStream() {
+				yield { type: "reasoning", text: "Let me think about this..." }
+				yield { type: "reasoning", text: " I'll analyze step by step." }
+				yield { type: "text-delta", text: "Test response" }
+			}
+
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+				details: {
+					reasoningTokens: 15,
+				},
+			})
+
+			const mockProviderMetadata = Promise.resolve({
+				deepseek: {
+					promptCacheHitTokens: 2,
+					promptCacheMissTokens: 8,
+				},
+			})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
 			const stream = reasonerHandler.createMessage(systemPrompt, messages)
 			const stream = reasonerHandler.createMessage(systemPrompt, messages)
 			const chunks: any[] = []
 			const chunks: any[] = []
 			for await (const chunk of stream) {
 			for await (const chunk of stream) {
@@ -419,54 +441,91 @@ describe("DeepSeekHandler", () => {
 
 
 			// Should have reasoning chunks
 			// Should have reasoning chunks
 			const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
 			const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
-			expect(reasoningChunks.length).toBeGreaterThan(0)
+			expect(reasoningChunks.length).toBe(2)
 			expect(reasoningChunks[0].text).toBe("Let me think about this...")
 			expect(reasoningChunks[0].text).toBe("Let me think about this...")
 			expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
 			expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
+
+			// Should also have text chunks
+			const textChunks = chunks.filter((chunk) => chunk.type === "text")
+			expect(textChunks.length).toBe(1)
+			expect(textChunks[0].text).toBe("Test response")
 		})
 		})
 
 
-		it("should pass thinking parameter for deepseek-reasoner model", async () => {
+		it("should include reasoningTokens in usage for deepseek-reasoner", async () => {
 			const reasonerHandler = new DeepSeekHandler({
 			const reasonerHandler = new DeepSeekHandler({
 				...mockOptions,
 				...mockOptions,
 				apiModelId: "deepseek-reasoner",
 				apiModelId: "deepseek-reasoner",
 			})
 			})
 
 
-			const stream = reasonerHandler.createMessage(systemPrompt, messages)
-			for await (const _chunk of stream) {
-				// Consume the stream
+			async function* mockFullStream() {
+				yield { type: "reasoning", text: "Thinking..." }
+				yield { type: "text-delta", text: "Answer" }
 			}
 			}
 
 
-			// Verify that the thinking parameter was passed to the API
-			// Note: mockCreate receives two arguments - request options and path options
-			expect(mockCreate).toHaveBeenCalledWith(
-				expect.objectContaining({
-					thinking: { type: "enabled" },
-				}),
-				{}, // Empty path options for non-Azure URLs
-			)
-		})
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+				details: {
+					reasoningTokens: 15,
+				},
+			})
 
 
-		it("should NOT pass thinking parameter for deepseek-chat model", async () => {
-			const chatHandler = new DeepSeekHandler({
-				...mockOptions,
-				apiModelId: "deepseek-chat",
+			const mockProviderMetadata = Promise.resolve({})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
 			})
 			})
 
 
-			const stream = chatHandler.createMessage(systemPrompt, messages)
-			for await (const _chunk of stream) {
-				// Consume the stream
+			const stream = reasonerHandler.createMessage(systemPrompt, messages)
+			const chunks: any[] = []
+			for await (const chunk of stream) {
+				chunks.push(chunk)
 			}
 			}
 
 
-			// Verify that the thinking parameter was NOT passed to the API
-			const callArgs = mockCreate.mock.calls[0][0]
-			expect(callArgs.thinking).toBeUndefined()
+			const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
+			expect(usageChunks.length).toBe(1)
+			expect(usageChunks[0].reasoningTokens).toBe(15)
 		})
 		})
 
 
-		it("should handle tool calls with reasoning_content", async () => {
+		it("should handle tool calls with reasoning content", async () => {
 			const reasonerHandler = new DeepSeekHandler({
 			const reasonerHandler = new DeepSeekHandler({
 				...mockOptions,
 				...mockOptions,
 				apiModelId: "deepseek-reasoner",
 				apiModelId: "deepseek-reasoner",
 			})
 			})
 
 
+			// Mock stream with reasoning followed by tool call via streaming events
+			// (tool-input-start/delta/end, NOT tool-call which is ignored to prevent duplicates)
+			async function* mockFullStream() {
+				yield { type: "reasoning", text: "Let me think about this..." }
+				yield { type: "reasoning", text: " I'll analyze step by step." }
+				yield { type: "tool-input-start", id: "call_123", toolName: "get_weather" }
+				yield { type: "tool-input-delta", id: "call_123", delta: '{"location":"SF"}' }
+				yield { type: "tool-input-end", id: "call_123" }
+			}
+
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+				details: {
+					reasoningTokens: 15,
+				},
+			})
+
+			const mockProviderMetadata = Promise.resolve({
+				deepseek: {
+					promptCacheHitTokens: 2,
+					promptCacheMissTokens: 8,
+				},
+			})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
 			const tools: any[] = [
 			const tools: any[] = [
 				{
 				{
 					type: "function",
 					type: "function",
@@ -486,12 +545,239 @@ describe("DeepSeekHandler", () => {
 
 
 			// Should have reasoning chunks
 			// Should have reasoning chunks
 			const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
 			const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
-			expect(reasoningChunks.length).toBeGreaterThan(0)
+			expect(reasoningChunks.length).toBe(2)
+
+			// Should have tool call streaming chunks (start/delta/end, NOT tool_call)
+			const toolCallStartChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
+			expect(toolCallStartChunks.length).toBe(1)
+			expect(toolCallStartChunks[0].name).toBe("get_weather")
+		})
+	})
+
+	describe("tool handling", () => {
+		const systemPrompt = "You are a helpful assistant."
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [{ type: "text" as const, text: "Hello!" }],
+			},
+		]
+
+		it("should handle tool calls in streaming", async () => {
+			async function* mockFullStream() {
+				yield {
+					type: "tool-input-start",
+					id: "tool-call-1",
+					toolName: "read_file",
+				}
+				yield {
+					type: "tool-input-delta",
+					id: "tool-call-1",
+					delta: '{"path":"test.ts"}',
+				}
+				yield {
+					type: "tool-input-end",
+					id: "tool-call-1",
+				}
+			}
+
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+
+			const mockProviderMetadata = Promise.resolve({})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
+			const stream = handler.createMessage(systemPrompt, messages, {
+				taskId: "test-task",
+				tools: [
+					{
+						type: "function",
+						function: {
+							name: "read_file",
+							description: "Read a file",
+							parameters: {
+								type: "object",
+								properties: { path: { type: "string" } },
+								required: ["path"],
+							},
+						},
+					},
+				],
+			})
+
+			const chunks: any[] = []
+			for await (const chunk of stream) {
+				chunks.push(chunk)
+			}
+
+			const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
+			const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
+			const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
+
+			expect(toolCallStartChunks.length).toBe(1)
+			expect(toolCallStartChunks[0].id).toBe("tool-call-1")
+			expect(toolCallStartChunks[0].name).toBe("read_file")
+
+			expect(toolCallDeltaChunks.length).toBe(1)
+			expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
+
+			expect(toolCallEndChunks.length).toBe(1)
+			expect(toolCallEndChunks[0].id).toBe("tool-call-1")
+		})
+
+		it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
+			// tool-call events are intentionally ignored because tool-input-start/delta/end
+			// already provide complete tool call information. Emitting tool-call would cause
+			// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
+			async function* mockFullStream() {
+				yield {
+					type: "tool-call",
+					toolCallId: "tool-call-1",
+					toolName: "read_file",
+					input: { path: "test.ts" },
+				}
+			}
+
+			const mockUsage = Promise.resolve({
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+
+			const mockProviderMetadata = Promise.resolve({})
+
+			mockStreamText.mockReturnValue({
+				fullStream: mockFullStream(),
+				usage: mockUsage,
+				providerMetadata: mockProviderMetadata,
+			})
+
+			const stream = handler.createMessage(systemPrompt, messages, {
+				taskId: "test-task",
+				tools: [
+					{
+						type: "function",
+						function: {
+							name: "read_file",
+							description: "Read a file",
+							parameters: {
+								type: "object",
+								properties: { path: { type: "string" } },
+								required: ["path"],
+							},
+						},
+					},
+				],
+			})
+
+			const chunks: any[] = []
+			for await (const chunk of stream) {
+				chunks.push(chunk)
+			}
+
+			// tool-call events are ignored, so no tool_call chunks should be emitted
+			const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
+			expect(toolCallChunks.length).toBe(0)
+		})
+	})
+
+	describe("getMaxOutputTokens", () => {
+		it("should return maxTokens from model info", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testGetMaxOutputTokens() {
+					return this.getMaxOutputTokens()
+				}
+			}
+
+			const testHandler = new TestDeepSeekHandler(mockOptions)
+			const result = testHandler.testGetMaxOutputTokens()
+
+			// Default model maxTokens is 8192
+			expect(result).toBe(8192)
+		})
+
+		it("should use modelMaxTokens when provided", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testGetMaxOutputTokens() {
+					return this.getMaxOutputTokens()
+				}
+			}
+
+			const customMaxTokens = 5000
+			const testHandler = new TestDeepSeekHandler({
+				...mockOptions,
+				modelMaxTokens: customMaxTokens,
+			})
+
+			const result = testHandler.testGetMaxOutputTokens()
+			expect(result).toBe(customMaxTokens)
+		})
+
+		it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testGetMaxOutputTokens() {
+					return this.getMaxOutputTokens()
+				}
+			}
+
+			const testHandler = new TestDeepSeekHandler(mockOptions)
+			const result = testHandler.testGetMaxOutputTokens()
+
+			// deepseek-chat has maxTokens of 8192
+			expect(result).toBe(8192)
+		})
+	})
+
+	describe("mapToolChoice", () => {
+		it("should handle string tool choices", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testMapToolChoice(toolChoice: any) {
+					return this.mapToolChoice(toolChoice)
+				}
+			}
+
+			const testHandler = new TestDeepSeekHandler(mockOptions)
+
+			expect(testHandler.testMapToolChoice("auto")).toBe("auto")
+			expect(testHandler.testMapToolChoice("none")).toBe("none")
+			expect(testHandler.testMapToolChoice("required")).toBe("required")
+			expect(testHandler.testMapToolChoice("unknown")).toBe("auto")
+		})
+
+		it("should handle object tool choice with function name", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testMapToolChoice(toolChoice: any) {
+					return this.mapToolChoice(toolChoice)
+				}
+			}
+
+			const testHandler = new TestDeepSeekHandler(mockOptions)
+
+			const result = testHandler.testMapToolChoice({
+				type: "function",
+				function: { name: "my_tool" },
+			})
+
+			expect(result).toEqual({ type: "tool", toolName: "my_tool" })
+		})
+
+		it("should return undefined for null or undefined", () => {
+			class TestDeepSeekHandler extends DeepSeekHandler {
+				public testMapToolChoice(toolChoice: any) {
+					return this.mapToolChoice(toolChoice)
+				}
+			}
+
+			const testHandler = new TestDeepSeekHandler(mockOptions)
 
 
-			// Should have tool call chunks
-			const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
-			expect(toolCallChunks.length).toBeGreaterThan(0)
-			expect(toolCallChunks[0].name).toBe("get_weather")
+			expect(testHandler.testMapToolChoice(null)).toBeUndefined()
+			expect(testHandler.testMapToolChoice(undefined)).toBeUndefined()
 		})
 		})
 	})
 	})
 })
 })

+ 6 - 5
src/api/providers/__tests__/moonshot.spec.ts

@@ -419,7 +419,10 @@ describe("MoonshotHandler", () => {
 			expect(toolCallEndChunks[0].id).toBe("tool-call-1")
 			expect(toolCallEndChunks[0].id).toBe("tool-call-1")
 		})
 		})
 
 
-		it("should handle complete tool calls", async () => {
+		it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
+			// tool-call events are intentionally ignored because tool-input-start/delta/end
+			// already provide complete tool call information. Emitting tool-call would cause
+			// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
 			async function* mockFullStream() {
 			async function* mockFullStream() {
 				yield {
 				yield {
 					type: "tool-call",
 					type: "tool-call",
@@ -464,11 +467,9 @@ describe("MoonshotHandler", () => {
 				chunks.push(chunk)
 				chunks.push(chunk)
 			}
 			}
 
 
+			// tool-call events are ignored, so no tool_call chunks should be emitted
 			const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
 			const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
-			expect(toolCallChunks.length).toBe(1)
-			expect(toolCallChunks[0].id).toBe("tool-call-1")
-			expect(toolCallChunks[0].name).toBe("read_file")
-			expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}')
+			expect(toolCallChunks.length).toBe(0)
 		})
 		})
 	})
 	})
 })
 })

+ 154 - 112
src/api/providers/deepseek.ts

@@ -1,150 +1,192 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import { Anthropic } from "@anthropic-ai/sdk"
-import OpenAI from "openai"
+import { createDeepSeek } from "@ai-sdk/deepseek"
+import { streamText, generateText, ToolSet } from "ai"
 
 
-import {
-	deepSeekModels,
-	deepSeekDefaultModelId,
-	DEEP_SEEK_DEFAULT_TEMPERATURE,
-	OPENAI_AZURE_AI_INFERENCE_PATH,
-} from "@roo-code/types"
+import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types"
 
 
 import type { ApiHandlerOptions } from "../../shared/api"
 import type { ApiHandlerOptions } from "../../shared/api"
 
 
+import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk"
 import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
 import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
 import { getModelParams } from "../transform/model-params"
 import { getModelParams } from "../transform/model-params"
-import { convertToR1Format } from "../transform/r1-format"
 
 
-import { OpenAiHandler } from "./openai"
-import type { ApiHandlerCreateMessageMetadata } from "../index"
+import { DEFAULT_HEADERS } from "./constants"
+import { BaseProvider } from "./base-provider"
+import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
 
 
-// Custom interface for DeepSeek params to support thinking mode
-type DeepSeekChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
-	thinking?: { type: "enabled" | "disabled" }
-}
+/**
+ * DeepSeek provider using the dedicated @ai-sdk/deepseek package.
+ * Provides native support for reasoning (deepseek-reasoner) and prompt caching.
+ */
+export class DeepSeekHandler extends BaseProvider implements SingleCompletionHandler {
+	protected options: ApiHandlerOptions
+	protected provider: ReturnType<typeof createDeepSeek>
 
 
-export class DeepSeekHandler extends OpenAiHandler {
 	constructor(options: ApiHandlerOptions) {
 	constructor(options: ApiHandlerOptions) {
-		super({
-			...options,
-			openAiApiKey: options.deepSeekApiKey ?? "not-provided",
-			openAiModelId: options.apiModelId ?? deepSeekDefaultModelId,
-			openAiBaseUrl: options.deepSeekBaseUrl ?? "https://api.deepseek.com",
-			openAiStreamingEnabled: true,
-			includeMaxTokens: true,
+		super()
+		this.options = options
+
+		// Create the DeepSeek provider using AI SDK
+		this.provider = createDeepSeek({
+			baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1",
+			apiKey: options.deepSeekApiKey ?? "not-provided",
+			headers: DEFAULT_HEADERS,
 		})
 		})
 	}
 	}
 
 
-	override getModel() {
+	override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
 		const id = this.options.apiModelId ?? deepSeekDefaultModelId
 		const id = this.options.apiModelId ?? deepSeekDefaultModelId
 		const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId]
 		const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId]
 		const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
 		const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
 		return { id, info, ...params }
 		return { id, info, ...params }
 	}
 	}
 
 
-	override async *createMessage(
-		systemPrompt: string,
-		messages: Anthropic.Messages.MessageParam[],
-		metadata?: ApiHandlerCreateMessageMetadata,
-	): ApiStream {
-		const modelId = this.options.apiModelId ?? deepSeekDefaultModelId
-		const { info: modelInfo } = this.getModel()
-
-		// Check if this is a thinking-enabled model (deepseek-reasoner)
-		const isThinkingModel = modelId.includes("deepseek-reasoner")
-
-		// Convert messages to R1 format (merges consecutive same-role messages)
-		// This is required for DeepSeek which does not support successive messages with the same role
-		// For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content
-		// during tool call sequences. Without this, environment_details text after tool_results would
-		// create user messages that cause DeepSeek to drop all previous reasoning_content.
-		// See: https://api-docs.deepseek.com/guides/thinking_mode
-		const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
-			mergeToolResultText: isThinkingModel,
-		})
+	/**
+	 * Get the language model for the configured model ID.
+	 */
+	protected getLanguageModel() {
+		const { id } = this.getModel()
+		return this.provider(id)
+	}
 
 
-		const requestOptions: DeepSeekChatCompletionParams = {
-			model: modelId,
-			temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
-			messages: convertedMessages,
-			stream: true as const,
-			stream_options: { include_usage: true },
-			// Enable thinking mode for deepseek-reasoner or when tools are used with thinking model
-			...(isThinkingModel && { thinking: { type: "enabled" } }),
-			tools: this.convertToolsForOpenAI(metadata?.tools),
-			tool_choice: metadata?.tool_choice,
-			parallel_tool_calls: metadata?.parallelToolCalls ?? true,
-		}
+	/**
+	 * Process usage metrics from the AI SDK response, including DeepSeek's cache metrics.
+	 * DeepSeek provides cache hit/miss info via providerMetadata.
+	 */
+	protected processUsageMetrics(
+		usage: {
+			inputTokens?: number
+			outputTokens?: number
+			details?: {
+				cachedInputTokens?: number
+				reasoningTokens?: number
+			}
+		},
+		providerMetadata?: {
+			deepseek?: {
+				promptCacheHitTokens?: number
+				promptCacheMissTokens?: number
+			}
+		},
+	): ApiStreamUsageChunk {
+		// Extract cache metrics from DeepSeek's providerMetadata
+		const cacheReadTokens = providerMetadata?.deepseek?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
+		const cacheWriteTokens = providerMetadata?.deepseek?.promptCacheMissTokens
 
 
-		// Add max_tokens if needed
-		this.addMaxTokensIfNeeded(requestOptions, modelInfo)
-
-		// Check if base URL is Azure AI Inference (for DeepSeek via Azure)
-		const isAzureAiInference = this._isAzureAiInference(this.options.deepSeekBaseUrl)
-
-		let stream
-		try {
-			stream = await this.client.chat.completions.create(
-				requestOptions,
-				isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
-			)
-		} catch (error) {
-			const { handleOpenAIError } = await import("./utils/openai-error-handler")
-			throw handleOpenAIError(error, "DeepSeek")
+		return {
+			type: "usage",
+			inputTokens: usage.inputTokens || 0,
+			outputTokens: usage.outputTokens || 0,
+			cacheReadTokens,
+			cacheWriteTokens,
+			reasoningTokens: usage.details?.reasoningTokens,
 		}
 		}
+	}
 
 
-		let lastUsage
-
-		for await (const chunk of stream) {
-			const delta = chunk.choices?.[0]?.delta ?? {}
+	/**
+	 * Map OpenAI tool_choice to AI SDK toolChoice format.
+	 */
+	protected mapToolChoice(
+		toolChoice: any,
+	): "auto" | "none" | "required" | { type: "tool"; toolName: string } | undefined {
+		if (!toolChoice) {
+			return undefined
+		}
 
 
-			// Handle regular text content
-			if (delta.content) {
-				yield {
-					type: "text",
-					text: delta.content,
-				}
+		// Handle string values
+		if (typeof toolChoice === "string") {
+			switch (toolChoice) {
+				case "auto":
+					return "auto"
+				case "none":
+					return "none"
+				case "required":
+					return "required"
+				default:
+					return "auto"
 			}
 			}
+		}
 
 
-			// Handle reasoning_content from DeepSeek's interleaved thinking
-			// This is the proper way DeepSeek sends thinking content in streaming
-			if ("reasoning_content" in delta && delta.reasoning_content) {
-				yield {
-					type: "reasoning",
-					text: (delta.reasoning_content as string) || "",
-				}
+		// Handle object values (OpenAI ChatCompletionNamedToolChoice format)
+		if (typeof toolChoice === "object" && "type" in toolChoice) {
+			if (toolChoice.type === "function" && "function" in toolChoice && toolChoice.function?.name) {
+				return { type: "tool", toolName: toolChoice.function.name }
 			}
 			}
+		}
 
 
-			// Handle tool calls
-			if (delta.tool_calls) {
-				for (const toolCall of delta.tool_calls) {
-					yield {
-						type: "tool_call_partial",
-						index: toolCall.index,
-						id: toolCall.id,
-						name: toolCall.function?.name,
-						arguments: toolCall.function?.arguments,
-					}
-				}
-			}
+		return undefined
+	}
 
 
-			if (chunk.usage) {
-				lastUsage = chunk.usage
+	/**
+	 * Get the max tokens parameter to include in the request.
+	 */
+	protected getMaxOutputTokens(): number | undefined {
+		const { info } = this.getModel()
+		return this.options.modelMaxTokens || info.maxTokens || undefined
+	}
+
+	/**
+	 * Create a message stream using the AI SDK.
+	 * The AI SDK automatically handles reasoning for deepseek-reasoner model.
+	 */
+	override async *createMessage(
+		systemPrompt: string,
+		messages: Anthropic.Messages.MessageParam[],
+		metadata?: ApiHandlerCreateMessageMetadata,
+	): ApiStream {
+		const { temperature } = this.getModel()
+		const languageModel = this.getLanguageModel()
+
+		// Convert messages to AI SDK format
+		const aiSdkMessages = convertToAiSdkMessages(messages)
+
+		// Convert tools to OpenAI format first, then to AI SDK format
+		const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
+		const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
+
+		// Build the request options
+		const requestOptions: Parameters<typeof streamText>[0] = {
+			model: languageModel,
+			system: systemPrompt,
+			messages: aiSdkMessages,
+			temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
+			maxOutputTokens: this.getMaxOutputTokens(),
+			tools: aiSdkTools,
+			toolChoice: this.mapToolChoice(metadata?.tool_choice),
+		}
+
+		// Use streamText for streaming responses
+		const result = streamText(requestOptions)
+
+		// Process the full stream to get all events including reasoning
+		for await (const part of result.fullStream) {
+			for (const chunk of processAiSdkStreamPart(part)) {
+				yield chunk
 			}
 			}
 		}
 		}
 
 
-		if (lastUsage) {
-			yield this.processUsageMetrics(lastUsage, modelInfo)
+		// Yield usage metrics at the end, including cache metrics from providerMetadata
+		const usage = await result.usage
+		const providerMetadata = await result.providerMetadata
+		if (usage) {
+			yield this.processUsageMetrics(usage, providerMetadata as any)
 		}
 		}
 	}
 	}
 
 
-	// Override to handle DeepSeek's usage metrics, including caching.
-	protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk {
-		return {
-			type: "usage",
-			inputTokens: usage?.prompt_tokens || 0,
-			outputTokens: usage?.completion_tokens || 0,
-			cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens,
-			cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens,
-		}
+	/**
+	 * Complete a prompt using the AI SDK generateText.
+	 */
+	async completePrompt(prompt: string): Promise<string> {
+		const { temperature } = this.getModel()
+		const languageModel = this.getLanguageModel()
+
+		const { text } = await generateText({
+			model: languageModel,
+			prompt,
+			maxOutputTokens: this.getMaxOutputTokens(),
+			temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
+		})
+
+		return text
 	}
 	}
 }
 }

+ 5 - 8
src/api/transform/__tests__/ai-sdk.spec.ts

@@ -419,7 +419,10 @@ describe("AI SDK conversion utilities", () => {
 			expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
 			expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
 		})
 		})
 
 
-		it("processes complete tool-call chunks", () => {
+		it("ignores tool-call chunks to prevent duplicate tools in UI", () => {
+			// tool-call is intentionally ignored because tool-input-start/delta/end already
+			// provide complete tool call information. Emitting tool-call would cause duplicate
+			// tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
 			const part = {
 			const part = {
 				type: "tool-call" as const,
 				type: "tool-call" as const,
 				toolCallId: "call_1",
 				toolCallId: "call_1",
@@ -428,13 +431,7 @@ describe("AI SDK conversion utilities", () => {
 			}
 			}
 			const chunks = [...processAiSdkStreamPart(part)]
 			const chunks = [...processAiSdkStreamPart(part)]
 
 
-			expect(chunks).toHaveLength(1)
-			expect(chunks[0]).toEqual({
-				type: "tool_call",
-				id: "call_1",
-				name: "read_file",
-				arguments: '{"path":"test.ts"}',
-			})
+			expect(chunks).toHaveLength(0)
 		})
 		})
 
 
 		it("processes source chunks with URL", () => {
 		it("processes source chunks with URL", () => {

+ 5 - 12
src/api/transform/ai-sdk.ts

@@ -228,16 +228,6 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
 			}
 			}
 			break
 			break
 
 
-		case "tool-call":
-			// Complete tool call - emit for compatibility
-			yield {
-				type: "tool_call",
-				id: part.toolCallId,
-				name: part.toolName,
-				arguments: typeof part.input === "string" ? part.input : JSON.stringify(part.input),
-			}
-			break
-
 		case "source":
 		case "source":
 			// Handle both URL and document source types
 			// Handle both URL and document source types
 			if ("url" in part) {
 			if ("url" in part) {
@@ -262,7 +252,10 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
 			}
 			}
 			break
 			break
 
 
-		// Ignore lifecycle events that don't need to yield chunks
+		// Ignore lifecycle events that don't need to yield chunks.
+		// Note: tool-call is intentionally ignored because tool-input-start/delta/end already
+		// provide complete tool call information. Emitting tool-call would cause duplicate
+		// tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
 		case "text-start":
 		case "text-start":
 		case "text-end":
 		case "text-end":
 		case "reasoning-start":
 		case "reasoning-start":
@@ -275,8 +268,8 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
 		case "file":
 		case "file":
 		case "tool-result":
 		case "tool-result":
 		case "tool-error":
 		case "tool-error":
+		case "tool-call":
 		case "raw":
 		case "raw":
-			// These events don't need to be yielded
 			break
 			break
 	}
 	}
 }
 }

+ 130 - 109
src/core/task/Task.ts

@@ -390,6 +390,127 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		this.userMessageContent.push(toolResult)
 		this.userMessageContent.push(toolResult)
 		return true
 		return true
 	}
 	}
+
+	/**
+	 * Handle a tool call streaming event (tool_call_start, tool_call_delta, or tool_call_end).
+	 * This is used both for processing events from NativeToolCallParser (legacy providers)
+	 * and for direct AI SDK events (DeepSeek, Moonshot, etc.).
+	 *
+	 * @param event - The tool call event to process
+	 */
+	private handleToolCallEvent(
+		event:
+			| { type: "tool_call_start"; id: string; name: string }
+			| { type: "tool_call_delta"; id: string; delta: string }
+			| { type: "tool_call_end"; id: string },
+	): void {
+		if (event.type === "tool_call_start") {
+			// Guard against duplicate tool_call_start events for the same tool ID.
+			// This can occur due to stream retry, reconnection, or API quirks.
+			// Without this check, duplicate tool_use blocks with the same ID would
+			// be added to assistantMessageContent, causing API 400 errors:
+			// "tool_use ids must be unique"
+			if (this.streamingToolCallIndices.has(event.id)) {
+				console.warn(
+					`[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`,
+				)
+				return
+			}
+
+			// Initialize streaming in NativeToolCallParser
+			NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName)
+
+			// Before adding a new tool, finalize any preceding text block
+			// This prevents the text block from blocking tool presentation
+			const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1]
+			if (lastBlock?.type === "text" && lastBlock.partial) {
+				lastBlock.partial = false
+			}
+
+			// Track the index where this tool will be stored
+			const toolUseIndex = this.assistantMessageContent.length
+			this.streamingToolCallIndices.set(event.id, toolUseIndex)
+
+			// Create initial partial tool use
+			const partialToolUse: ToolUse = {
+				type: "tool_use",
+				name: event.name as ToolName,
+				params: {},
+				partial: true,
+			}
+
+			// Store the ID for native protocol
+			;(partialToolUse as any).id = event.id
+
+			// Add to content and present
+			this.assistantMessageContent.push(partialToolUse)
+			this.userMessageContentReady = false
+			presentAssistantMessage(this)
+		} else if (event.type === "tool_call_delta") {
+			// Process chunk using streaming JSON parser
+			const partialToolUse = NativeToolCallParser.processStreamingChunk(event.id, event.delta)
+
+			if (partialToolUse) {
+				// Get the index for this tool call
+				const toolUseIndex = this.streamingToolCallIndices.get(event.id)
+				if (toolUseIndex !== undefined) {
+					// Store the ID for native protocol
+					;(partialToolUse as any).id = event.id
+
+					// Update the existing tool use with new partial data
+					this.assistantMessageContent[toolUseIndex] = partialToolUse
+
+					// Present updated tool use
+					presentAssistantMessage(this)
+				}
+			}
+		} else if (event.type === "tool_call_end") {
+			// Finalize the streaming tool call
+			const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)
+
+			// Get the index for this tool call
+			const toolUseIndex = this.streamingToolCallIndices.get(event.id)
+
+			if (finalToolUse) {
+				// Store the tool call ID
+				;(finalToolUse as any).id = event.id
+
+				// Get the index and replace partial with final
+				if (toolUseIndex !== undefined) {
+					this.assistantMessageContent[toolUseIndex] = finalToolUse
+				}
+
+				// Clean up tracking
+				this.streamingToolCallIndices.delete(event.id)
+
+				// Mark that we have new content to process
+				this.userMessageContentReady = false
+
+				// Present the finalized tool call
+				presentAssistantMessage(this)
+			} else if (toolUseIndex !== undefined) {
+				// finalizeStreamingToolCall returned null (malformed JSON or missing args)
+				// Mark the tool as non-partial so it's presented as complete, but execution
+				// will be short-circuited in presentAssistantMessage with a structured tool_result.
+				const existingToolUse = this.assistantMessageContent[toolUseIndex]
+				if (existingToolUse && existingToolUse.type === "tool_use") {
+					existingToolUse.partial = false
+					// Ensure it has the ID for native protocol
+					;(existingToolUse as any).id = event.id
+				}
+
+				// Clean up tracking
+				this.streamingToolCallIndices.delete(event.id)
+
+				// Mark that we have new content to process
+				this.userMessageContentReady = false
+
+				// Present the tool call - validation will handle missing params
+				presentAssistantMessage(this)
+			}
+		}
+	}
+
 	didRejectTool = false
 	didRejectTool = false
 	didAlreadyUseTool = false
 	didAlreadyUseTool = false
 	didToolFailInCurrentTurn = false
 	didToolFailInCurrentTurn = false
@@ -2859,119 +2980,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 								})
 								})
 
 
 								for (const event of events) {
 								for (const event of events) {
-									if (event.type === "tool_call_start") {
-										// Guard against duplicate tool_call_start events for the same tool ID.
-										// This can occur due to stream retry, reconnection, or API quirks.
-										// Without this check, duplicate tool_use blocks with the same ID would
-										// be added to assistantMessageContent, causing API 400 errors:
-										// "tool_use ids must be unique"
-										if (this.streamingToolCallIndices.has(event.id)) {
-											console.warn(
-												`[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`,
-											)
-											continue
-										}
-
-										// Initialize streaming in NativeToolCallParser
-										NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName)
-
-										// Before adding a new tool, finalize any preceding text block
-										// This prevents the text block from blocking tool presentation
-										const lastBlock =
-											this.assistantMessageContent[this.assistantMessageContent.length - 1]
-										if (lastBlock?.type === "text" && lastBlock.partial) {
-											lastBlock.partial = false
-										}
-
-										// Track the index where this tool will be stored
-										const toolUseIndex = this.assistantMessageContent.length
-										this.streamingToolCallIndices.set(event.id, toolUseIndex)
-
-										// Create initial partial tool use
-										const partialToolUse: ToolUse = {
-											type: "tool_use",
-											name: event.name as ToolName,
-											params: {},
-											partial: true,
-										}
-
-										// Store the ID for native protocol
-										;(partialToolUse as any).id = event.id
-
-										// Add to content and present
-										this.assistantMessageContent.push(partialToolUse)
-										this.userMessageContentReady = false
-										presentAssistantMessage(this)
-									} else if (event.type === "tool_call_delta") {
-										// Process chunk using streaming JSON parser
-										const partialToolUse = NativeToolCallParser.processStreamingChunk(
-											event.id,
-											event.delta,
-										)
-
-										if (partialToolUse) {
-											// Get the index for this tool call
-											const toolUseIndex = this.streamingToolCallIndices.get(event.id)
-											if (toolUseIndex !== undefined) {
-												// Store the ID for native protocol
-												;(partialToolUse as any).id = event.id
-
-												// Update the existing tool use with new partial data
-												this.assistantMessageContent[toolUseIndex] = partialToolUse
-
-												// Present updated tool use
-												presentAssistantMessage(this)
-											}
-										}
-									} else if (event.type === "tool_call_end") {
-										// Finalize the streaming tool call
-										const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)
-
-										// Get the index for this tool call
-										const toolUseIndex = this.streamingToolCallIndices.get(event.id)
-
-										if (finalToolUse) {
-											// Store the tool call ID
-											;(finalToolUse as any).id = event.id
-
-											// Get the index and replace partial with final
-											if (toolUseIndex !== undefined) {
-												this.assistantMessageContent[toolUseIndex] = finalToolUse
-											}
-
-											// Clean up tracking
-											this.streamingToolCallIndices.delete(event.id)
-
-											// Mark that we have new content to process
-											this.userMessageContentReady = false
-
-											// Present the finalized tool call
-											presentAssistantMessage(this)
-										} else if (toolUseIndex !== undefined) {
-											// finalizeStreamingToolCall returned null (malformed JSON or missing args)
-											// Mark the tool as non-partial so it's presented as complete, but execution
-											// will be short-circuited in presentAssistantMessage with a structured tool_result.
-											const existingToolUse = this.assistantMessageContent[toolUseIndex]
-											if (existingToolUse && existingToolUse.type === "tool_use") {
-												existingToolUse.partial = false
-												// Ensure it has the ID for native protocol
-												;(existingToolUse as any).id = event.id
-											}
-
-											// Clean up tracking
-											this.streamingToolCallIndices.delete(event.id)
-
-											// Mark that we have new content to process
-											this.userMessageContentReady = false
-
-											// Present the tool call - validation will handle missing params
-											presentAssistantMessage(this)
-										}
-									}
+									this.handleToolCallEvent(event)
 								}
 								}
 								break
 								break
 							}
 							}
 
 
+							// Direct handlers for AI SDK tool streaming events (DeepSeek, Moonshot, etc.)
+							// These providers emit tool_call_start/delta/end directly instead of tool_call_partial
+							case "tool_call_start":
+							case "tool_call_delta":
+							case "tool_call_end":
+								this.handleToolCallEvent(chunk)
+								break
+
 							case "tool_call": {
 							case "tool_call": {
 								// Legacy: Handle complete tool calls (for backward compatibility)
 								// Legacy: Handle complete tool calls (for backward compatibility)
 								// Convert native tool call to ToolUse format
 								// Convert native tool call to ToolUse format

+ 1 - 0
src/package.json

@@ -450,6 +450,7 @@
 		"clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo"
 		"clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo"
 	},
 	},
 	"dependencies": {
 	"dependencies": {
+		"@ai-sdk/deepseek": "^2.0.14",
 		"@anthropic-ai/bedrock-sdk": "^0.10.2",
 		"@anthropic-ai/bedrock-sdk": "^0.10.2",
 		"@anthropic-ai/sdk": "^0.37.0",
 		"@anthropic-ai/sdk": "^0.37.0",
 		"@anthropic-ai/vertex-sdk": "^0.7.0",
 		"@anthropic-ai/vertex-sdk": "^0.7.0",