Files
openteam/server/internal/proxy/convert/convert_test.go
T
Sakurasan 04dfb4285d feat: 会话亲和粘住缓存渠道;透传 opaque 字段;用量记思考/1h 缓存写/实际模型
会话亲和(proxy/affinity.go):同一会话粘住上次应答的渠道,命中上游提示词
缓存而非被加权随机打散后全价重算。会话身份 = 密钥 + 模型 + 请求文本稳定前缀
摘要,不依赖各客户端会话头;TTL 默认 5m(OT_PROXY_AFFINITY_TTL,0=关闭),
成功应答刷新。亲和渠道必须在候选集内(健康/未冷却/绑定),否则回退加权随机。
proxyOne 增加 served 返回值区分「应答成功」与「已终结但失败」。

opaque 透传(convert):chat↔responses 互转原样携带 metadata、service_tier、
parallel_tool_calls。此前逐字段重建请求体会丢它们,而中转上游会校验/使用。

用量细化(proxy+store):usage_logs 新增 reasoning_tokens(展示口径,不重复
计费)、cache_write_1h_tokens(Anthropic 1h 缓存写按 2× 输入价,此前统一
1.25× 少算 60%)、served_model(上游实际应答模型,映射/聚合商场景对账)。
schema v6→v7。

验证:go vet ./... 干净;go test ./... 与 go test -race ./... 全绿;
新增测试覆盖亲和粘性与冷却回退、opaque 双向透传、reasoning 不重复计费、
1h/5m 缓存写分开计价、served_model 提取(非流式与流式)。
2026-10-30 11:41:51 +08:00

1185 lines
44 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)
}
}
// ---------------------------------------------------------------------------
// 流式工具调用:三个此前缺失的方向(chat→responses / responses→chat / messages→responses)
func TestStreamChatToResponsesToolCall(t *testing.T) {
tf := newChatToResponses().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":{"tool_calls":[{"index":0,"id":"call_9","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"北京\"}"}}]},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}` + "\n\n",
"data: [DONE]\n\n",
})
if !strings.Contains(out, "event: response.output_item.added") || !strings.Contains(out, `"name":"get_weather"`) {
t.Fatalf("missing function_call item added: %s", out)
}
if !strings.Contains(out, "event: response.function_call_arguments.delta") {
t.Fatalf("missing arguments delta: %s", out)
}
if !strings.Contains(out, "event: response.output_item.done") {
t.Fatalf("missing output_item.done: %s", out)
}
// completed 的 output 数组要携带完整参数,客户端不依赖事件也能拿到最终形态
if !strings.Contains(out, `"call_id":"call_9"`) {
t.Fatalf("missing call_id: %s", out)
}
if !strings.Contains(out, `\"city\":\"北京\"`) {
t.Fatalf("missing accumulated arguments in completed output: %s", out)
}
if !strings.Contains(out, `"usage":{"completion_tokens":5,"prompt_tokens":10}`) {
t.Fatalf("missing usage in completed: %s", out)
}
}
func TestStreamResponsesToChatToolCall(t *testing.T) {
tf := newResponsesToChat().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-4o"}}` + "\n\n",
"event: response.output_item.added\n",
`data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"get_weather","arguments":""}}` + "\n\n",
"event: response.function_call_arguments.delta\n",
`data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"city\":\"北京\"}"}` + "\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, `"tool_calls"`) || !strings.Contains(out, `"name":"get_weather"`) {
t.Fatalf("missing tool_calls chunk: %s", out)
}
if !strings.Contains(out, `"arguments":"{\"city\":\"北京\"}"`) {
t.Fatalf("missing arguments delta chunk: %s", out)
}
if !strings.Contains(out, `"finish_reason":"tool_calls"`) {
t.Fatalf("finish_reason should be tool_calls: %s", out)
}
if !strings.Contains(out, `"usage":{"input_tokens":7,"output_tokens":8}`) {
t.Fatalf("missing usage chunk: %s", out)
}
if !strings.Contains(out, "data: [DONE]") {
t.Fatalf("missing [DONE]: %s", out)
}
}
func TestStreamMessagesToResponsesToolCall(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_start\n",
`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather"}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"北京\"}"}}` + "\n\n",
"event: content_block_stop\n",
`data: {"type":"content_block_stop","index":0}` + "\n\n",
"event: message_delta\n",
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":10,"output_tokens":5}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
if !strings.Contains(out, "event: response.output_item.added") || !strings.Contains(out, `"call_id":"toolu_1"`) {
t.Fatalf("missing function_call item added: %s", out)
}
if !strings.Contains(out, "event: response.function_call_arguments.delta") {
t.Fatalf("missing arguments delta: %s", out)
}
if !strings.Contains(out, "event: response.output_item.done") {
t.Fatalf("missing output_item.done: %s", out)
}
if !strings.Contains(out, `"call_id":"toolu_1"`) || !strings.Contains(out, `"arguments":"{\"city\":\"北京\"}"`) {
t.Fatalf("completed output array missing function_call with full arguments: %s", out)
}
if !strings.Contains(out, `"usage":{"input_tokens":10,"output_tokens":5}`) {
t.Fatalf("missing usage in completed: %s", out)
}
}
func TestStreamResponsesToChatTextStillWorks(t *testing.T) {
tf := newResponsesToChat().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-4o"}}` + "\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":1,"output_tokens":1}}}` + "\n\n",
})
if !strings.Contains(out, `"content":"hi"`) {
t.Fatalf("missing text chunk: %s", out)
}
if !strings.Contains(out, `"finish_reason":"stop"`) {
t.Fatalf("missing finish chunk: %s", out)
}
}
// OpenAI 两协议同形的 opaque 字段(metadata / service_tier / parallel_tool_calls)
// 在 chat↔responses 互转时原样透传——上游可能是另一层中转,会校验/使用它们。
func TestChatToResponsesCarriesOpaque(t *testing.T) {
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}],"service_tier":"priority","metadata":{"session_id":"s1"},"parallel_tool_calls":false}`
out, err := ConvertRequest([]byte(body), ProtoChat, ProtoResponses)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if json.Unmarshal(out, &m) != nil {
t.Fatal("invalid json")
}
if m["service_tier"] != "priority" {
t.Fatalf("service_tier 丢失:%v", m["service_tier"])
}
md, _ := m["metadata"].(map[string]any)
if md == nil || md["session_id"] != "s1" {
t.Fatalf("metadata 丢失:%v", m["metadata"])
}
if m["parallel_tool_calls"] != false {
t.Fatalf("parallel_tool_calls 丢失:%v", m["parallel_tool_calls"])
}
}
func TestResponsesToChatCarriesOpaque(t *testing.T) {
body := `{"model":"m1","input":"hi","service_tier":"flex","metadata":{"installation_id":"i9"},"parallel_tool_calls":true}`
out, err := ConvertRequest([]byte(body), ProtoResponses, ProtoChat)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if json.Unmarshal(out, &m) != nil {
t.Fatal("invalid json")
}
if m["service_tier"] != "flex" {
t.Fatalf("service_tier 丢失:%v", m["service_tier"])
}
md, _ := m["metadata"].(map[string]any)
if md == nil || md["installation_id"] != "i9" {
t.Fatalf("metadata 丢失:%v", m["metadata"])
}
if m["parallel_tool_calls"] != true {
t.Fatalf("parallel_tool_calls 丢失:%v", m["parallel_tool_calls"])
}
}