1025 lines
37 KiB
Go
1025 lines
37 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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|