mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
562a799c5b
commit
cdf7f739fb
2 changed files with 579 additions and 2 deletions
|
|
@ -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!")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue