fix(openai): resolve 400 error on cross-model switching with DeepSeek V4 thinking mode

This commit is contained in:
Qiang 2026-04-27 18:47:08 +08:00
parent fc15778e20
commit a2d7b64349
3 changed files with 38 additions and 4 deletions

View file

@ -99,7 +99,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
const modelId = this.options.openAiModelId ?? ""
const enabledR1Format = this.options.openAiR1FormatEnabled ?? false
const isAzureAiInference = this._isAzureAiInference(modelUrl)
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
const deepseekReasoner =
modelId.includes("deepseek-reasoner") ||
modelId.includes("deepseek-v4-pro") ||
modelId.includes("deepseek-v4-flash") ||
enabledR1Format
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata)
@ -340,7 +344,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
})
const enabledR1Format = this.options.openAiR1FormatEnabled ?? false
const deepseekReasoner = id.includes("deepseek-reasoner") || enabledR1Format
const deepseekReasoner =
id.includes("deepseek-reasoner") ||
id.includes("deepseek-v4-pro") ||
id.includes("deepseek-v4-flash") ||
enabledR1Format
return {
id,

View file

@ -317,6 +317,9 @@ export function convertToOpenAiMessages(
if (mapped) {
;(baseMessage as any).reasoning_details = mapped
}
if (messageWithDetails.reasoning_content !== undefined) {
;(baseMessage as any).reasoning_content = messageWithDetails.reasoning_content
}
}
openAiMessages.push(baseMessage)
@ -494,6 +497,11 @@ export function convertToOpenAiMessages(
baseMessage.reasoning_details = mapped
}
// Pass through reasoning_content for DeepSeek V4/R1 thinking mode
if (messageWithDetails.reasoning_content !== undefined) {
;(baseMessage as any).reasoning_content = messageWithDetails.reasoning_content
}
// Add tool_calls after reasoning_details
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
if (tool_calls.length > 0) {

View file

@ -4469,6 +4469,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = []
// Check if the current model requires reasoning to be preserved (e.g., DeepSeek V4/R1).
// When preserveReasoning is true, ALL assistant messages must include reasoning_content
// to satisfy the API protocol. Messages from other models (e.g., Gemini) that lack
// reasoning_content need to be backfilled with an empty string.
const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true
for (const msg of messages) {
// Standalone reasoning: send encrypted, skip plain text
if (msg.type === "reasoning") {
@ -4595,10 +4601,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Default path for regular messages (no embedded reasoning)
if (msg.role) {
cleanConversationHistory.push({
const baseMessage: any = {
role: msg.role,
content: msg.content as Anthropic.Messages.ContentBlockParam[] | string,
})
}
// DeepSeek thinking mode requires ALL assistant messages to have reasoning_content.
// If a message from another model (e.g., Gemini) lacks it, add an empty placeholder
// to prevent 400 errors when switching back to DeepSeek mid-conversation.
if (msg.role === "assistant" && shouldPreserveForApi) {
// Use a space instead of an empty string. Some proxies/gateways or
// internal SDK logic may silently strip empty string fields, causing
// DeepSeek to reject the request with 400.
baseMessage.reasoning_content = " "
}
cleanConversationHistory.push(baseMessage)
}
}