mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
/v1/messages 打到只支持 Chat Completions 的 OpenAI 兼容上游时, anthropicAssistantToChatMessages 把历史 assistant 消息里的 thinking 块整块丢弃。 DeepSeek 的 thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传,缺失即 400 —— 表现为「单轮正常,一进多轮工具对话必现失败」。 这些 thinking 块本来就是同一个文件的出站方向 (chatMessageToAnthropicBlocks) 用上游 reasoning_content 生成的,客户端只是 原样回传:桥自己造出来的东西,回来时被自己丢掉。 兄弟路径 Responses→Chat (buildChatMessagesFromItems 的 pendingReasoning) 早已 处理同一约束。本次把同一策略补到漏掉的这条桥上,作用域严格对齐:reasoning 只挂 到带 tool_calls 的 assistant 消息,纯文本轮次维持现状。 Fixes #5528
230 lines
8.4 KiB
Go
230 lines
8.4 KiB
Go
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))
|
||
}
|