fix(apicompat): 链式工具调用回放本轮 reasoning_content

codex 0.147.0 真实 resume 历史复现:DeepSeek 每轮只在开头流式产生一次
reasoning,reasoning → call A → output A → call B 的链式调用中 call B 前
没有 reasoning item,其 assistant 消息缺 reasoning_content,DeepSeek
thinking mode 400 整个历史。

buildChatMessagesFromItems 新增 lastTurnReasoning:记录本轮最近一次
reasoning,跨 tool output 存活,仅被 user 侧 item 清除;assistant 消息
(文本或 tool_calls)在 pendingReasoning 为空时回放本轮 reasoning。
This commit is contained in:
lbyxiaolizi
2026-08-17 18:51:17 +08:00
parent 612436a5a7
commit 401dd43b4b
2 changed files with 83 additions and 6 deletions
@@ -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
@@ -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"}]}`))