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
This commit is contained in:
Roo Code 2025-12-02 15:41:13 +00:00
parent 562a799c5b
commit cdf7f739fb
2 changed files with 579 additions and 2 deletions

View file

@ -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!")
})
})
})

View file

@ -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() {