mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
Merge pull request #5620 from fengshao1227/fix/anthropic-chat-bridge-reasoning-passback
fix(apicompat): Anthropic→Chat 桥回传工具调用的 thinking,修复 DeepSeek 多轮 400
This commit is contained in:
@@ -249,8 +249,8 @@ func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) {
|
||||
|
||||
// anthropicAssistantToChatMessages handles an Anthropic assistant message.
|
||||
// Text content → assistant message content; tool_use blocks → tool_calls on the
|
||||
// same assistant message; thinking blocks are dropped (Chat Completions has no
|
||||
// inbound thinking field, matching anthropicAssistantToResponses).
|
||||
// same assistant message; thinking blocks → reasoning_content, but only on a
|
||||
// message that carries tool calls (see anthropicThinkingToReasoningContent).
|
||||
func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error) {
|
||||
// Plain string → single assistant message.
|
||||
var s string
|
||||
@@ -289,9 +289,40 @@ func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error
|
||||
})
|
||||
}
|
||||
|
||||
msg.ReasoningContent = anthropicThinkingToReasoningContent(blocks, len(msg.ToolCalls) > 0)
|
||||
|
||||
return []ChatMessage{msg}, nil
|
||||
}
|
||||
|
||||
// anthropicThinkingToReasoningContent folds thinking blocks back into the
|
||||
// Chat Completions reasoning_content field.
|
||||
//
|
||||
// chatMessageToAnthropicBlocks emits the upstream's reasoning_content as a
|
||||
// thinking block on the way out, so a multi-turn client echoes it back on the
|
||||
// next request; dropping it here made the bridge lose exactly what it had just
|
||||
// produced. DeepSeek's thinking mode requires the reasoning_content that
|
||||
// produced a tool call to be replayed on that assistant message and answers
|
||||
// 400 otherwise, which is why buildChatMessagesFromItems already carries
|
||||
// pendingReasoning onto assistant tool-call messages in the Responses→Chat
|
||||
// bridge. hasToolCalls keeps the scope identical to that sibling: reasoning
|
||||
// rides along with tool calls only, never on a plain assistant text turn.
|
||||
//
|
||||
// redacted_thinking blocks and signature-only placeholders carry no plaintext
|
||||
// and contribute nothing. Multiple blocks join with "\n", matching
|
||||
// extractResponsesReasoningText.
|
||||
func anthropicThinkingToReasoningContent(blocks []AnthropicContentBlock, hasToolCalls bool) string {
|
||||
if !hasToolCalls {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, b := range blocks {
|
||||
if b.Type == "thinking" && b.Thinking != "" {
|
||||
parts = append(parts, b.Thinking)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// anthropicToolsToChatTools maps Anthropic tool definitions to Chat Completions
|
||||
// function tools. Server-side tools (web_search_*) are dropped — they have no
|
||||
// Chat Completions equivalent.
|
||||
|
||||
@@ -143,8 +143,11 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) {
|
||||
out, err := AnthropicToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 1)
|
||||
// Only text survives; thinking is dropped
|
||||
// Only text survives. Thinking is dropped because this turn carries no tool
|
||||
// calls — reasoning rides along with tool calls only, matching the
|
||||
// Responses→Chat bridge (see anthropicThinkingToReasoningContent).
|
||||
require.Equal(t, `"answer"`, string(out.Messages[0].Content))
|
||||
require.Empty(t, out.Messages[0].ReasoningContent)
|
||||
}
|
||||
|
||||
func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// issue #5528:/v1/messages 客户端(Claude Code 等)打到只会 Chat Completions 的
|
||||
// OpenAI 兼容上游时,历史 assistant 消息里的 thinking 块被整块丢弃。DeepSeek 的
|
||||
// thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传,
|
||||
// 于是「单轮正常、一进多轮工具对话必现 400」。
|
||||
|
||||
func anthropicAssistantMsg(t *testing.T, blocks string) *AnthropicRequest {
|
||||
t.Helper()
|
||||
return &AnthropicRequest{
|
||||
Model: "deepseek-v4-flash",
|
||||
MaxTokens: 256,
|
||||
Messages: []AnthropicMessage{
|
||||
{Role: "user", Content: json.RawMessage(`"what's the weather?"`)},
|
||||
{Role: "assistant", Content: json.RawMessage(blocks)},
|
||||
{Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const anthropicThinkingToolTurn = `[
|
||||
{"type":"thinking","thinking":"user wants weather, call the tool"},
|
||||
{"type":"text","text":"checking"},
|
||||
{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}
|
||||
]`
|
||||
|
||||
func TestAnthropicToChatCompletionsRequest_ThinkingBecomesReasoningContentOnToolTurn(t *testing.T) {
|
||||
out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn))
|
||||
require.NoError(t, err)
|
||||
|
||||
var assistant *ChatMessage
|
||||
for i := range out.Messages {
|
||||
if out.Messages[i].Role == "assistant" {
|
||||
assistant = &out.Messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, assistant, "assistant message must survive the bridge")
|
||||
require.Equal(t, "user wants weather, call the tool", assistant.ReasoningContent,
|
||||
"产生工具调用的 thinking 必须作为 reasoning_content 回传,否则 DeepSeek 400")
|
||||
require.Len(t, assistant.ToolCalls, 1)
|
||||
require.Equal(t, `"checking"`, string(assistant.Content), "text/tool_use 处理保持不变")
|
||||
}
|
||||
|
||||
// 上游线格式才是上游看到的东西:字段没序列化出去,等于没修。
|
||||
func TestAnthropicToChatCompletionsRequest_ReasoningContentSerializesOnWire(t *testing.T) {
|
||||
out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(payload), `"reasoning_content":"user wants weather, call the tool"`)
|
||||
}
|
||||
|
||||
// 闭环不变式:thinking 块本来就是本桥出站时用上游 reasoning_content 生成的
|
||||
// (chatMessageToAnthropicBlocks),客户端只是原样回传。出站造、入站丢 = 自己丢自己的东西。
|
||||
func TestAnthropicChatBridge_ReasoningSurvivesOutboundInboundRoundTrip(t *testing.T) {
|
||||
upstream := ChatMessage{
|
||||
Role: "assistant",
|
||||
ReasoningContent: "step 1: need the weather tool",
|
||||
Content: json.RawMessage(`"checking"`),
|
||||
ToolCalls: []ChatToolCall{{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"SF"}`},
|
||||
}},
|
||||
}
|
||||
|
||||
// 出站:Chat 响应 → Anthropic content blocks
|
||||
blocks := chatMessageToAnthropicBlocks(upstream)
|
||||
require.Equal(t, "thinking", blocks[0].Type)
|
||||
require.Equal(t, upstream.ReasoningContent, blocks[0].Thinking)
|
||||
|
||||
// 客户端下一轮把同一组 blocks 原样回传
|
||||
raw, err := json.Marshal(blocks)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 入站:Anthropic content blocks → Chat 请求
|
||||
back, err := anthropicAssistantToChatMessages(raw)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, back, 1)
|
||||
require.Equal(t, upstream.ReasoningContent, back[0].ReasoningContent,
|
||||
"出站生成的 thinking 必须能原样还原回 reasoning_content")
|
||||
require.Len(t, back[0].ToolCalls, 1)
|
||||
}
|
||||
|
||||
// 兄弟不变式:Responses→Chat 桥(buildChatMessagesFromItems 的 pendingReasoning)
|
||||
// 早就把 reasoning 挂到带 tool_calls 的 assistant 消息上了。等价历史下两条桥必须一致。
|
||||
func TestAnthropicChatBridge_MatchesResponsesChatBridgeReasoningPlacement(t *testing.T) {
|
||||
responsesReq := &ResponsesRequest{
|
||||
Model: "deepseek-v4-flash",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"what's the weather?"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"call the tool"}]},
|
||||
{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"SF\"}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"sunny"}
|
||||
]`),
|
||||
}
|
||||
viaResponses, err := ResponsesToChatCompletionsRequest(responsesReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
viaAnthropic, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, `[
|
||||
{"type":"thinking","thinking":"call the tool"},
|
||||
{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}
|
||||
]`))
|
||||
require.NoError(t, err)
|
||||
|
||||
reasoningOnToolCallMessage := func(msgs []ChatMessage) string {
|
||||
for _, m := range msgs {
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
return m.ReasoningContent
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
require.Equal(t, "call the tool", reasoningOnToolCallMessage(viaResponses.Messages),
|
||||
"前置条件:兄弟桥本来就带 reasoning_content")
|
||||
require.Equal(t, reasoningOnToolCallMessage(viaResponses.Messages),
|
||||
reasoningOnToolCallMessage(viaAnthropic.Messages),
|
||||
"两条桥对等价历史必须产出同样的 reasoning_content 位置")
|
||||
}
|
||||
|
||||
// 作用域守卫:不带工具调用的纯文本轮次维持现状(与兄弟桥一致 —— reasoning 只随
|
||||
// 工具调用回传),避免把 reasoning_content 撒到不需要它的上游请求上。
|
||||
func TestAnthropicToChatCompletionsRequest_ThinkingWithoutToolCallsStaysDropped(t *testing.T) {
|
||||
req := &AnthropicRequest{
|
||||
Model: "deepseek-v4-flash",
|
||||
MaxTokens: 100,
|
||||
Messages: []AnthropicMessage{
|
||||
{Role: "assistant", Content: json.RawMessage(
|
||||
`[{"type":"thinking","thinking":"secret thoughts"},{"type":"text","text":"answer"}]`)},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := AnthropicToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 1)
|
||||
require.Empty(t, out.Messages[0].ReasoningContent)
|
||||
require.Equal(t, `"answer"`, string(out.Messages[0].Content))
|
||||
|
||||
payload, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(payload), "reasoning_content")
|
||||
}
|
||||
|
||||
func TestAnthropicThinkingToReasoningContent(t *testing.T) {
|
||||
blocksOf := func(t *testing.T, raw string) []AnthropicContentBlock {
|
||||
t.Helper()
|
||||
var blocks []AnthropicContentBlock
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &blocks))
|
||||
return blocks
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
hasToolCalls bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single_thinking_block",
|
||||
raw: `[{"type":"thinking","thinking":"a"}]`,
|
||||
hasToolCalls: true,
|
||||
want: "a",
|
||||
},
|
||||
{
|
||||
// 多个 thinking 块用 "\n" 连接,与 extractResponsesReasoningText 一致。
|
||||
name: "multiple_blocks_join_with_newline",
|
||||
raw: `[{"type":"thinking","thinking":"a"},{"type":"text","text":"x"},{"type":"thinking","thinking":"b"}]`,
|
||||
hasToolCalls: true,
|
||||
want: "a\nb",
|
||||
},
|
||||
{
|
||||
// redacted_thinking 没有明文可回传。
|
||||
name: "redacted_thinking_has_no_plaintext",
|
||||
raw: `[{"type":"redacted_thinking","signature":"abc"}]`,
|
||||
hasToolCalls: true,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
// 只带 signature 的 thinking 占位块(xAI/Codex 密文回放形态)同样无明文。
|
||||
name: "signature_only_thinking",
|
||||
raw: `[{"type":"thinking","thinking":"","signature":"gAAAAxxx"}]`,
|
||||
hasToolCalls: true,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no_tool_calls_returns_empty",
|
||||
raw: `[{"type":"thinking","thinking":"a"}]`,
|
||||
hasToolCalls: false,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no_thinking_blocks",
|
||||
raw: `[{"type":"text","text":"x"}]`,
|
||||
hasToolCalls: true,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_blocks",
|
||||
raw: `[]`,
|
||||
hasToolCalls: true,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want,
|
||||
anthropicThinkingToReasoningContent(blocksOf(t, tc.raw), tc.hasToolCalls))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 纯字符串形态的 assistant content 没有 blocks 可读,走早返回分支,不得 panic。
|
||||
func TestAnthropicAssistantToChatMessages_PlainStringContentUnaffected(t *testing.T) {
|
||||
msgs, err := anthropicAssistantToChatMessages(json.RawMessage(`"just text"`))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, msgs, 1)
|
||||
require.Empty(t, msgs[0].ReasoningContent)
|
||||
require.Equal(t, `"just text"`, string(msgs[0].Content))
|
||||
}
|
||||
Reference in New Issue
Block a user