From 30ab9e842cb722d60b378928ac8c6310e51fae39 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:46:39 +0800 Subject: [PATCH] claude code tools error --- server/internal/proxy/convert/convert_test.go | 473 ++++++++++++++++++ server/internal/proxy/convert/json_chat.go | 4 +- .../internal/proxy/convert/json_responses.go | 7 +- server/internal/proxy/convert/stream.go | 302 ++++++++--- 4 files changed, 709 insertions(+), 77 deletions(-) diff --git a/server/internal/proxy/convert/convert_test.go b/server/internal/proxy/convert/convert_test.go index b38ba81..4914fbc 100644 --- a/server/internal/proxy/convert/convert_test.go +++ b/server/internal/proxy/convert/convert_test.go @@ -202,6 +202,51 @@ func TestChatToMessagesResp(t *testing.T) { } } +// TestResponsesToMessagesRespToolCall 非流式 Responses 响应 → Messages 响应(经 chat 中转)。 +func TestResponsesToMessagesRespToolCall(t *testing.T) { + in := `{"id":"resp_abc","object":"response","model":"glm-4","status":"completed", + "output":[ + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"我来查询"}]}, + {"type":"function_call","id":"fc_1","call_id":"call_9","name":"get_weather","arguments":"{\"city\":\"北京\"}"} + ], + "usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}` + out, err := ConvertResponse([]byte(in), ProtoResponses, ProtoMessages) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + if m["stop_reason"] != "tool_use" { + t.Fatalf("stop_reason = %v", m["stop_reason"]) + } + if m["model"] != "glm-4" { + t.Fatalf("model = %v", m["model"]) + } + content := m["content"].([]any) + var text, tool string + for _, b := range content { + bm := b.(map[string]any) + switch bm["type"] { + case "text": + text, _ = bm["text"].(string) + case "tool_use": + tool = "y" + if bm["id"] != "call_9" || bm["name"] != "get_weather" { + t.Fatalf("tool block = %v", bm) + } + if in, _ := bm["input"].(map[string]any); in["city"] != "北京" { + t.Fatalf("tool input = %v", bm["input"]) + } + } + } + if text != "我来查询" { + t.Fatalf("text = %q", text) + } + if tool != "y" { + t.Fatal("expected tool_use block") + } +} + // --------------------------------------------------------------------------- // 流式转换 @@ -549,3 +594,431 @@ func TestMessagesToResponsesReqImage(t *testing.T) { t.Fatalf("input_image = %v", img) } } + +// --------------------------------------------------------------------------- +// 流式工具调用转换(chat tool_calls / messages tool_use / responses function_call 互转不丢) + +// sseEvent 一条解析出的 SSE 事件(dataLine 无 event 行时 typ 为空串)。 +type sseEvent struct { + typ string + data map[string]any +} + +// parseSSE 把转换器输出拆成事件列表。 +func parseSSE(t *testing.T, s string) []sseEvent { + t.Helper() + var evs []sseEvent + cur := "" + for _, ln := range strings.Split(s, "\n") { + switch { + case strings.HasPrefix(ln, "event: "): + cur = strings.TrimSpace(strings.TrimPrefix(ln, "event: ")) + case strings.HasPrefix(ln, "data: "): + d := strings.TrimPrefix(ln, "data: ") + if d == "[DONE]" { + evs = append(evs, sseEvent{typ: "[DONE]"}) + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(d), &m); err != nil { + t.Fatalf("parse sse data: %v\n%s", err, d) + } + evs = append(evs, sseEvent{typ: cur, data: m}) + } + } + return evs +} + +// findSSE 返回第一个 typ 匹配且(index>=0 时)index 字段相符的事件。 +func findSSE(evs []sseEvent, typ string, index int) *sseEvent { + for i := range evs { + if evs[i].typ != typ { + continue + } + if index >= 0 { + if idx, ok := evs[i].data["index"].(float64); !ok || int(idx) != index { + continue + } + } + return &evs[i] + } + return nil +} + +// inputJSONDeltas 汇总所有 input_json_delta:index → 拼接后的 partial_json。 +func inputJSONDeltas(evs []sseEvent) map[int]string { + out := map[int]string{} + for _, e := range evs { + if e.typ != "content_block_delta" { + continue + } + d, _ := e.data["delta"].(map[string]any) + if d == nil || d["type"] != "input_json_delta" { + continue + } + idx, _ := e.data["index"].(float64) + p, _ := d["partial_json"].(string) + out[int(idx)] += p + } + return out +} + +func TestStreamChatToMessagesToolCall(t *testing.T) { + tf := newChatToMessages().line + out := feedLines(t, tf, []string{ + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"用户"}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"content":"我来帮您查询"}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_dbcef6a399ad47bb973fa83a","index":0,"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":167,"completion_tokens":48}}` + "\n\n", + "data: [DONE]\n\n", + }) + evs := parseSSE(t, out) + + // message_start + if ms := findSSE(evs, "message_start", -1); ms == nil { + t.Fatal("missing message_start") + } + // 文本块 index 0 + if cs := findSSE(evs, "content_block_start", 0); cs == nil { + t.Fatal("missing text content_block_start") + } else if cb := cs.data["content_block"].(map[string]any); cb["type"] != "text" { + t.Fatalf("block 0 type = %v", cb["type"]) + } + if !strings.Contains(out, `"text":"我来帮您查询"`) { + t.Fatalf("missing text delta: %s", out) + } + // 工具块 index 1 + cs := findSSE(evs, "content_block_start", 1) + if cs == nil { + t.Fatal("missing tool content_block_start") + } + cb := cs.data["content_block"].(map[string]any) + if cb["type"] != "tool_use" || cb["id"] != "call_dbcef6a399ad47bb973fa83a" || cb["name"] != "get_weather" { + t.Fatalf("tool block = %v", cb) + } + if input, ok := cb["input"].(map[string]any); !ok || len(input) != 0 { + t.Fatalf("tool input should be empty object, got %v", cb["input"]) + } + // input_json_delta 整段 args + if d := inputJSONDeltas(evs)[1]; d != `{"city":"北京"}` { + t.Fatalf("tool args = %q", d) + } + // 两个块都有 stop + if findSSE(evs, "content_block_stop", 0) == nil || findSSE(evs, "content_block_stop", 1) == nil { + t.Fatalf("missing per-block stops: %s", out) + } + // stop_reason tool_use + if md := findSSE(evs, "message_delta", -1); md == nil { + t.Fatal("missing message_delta") + } else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" { + t.Fatalf("stop_reason = %v", d["stop_reason"]) + } + if findSSE(evs, "message_stop", -1) == nil { + t.Fatal("missing message_stop") + } +} + +func TestStreamChatToMessagesToolOnly(t *testing.T) { + tf := newChatToMessages().line + out := feedLines(t, tf, []string{ + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"查询"}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","index":0,"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}]}` + "\n\n", + "data: [DONE]\n\n", + }) + evs := parseSSE(t, out) + + // 首块即 tool_use index 0 + cs := findSSE(evs, "content_block_start", 0) + if cs == nil { + t.Fatal("missing first block") + } + if cb := cs.data["content_block"].(map[string]any); cb["type"] != "tool_use" { + t.Fatalf("first block type = %v", cb["type"]) + } + // 不应出现空文本块 + for _, e := range evs { + if e.typ == "content_block_start" { + if cb := e.data["content_block"].(map[string]any); cb["type"] == "text" { + t.Fatalf("unexpected text block: %s", out) + } + } + } + // 单个 stop + if findSSE(evs, "content_block_stop", 0) == nil { + t.Fatal("missing stop 0") + } + if findSSE(evs, "content_block_stop", 1) != nil { + t.Fatalf("unexpected stop 1: %s", out) + } + if md := findSSE(evs, "message_delta", -1); md == nil { + t.Fatal("missing message_delta") + } else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" { + t.Fatalf("stop_reason = %v", d["stop_reason"]) + } +} + +func TestStreamChatToMessagesParallelTools(t *testing.T) { + tf := newChatToMessages().line + out := feedLines(t, tf, []string{ + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_0","index":0,"type":"function","function":{"name":"search","arguments":""}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","index":1,"type":"function","function":{"name":"read","arguments":""}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":"}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"weather\"}"}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"a.txt\"}"}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"finish_reason":"tool_calls","delta":{}}]}` + "\n\n", + "data: [DONE]\n\n", + }) + evs := parseSSE(t, out) + + // 块 0 = search,块 1 = read(按首现顺序分配) + if cb := findSSE(evs, "content_block_start", 0); cb == nil { + t.Fatal("missing block 0") + } else if n := cb.data["content_block"].(map[string]any)["name"]; n != "search" { + t.Fatalf("block 0 name = %v", n) + } + if cb := findSSE(evs, "content_block_start", 1); cb == nil { + t.Fatal("missing block 1") + } else if n := cb.data["content_block"].(map[string]any)["name"]; n != "read" { + t.Fatalf("block 1 name = %v", n) + } + // 参数分段正确路由 + deltas := inputJSONDeltas(evs) + if deltas[0] != `{"query":"weather"}` { + t.Fatalf("block 0 args = %q", deltas[0]) + } + if deltas[1] != `{"path":"a.txt"}` { + t.Fatalf("block 1 args = %q", deltas[1]) + } + if findSSE(evs, "content_block_stop", 0) == nil || findSSE(evs, "content_block_stop", 1) == nil { + t.Fatalf("missing stops: %s", out) + } +} + +func TestStreamChatToMessagesTextAfterTool(t *testing.T) { + tf := newChatToMessages().line + out := feedLines(t, tf, []string{ + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_1","index":0,"type":"function","function":{"name":"get_weather","arguments":""}}]}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"content":"天气查询中"}}]}` + "\n\n", + `data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"finish_reason":"stop","delta":{}}]}` + "\n\n", + "data: [DONE]\n\n", + }) + evs := parseSSE(t, out) + + // 工具块 index 0、文本块 index 1 + if cb := findSSE(evs, "content_block_start", 0); cb == nil { + t.Fatal("missing tool block") + } else if cb.data["content_block"].(map[string]any)["type"] != "tool_use" { + t.Fatalf("block 0 type = %v", cb.data["content_block"].(map[string]any)["type"]) + } + if cb := findSSE(evs, "content_block_start", 1); cb == nil { + t.Fatal("missing text block") + } else if cb.data["content_block"].(map[string]any)["type"] != "text" { + t.Fatalf("block 1 type = %v", cb.data["content_block"].(map[string]any)["type"]) + } + // stop 顺序 0,1 + var stops []int + for _, e := range evs { + if e.typ == "content_block_stop" { + idx, _ := e.data["index"].(float64) + stops = append(stops, int(idx)) + } + } + if len(stops) != 2 || stops[0] != 0 || stops[1] != 1 { + t.Fatalf("stop order = %v", stops) + } +} + +func TestStreamMessagesToChatToolUse(t *testing.T) { + tf := newMessagesToChat().line + out := feedLines(t, tf, []string{ + "event: message_start\n", + `data: {"type":"message_start","message":{"id":"msg_1","model":"glm-4.7-flash"}}` + "\n\n", + "event: content_block_delta\n", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n", + "event: content_block_start\n", + `data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_x","name":"get_weather","input":{}}}` + "\n\n", + "event: content_block_delta\n", + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\""}}` + "\n\n", + "event: content_block_delta\n", + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"city\":\"北京\"}"}}` + "\n\n", + "event: content_block_stop\n", + `data: {"type":"content_block_stop","index":1}` + "\n\n", + "event: message_delta\n", + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}` + "\n\n", + "event: message_stop\n", + `data: {"type":"message_stop"}` + "\n\n", + }) + evs := parseSSE(t, out) + + // 文本增量 + if !strings.Contains(out, `"content":"你好"`) { + t.Fatalf("missing text chunk: %s", out) + } + // tool_use → chat tool_calls:messages 块 index 1 映射为 chat 工具索引 0 + var toolStart, argsSeen, finishSeen bool + for _, e := range evs { + choices, _ := e.data["choices"].([]any) + if len(choices) == 0 { + continue + } + c0, _ := choices[0].(map[string]any) + delta, _ := c0["delta"].(map[string]any) + if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 { + tc, _ := tcs[0].(map[string]any) + idx, _ := tc["index"].(float64) + if int(idx) != 0 { + continue + } + fn, _ := tc["function"].(map[string]any) + if tc["id"] == "call_x" && fn["name"] == "get_weather" { + toolStart = true + } + if s, _ := fn["arguments"].(string); s != "" { + argsSeen = true + } + } + if fr, _ := c0["finish_reason"].(string); fr == "tool_calls" { + finishSeen = true + } + } + if !toolStart { + t.Fatalf("missing tool_calls start: %s", out) + } + if !argsSeen { + t.Fatalf("missing tool_calls arguments chunks: %s", out) + } + if !finishSeen { + t.Fatalf("missing finish_reason tool_calls: %s", out) + } + if !strings.Contains(out, "data: [DONE]") { + t.Fatalf("missing [DONE]: %s", out) + } +} + +func TestStreamResponsesToMessagesFunctionCall(t *testing.T) { + tf := newResponsesToMessages().line + out := feedLines(t, tf, []string{ + "event: response.created\n", + `data: {"type":"response.created","response":{"id":"resp_1","model":"glm-4.7-flash"}}` + "\n\n", + "event: response.output_item.added\n", + `data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_call_1","call_id":"call_1","name":"get_weather","arguments":"","status":"in_progress"},"output_index":0}` + "\n\n", + "event: response.function_call_arguments.delta\n", + `data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_call_1"}` + "\n\n", + "event: response.function_call_arguments.delta\n", + `data: {"type":"response.function_call_arguments.delta","delta":"city\":\"北京\"}","item_id":"fc_call_1"}` + "\n\n", + "event: response.output_item.done\n", + `data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_call_1","arguments":"{\"city\":\"北京\"}","status":"completed"}}` + "\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", + }) + evs := parseSSE(t, out) + + if findSSE(evs, "message_start", -1) == nil { + t.Fatal("missing message_start") + } + // 首块即 tool_use index 0 + cs := findSSE(evs, "content_block_start", 0) + if cs == nil { + t.Fatal("missing tool block") + } + cb := cs.data["content_block"].(map[string]any) + if cb["type"] != "tool_use" || cb["id"] != "call_1" || cb["name"] != "get_weather" { + t.Fatalf("tool block = %v", cb) + } + if d := inputJSONDeltas(evs)[0]; d != `{"city":"北京"}` { + t.Fatalf("tool args = %q", d) + } + if findSSE(evs, "content_block_stop", 0) == nil { + t.Fatal("missing stop") + } + if md := findSSE(evs, "message_delta", -1); md == nil { + t.Fatal("missing message_delta") + } else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" { + t.Fatalf("stop_reason = %v", d["stop_reason"]) + } + if findSSE(evs, "message_stop", -1) == nil { + t.Fatal("missing message_stop") + } +} + +func TestStreamResponsesToMessagesTextThenFunction(t *testing.T) { + tf := newResponsesToMessages().line + out := feedLines(t, tf, []string{ + "event: response.created\n", + `data: {"type":"response.created","response":{"id":"resp_1","model":"glm-4.7-flash"}}` + "\n\n", + "event: response.output_text.delta\n", + `data: {"type":"response.output_text.delta","delta":"我来查询"}` + "\n\n", + "event: response.output_item.added\n", + `data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_call_1","call_id":"call_1","name":"get_weather","arguments":"","status":"in_progress"},"output_index":1}` + "\n\n", + "event: response.function_call_arguments.delta\n", + `data: {"type":"response.function_call_arguments.delta","delta":"{\"city\":\"北京\"}","item_id":"fc_call_1"}` + "\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", + }) + evs := parseSSE(t, out) + + // 文本块 index 0、工具块 index 1 + if cb := findSSE(evs, "content_block_start", 0); cb == nil { + t.Fatal("missing text block") + } else if cb.data["content_block"].(map[string]any)["type"] != "text" { + t.Fatalf("block 0 type = %v", cb.data["content_block"].(map[string]any)["type"]) + } + if cb := findSSE(evs, "content_block_start", 1); cb == nil { + t.Fatal("missing tool block") + } else if cb.data["content_block"].(map[string]any)["type"] != "tool_use" { + t.Fatalf("block 1 type = %v", cb.data["content_block"].(map[string]any)["type"]) + } + if !strings.Contains(out, `"text":"我来查询"`) { + t.Fatalf("missing text delta: %s", out) + } + // stop 顺序 0,1 + var stops []int + for _, e := range evs { + if e.typ == "content_block_stop" { + idx, _ := e.data["index"].(float64) + stops = append(stops, int(idx)) + } + } + if len(stops) != 2 || stops[0] != 0 || stops[1] != 1 { + t.Fatalf("stop order = %v", stops) + } + if md := findSSE(evs, "message_delta", -1); md == nil { + t.Fatal("missing message_delta") + } else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" { + t.Fatalf("stop_reason = %v", d["stop_reason"]) + } +} + +func TestMessagesToChatReqToolOrder(t *testing.T) { + in := mustJSON(t, map[string]any{ + "model": "gpt-4o", + "messages": []any{map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "text", "text": "我来"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{"city": "北京"}}, + map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "晴"}, + }}}, + }) + out, err := messagesToChatReq([]byte(in)) + if err != nil { + t.Fatalf("messagesToChatReq: %v", err) + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + msgs := m["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("messages len = %d: %s", len(msgs), out) + } + if r := msgs[0].(map[string]any)["role"]; r != "assistant" { + t.Fatalf("msg[0] role = %v", r) + } + if r := msgs[1].(map[string]any)["role"]; r != "tool" { + t.Fatalf("msg[1] role = %v", r) + } + if _, ok := msgs[0].(map[string]any)["tool_calls"]; !ok { + t.Fatalf("msg[0] missing tool_calls: %s", out) + } +} diff --git a/server/internal/proxy/convert/json_chat.go b/server/internal/proxy/convert/json_chat.go index c1a0a1d..7beb456 100644 --- a/server/internal/proxy/convert/json_chat.go +++ b/server/internal/proxy/convert/json_chat.go @@ -230,6 +230,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any { var blocks []map[string]any if json.Unmarshal(content, &blocks) == nil && blocks != nil { var out []any + var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后 var textParts []string var contentBlocks []any // text / image_url 块,保留原始顺序 var toolCalls []any @@ -259,7 +260,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any { case "tool_result": callID, _ := b["tool_use_id"].(string) res := strField(b["content"]) - out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res}) + toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res}) } } hasImage := false @@ -282,6 +283,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any { } out = append(out, msg) } + out = append(out, toolMsgs...) if len(out) > 0 { return out } diff --git a/server/internal/proxy/convert/json_responses.go b/server/internal/proxy/convert/json_responses.go index 2524fd5..2c12c87 100644 --- a/server/internal/proxy/convert/json_responses.go +++ b/server/internal/proxy/convert/json_responses.go @@ -305,8 +305,11 @@ func responsesToChatResp(body []byte) ([]byte, error) { msg["tool_calls"] = toolCalls } finish := "stop" - if string(rawJSON(m, "status")) == `"incomplete"` { - finish = "length" + switch { + case string(rawJSON(m, "status")) == `"incomplete"`: + finish = "length" // 截断优先,客户端可据此区分 + case len(toolCalls) > 0: + finish = "tool_calls" // 有工具调用时映射 tool_calls,否则中转后 stop_reason 成 end_turn } var prompt, completion int64 if u := rawJSON(m, "usage"); u != nil { diff --git a/server/internal/proxy/convert/stream.go b/server/internal/proxy/convert/stream.go index 7630af1..644a82f 100644 --- a/server/internal/proxy/convert/stream.go +++ b/server/internal/proxy/convert/stream.go @@ -49,9 +49,11 @@ func eventLine(name string, obj any) []byte { type messagesToChat struct { sseState id, model string + toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块) + nextTool int } -func newMessagesToChat() *messagesToChat { return &messagesToChat{} } +func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} } func (t *messagesToChat) line(line []byte) []byte { isData, data, done := t.parseLine(line) @@ -72,8 +74,43 @@ func (t *messagesToChat) line(line []byte) []byte { "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}}, }) + case "content_block_start": + cb, _ := m["content_block"].(map[string]any) + if cb == nil || cb["type"] != "tool_use" { + return nil + } + blockIdx, _ := m["index"].(float64) + tool := t.nextTool + t.nextTool++ + t.toolIdx[int(blockIdx)] = tool + toolID, _ := cb["id"].(string) + name, _ := cb["name"].(string) + return dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{ + "tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}}, + }, "finish_reason": nil}}, + }) case "content_block_delta": delta, _ := m["delta"].(map[string]any) + deltaType, _ := delta["type"].(string) + if deltaType == "input_json_delta" { + blockIdx, _ := m["index"].(float64) + tool, ok := t.toolIdx[int(blockIdx)] + if !ok { + return nil + } + partial, _ := delta["partial_json"].(string) + if partial == "" { + return nil + } + return dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{ + "tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}}, + }, "finish_reason": nil}}, + }) + } text, _ := delta["text"].(string) if text == "" { return nil @@ -118,14 +155,19 @@ func joinLines(lines [][]byte) []byte { type chatToMessages struct { sseState - started bool - blockStarted bool - model string - stopReason string - usage any + started bool // message_start 已发出 + nextIndex int // 下一个 content block index(顺序分配) + textIndex int // 文本块 index;-1 = 未开始 + toolIdx map[int]int // chat delta.tool_calls[].index → messages block index + openBlocks []int // 已开始未停止的 block index,按开始顺序 + model string + stopReason string + usage any } -func newChatToMessages() *chatToMessages { return &chatToMessages{} } +func newChatToMessages() *chatToMessages { + return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}} +} func (t *chatToMessages) line(line []byte) []byte { isData, data, done := t.parseLine(line) @@ -133,18 +175,18 @@ func (t *chatToMessages) line(line []byte) []byte { return nil } if done { - // 汇聚最终 message_delta + content_block_stop + message_stop + // 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop + var out [][]byte + for _, idx := range t.openBlocks { + out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx})) + } md := map[string]any{"type": "message_delta", "delta": map[string]any{ "stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil, }} if t.usage != nil { md["usage"] = t.usage } - var out [][]byte out = append(out, eventLine("message_delta", md)) - if t.blockStarted { - out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})) - } out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"})) return joinLines(out) } @@ -167,47 +209,97 @@ func (t *chatToMessages) line(line []byte) []byte { id, _ := m["id"].(string) var out [][]byte - // 首个包含内容或角色的块前,先发 message_start + content_block_start - if !t.started { - role, _ := delta["role"].(string) - content, _ := delta["content"].(string) - if role == "assistant" || content != "" { - t.started = true - out = append(out, eventLine("message_start", map[string]any{ - "type": "message_start", - "message": map[string]any{ - "id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", - "model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0}, - }, - })) - out = append(out, eventLine("content_block_start", map[string]any{ - "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, - })) - t.blockStarted = true + // message_start 只在实际有内容(文本或工具)时发出,避免 GLM 等模型的 + // reasoning_content 块(带 role 无 content)提前开出一个空文本块。 + ensureStarted := func() { + if t.started { + return } - } - if content, _ := delta["content"].(string); content != "" { - if !t.started { - t.started = true - t.blockStarted = true - out = append(out, eventLine("message_start", map[string]any{ - "type": "message_start", - "message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}}, - })) - out = append(out, eventLine("content_block_start", map[string]any{ - "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, - })) - } - out = append(out, eventLine("content_block_delta", map[string]any{ - "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content}, + t.started = true + out = append(out, eventLine("message_start", map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", + "model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0}, + }, })) } + + // 文本:delta.content(string;兼容 {type:text,text} 数组) + if content := deltaText(delta); content != "" { + if t.textIndex < 0 { + t.textIndex = t.nextIndex + t.nextIndex++ + ensureStarted() + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""}, + })) + t.openBlocks = append(t.openBlocks, t.textIndex) + } + out = append(out, eventLine("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content}, + })) + } + + // 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式) + if tcs, ok := delta["tool_calls"].([]any); ok { + for _, tc := range tcs { + call, ok := tc.(map[string]any) + if !ok { + continue + } + idx, _ := call["index"].(float64) + tcIdx := int(idx) + fn, _ := call["function"].(map[string]any) + name, _ := fn["name"].(string) + args, _ := fn["arguments"].(string) + blockIdx, seen := t.toolIdx[tcIdx] + if !seen { + blockIdx = t.nextIndex + t.nextIndex++ + t.toolIdx[tcIdx] = blockIdx + toolID, _ := call["id"].(string) + ensureStarted() + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": blockIdx, "content_block": map[string]any{ + "type": "tool_use", "id": toolID, "name": name, "input": map[string]any{}, + }, + })) + t.openBlocks = append(t.openBlocks, blockIdx) + } + if args != "" { + out = append(out, eventLine("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args}, + })) + } + } + } + if u, ok := m["usage"]; ok { t.usage = u } return joinLines(out) } +// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。 +func deltaText(delta map[string]any) string { + if s, ok := delta["content"].(string); ok { + return s + } + if arr, ok := delta["content"].([]any); ok { + var parts []string + for _, b := range arr { + if bm, ok := b.(map[string]any); ok { + if t, _ := bm["text"].(string); t != "" { + parts = append(parts, t) + } + } + } + return strings.Join(parts, "") + } + return "" +} + func stopReasonOrEnd(s string) string { if s == "" { return "end_turn" @@ -220,12 +312,19 @@ func stopReasonOrEnd(s string) string { type responsesToMessages struct { sseState - started bool - model string - usage any + started bool + model string + usage any + nextIndex int // 下一个 content block index(顺序分配) + textIndex int // 文本块 index;-1 = 未开始 + toolIdx map[string]int // function_call item_id → messages block index + openBlocks []int // 已开始未停止的 block index,按开始顺序 + anyTool bool } -func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} } +func newResponsesToMessages() *responsesToMessages { + return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}} +} func (t *responsesToMessages) line(line []byte) []byte { isData, data, done := t.parseLine(line) @@ -243,38 +342,93 @@ func (t *responsesToMessages) line(line []byte) []byte { } } var out [][]byte + // message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开, + // 避免纯函数调用响应提前开出一个空文本块。 + ensureStarted := func() { + if t.started { + return + } + t.started = true + rid := "" + if resp, ok := m["response"].(map[string]any); ok { + rid, _ = resp["id"].(string) + } + out = append(out, eventLine("message_start", map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant", + "model": t.model, "content": []any{}, + }, + })) + } switch evt { case "response.created": - if !t.started { - t.started = true - id, _ := m["response"].(map[string]any) - rid := "" - if id != nil { - rid, _ = id["id"].(string) - } - out = append(out, eventLine("message_start", map[string]any{ - "type": "message_start", - "message": map[string]any{ - "id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant", - "model": t.model, "content": []any{}, - }, - })) - out = append(out, eventLine("content_block_start", map[string]any{ - "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, - })) - } + ensureStarted() case "response.output_text.delta": delta, _ := m["delta"].(string) - if delta != "" { - out = append(out, eventLine("content_block_delta", map[string]any{ - "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta}, - })) + if delta == "" { + return nil } - case "response.completed": - out = append(out, eventLine("message_delta", map[string]any{ - "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, + if t.textIndex < 0 { + t.textIndex = t.nextIndex + t.nextIndex++ + ensureStarted() + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""}, + })) + t.openBlocks = append(t.openBlocks, t.textIndex) + } + out = append(out, eventLine("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta}, })) - out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})) + case "response.output_item.added": + item, _ := m["item"].(map[string]any) + if item == nil || item["type"] != "function_call" { + return nil + } + blockIdx := t.nextIndex + t.nextIndex++ + t.anyTool = true + itemID, _ := item["id"].(string) + t.toolIdx[itemID] = blockIdx + toolUseID, _ := item["call_id"].(string) + if toolUseID == "" { + toolUseID = itemID + } + name, _ := item["name"].(string) + ensureStarted() + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": blockIdx, "content_block": map[string]any{ + "type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{}, + }, + })) + t.openBlocks = append(t.openBlocks, blockIdx) + case "response.function_call_arguments.delta": + itemID, _ := m["item_id"].(string) + blockIdx, ok := t.toolIdx[itemID] + if !ok { + return nil + } + delta, _ := m["delta"].(string) + if delta == "" { + return nil + } + out = append(out, eventLine("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta}, + })) + case "response.completed": + for _, idx := range t.openBlocks { + out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx})) + } + stop := "end_turn" + if t.anyTool { + stop = "tool_use" + } + md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}} + if t.usage != nil { + md["usage"] = t.usage + } + out = append(out, eventLine("message_delta", md)) out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"})) } return joinLines(out)