Files
openteam/server/internal/proxy/convert/convert_test.go
T
SakurasanandClaude 4b507d8064 转换器: 图片内容块跨协议互转保留(base64/url)
- 支持 OpenAI image_url ↔ Anthropic image.source(base64/url) 双向转换
- messages→chat / responses→chat 不再丢弃图片块, 保留图文原始顺序
- chat→messages 的 data URL 拆成 base64 source(media_type+data)
- chat→responses 的 image_url 转 input_image
- responses↔messages 经 chat 中转, 六方向全覆盖
- 新增多图/顺序回归测试

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 09:04:09 +08:00

552 lines
18 KiB
Go

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)
}
}
// ---------------------------------------------------------------------------
// 图片内容块转换(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)
}
}