From 908677b60562e500e55f943efc22441cc43521ef Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 29 Jan 2026 12:12:25 -0500 Subject: [PATCH] feat: migrate DeepSeek to @ai-sdk/deepseek + fix AI SDK tool streaming - Migrate DeepSeek provider from OpenAI SDK to @ai-sdk/deepseek - Add native reasoning support for deepseek-reasoner model - Extract cache metrics from providerMetadata.deepseek - Fix tool streaming for all AI SDK providers (DeepSeek, Moonshot) - Add handleToolCallEvent() helper in Task.ts for DRY event handling - Add top-level handlers for tool_call_start/delta/end events - Fix duplicate tool rendering by ignoring redundant tool-call events - Update all tests to use AI SDK mocks (29/29 passing) Closes EXT-644 --- pnpm-lock.yaml | 17 +- src/api/providers/__tests__/deepseek.spec.ts | 680 +++++++++++++------ src/api/providers/__tests__/moonshot.spec.ts | 11 +- src/api/providers/deepseek.ts | 266 +++++--- src/api/transform/__tests__/ai-sdk.spec.ts | 13 +- src/api/transform/ai-sdk.ts | 17 +- src/core/task/Task.ts | 239 ++++--- src/package.json | 1 + 8 files changed, 800 insertions(+), 444 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32d5ddfba8c..7930fe5352c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -743,6 +743,9 @@ importers: src: dependencies: + '@ai-sdk/deepseek': + specifier: ^2.0.14 + version: 2.0.14(zod@3.25.76) '@anthropic-ai/bedrock-sdk': specifier: ^0.10.2 version: 0.10.4 @@ -1387,6 +1390,12 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ai-sdk/deepseek@2.0.14': + resolution: {integrity: sha512-1vXh8sVwRJYd1JO57qdy1rACucaNLDoBRCwOER3EbPgSF2vNVPcdJywGutA01Bhn7Cta+UJQ+k5y/yzMAIpP2w==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/gateway@3.0.25': resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==} engines: {node: '>=18'} @@ -10810,6 +10819,12 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@ai-sdk/deepseek@2.0.14(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/gateway@3.0.25(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.5 @@ -14671,7 +14686,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 1aac662d9a8..82b08aaad5d 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/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() 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 { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types" @@ -148,15 +51,6 @@ describe("DeepSeekHandler", () => { 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", () => { const handlerWithoutModel = new DeepSeekHandler({ ...mockOptions, @@ -171,12 +65,6 @@ describe("DeepSeekHandler", () => { deepSeekBaseUrl: undefined, }) 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", () => { @@ -186,18 +74,6 @@ describe("DeepSeekHandler", () => { deepSeekBaseUrl: customBaseUrl, }) 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 () => { + // 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 chunks: any[] = [] for await (const chunk of stream) { @@ -309,6 +210,28 @@ describe("DeepSeekHandler", () => { }) 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 chunks: any[] = [] for await (const chunk of stream) { @@ -321,7 +244,30 @@ describe("DeepSeekHandler", () => { 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 chunks: any[] = [] for await (const chunk of stream) { @@ -330,29 +276,76 @@ describe("DeepSeekHandler", () => { const usageChunks = chunks.filter((chunk) => chunk.type === "usage") 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", () => { - 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 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 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.inputTokens).toBe(100) 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", () => { 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 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) @@ -391,7 +384,7 @@ describe("DeepSeekHandler", () => { }) }) - describe("interleaved thinking mode", () => { + describe("reasoning content with deepseek-reasoner", () => { const systemPrompt = "You are a helpful assistant." 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({ ...mockOptions, 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 chunks: any[] = [] for await (const chunk of stream) { @@ -419,54 +441,91 @@ describe("DeepSeekHandler", () => { // Should have reasoning chunks 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[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({ ...mockOptions, 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({ ...mockOptions, 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[] = [ { type: "function", @@ -486,12 +545,239 @@ describe("DeepSeekHandler", () => { // Should have reasoning chunks 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() }) }) }) diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index 9040ed23ca7..1bfd482fd94 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -419,7 +419,10 @@ describe("MoonshotHandler", () => { 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() { yield { type: "tool-call", @@ -464,11 +467,9 @@ describe("MoonshotHandler", () => { 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(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) }) }) }) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 17ce6e0db76..27ecaebd5e9 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,150 +1,192 @@ 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 { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" 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 -export class DeepSeekHandler extends OpenAiHandler { 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 info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId] const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) 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[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 { + 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 } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 4a82ecac4ee..293d720d482 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/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" }) }) - 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 = { type: "tool-call" as const, toolCallId: "call_1", @@ -428,13 +431,7 @@ describe("AI SDK conversion utilities", () => { } 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", () => { diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 535b932aba7..fd86532bf17 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -228,16 +228,6 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator implements TaskLike { this.userMessageContent.push(toolResult) 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 didAlreadyUseTool = false didToolFailInCurrentTurn = false @@ -2859,119 +2980,19 @@ export class Task extends EventEmitter implements TaskLike { }) 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 } + // 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": { // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format diff --git a/src/package.json b/src/package.json index acea49056af..0640ed00d1d 100644 --- a/src/package.json +++ b/src/package.json @@ -450,6 +450,7 @@ "clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo" }, "dependencies": { + "@ai-sdk/deepseek": "^2.0.14", "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0",