mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
5e934f0a9c
commit
9eba2970cc
4 changed files with 376 additions and 4 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue