302 lines
11 KiB
Go
302 lines
11 KiB
Go
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)
|
|
}
|
|
}
|