From cdf7f739fbf841748c56b8d5c4607bda9468a0a7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 2 Dec 2025 15:41:13 +0000 Subject: [PATCH] feat: add native tool calling support for DeepSeek Reasoner model - Override createMessage in DeepSeekHandler to use convertToOpenAiMessages for deepseek-reasoner with native tools - Maintain R1 format for deepseek-reasoner without native tools (XML protocol) - Add comprehensive tests for native tool calling scenarios - Preserve reasoning_content display alongside tool usage Fixes #9744 --- src/api/providers/__tests__/deepseek.spec.ts | 445 +++++++++++++++++++ src/api/providers/deepseek.ts | 136 +++++- 2 files changed, 579 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 50cabfa922..93446dbd53 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -317,4 +317,449 @@ describe("DeepSeekHandler", () => { expect(result.cacheReadTokens).toBeUndefined() }) }) + + describe("native tool calling", () => { + it("should use OpenAI format for deepseek-reasoner with native tools", async () => { + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Use the calculator tool to add 2 + 2", + }, + ], + }, + ] + + const metadata = { + taskId: "test-task-id", + toolProtocol: "native" as const, + tools: [ + { + type: "function" as const, + function: { + name: "calculator", + description: "A simple calculator", + parameters: { + type: "object", + properties: { + operation: { type: "string" }, + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["operation", "a", "b"], + }, + }, + }, + ], + } + + // Mock the stream response with tool calls + mockCreate.mockImplementationOnce(async (options) => { + // Verify that the messages are in OpenAI format (not R1 format) + expect(options.messages).toBeDefined() + expect(options.messages.length).toBeGreaterThan(0) + // First message should be user role with system prompt + expect(options.messages[0].role).toBe("user") + + // Verify tools are included + expect(options.tools).toBeDefined() + expect(options.tools.length).toBe(1) + expect(options.tools[0].function.name).toBe("calculator") + + // Return a mock stream with tool call + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { + name: "calculator", + arguments: '{"operation":"add","a":2,"b":2}', + }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 20, + completion_tokens: 10, + total_tokens: 30, + }, + } + }, + } + }) + + const stream = handlerWithReasoner.createMessage(systemPrompt, messages, metadata) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify tool call chunks were emitted + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallChunks.length).toBeGreaterThan(0) + expect(toolCallChunks[0].name).toBe("calculator") + expect(toolCallChunks[0].arguments).toBe('{"operation":"add","a":2,"b":2}') + }) + + it("should handle tool results properly with deepseek-reasoner", async () => { + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Use the calculator tool to add 2 + 2", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "tool_use" as const, + id: "tool_use_123", + name: "calculator", + input: { operation: "add", a: 2, b: 2 }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "tool_use_123", + content: "4", + }, + ], + }, + ] + + const metadata = { + taskId: "test-task-id", + toolProtocol: "native" as const, + } + + mockCreate.mockImplementationOnce(async (options) => { + // Verify tool result is properly converted to OpenAI format + const toolMessage = options.messages.find((msg: any) => msg.role === "tool") + expect(toolMessage).toBeDefined() + expect(toolMessage.tool_call_id).toBe("tool_use_123") + expect(toolMessage.content).toBe("4") + + // Return a mock stream + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { content: "The result is 4" }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 30, + completion_tokens: 5, + total_tokens: 35, + }, + } + }, + } + }) + + const stream = handlerWithReasoner.createMessage(systemPrompt, messages, metadata) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks[0].text).toBe("The result is 4") + }) + + it("should use R1 format for deepseek-reasoner without native tools", async () => { + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + // No metadata or toolProtocol is "xml" + const metadata = { + taskId: "test-task-id", + toolProtocol: "xml" as const, + } + + mockCreate.mockImplementationOnce(async (options) => { + // Verify that messages are in R1 format (merged consecutive same-role messages) + expect(options.messages).toBeDefined() + // In R1 format, system prompt is merged with user message + expect(options.messages[0].role).toBe("user") + expect(options.messages[0].content).toContain("You are a helpful assistant") + expect(options.messages[0].content).toContain("Hello!") + + // Return a mock stream + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { content: "Hi there!" }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 3, + total_tokens: 13, + }, + } + }, + } + }) + + const stream = handlerWithReasoner.createMessage(systemPrompt, messages, metadata) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks[0].text).toBe("Hi there!") + }) + + it("should handle reasoning_content for deepseek-reasoner", async () => { + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "What is 2 + 2?", + }, + ], + }, + ] + + const metadata = { + taskId: "test-task-id", + toolProtocol: "native" as const, + } + + mockCreate.mockImplementationOnce(async () => { + // Return a mock stream with reasoning_content + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + reasoning_content: "Let me calculate 2 + 2...", + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + content: "2 + 2 equals 4", + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 15, + completion_tokens: 10, + total_tokens: 25, + }, + } + }, + } + }) + + const stream = handlerWithReasoner.createMessage(systemPrompt, messages, metadata) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify reasoning chunks were emitted + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks.length).toBeGreaterThan(0) + expect(reasoningChunks[0].text).toBe("Let me calculate 2 + 2...") + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks[0].text).toBe("2 + 2 equals 4") + }) + + it("should use parent implementation for deepseek-chat model", async () => { + // deepseek-chat should always use parent implementation + const handlerWithChat = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + const metadata = { + taskId: "test-task-id", + toolProtocol: "native" as const, + tools: [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + ], + } + + mockCreate.mockImplementationOnce(async (options) => { + // For deepseek-chat, it should use the parent's OpenAI format handling + expect(options.messages).toBeDefined() + // Should have system message and user message + expect(options.messages[0].role).toBe("system") + // The content might be wrapped in an array for prompt caching + if (Array.isArray(options.messages[0].content)) { + expect(options.messages[0].content[0].text).toBe("You are a helpful assistant.") + } else { + expect(options.messages[0].content).toBe("You are a helpful assistant.") + } + expect(options.messages[1].role).toBe("user") + + // Return a mock stream + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { content: "Hello!" }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 2, + total_tokens: 12, + }, + } + }, + } + }) + + const stream = handlerWithChat.createMessage(systemPrompt, messages, metadata) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks[0].text).toBe("Hello!") + }) + }) }) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index de119de6db..7908ff69da 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,13 +1,23 @@ -import { deepSeekModels, deepSeekDefaultModelId } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import type { ApiStreamUsageChunk } from "../transform/stream" +import type { ApiStreamUsageChunk, ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToR1Format } from "../transform/r1-format" +import { XmlMatcher } from "../../utils/xml-matcher" import { OpenAiHandler } from "./openai" +import type { ApiHandlerCreateMessageMetadata } from "../index" +import { handleOpenAIError } from "./utils/openai-error-handler" export class DeepSeekHandler extends OpenAiHandler { + private client: OpenAI + constructor(options: ApiHandlerOptions) { super({ ...options, @@ -17,6 +27,128 @@ export class DeepSeekHandler extends OpenAiHandler { openAiStreamingEnabled: true, includeMaxTokens: true, }) + + // Create our own OpenAI client since the parent's is private + this.client = new OpenAI({ + baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com", + apiKey: options.deepSeekApiKey ?? "not-provided", + }) + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const modelId = this.options.apiModelId ?? deepSeekDefaultModelId + const isReasoner = modelId === "deepseek-reasoner" + const useNativeTools = metadata?.toolProtocol === "native" + + // If it's deepseek-reasoner with native tools, use OpenAI format for proper tool handling + if (isReasoner && useNativeTools) { + yield* this.createMessageWithNativeTools(systemPrompt, messages, metadata) + } else { + // Otherwise, use the parent implementation (which uses R1 format for reasoner) + yield* super.createMessage(systemPrompt, messages, metadata) + } + } + + private async *createMessageWithNativeTools( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { info: modelInfo } = this.getModel() + const modelId = this.options.apiModelId ?? deepSeekDefaultModelId + + // Convert messages to OpenAI format to properly handle tool messages + const convertedMessages = [ + { role: "user" as const, content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: modelId, + temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, + messages: convertedMessages, + stream: true as const, + stream_options: { include_usage: true }, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), + parallel_tool_calls: metadata?.parallelToolCalls ?? false, + } + + // Add max_tokens if needed + if (this.options.includeMaxTokens === true) { + requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens + } + + let stream + try { + stream = await this.client.chat.completions.create(requestOptions) + } catch (error) { + throw handleOpenAIError(error, "DeepSeek") + } + + const matcher = new XmlMatcher( + "think", + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) + + let lastUsage + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta ?? {} + + if (delta.content) { + for (const chunk of matcher.update(delta.content)) { + yield chunk + } + } + + // Handle reasoning_content for DeepSeek Reasoner + 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) + } + } + + // Helper method to convert tools to OpenAI format + protected override convertToolsForOpenAI(tools: any[]): any[] | undefined { + // This method is inherited from BaseProvider + return super.convertToolsForOpenAI(tools) } override getModel() {