package service import ( "encoding/json" "strings" "testing" "oci-portal/internal/aiwire" ) func mustChatReq(t *testing.T, raw string) aiwire.ChatRequest { t.Helper() var req aiwire.ChatRequest if err := json.Unmarshal([]byte(raw), &req); err != nil { t.Fatalf("解析请求: %v", err) } return req } func mustChatBody(t *testing.T, raw string) map[string]any { t.Helper() payload, err := ChatToResponsesBody(mustChatReq(t, raw)) if err != nil { t.Fatalf("ChatToResponsesBody: %v", err) } var body map[string]any if err := json.Unmarshal(payload, &body); err != nil { t.Fatalf("unmarshal: %v", err) } return body } // TestChatToResponsesBody 断言消息展开、instructions 聚合、工具与顶层字段装配。 func TestChatToResponsesBody(t *testing.T) { body := mustChatBody(t, `{ "model": "xai.grok-4.3", "max_tokens": 64, "max_completion_tokens": 128, "temperature": 0.5, "top_p": 0.9, "parallel_tool_calls": false, "stream": true, "reasoning_effort": "HIGH", "messages": [ {"role": "system", "content": "你是助手"}, {"role": "developer", "content": [{"type": "text", "text": "简洁作答"}]}, {"role": "user", "content": "东京天气?"}, {"role": "assistant", "content": "查询中", "tool_calls": [ {"id": "t1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"东京\"}"}} ]}, {"role": "tool", "tool_call_id": "t1", "content": "晴 25 度"}, {"role": "user", "content": "继续"} ], "tools": [{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object"}}}], "tool_choice": "required" }`) if body["model"] != "xai.grok-4.3" || body["store"] != false || body["stream"] != true { t.Fatalf("顶层字段装配错误: %v", body) } if body["max_output_tokens"] != float64(128) { t.Fatalf("max_output_tokens = %v, want 128(max_completion_tokens 优先)", body["max_output_tokens"]) } if body["instructions"] != "你是助手\n\n简洁作答" { t.Fatalf("instructions = %v", body["instructions"]) } if body["temperature"] != 0.5 || body["top_p"] != 0.9 || body["parallel_tool_calls"] != false { t.Fatalf("采样字段装配错误: %v", body) } if body["tool_choice"] != "required" { t.Fatalf("tool_choice = %v", body["tool_choice"]) } if reasoning, _ := body["reasoning"].(map[string]any); reasoning["effort"] != "high" { t.Fatalf("effort = %v, want high(小写透传)", body["reasoning"]) } kinds := chatInputKinds(t, body) want := "message/user,message/assistant,function_call,function_call_output,message/user" if kinds != want { t.Fatalf("input 顺序 = %s, want %s", kinds, want) } tools, _ := body["tools"].([]any) tool, _ := tools[0].(map[string]any) if tool["type"] != "function" || tool["name"] != "get_weather" { t.Fatalf("工具应拍平为 Responses 形态: %v", tool) } } func chatInputKinds(t *testing.T, body map[string]any) string { t.Helper() input, _ := body["input"].([]any) 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)) } } return strings.Join(kinds, ",") } // TestChatToResponsesBodyContent 断言文本部件按角色定型与图片装配/拒绝。 func TestChatToResponsesBodyContent(t *testing.T) { body := mustChatBody(t, `{"model":"m","messages":[ {"role":"user","content":[ {"type":"text","text":"看图"}, {"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}, {"role":"assistant","content":"这是猫"}]}`) raw, _ := json.Marshal(body["input"]) if !strings.Contains(string(raw), `"input_image"`) || !strings.Contains(string(raw), "data:image/png;base64,QUJD") { t.Fatalf("图片应转 input_image: %s", raw) } if !strings.Contains(string(raw), `"output_text"`) { t.Fatalf("assistant 历史文本应为 output_text 部件: %s", raw) } for name, msg := range map[string]string{ "audio 块": `{"role":"user","content":[{"type":"input_audio","input_audio":{}}]}`, "图片缺 url": `{"role":"user","content":[{"type":"image_url","image_url":{}}]}`, } { raw := `{"model":"m","messages":[` + msg + `]}` if _, err := ChatToResponsesBody(mustChatReq(t, raw)); err == nil { t.Errorf("%s 应拒绝", name) } } badTool := mustChatReq(t, `{"model":"m","messages":[{"role":"user","content":"hi"}], "tools":[{"type":"web_search","function":{}}]}`) if _, err := ChatToResponsesBody(badTool); err == nil { t.Error("非 function 工具类型应拒绝") } } // TestChatRespToolChoice 断言 tool_choice 各形态映射。 func TestChatRespToolChoice(t *testing.T) { cases := []struct { name, raw string want any }{ {"auto", `"auto"`, "auto"}, {"none", `"none"`, "none"}, {"required", `"required"`, "required"}, {"具名 function", `{"type":"function","function":{"name":"f1"}}`, map[string]string{"type": "function", "name": "f1"}}, {"未知 string", `"whatever"`, nil}, {"缺 name", `{"type":"function","function":{}}`, nil}, {"空", ``, nil}, } for _, tc := range cases { got := chatRespToolChoice(json.RawMessage(tc.raw)) if gotMap, ok := got.(map[string]string); ok { wantMap, _ := tc.want.(map[string]string) if wantMap == nil || gotMap["name"] != wantMap["name"] { t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) } continue } if got != tc.want { t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) } } } // TestChatTextFormat 断言 response_format 拍平映射。 func TestChatTextFormat(t *testing.T) { if got := chatTextFormat(nil); got != nil { t.Errorf("nil 应不下发: %v", got) } if got := chatTextFormat(&aiwire.ResponseFormat{Type: "text"}); got != nil { t.Errorf("text 应不下发: %v", got) } if got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_object"}); got["type"] != "json_object" { t.Errorf("json_object: %v", got) } got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_schema", JSONSchema: json.RawMessage(`{"name":"out","schema":{"type":"object"},"strict":true}`)}) if got["type"] != "json_schema" || got["name"] != "out" || got["strict"] != true { t.Errorf("json_schema 应提升嵌套字段: %v", got) } if _, ok := got["schema"]; !ok { t.Errorf("schema 缺失: %v", got) } } // TestResponsesToChat 断言文本聚合、tool_calls、finish_reason 与 usage 回转。 func TestResponsesToChat(t *testing.T) { payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[ {"type":"reasoning","summary":[]}, {"type":"message","content":[{"type":"output_text","text":"你"},{"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 := ResponsesToChat(payload, "chatcmpl-1", 1700000000) if err != nil { t.Fatalf("ResponsesToChat: %v", err) } if out.Object != "chat.completion" || out.ID != "chatcmpl-1" || out.Created != 1700000000 { t.Fatalf("响应骨架错误: %+v", out) } msg := out.Choices[0].Message if msg.Role != "assistant" || msg.Content.JoinText() != "你好" { t.Fatalf("content 装配错误: %+v", msg) } if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].ID != "c1" || msg.ToolCalls[0].Function.Arguments != `{"city":"东京"}` { t.Fatalf("tool_calls 装配错误: %+v", msg.ToolCalls) } if out.Choices[0].FinishReason != "tool_calls" { t.Fatalf("finish_reason = %s, want tool_calls", out.Choices[0].FinishReason) } if out.Usage.PromptTokens != 10 || out.Usage.CompletionTokens != 5 || out.Usage.CachedTokens() != 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 := ResponsesToChat(trunc, "chatcmpl-2", 1) if err != nil || out2.Choices[0].FinishReason != "length" { t.Fatalf("截断 finish_reason = %+v, %v", out2.Choices, err) } if out2.Usage != nil { t.Fatalf("无 usage 时应为 nil: %+v", out2.Usage) } } func chatChunkShapes(chunks []aiwire.ChatChunk) string { kinds := make([]string, 0, len(chunks)) for _, ch := range chunks { switch { case len(ch.Choices) == 0: kinds = append(kinds, "usage") case ch.Choices[0].FinishReason != nil: kinds = append(kinds, "finish:"+*ch.Choices[0].FinishReason) case len(ch.Choices[0].Delta.ToolCalls) > 0: kinds = append(kinds, "tool") case ch.Choices[0].Delta.Role != "": kinds = append(kinds, "role+text") default: kinds = append(kinds, "text") } } return strings.Join(kinds, ",") } // TestChatRespBridge 断言流桥:首块 role、文本与工具增量、reasoning 丢弃、usage 块。 func TestChatRespBridge(t *testing.T) { b := NewChatRespBridge("chatcmpl-1", "m1", 1700000000, true) var chunks []aiwire.ChatChunk feed := func(lines ...string) { for _, l := range lines { chunks = append(chunks, b.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\":"}`, `{"type":"response.function_call_arguments.delta","delta":"1}"}`, `{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4,"total_tokens":12}}}`) chunks = append(chunks, b.Finish()...) got := chatChunkShapes(chunks) want := "role+text,text,tool,tool,tool,finish:tool_calls,usage" if got != want { t.Fatalf("chunk 序列:\n got %s\nwant %s", got, want) } if chunks[0].Choices[0].Delta.Role != "assistant" || chunks[0].Choices[0].Delta.Content != "你" { t.Fatalf("首块应含 role 与文本: %+v", chunks[0].Choices[0].Delta) } tool := chunks[2].Choices[0].Delta.ToolCalls[0] if tool.Index != 0 || tool.ID != "c1" || tool.Function.Name != "f" { t.Fatalf("工具首块 = %+v", tool) } args := chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments + chunks[4].Choices[0].Delta.ToolCalls[0].Function.Arguments if args != `{"a":1}` { t.Fatalf("实参增量聚合 = %s", args) } last := chunks[len(chunks)-1] if last.Usage == nil || last.Usage.PromptTokens != 8 || len(last.Choices) != 0 { t.Fatalf("usage 块 = %+v", last) } if b.Usage().TotalTokens != 12 { t.Fatalf("Usage() = %+v", b.Usage()) } } // TestChatRespBridgeVariants 断言 include_usage 关闭、截断与空流的收尾形态。 func TestChatRespBridgeVariants(t *testing.T) { noUsage := NewChatRespBridge("c", "m", 1, false) noUsage.Feed([]byte(`{"type":"response.output_text.delta","delta":"x"}`)) noUsage.Feed([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`)) if got := chatChunkShapes(noUsage.Finish()); got != "finish:length" { t.Errorf("截断且不带 usage 块: %s", got) } empty := NewChatRespBridge("c", "m", 1, false) fin := empty.Finish() if got := chatChunkShapes(fin); got != "finish:stop" { t.Errorf("空流收尾 = %s", got) } if fin[0].Choices[0].Delta.Role != "assistant" { t.Errorf("空流终块应补 role: %+v", fin[0].Choices[0].Delta) } }