feat: add DeepSeek V4 / R1 compatibility and enhance OpenAiHandler for reasoning details

This commit is contained in:
Qiang 2026-04-26 23:16:01 +08:00
parent aec3f1184c
commit 9dfc320bf0
3 changed files with 146 additions and 13 deletions

View file

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

View file

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

View file

@ -4498,8 +4498,11 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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