diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index b9082c8541..36b153fc1e 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -7,17 +7,20 @@ export type DeepSeekModelId = keyof typeof deepSeekModels export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-chat" // DeepSeek V3 model info (shared between deepseek-chat and aliases) +// DeepSeek V3.2 supports thinking mode with tool calling via the "thinking" parameter +// See: https://api-docs.deepseek.com/guides/thinking_mode const deepSeekV3Info: ModelInfo = { maxTokens: 8192, // 8K max output contextWindow: 128_000, supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + supportsReasoningBinary: true, // Supports thinking mode via { thinking: { type: "enabled" } } inputPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 outputPrice: 1.68, // $1.68 per million tokens - Updated Sept 5, 2025 cacheWritesPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 cacheReadsPrice: 0.07, // $0.07 per million tokens (cache hit) - Updated Sept 5, 2025 - description: `DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.`, + description: `DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally. Supports thinking mode with tool calling when enabled.`, } export const deepSeekModels = { diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 9d53898b83..b28f6b7ed9 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -349,4 +349,219 @@ describe("DeepSeekHandler", () => { expect(result.cacheReadTokens).toBeUndefined() }) }) + + describe("Thinking Mode Support", () => { + it("should add thinking parameter when enableReasoningEffort is true for V3 models", async () => { + vi.clearAllMocks() + const handlerWithThinking = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + enableReasoningEffort: true, + }) + const stream = handlerWithThinking.createMessage("test", []) + for await (const _chunk of stream) { + // consume stream + } + // Verify the API was called with the thinking parameter + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "deepseek-chat", + thinking: { type: "enabled" }, + }), + ) + }) + + it("should add thinking parameter when enableReasoningEffort is true for deepseek-3.2 alias", async () => { + vi.clearAllMocks() + const handlerWithThinking = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-3.2", + enableReasoningEffort: true, + }) + const stream = handlerWithThinking.createMessage("test", []) + for await (const _chunk of stream) { + // consume stream + } + // Verify the API was called with the thinking parameter and mapped model ID + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "deepseek-chat", + thinking: { type: "enabled" }, + }), + ) + }) + + it("should NOT add thinking parameter when enableReasoningEffort is false", async () => { + vi.clearAllMocks() + const handlerWithoutThinking = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + enableReasoningEffort: false, + }) + const stream = handlerWithoutThinking.createMessage("test", []) + for await (const _chunk of stream) { + // consume stream + } + // Verify the API was called WITHOUT the thinking parameter + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ + thinking: expect.anything(), + }), + expect.anything(), + ) + }) + + it("should NOT add thinking parameter for deepseek-reasoner model even with enableReasoningEffort", async () => { + vi.clearAllMocks() + const handlerReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + enableReasoningEffort: true, + }) + const stream = handlerReasoner.createMessage("test", []) + for await (const _chunk of stream) { + // consume stream + } + // Verify the API was called WITHOUT the thinking parameter + // (deepseek-reasoner uses R1 format, not thinking mode) + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ + thinking: expect.anything(), + }), + expect.anything(), + ) + }) + + it("should handle reasoning_content in response when thinking mode is enabled", async () => { + // Mock a response with reasoning_content + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { reasoning_content: "Let me think about this..." }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { content: "Here is my answer." }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + } + }, + })) + + const handlerWithThinking = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + enableReasoningEffort: true, + }) + const stream = handlerWithThinking.createMessage("test", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have a reasoning chunk + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks.length).toBeGreaterThan(0) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + + // Should have a text chunk + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks[0].text).toBe("Here is my answer.") + }) + + it("should handle tool calls with thinking mode enabled", async () => { + // Mock a response with tool calls in thinking mode + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { reasoning_content: "I need to call a tool..." }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { + name: "read_file", + arguments: '{"path": "/test.txt"}', + }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + } + }, + })) + + const handlerWithThinking = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + enableReasoningEffort: true, + }) + // Note: tools are passed in Anthropic format and converted internally + const stream = handlerWithThinking.createMessage("test", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have a reasoning chunk + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks.length).toBeGreaterThan(0) + + // Should have a tool call chunk + const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial") + expect(toolCallChunks.length).toBeGreaterThan(0) + expect(toolCallChunks[0].name).toBe("read_file") + expect(toolCallChunks[0].id).toBe("call_123") + }) + }) }) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 14bd3555d4..731a9d6f1d 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,11 +1,23 @@ -import { deepSeekModels, deepSeekDefaultModelId, deepSeekModelAliases } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + deepSeekModels, + deepSeekDefaultModelId, + deepSeekModelAliases, + DEEP_SEEK_DEFAULT_TEMPERATURE, + type ModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import type { ApiStreamUsageChunk } from "../transform/stream" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { convertToOpenAiMessages } from "../transform/openai-format" import { OpenAiHandler } from "./openai" +import type { ApiHandlerCreateMessageMetadata } from "../index" +import { XmlMatcher } from "../../utils/xml-matcher" /** * Maps a user-provided model ID to the official DeepSeek API model name. @@ -16,6 +28,17 @@ function getApiModelId(modelId: string): string { return deepSeekModelAliases[modelId] ?? modelId } +/** + * Checks if a model is a DeepSeek V3/Chat model (not a reasoner model). + * V3/Chat models support thinking mode with tool calling via the "thinking" parameter. + */ +function isDeepSeekV3Model(modelId: string): boolean { + // Map alias to actual model ID for checking + const actualModelId = getApiModelId(modelId) + // V3/Chat models use deepseek-chat, not deepseek-reasoner + return actualModelId === "deepseek-chat" +} + export class DeepSeekHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { const userModelId = options.apiModelId ?? deepSeekDefaultModelId @@ -46,6 +69,127 @@ export class DeepSeekHandler extends OpenAiHandler { return { id, info, ...params } } + /** + * Override createMessage to add DeepSeek V3.2 thinking mode support. + * When enableReasoningEffort is true and the model is a V3/Chat model, + * we add the thinking parameter to enable thinking mode with tool calling. + * See: https://api-docs.deepseek.com/guides/thinking_mode + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { info: modelInfo } = this.getModel() + const modelId = this.options.openAiModelId ?? "" + + // Check if thinking mode should be enabled for DeepSeek V3 models + // Cast to ModelInfo to access optional supportsReasoningBinary property + const shouldEnableThinking = + this.options.enableReasoningEffort && + (modelInfo as ModelInfo).supportsReasoningBinary && + isDeepSeekV3Model(this.userModelId) + + // If thinking mode is not enabled, use the default OpenAI handler behavior + if (!shouldEnableThinking) { + yield* super.createMessage(systemPrompt, messages, metadata) + return + } + + // For DeepSeek V3 with thinking mode enabled, we need to: + // 1. Add the thinking parameter to the request + // 2. Handle reasoning_content in the response + // 3. Preserve reasoning_content in conversation history (handled by openai-format.ts) + + const temperature = this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE + + // Convert messages to OpenAI format, preserving reasoning_content + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // Build the request with thinking mode enabled + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { + thinking?: { type: string } + } = { + model: modelId, + temperature, + messages: openAiMessages, + stream: true as const, + stream_options: { include_usage: true }, + // Enable thinking mode for DeepSeek V3.2 + // See: https://api-docs.deepseek.com/guides/thinking_mode + thinking: { type: "enabled" }, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), + } + + // Add max_tokens if needed + if (this.options.includeMaxTokens && modelInfo.maxTokens) { + requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens + } + + // Create the stream using the protected client from OpenAiHandler + // We need to access the client directly since we're overriding the method + const client = (this as any).client as OpenAI + const stream = await client.chat.completions.create(requestOptions) + + const matcher = new XmlMatcher( + "think", + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) + + let lastUsage: any + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta ?? {} + + if (delta.content) { + for (const processedChunk of matcher.update(delta.content)) { + yield processedChunk + } + } + + // Handle reasoning_content from DeepSeek thinking mode + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + text: (delta.reasoning_content as string | undefined) || "", + } + } + + // 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, + } + } + } + + if (chunk.usage) { + lastUsage = chunk.usage + } + } + + for (const chunk of matcher.final()) { + yield chunk + } + + if (lastUsage) { + yield this.processUsageMetrics(lastUsage) + } + } + // Override to handle DeepSeek's usage metrics, including caching. protected override processUsageMetrics(usage: any): ApiStreamUsageChunk { return { diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 6a88491b7e..404375f2aa 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -132,7 +132,10 @@ export function convertToOpenAiMessages( }, })) - // Check if the message has reasoning_details (used by Gemini 3, etc.) + // Check if the message has reasoning_details or reasoning_content + // - reasoning_details: used by Gemini 3, OpenRouter, etc. + // - reasoning_content: used by DeepSeek V3.2 thinking mode + // See: https://api-docs.deepseek.com/guides/thinking_mode const messageWithDetails = anthropicMessage as any const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = { role: "assistant", @@ -146,6 +149,13 @@ export function convertToOpenAiMessages( ;(baseMessage as any).reasoning_details = messageWithDetails.reasoning_details } + // Preserve reasoning_content if present (used by DeepSeek V3.2 thinking mode) + // DeepSeek requires reasoning_content to be passed back in subsequent API calls + // when using thinking mode with tool calling + if (messageWithDetails.reasoning_content && typeof messageWithDetails.reasoning_content === "string") { + ;(baseMessage as any).reasoning_content = messageWithDetails.reasoning_content + } + openAiMessages.push(baseMessage) } }