package service import ( "encoding/json" "strings" "testing" "oci-portal/internal/aiwire" ) func mustAnthReq(t *testing.T, raw string) aiwire.MessagesRequest { t.Helper() var req aiwire.MessagesRequest if err := json.Unmarshal([]byte(raw), &req); err != nil { t.Fatalf("解析请求: %v", err) } return req } // TestAnthropicToResponsesBody 断言 system/消息/工具/effort 的直通装配与 store 强制。 func TestAnthropicToResponsesBody(t *testing.T) { req := mustAnthReq(t, `{ "model": "xai.grok-4.3", "max_tokens": 128, "system": "你是助手", "stream": true, "temperature": 0.5, "messages": [ {"role": "user", "content": "东京天气?"}, {"role": "assistant", "content": [ {"type": "text", "text": "查询中"}, {"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}} ]}, {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"}, {"type": "text", "text": "继续"} ]} ], "tools": [{"name": "get_weather", "description": "查天气", "input_schema": {"type": "object"}}], "tool_choice": {"type": "any"}, "output_config": {"effort": "HIGH"} }`) payload, err := AnthropicToResponsesBody(req) if err != nil { t.Fatalf("AnthropicToResponsesBody: %v", err) } var body map[string]any if err := json.Unmarshal(payload, &body); err != nil { t.Fatalf("unmarshal: %v", err) } if body["model"] != "xai.grok-4.3" || body["max_output_tokens"] != float64(128) || body["instructions"] != "你是助手" || body["store"] != false || body["stream"] != true { t.Fatalf("顶层字段装配错误: %v", body) } if body["tool_choice"] != "required" { t.Fatalf("tool_choice = %v, want required", body["tool_choice"]) } reasoning, _ := body["reasoning"].(map[string]any) if reasoning["effort"] != "high" { t.Fatalf("effort = %v, want high(小写透传)", reasoning) } input, _ := body["input"].([]any) // user 消息、assistant 文本消息、function_call、function_call_output、末条 user 文本 if len(input) != 5 { t.Fatalf("input 项数 = %d, want 5: %s", len(input), payload) } kinds := make([]string, 0, len(input)) for _, it := range input { m := it.(map[string]any) if ty, ok := m["type"].(string); ok { kinds = append(kinds, ty) } else { kinds = append(kinds, "message/"+m["role"].(string)) } } want := "message/user,message/assistant,function_call,function_call_output,message/user" if got := strings.Join(kinds, ","); got != want { t.Fatalf("input 顺序 = %s, want %s", got, want) } if !strings.Contains(string(payload), `"output_text"`) { t.Fatalf("assistant 历史文本应为 output_text 部件: %s", payload) } } // TestAnthropicToResponsesBodyRejects 断言不支持的内容块拒绝与图片装配。 func TestAnthropicToResponsesBodyRejects(t *testing.T) { bad := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[ {"role":"user","content":[{"type":"document","source":{}}]}]}`) if _, err := AnthropicToResponsesBody(bad); err == nil { t.Fatal("document 块应拒绝") } img := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[ {"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"QUJD"}}]}]}`) payload, err := AnthropicToResponsesBody(img) if err != nil || !strings.Contains(string(payload), "data:image/png;base64,QUJD") { t.Fatalf("图片应转 data URI: %v %s", err, payload) } } // TestResponsesToAnthropic 断言输出块、stop_reason 与 usage 的回转。 func TestResponsesToAnthropic(t *testing.T) { payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[ {"type":"reasoning","summary":[]}, {"type":"message","content":[{"type":"output_text","text":"你好"}]}, {"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}], "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`) out, err := ResponsesToAnthropic(payload, "msg_1") if err != nil { t.Fatalf("ResponsesToAnthropic: %v", err) } if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[0].Text != "你好" { t.Fatalf("content 装配错误: %+v", out.Content) } if out.Content[1].Type != "tool_use" || out.Content[1].ID != "c1" || string(out.Content[1].Input) != `{"city":"东京"}` { t.Fatalf("tool_use 装配错误: %+v", out.Content[1]) } if out.StopReason != "tool_use" { t.Fatalf("stop_reason = %s, want tool_use", out.StopReason) } if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Usage.CacheReadInputTokens != 4 { t.Fatalf("usage = %+v", out.Usage) } trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}, "output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`) out2, err := ResponsesToAnthropic(trunc, "msg_2") if err != nil || out2.StopReason != "max_tokens" { t.Fatalf("截断 stop_reason = %v, %v", out2, err) } } func bridgeEventTypes(events []AnthEvent) string { kinds := make([]string, 0, len(events)) for _, ev := range events { kinds = append(kinds, ev.Event) } return strings.Join(kinds, ",") } // TestAnthRespBridge 断言流桥:文本增量、工具调用切块、reasoning 丢弃与收尾事件。 func TestAnthRespBridge(t *testing.T) { st := NewAnthRespBridge("msg_1", "m1") var events []AnthEvent feed := func(lines ...string) { for _, l := range lines { events = append(events, st.Feed([]byte(l))...) } } feed(`{"type":"response.created","response":{"model":"m1"}}`, `{"type":"response.reasoning_text.delta","delta":"思考中"}`, `{"type":"response.output_text.delta","delta":"你"}`, `{"type":"response.output_text.delta","delta":"好"}`, `{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`, `{"type":"response.function_call_arguments.delta","delta":"{\"a\":1}"}`, `{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4}}}`) events = append(events, st.Finish()...) got := bridgeEventTypes(events) want := "message_start,content_block_start,content_block_delta,content_block_delta," + "content_block_stop,content_block_start,content_block_delta,content_block_stop," + "message_delta,message_stop" if got != want { t.Fatalf("事件序列:\n got %s\nwant %s", got, want) } if st.Usage().InputTokens != 8 || st.Usage().OutputTokens != 4 { t.Fatalf("usage = %+v", st.Usage()) } } // TestAnthRespBridgeFailure 断言异常流的错误透传:上游 error / response.failed // 事件转 Anthropic error 事件,提前 EOF(未见终态)不再伪装正常结束。 func TestAnthRespBridgeFailure(t *testing.T) { cases := []struct { name string lines []string wantKinds string wantErr string }{ { name: "上游 error 事件透传", lines: []string{`{"type":"error","message":"model overloaded"}`}, wantKinds: "error", wantErr: "model overloaded", }, { name: "response.failed 提取错误消息", lines: []string{ `{"type":"response.output_text.delta","delta":"你"}`, `{"type":"response.failed","response":{"status":"failed","error":{"message":"content filtered"}}}`, }, wantKinds: "message_start,content_block_start,content_block_delta,error", wantErr: "content filtered", }, { name: "空流提前终止", lines: nil, wantKinds: "error", wantErr: "上游流提前终止,未返回终态事件", }, { name: "输出中途 EOF 无终态", lines: []string{ `{"type":"response.output_text.delta","delta":"你"}`, }, wantKinds: "message_start,content_block_start,content_block_delta,error", wantErr: "上游流提前终止,未返回终态事件", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { st := NewAnthRespBridge("msg_1", "m1") var events []AnthEvent for _, l := range tc.lines { events = append(events, st.Feed([]byte(l))...) } events = append(events, st.Finish()...) if got := bridgeEventTypes(events); got != tc.wantKinds { t.Fatalf("事件序列 = %s, want %s", got, tc.wantKinds) } if st.Err() != tc.wantErr { t.Fatalf("Err() = %q, want %q", st.Err(), tc.wantErr) } }) } } // TestRespStreamErrorMsg 断言直通流错误事件消息提取。 func TestRespStreamErrorMsg(t *testing.T) { cases := []struct { name string data string want string }{ {name: "error 事件", data: `{"type":"error","message":"boom"}`, want: "boom"}, {name: "error 无消息用占位", data: `{"type":"error"}`, want: "上游返回错误事件 error"}, {name: "failed 事件", data: `{"type":"response.failed","response":{"error":{"message":"bad"}}}`, want: "bad"}, {name: "正常事件返回空", data: `{"type":"response.completed","response":{}}`, want: ""}, {name: "非 JSON 返回空", data: ``, want: ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := RespStreamErrorMsg([]byte(tc.data)); got != tc.want { t.Fatalf("RespStreamErrorMsg = %q, want %q", got, tc.want) } }) } } // TestAnthMessageEvents 断言非流式降级结果展开的事件序列与 usage。 func TestAnthMessageEvents(t *testing.T) { msg := &aiwire.MessagesResponse{ ID: "msg_1", Type: "message", Role: "assistant", Model: "m1", Content: []aiwire.AnthBlock{ {Type: "text", Text: "好"}, {Type: "tool_use", ID: "c1", Name: "f", Input: json.RawMessage(`{"a":1}`)}, }, StopReason: "tool_use", Usage: aiwire.AnthUsage{InputTokens: 9, OutputTokens: 3, CacheReadInputTokens: 5}, } events := AnthMessageEvents(msg) want := "message_start,content_block_start,content_block_delta,content_block_stop," + "content_block_start,content_block_delta,content_block_stop,message_delta,message_stop" if got := bridgeEventTypes(events); got != want { t.Fatalf("事件序列:\n got %s\nwant %s", got, want) } delta := events[len(events)-2].Data.(map[string]any) usage := delta["usage"].(map[string]int) if usage["input_tokens"] != 9 || usage["output_tokens"] != 3 || usage["cache_read_input_tokens"] != 5 { t.Fatalf("usage = %+v", usage) } }