diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 05eb95af6e..2b9d69eae7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -271,8 +271,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa // across an assistant message (so a following tool call in the same turn // still receives it); any other role ends the thinking span. var pendingReasoning string + // lastTurnReasoning is the most recent reasoning text of the current turn, + // surviving tool outputs. DeepSeek emits reasoning only once per turn, so + // chained tool calls (reasoning → call A → output A → call B) leave call B's + // assistant message without reasoning_content and DeepSeek 400s the history; + // replaying the turn's reasoning on B's message satisfies the contract. Only + // a user-side item ends the turn and clears it. + var lastTurnReasoning string mediaByCallID := make(toolOutputMediaByCallID) + reasoningForAssistant := func() string { + if pendingReasoning != "" { + return pendingReasoning + } + return lastTurnReasoning + } + for _, raw := range rawItems { raw = bytesTrimSpace(raw) if len(raw) == 0 || string(raw) == "null" { @@ -286,6 +300,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa content, _ := json.Marshal(text) messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue } return nil, nil, fmt.Errorf("parse responses input item: %w", err) @@ -307,6 +322,9 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa } } } + if pendingReasoning != "" { + lastTurnReasoning = pendingReasoning + } continue case "function_call": arguments := rawString(item["arguments"]) @@ -327,7 +345,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: arguments, }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "tool_search_call": @@ -348,7 +366,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: arguments, }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "custom_tool_call": @@ -364,7 +382,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: string(arguments), }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "function_call_output", "custom_tool_call_output", "tool_search_output": @@ -396,6 +414,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa content, _ := json.Marshal(rawString(item["text"])) messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue case "input_image": content, err := chatContentFromSingleResponsesPart(itemType, item) @@ -404,6 +423,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa } messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue } @@ -428,11 +448,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa if err != nil { return nil, nil, err } - messages = append(messages, ChatMessage{Role: role, Content: chatContent}) - // Reasoning only survives across an assistant text message. - if role != "assistant" { + msg := ChatMessage{Role: role, Content: chatContent} + // DeepSeek thinking mode requires the reasoning_content from a prior + // reasoning-only / plain-text assistant turn to be passed back on its + // assistant message; dropping it yields 400 "The `reasoning_content` in + // the thinking mode must be passed back to the API" on the next turn. + // A following function_call in the same turn still receives it because + // appendAssistantToolCall merges into this message and only fills + // ReasoningContent when it is still empty. + if role == "assistant" { + msg.ReasoningContent = reasoningForAssistant() pendingReasoning = "" + } else { + pendingReasoning = "" + lastTurnReasoning = "" } + messages = append(messages, msg) } return messages, mediaByCallID, nil diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go index 33816f7abb..dacedfc61d 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go @@ -91,6 +91,52 @@ func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) { require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run") } +// DeepSeek emits reasoning only once per turn; chained tool calls +// (reasoning → call A → output A → call B) have no reasoning item before call +// B. The turn's reasoning must be replayed on B's assistant message, otherwise +// DeepSeek thinking mode 400s the history ("reasoning_content ... must be +// passed back"). Reproduced from a real codex 0.147.0 resume history. +func TestResponsesToChat_ChainedToolCallsReplayTurnReasoning(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_r1","summary":[{"type":"summary_text","text":"turn thinking"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n\n"}]}, + {"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_a","output":"ok"}, + {"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_b","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}, + {"type":"reasoning","id":"item_r2","summary":[{"type":"summary_text","text":"second turn"}]}, + {"type":"function_call","call_id":"call_c","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_c","output":"ok"} + ]`), + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + + byCallID := map[string]ChatMessage{} + for _, m := range out.Messages { + for _, tc := range m.ToolCalls { + byCallID[tc.ID] = m + } + } + require.Len(t, byCallID, 3) + require.Equal(t, "turn thinking", byCallID["call_a"].ReasoningContent) + require.Equal(t, "turn thinking", byCallID["call_b"].ReasoningContent, + "链式第二个工具调用必须回放本轮 reasoning") + require.Equal(t, "second turn", byCallID["call_c"].ReasoningContent, + "user 消息后开启新轮次,不得沿用上一轮 reasoning") + + // 每一条 assistant 消息都必须带 reasoning_content(DeepSeek 契约)。 + for i, m := range out.Messages { + if m.Role == "assistant" { + require.NotEmpty(t, m.ReasoningContent, "messages[%d] 缺 reasoning_content", i) + } + } +} + func TestExtractResponsesReasoningItem(t *testing.T) { id, text, ok := ExtractResponsesReasoningItem(json.RawMessage( `{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`))