feat: 补齐三个方向的流式工具调用;Anthropic 上游走 x-api-key;禁用模型真正下架

- 流式转换补齐 chat→responses / responses→chat / messages→responses 的工具调用(此前只转文本,SDK 客户端拿不到 tool_calls);completed 事件携带完整 output 数组
- Anthropic 协议上游同时发 x-api-key(官方 API 不接受 API key 走 Bearer)并透传 anthropic-beta
- 请求侧校验模型存在且启用:禁用模型 404 model_not_found,不再只是从 /v1/models 消失、实际照发照扣费;空模型名 400
- 模型在请求中途被删时按 0 记账留痕,不再按硬编码示例价扣用户的钱
- finishUsage 新增一行式请求日志(trace/user/model/channel/tokens/cost/status/latency),记账是异步的,日志是唯一即时排障依据
This commit is contained in:
Sakurasan committed 2026-10-29 02:56:37 +08:00
1 parent 984a31cbc5
commit d6db0f8162
6 files changed
+485 -31

No files matched your search

@@ -1022,3 +1022,117 @@ func TestMessagesToChatReqToolOrder(t *testing.T) {
t.Fatalf("msg[0] missing tool_calls: %s", out)
}
}
// ---------------------------------------------------------------------------
// 流式工具调用:三个此前缺失的方向(chat→responses / responses→chat / messages→responses)
func TestStreamChatToResponsesToolCall(t *testing.T) {
tf := newChatToResponses().line
out := feedLines(t, tf, []string{
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"北京\"}"}}]},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}` + "\n\n",
"data: [DONE]\n\n",
})
if !strings.Contains(out, "event: response.output_item.added") || !strings.Contains(out, `"name":"get_weather"`) {
t.Fatalf("missing function_call item added: %s", out)
}
if !strings.Contains(out, "event: response.function_call_arguments.delta") {
t.Fatalf("missing arguments delta: %s", out)
}
if !strings.Contains(out, "event: response.output_item.done") {
t.Fatalf("missing output_item.done: %s", out)
}
// completed 的 output 数组要携带完整参数,客户端不依赖事件也能拿到最终形态
if !strings.Contains(out, `"call_id":"call_9"`) {
t.Fatalf("missing call_id: %s", out)
}
if !strings.Contains(out, `\"city\":\"北京\"`) {
t.Fatalf("missing accumulated arguments in completed output: %s", out)
}
if !strings.Contains(out, `"usage":{"completion_tokens":5,"prompt_tokens":10}`) {
t.Fatalf("missing usage in completed: %s", out)
}
}
func TestStreamResponsesToChatToolCall(t *testing.T) {
tf := newResponsesToChat().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-4o"}}` + "\n\n",
"event: response.output_item.added\n",
`data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"get_weather","arguments":""}}` + "\n\n",
"event: response.function_call_arguments.delta\n",
`data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"city\":\"北京\"}"}` + "\n\n",
"event: response.completed\n",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
})
if !strings.Contains(out, `"tool_calls"`) || !strings.Contains(out, `"name":"get_weather"`) {
t.Fatalf("missing tool_calls chunk: %s", out)
}
if !strings.Contains(out, `"arguments":"{\"city\":\"北京\"}"`) {
t.Fatalf("missing arguments delta chunk: %s", out)
}
if !strings.Contains(out, `"finish_reason":"tool_calls"`) {
t.Fatalf("finish_reason should be tool_calls: %s", out)
}
if !strings.Contains(out, `"usage":{"input_tokens":7,"output_tokens":8}`) {
t.Fatalf("missing usage chunk: %s", out)
}
if !strings.Contains(out, "data: [DONE]") {
t.Fatalf("missing [DONE]: %s", out)
}
}
func TestStreamMessagesToResponsesToolCall(t *testing.T) {
tf := newMessagesToResponses().line
out := feedLines(t, tf, []string{
"event: message_start\n",
`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n",
"event: content_block_start\n",
`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather"}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"北京\"}"}}` + "\n\n",
"event: content_block_stop\n",
`data: {"type":"content_block_stop","index":0}` + "\n\n",
"event: message_delta\n",
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":10,"output_tokens":5}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
if !strings.Contains(out, "event: response.output_item.added") || !strings.Contains(out, `"call_id":"toolu_1"`) {
t.Fatalf("missing function_call item added: %s", out)
}
if !strings.Contains(out, "event: response.function_call_arguments.delta") {
t.Fatalf("missing arguments delta: %s", out)
}
if !strings.Contains(out, "event: response.output_item.done") {
t.Fatalf("missing output_item.done: %s", out)
}
if !strings.Contains(out, `"call_id":"toolu_1"`) || !strings.Contains(out, `"arguments":"{\"city\":\"北京\"}"`) {
t.Fatalf("completed output array missing function_call with full arguments: %s", out)
}
if !strings.Contains(out, `"usage":{"input_tokens":10,"output_tokens":5}`) {
t.Fatalf("missing usage in completed: %s", out)
}
}
func TestStreamResponsesToChatTextStillWorks(t *testing.T) {
tf := newResponsesToChat().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-4o"}}` + "\n\n",
"event: response.output_text.delta\n",
`data: {"type":"response.output_text.delta","delta":"hi"}` + "\n\n",
"event: response.completed\n",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n",
})
if !strings.Contains(out, `"content":"hi"`) {
t.Fatalf("missing text chunk: %s", out)
}
if !strings.Contains(out, `"finish_reason":"stop"`) {
t.Fatalf("missing finish chunk: %s", out)
}
}