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) } } // --------------------------------------------------------------------------- // 流式转换 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) } }