Files
openteam/server/internal/proxy/convert/convert_test.go
T
2026-08-19 03:46:39 +08:00

1025 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package convert
import (
"encoding/json"
"strings"
"testing"
)
func mustJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(b)
}
func TestChatToMessagesReq(t *testing.T) {
in := `{
"model":"claude-sonnet-5",
"messages":[
{"role":"system","content":"你是助手"},
{"role":"user","content":"hi"},
{"role":"assistant","content":"hello","tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"sz\"}"}}]},
{"role":"tool","tool_call_id":"call_1","content":"sunny"}
],
"tools":[{"type":"function","function":{"name":"get_weather","description":"查天气","parameters":{"type":"object"}}}],
"max_tokens":100,
"stream":true
}`
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal out: %v\n%s", err, out)
}
if m["system"] != "你是助手" {
t.Fatalf("system = %v", m["system"])
}
if m["max_tokens"] != float64(100) {
t.Fatalf("max_tokens = %v", m["max_tokens"])
}
msgs := m["messages"].([]any)
if len(msgs) != 3 {
t.Fatalf("messages len = %d", len(msgs))
}
// assistant 含 tool_use 块
assistant := msgs[1].(map[string]any)
content := assistant["content"].([]any)
foundToolUse := false
for _, c := range content {
cm := c.(map[string]any)
if cm["type"] == "tool_use" {
foundToolUse = true
if cm["name"] != "get_weather" || cm["id"] != "call_1" {
t.Fatalf("tool_use mismatch: %v", cm)
}
}
}
if !foundToolUse {
t.Fatal("expected tool_use block")
}
// tool 消息 → user 消息的 tool_result 块
tool := msgs[2].(map[string]any)
if tool["role"] != "user" {
t.Fatalf("tool message role = %v", tool["role"])
}
}
func TestMessagesToChatReq(t *testing.T) {
in := `{
"model":"gpt-4o-mini",
"system":"你是助手",
"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"sunny"}]}
],
"tools":[{"name":"get_weather","description":"查天气","input_schema":{"type":"object"}}],
"max_tokens":100,
"stream":false
}`
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
// system + user + assistant + tool = 4 条
if len(msgs) != 4 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
if msgs[0].(map[string]any)["role"] != "system" {
t.Fatal("expected system message first")
}
assistant := msgs[2].(map[string]any)
if tc := assistant["tool_calls"]; tc == nil {
t.Fatalf("expected tool_calls in assistant: %s", out)
}
tool := msgs[3].(map[string]any)
if tool["role"] != "tool" || tool["tool_call_id"] != "call_1" {
t.Fatalf("tool message mismatch: %v", tool)
}
}
func TestResponsesToChatReq(t *testing.T) {
in := `{
"model":"claude-sonnet-5",
"instructions":"你是助手",
"input":"hello",
"tools":[{"type":"function","name":"get_weather","description":"查天气","parameters":{"type":"object"}}],
"max_output_tokens":200,
"stream":false
}`
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
if msgs[0].(map[string]any)["role"] != "system" {
t.Fatal("expected system from instructions")
}
if m["max_tokens"] != float64(200) {
t.Fatalf("max_tokens = %v", m["max_tokens"])
}
}
func TestChatToResponsesReq(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{
map[string]any{"role": "system", "content": "sys"},
map[string]any{"role": "user", "content": "hi"},
},
"max_tokens": 300,
})
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
if m["instructions"] != "sys" {
t.Fatalf("instructions = %v", m["instructions"])
}
if m["max_output_tokens"] != float64(300) {
t.Fatalf("max_output_tokens = %v", m["max_output_tokens"])
}
}
func TestMessagesToChatResp(t *testing.T) {
in := `{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-5",
"content":[{"type":"text","text":"你好"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}],
"stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}`
out, err := ConvertResponse([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
choices := m["choices"].([]any)
msg := choices[0].(map[string]any)["message"].(map[string]any)
if msg["content"] != "你好" {
t.Fatalf("content = %v", msg["content"])
}
if msg["tool_calls"] == nil {
t.Fatal("expected tool_calls")
}
if choices[0].(map[string]any)["finish_reason"] != "tool_calls" {
t.Fatalf("finish_reason = %v", choices[0].(map[string]any)["finish_reason"])
}
}
func TestChatToMessagesResp(t *testing.T) {
in := `{"id":"chatcmpl-xyz","object":"chat.completion","model":"gpt-4o",
"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`
out, err := ConvertResponse([]byte(in), ProtoChat, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
if m["stop_reason"] != "end_turn" {
t.Fatalf("stop_reason = %v", m["stop_reason"])
}
content := m["content"].([]any)
if content[0].(map[string]any)["text"] != "hi" {
t.Fatalf("content = %v", content)
}
usage := m["usage"].(map[string]any)
if usage["input_tokens"] != float64(3) || usage["output_tokens"] != float64(2) {
t.Fatalf("usage = %v", usage)
}
}
// 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")
}
}
// ---------------------------------------------------------------------------
// 流式转换
func feedLines(t *testing.T, transformer func([]byte) []byte, lines []string) string {
t.Helper()
var sb strings.Builder
for _, l := range lines {
if out := transformer([]byte(l)); out != nil {
sb.Write(out)
}
}
return sb.String()
}
func TestStreamMessagesToChat(t *testing.T) {
tf := newMessagesToChat().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_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n",
"event: message_delta\n",
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
if !strings.Contains(out, `"content":"你好"`) {
t.Fatalf("missing content chunk: %s", out)
}
if !strings.Contains(out, `"finish_reason":"stop"`) {
t.Fatalf("missing finish chunk: %s", out)
}
if !strings.Contains(out, `"usage"`) {
t.Fatalf("missing usage chunk: %s", out)
}
if !strings.Contains(out, "data: [DONE]") {
t.Fatalf("missing [DONE]: %s", out)
}
}
func TestStreamChatToMessages(t *testing.T) {
tf := newChatToMessages().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":{"content":"你好"},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9}}` + "\n\n",
"data: [DONE]\n\n",
})
if !strings.Contains(out, "event: message_start") {
t.Fatalf("missing message_start: %s", out)
}
if !strings.Contains(out, `"text":"你好"`) || !strings.Contains(out, `"type":"text_delta"`) {
t.Fatalf("missing content delta: %s", out)
}
if !strings.Contains(out, `"stop_reason":"end_turn"`) {
t.Fatalf("missing message_delta: %s", out)
}
if !strings.Contains(out, "event: message_stop") {
t.Fatalf("missing message_stop: %s", out)
}
}
func TestStreamResponsesToMessages(t *testing.T) {
tf := newResponsesToMessages().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"claude-sonnet-5"}}` + "\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":7,"output_tokens":8}}}` + "\n\n",
})
if !strings.Contains(out, "event: message_start") {
t.Fatalf("missing message_start: %s", out)
}
if !strings.Contains(out, `"text":"hi"`) {
t.Fatalf("missing content: %s", out)
}
if !strings.Contains(out, "event: message_stop") {
t.Fatalf("missing message_stop: %s", out)
}
}
func TestStreamMessagesToResponses(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_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
if !strings.Contains(out, "event: response.created") {
t.Fatalf("missing response.created: %s", out)
}
if !strings.Contains(out, "event: response.output_text.delta") {
t.Fatalf("missing output_text.delta: %s", out)
}
if !strings.Contains(out, "event: response.completed") {
t.Fatalf("missing response.completed: %s", out)
}
}
// ---------------------------------------------------------------------------
// 图片内容块转换(messages image / chat image_url / responses input_image 互转不丢图)
const testB64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
// userContentBlocks 断言 Chat 用户消息 content 为块数组并返回。
func userContentBlocks(t *testing.T, out []byte) []any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, out)
}
msgs := m["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
content, ok := msgs[0].(map[string]any)["content"].([]any)
if !ok {
t.Fatalf("content not array: %s", out)
}
return content
}
// blockURL 取 image_url 块的 url。
func blockURL(t *testing.T, b any) string {
t.Helper()
bm := b.(map[string]any)
if bm["type"] != "image_url" {
t.Fatalf("block type = %v", bm["type"])
}
iu := bm["image_url"].(map[string]any)
url, _ := iu["url"].(string)
return url
}
func TestMessagesToChatReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o-mini",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": testB64}},
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/a.png"}},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
blocks := userContentBlocks(t, out)
if len(blocks) != 3 {
t.Fatalf("blocks len = %d: %s", len(blocks), out)
}
if got := blockURL(t, blocks[1]); got != "data:image/png;base64,"+testB64 {
t.Fatalf("base64 image url = %q", got)
}
if got := blockURL(t, blocks[2]); got != "https://example.com/a.png" {
t.Fatalf("url image = %q", got)
}
}
// 多图 + 图/文交错顺序:转换后块数量与顺序保持不变(文档推荐的 Image 1:/Image 2: 标签模式)。
func TestMessagesToChatReqMultiImage(t *testing.T) {
img := func(url string) any {
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
}
in := mustJSON(t, map[string]any{
"model": "gpt-4o-mini",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "Image 1:"},
img("https://example.com/1.png"),
map[string]any{"type": "text", "text": "Image 2:"},
img("https://example.com/2.png"),
map[string]any{"type": "text", "text": "How are these images different?"},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
blocks := userContentBlocks(t, out)
if len(blocks) != 5 {
t.Fatalf("blocks len = %d: %s", len(blocks), out)
}
types := []string{}
for _, b := range blocks {
types = append(types, b.(map[string]any)["type"].(string))
}
want := []string{"text", "image_url", "text", "image_url", "text"}
for i := range want {
if types[i] != want[i] {
t.Fatalf("block[%d] type = %s, want %s: %v", i, types[i], want[i], types)
}
}
// 第二张图仍是第二张
if got := blockURL(t, blocks[3]); got != "https://example.com/2.png" {
t.Fatalf("image2 url = %q", got)
}
}
func TestChatToMessagesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "claude-sonnet-5",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64," + testB64}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/b.png"}},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
content := msgs[0].(map[string]any)["content"].([]any)
if len(content) != 3 {
t.Fatalf("content len = %d: %s", len(content), out)
}
src1 := content[1].(map[string]any)["source"].(map[string]any)
if src1["type"] != "base64" || src1["media_type"] != "image/jpeg" || src1["data"] != testB64 {
t.Fatalf("base64 source = %v", src1)
}
src2 := content[2].(map[string]any)["source"].(map[string]any)
if src2["type"] != "url" || src2["url"] != "https://example.com/b.png" {
t.Fatalf("url source = %v", src2)
}
}
func TestResponsesToChatReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "claude-sonnet-5",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "describe"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + testB64},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
blocks := userContentBlocks(t, out)
if len(blocks) != 2 {
t.Fatalf("blocks len = %d: %s", len(blocks), out)
}
if got := blockURL(t, blocks[1]); got != "data:image/png;base64,"+testB64 {
t.Fatalf("image url = %q", got)
}
}
func TestChatToResponsesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/c.png"}},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
var input []map[string]any
// 单条 user 消息项
switch v := m["input"].(type) {
case map[string]any:
input = []map[string]any{v}
case []any:
for _, it := range v {
input = append(input, it.(map[string]any))
}
}
content := input[0]["content"].([]any)
if len(content) != 2 {
t.Fatalf("content len = %d: %s", len(content), out)
}
img := content[1].(map[string]any)
if img["type"] != "input_image" {
t.Fatalf("block type = %v", img["type"])
}
if img["image_url"] != "https://example.com/c.png" {
t.Fatalf("image_url = %v", img["image_url"])
}
}
// 链式转换:responses→messages、messages→responses(经 chat 中转)也不丢图。
func TestResponsesToMessagesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "claude-sonnet-5",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "describe"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + testB64},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
content := msgs[0].(map[string]any)["content"].([]any)
if len(content) != 2 {
t.Fatalf("content len = %d: %s", len(content), out)
}
src := content[1].(map[string]any)["source"].(map[string]any)
if src["type"] != "base64" || src["data"] != testB64 {
t.Fatalf("source = %v", src)
}
}
func TestMessagesToResponsesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/d.png"}},
map[string]any{"type": "text", "text": "describe"},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoResponses)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
var content []any
switch v := m["input"].(type) {
case map[string]any:
content = v["content"].([]any)
case []any:
content = v[0].(map[string]any)["content"].([]any)
}
if len(content) != 2 {
t.Fatalf("content len = %d: %s", len(content), out)
}
img := content[0].(map[string]any)
if img["type"] != "input_image" || img["image_url"] != "https://example.com/d.png" {
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)
}
}