mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 16:20:27 +00:00
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:
@@ -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"}]}`))
|
||||
|
||||
Reference in New Issue
Block a user