package service import ( "encoding/json" "strings" "testing" "oci-portal/internal/aiwire" ) func respReq(t *testing.T, raw string) aiwire.RespRequest { t.Helper() var req aiwire.RespRequest if err := json.Unmarshal([]byte(raw), &req); err != nil { t.Fatalf("unmarshal req: %v", err) } return req } func TestResponsesToIR(t *testing.T) { req := respReq(t, `{ "model": "meta.llama-3.3-70b-instruct", "instructions": "你是助手", "max_output_tokens": 128, "input": [ {"role": "user", "content": "查天气"}, {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"上海\"}"}, {"type": "function_call_output", "call_id": "call_1", "output": "{\"temp\":31}"}, {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "31 度"}]} ], "tools": [{"type": "function", "name": "get_weather", "description": "查天气", "parameters": {"type": "object"}, "strict": true}], "tool_choice": {"type": "function", "name": "get_weather"}, "text": {"format": {"type": "json_schema", "name": "out", "schema": {"type": "object"}}} }`) ir, err := ResponsesToIR(req) if err != nil { t.Fatalf("ResponsesToIR: %v", err) } roles := make([]string, 0, len(ir.Messages)) for _, m := range ir.Messages { roles = append(roles, m.Role) } want := []string{"system", "user", "assistant", "tool", "assistant"} if strings.Join(roles, ",") != strings.Join(want, ",") { t.Errorf("roles = %v, want %v", roles, want) } if ir.Messages[2].ToolCalls[0].ID != "call_1" || ir.Messages[3].ToolCallID != "call_1" { t.Errorf("tool call 链接错误: %+v", ir.Messages) } if deref(ir.MaxTokens) != 128 || len(ir.Tools) != 1 || ir.Tools[0].Function.Name != "get_weather" { t.Errorf("参数映射错误: max=%v tools=%+v", ir.MaxTokens, ir.Tools) } if !strings.Contains(string(ir.ToolChoice), `"function"`) || !strings.Contains(string(ir.ToolChoice), "get_weather") { t.Errorf("tool_choice = %s", ir.ToolChoice) } if ir.ResponseFormat == nil || ir.ResponseFormat.Type != "json_schema" { t.Errorf("response_format = %+v", ir.ResponseFormat) } } func deref(p *int) int { if p == nil { return 0 } return *p } func TestResponsesToIRRejects(t *testing.T) { cases := []struct{ name, raw string }{ {"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`}, {"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`}, {"background", `{"model":"m","input":"hi","background":true}`}, {"builtin tool", `{"model":"m","input":"hi","tools":[{"type":"web_search"}]}`}, {"file_id image", `{"model":"m","input":[{"role":"user","content":[{"type":"input_image","file_id":"file_1"}]}]}`}, {"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`}, } for _, c := range cases { if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil { t.Errorf("%s 应被拒绝", c.name) } } // input 字符串形态 + store/reasoning 忽略项不报错 req := respReq(t, `{"model":"m","input":"你好","store":false,"reasoning":{"effort":"low"},"parallel_tool_calls":true}`) ir, err := ResponsesToIR(req) if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" { t.Errorf("字符串 input = %+v, %v", ir.Messages, err) } // input_image(url 形态)放行并保留图文顺序 req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`) ir2, err := ResponsesToIR(req2) if err != nil { t.Fatalf("input_image 应放行: %v", err) } parts := ir2.Messages[0].Content.Parts if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" { t.Errorf("parts = %+v", parts) } } func TestIRRespToResponses(t *testing.T) { resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{ Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"), ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function", Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}}, FinishReason: "length", }}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}} out := IRRespToResponses(resp, "resp_abc", 1751966400) if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" { t.Errorf("length 应映射 incomplete: %+v", out) } if len(out.Output) != 2 || out.Output[0].Type != "message" || out.Output[1].Type != "function_call" { t.Fatalf("output = %+v", out.Output) } if out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" { t.Errorf("item 装配错误: %+v", out.Output) } if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store { t.Errorf("usage/store 错误: %+v", out) } } func chunkText(text string) aiwire.ChatChunk { return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}} } func TestRespStreamTextAndTool(t *testing.T) { st := NewRespStream("resp_x", "m", 1751966400) var types []string collect := func(evs []RespEvent) { for _, ev := range evs { types = append(types, ev.Event) } } collect(st.Feed(chunkText("你"))) collect(st.Feed(chunkText("好"))) collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ ToolCalls: []aiwire.ToolCallDelta{{Index: 0, ID: "call_1", Function: aiwire.FunctionCallDelta{Name: "f", Arguments: "{\"a\""}}}}}}})) collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ ToolCalls: []aiwire.ToolCallDelta{{Index: 0, Function: aiwire.FunctionCallDelta{Arguments: ":1}"}}}}}}, Usage: &aiwire.Usage{PromptTokens: 3, CompletionTokens: 7, TotalTokens: 10}})) finish := st.Finish() collect(finish) want := []string{ "response.created", "response.in_progress", "response.output_item.added", "response.content_part.added", "response.output_text.delta", "response.output_text.delta", "response.output_text.done", "response.content_part.done", "response.output_item.done", "response.output_item.added", "response.function_call_arguments.delta", "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.output_item.done", "response.completed", } if strings.Join(types, "\n") != strings.Join(want, "\n") { t.Errorf("事件序列 =\n%s\nwant\n%s", strings.Join(types, "\n"), strings.Join(want, "\n")) } last := finish[len(finish)-1].Data resp := last["response"].(aiwire.Response) if len(resp.Output) != 2 || resp.Output[0].Content[0].Text != "你好" || resp.Output[1].Arguments != "{\"a\":1}" { t.Errorf("终态快照 = %+v", resp.Output) } if resp.Usage == nil || resp.Usage.TotalTokens != 10 { t.Errorf("终态 usage = %+v", resp.Usage) } // sequence_number 严格递增 if seq, ok := last["sequence_number"].(int); !ok || seq != len(want) { t.Errorf("最终 sequence_number = %v, want %d", last["sequence_number"], len(want)) } } func TestRespStreamEmpty(t *testing.T) { st := NewRespStream("resp_e", "m", 1) evs := st.Finish() if len(evs) != 2 || evs[0].Event != "response.created" || evs[1].Event != "response.completed" { t.Errorf("空流事件 = %+v", evs) } }