From 9dfc320bf025965e3947761b8cc3b941ffd036a7 Mon Sep 17 00:00:00 2001 From: Qiang Date: Sun, 26 Apr 2026 23:16:01 +0800 Subject: [PATCH] feat: add DeepSeek V4 / R1 compatibility and enhance OpenAiHandler for reasoning details --- src/api/providers/__tests__/openai.spec.ts | 73 ++++++++++++++++++++++ src/api/providers/openai.ts | 72 ++++++++++++++++++--- src/core/task/Task.ts | 14 ++++- 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 73b542dbc7..f1f0cbe2cf 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1139,6 +1139,79 @@ describe("OpenAiHandler", () => { ) }) }) + + describe("DeepSeek V4 / R1 Compatibility", () => { + it("should inject extra_body and reasoning_effort for DeepSeek R1 format", async () => { + const r1Options: ApiHandlerOptions = { + ...mockOptions, + openAiR1FormatEnabled: true, + openAiModelId: "deepseek-reasoner", + openAiBaseUrl: "https://api.deepseek.com/v1", + openAiCustomModelInfo: { + contextWindow: 64_000, + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + supportsPromptCache: false, + }, + } + const r1Handler = new OpenAiHandler(r1Options) + const stream = r1Handler.createMessage("system", [{ role: "user", content: "test" }]) + + for await (const _ of stream) { + } + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + reasoning_effort: "max", // xhigh -> max for deepseek + extra_body: { + thinking: { type: "enabled" }, + }, + temperature: undefined, // disabled for thinking mode + }), + expect.any(Object), + ) + + // T005 Verify preserveReasoning is enabled + const model = r1Handler.getModel() + expect(model.info.preserveReasoning).toBe(true) + }) + + it("should NOT inject R1 parameters for standard models even if model ID contains deepseek but R1 format is disabled", async () => { + const standardOptions: ApiHandlerOptions = { + ...mockOptions, + openAiR1FormatEnabled: false, + openAiModelId: "deepseek-chat", // standard model + } + const standardHandler = new OpenAiHandler(standardOptions) + const stream = standardHandler.createMessage("system", [{ role: "user", content: "test" }]) + + for await (const _ of stream) { + } + + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("extra_body") + expect(callArgs.temperature).toBeDefined() + }) + + it("should use standard OpenAI protocol when R1 format is disabled", async () => { + const standardOptions: ApiHandlerOptions = { + ...mockOptions, + openAiR1FormatEnabled: false, + openAiModelId: "gpt-4o", + } + const standardHandler = new OpenAiHandler(standardOptions) + const stream = standardHandler.createMessage("system", [{ role: "user", content: "test" }]) + + for await (const _ of stream) { + } + + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("extra_body") + expect(callArgs).not.toHaveProperty("reasoning_effort") + // Standard model should have temperature 0 by default + expect(callArgs.temperature).toBe(0) + }) + }) }) describe("getOpenAiModels", () => { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 33b29abcaf..0bfa899ff2 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -105,7 +105,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let convertedMessages if (deepseekReasoner) { - convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], { + mergeToolResultText: true, + }) } else { if (modelInfo.supportsPromptCache) { systemMessage = { @@ -152,13 +154,32 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + const isDeepSeekV4 = + deepseekReasoner && + (this.options.openAiBaseUrl?.includes("deepseek.com") || modelId.includes("deepseek")) + + const requestOptions: any = { model: modelId, - temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), + temperature: deepseekReasoner + ? undefined + : (this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0)), messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), ...(reasoning && reasoning), + ...(deepseekReasoner && { + reasoning_effort: isDeepSeekV4 + ? modelInfo.reasoningEffort === "xhigh" + ? "max" + : "high" + : (modelInfo.reasoningEffort as any), + extra_body: { + thinking: { type: "enabled" }, + }, + top_p: undefined, + presence_penalty: undefined, + frequency_penalty: undefined, + }), tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, @@ -169,10 +190,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let stream try { - stream = await this.client.chat.completions.create( + stream = (await this.client.chat.completions.create( requestOptions, isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) + )) as any } catch (error) { throw handleOpenAIError(error, this.providerName) } @@ -221,15 +242,35 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl yield this.processUsageMetrics(lastUsage, modelInfo) } } else { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + const isDeepSeekV4 = + deepseekReasoner && + (this.options.openAiBaseUrl?.includes("deepseek.com") || modelId.includes("deepseek")) + + const requestOptions: any = { model: modelId, messages: deepseekReasoner - ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], { + mergeToolResultText: true, + }) : [systemMessage, ...convertToOpenAiMessages(messages)], // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...(deepseekReasoner && { + reasoning_effort: isDeepSeekV4 + ? modelInfo.reasoningEffort === "xhigh" + ? "max" + : "high" + : (modelInfo.reasoningEffort as any), + extra_body: { + thinking: { type: "enabled" }, + }, + temperature: undefined, + top_p: undefined, + presence_penalty: undefined, + frequency_penalty: undefined, + }), } // Add max_tokens if needed @@ -237,10 +278,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let response try { - response = await this.client.chat.completions.create( + response = (await this.client.chat.completions.create( requestOptions, this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) + )) as any } catch (error) { throw handleOpenAIError(error, this.providerName) } @@ -289,7 +330,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl settings: this.options, defaultTemperature: 0, }) - return { id, info, ...params } + + const enabledR1Format = this.options.openAiR1FormatEnabled ?? false + const deepseekReasoner = id.includes("deepseek-reasoner") || enabledR1Format + + return { + id, + info: { + ...info, + ...(deepseekReasoner && { preserveReasoning: true }), + }, + ...params, + } } async completePrompt(prompt: string): Promise { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 005bb0f292..13da6f23b2 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4498,8 +4498,11 @@ export class Task extends EventEmitter implements TaskLike { const [first, ...rest] = contentArray // Check if this message has reasoning_details (OpenRouter format for Gemini 3, etc.) - const msgWithDetails = msg - if (msgWithDetails.reasoning_details && Array.isArray(msgWithDetails.reasoning_details)) { + const msgWithDetails = msg as any + if ( + (msgWithDetails.reasoning_details && Array.isArray(msgWithDetails.reasoning_details)) || + msgWithDetails.reasoning_content + ) { // Build the assistant message with reasoning_details let assistantContent: Anthropic.Messages.MessageParam["content"] @@ -4515,7 +4518,12 @@ export class Task extends EventEmitter implements TaskLike { cleanConversationHistory.push({ role: "assistant", content: assistantContent, - reasoning_details: msgWithDetails.reasoning_details, + ...(msgWithDetails.reasoning_details && { + reasoning_details: msgWithDetails.reasoning_details, + }), + ...(msgWithDetails.reasoning_content && { + reasoning_content: msgWithDetails.reasoning_content, + }), } as any) continue