feat: add DeepSeek V3.2 thinking mode support for tool calling

- Add supportsReasoningBinary flag to DeepSeek V3 model info
- Override createMessage in DeepSeekHandler to add thinking parameter when enableReasoningEffort is true
- Preserve reasoning_content in message conversion for subsequent API calls
- Add comprehensive tests for thinking mode tool calling

See: https://api-docs.deepseek.com/guides/thinking_mode
This commit is contained in:
Roo Code 2025-12-04 16:54:52 +00:00
parent 5e934f0a9c
commit 9eba2970cc
4 changed files with 376 additions and 4 deletions

View file

@ -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 = {

View file

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

View file

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

View file

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