// Mocks must come first, before imports const mockCreate = vi.fn() vi.mock("openai", () => { return { __esModule: true, default: vi.fn().mockImplementation(function () { return { 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 thinking mode const isThinkingModel = options.thinking?.type === "enabled" const isToolCallTest = options.tools?.length > 0 // Return async iterator for streaming return { [Symbol.asyncIterator]: async function* () { // For thinking models, emit reasoning_content first if (isThinkingModel) { 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 thinking mode, emit tool call if (isThinkingModel && 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, }, }, } }, } }), }, }, } }), } }) import OpenAI from "openai" import type { Anthropic } from "@anthropic-ai/sdk" import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" import { DeepSeekHandler } from "../deepseek" describe("DeepSeekHandler", () => { let handler: DeepSeekHandler let mockOptions: ApiHandlerOptions beforeEach(() => { mockOptions = { deepSeekApiKey: "test-api-key", apiModelId: "deepseek-v4-flash", deepSeekBaseUrl: "https://api.deepseek.com", } handler = new DeepSeekHandler(mockOptions) vi.clearAllMocks() }) describe("constructor", () => { it("should initialize with provided options", () => { expect(handler).toBeInstanceOf(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, apiModelId: undefined, }) expect(handlerWithoutModel.getModel().id).toBe(deepSeekDefaultModelId) }) it("should use default base URL if not provided", () => { const handlerWithoutBaseUrl = new DeepSeekHandler({ ...mockOptions, 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", () => { const customBaseUrl = "https://custom.deepseek.com/v1" const handlerWithCustomUrl = new DeepSeekHandler({ ...mockOptions, 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 })) }) }) describe("getModel", () => { it("should return model info for valid model ID", () => { const model = handler.getModel() expect(model.id).toBe(mockOptions.apiModelId) expect(model.info).toBeDefined() expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) expect(model.info.supportsImages).toBe(true) expect(model.info.supportsPromptCache).toBe(true) // Should be true now expect((model.info as ModelInfo).preserveReasoning).toBe(true) }) it("should use deepseek-v4-flash as the default model ID for new configs", () => { const handlerWithoutModel = new DeepSeekHandler({ ...mockOptions, apiModelId: undefined, }) const model = handlerWithoutModel.getModel() expect(model.id).toBe(deepSeekDefaultModelId) expect(model.id).toBe("deepseek-v4-flash") expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) expect(model.info.supportsImages).toBe(true) expect((model.info as ModelInfo).supportsReasoningEffort).toContain("max") }) it("should return correct model info for deepseek-v4-pro", () => { const handlerWithV4Pro = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-pro", }) const model = handlerWithV4Pro.getModel() expect(model.id).toBe("deepseek-v4-pro") expect(model.info).toBeDefined() expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) expect(model.info.supportsImages).toBe(true) expect(model.info.supportsPromptCache).toBe(true) expect((model.info as ModelInfo).preserveReasoning).toBe(true) expect((model.info as ModelInfo).reasoningEffort).toBe("high") }) it("should return provided model ID with default model info if model does not exist", () => { const handlerWithInvalidModel = new DeepSeekHandler({ ...mockOptions, apiModelId: "invalid-model", }) const defaultHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: undefined, }) const model = handlerWithInvalidModel.getModel() expect(model.id).toBe("invalid-model") // Returns provided ID expect(model.info).toBeDefined() // With the current implementation, it's the same object reference when using default model info expect(model.info).toBe(defaultHandler.getModel().info) // Should have the same base properties expect(model.info.contextWindow).toBe(defaultHandler.getModel().info.contextWindow) // And should have supportsPromptCache set to true expect(model.info.supportsPromptCache).toBe(true) }) it("should return default model if no model ID is provided", () => { const handlerWithoutModel = new DeepSeekHandler({ ...mockOptions, apiModelId: undefined, }) const model = handlerWithoutModel.getModel() expect(model.id).toBe(deepSeekDefaultModelId) expect(model.info).toBeDefined() expect(model.info.supportsPromptCache).toBe(true) }) it("should include model parameters from getModelParams", () => { const model = handler.getModel() expect(model).toHaveProperty("temperature") expect(model).toHaveProperty("maxTokens") }) it("should use DEEP_SEEK_DEFAULT_TEMPERATURE as the default temperature", () => { const model = handler.getModel() expect(model.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) }) it("should respect user-provided temperature over DEEP_SEEK_DEFAULT_TEMPERATURE", () => { const handlerWithTemp = new DeepSeekHandler({ ...mockOptions, modelTemperature: 0.9, }) const model = handlerWithTemp.getModel() expect(model.temperature).toBe(0.9) }) }) describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ { type: "text" as const, text: "Hello!", }, ], }, ] it("should handle streaming responses", async () => { const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } expect(chunks.length).toBeGreaterThan(0) const textChunks = chunks.filter((chunk) => chunk.type === "text") expect(textChunks).toHaveLength(1) expect(textChunks[0].text).toBe("Test response") }) it("should include usage information", async () => { const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } const usageChunks = chunks.filter((chunk) => chunk.type === "usage") expect(usageChunks.length).toBeGreaterThan(0) expect(usageChunks[0].inputTokens).toBe(10) expect(usageChunks[0].outputTokens).toBe(5) }) it("should include cache metrics in usage information", async () => { const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } 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) }) it("streams reasoning chunks from delta.reasoning_content", async () => { mockCreate.mockImplementationOnce(async () => ({ [Symbol.asyncIterator]: async function* () { yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] } yield { choices: [{ delta: { content: "answer" }, index: 0 }] } yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, } }, })) const chunks: any[] = [] for await (const chunk of handler.createMessage(systemPrompt, messages)) { chunks.push(chunk) } expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) }) it("falls back to delta.reasoning when reasoning_content is absent", async () => { mockCreate.mockImplementationOnce(async () => ({ [Symbol.asyncIterator]: async function* () { yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] } yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, } }, })) const chunks: any[] = [] for await (const chunk of handler.createMessage(systemPrompt, messages)) { chunks.push(chunk) } expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) }) it("should throw a wrapped error when the API call fails", async () => { const apiError = Object.assign(new Error("Invalid API key"), { status: 401 }) mockCreate.mockRejectedValueOnce(apiError) const stream = handler.createMessage(systemPrompt, messages) const err = await stream.next().catch((e) => e) expect(err.message).toBe("DeepSeek completion error: Invalid API key") expect((err as any).status).toBe(401) }) it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => { mockCreate.mockImplementationOnce(async () => ({ [Symbol.asyncIterator]: async function* () { yield { choices: [ { delta: { reasoning_content: "primary thought", reasoning: "fallback thought", }, index: 0, }, ], } yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, } }, })) const chunks: any[] = [] for await (const chunk of handler.createMessage(systemPrompt, messages)) { chunks.push(chunk) } const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) }) }) describe("processUsageMetrics", () => { it("should correctly process usage metrics including cache information", () => { // 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) } } 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, }, } const result = testHandler.testProcessUsageMetrics(usage) 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) }) it("should handle missing cache metrics gracefully", () => { class TestDeepSeekHandler extends DeepSeekHandler { public testProcessUsageMetrics(usage: any) { return this.processUsageMetrics(usage) } } const testHandler = new TestDeepSeekHandler(mockOptions) const usage = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150, // No prompt_tokens_details } const result = testHandler.testProcessUsageMetrics(usage) expect(result.type).toBe("usage") expect(result.inputTokens).toBe(100) expect(result.outputTokens).toBe(50) expect(result.cacheWriteTokens).toBeUndefined() expect(result.cacheReadTokens).toBeUndefined() }) }) describe("interleaved thinking mode", () => { const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ { type: "text" as const, text: "Hello!", }, ], }, ] it("should handle reasoning_content in streaming responses for deepseek-v4-pro", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-pro", }) const stream = reasonerHandler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } // Should have reasoning chunks const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks.length).toBeGreaterThan(0) expect(reasoningChunks[0].text).toBe("Let me think about this...") expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") }) it("should pass thinking parameter for deepseek-v4-pro model", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-pro", }) const stream = reasonerHandler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume the stream } // 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 callArgs = mockCreate.mock.calls[0][0] expect(callArgs.reasoning_effort).toBe("high") }) it("should enable thinking by default for deepseek-v4-flash", async () => { const v4Handler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-flash", }) const stream = v4Handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume the stream } expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ thinking: { type: "enabled" }, reasoning_effort: "high", max_completion_tokens: 200_000, }), {}, ) }) it("should respect user max token override for deepseek-v4 models", async () => { const v4Handler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-flash", modelMaxTokens: 32_000, }) const stream = v4Handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume the stream } const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.max_completion_tokens).toBe(32_000) }) it("should disable thinking for deepseek-v4 models when reasoning is disabled", async () => { const v4Handler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-pro", enableReasoningEffort: false, }) const stream = v4Handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume the stream } const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.thinking).toEqual({ type: "disabled" }) expect(callArgs.reasoning_effort).toBeUndefined() }) it("should not send V4 thinking parameters for unknown model IDs", async () => { const customHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "custom-deepseek-model", }) const stream = customHandler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume the stream } const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.thinking).toBeUndefined() expect(callArgs.reasoning_effort).toBeUndefined() expect(callArgs.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) }) it("should handle tool calls with reasoning_content", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-pro", }) const tools: any[] = [ { type: "function", function: { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: {} }, }, }, ] const stream = reasonerHandler.createMessage(systemPrompt, messages, { taskId: "test", tools }) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } // Should have reasoning chunks const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks.length).toBeGreaterThan(0) // 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") }) }) })