package responses import ( "encoding/json" "testing" "gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl" ) func normalizeJSON(t *testing.T, s string) string { t.Helper() var v any if err := json.Unmarshal([]byte(s), &v); err != nil { t.Fatalf("want is not JSON: %v (%s)", err, s) } out, err := json.Marshal(v) if err != nil { t.Fatalf("re-marshal want: %v", err) } return string(out) } func TestConvertRequest(t *testing.T) { cases := []struct { name string req string want string }{ { name: "instructions become the system message", req: `{ "model": "gpt-5-codex", "instructions": "You are Codex.", "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]}] }`, want: `{ "model": "up-model", "messages": [ {"role": "system", "content": "You are Codex."}, {"role": "user", "content": "hi"} ] }`, }, { name: "input as a plain string", req: `{"model": "m", "input": "hello"}`, want: `{"model": "up-model", "messages": [{"role": "user", "content": "hello"}]}`, }, { name: "system role input merges into instructions", req: `{ "instructions": "base", "input": [{"type": "message", "role": "system", "content": [{"type": "input_text", "text": "extra"}]}] }`, want: `{"model": "up-model", "messages": [{"role": "system", "content": "base\n\nextra"}]}`, }, { name: "image part becomes an image_url part", req: `{ "input": [{"type": "message", "role": "user", "content": [ {"type": "input_image", "image_url": "https://example.com/a.png"}, {"type": "input_text", "text": "look"} ]}] }`, want: `{ "model": "up-model", "messages": [{"role": "user", "content": [ {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}, {"type": "text", "text": "look"} ]}] }`, }, { name: "function_call and function_call_output round trip", req: `{ "input": [ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "read a.txt"}]}, {"type": "function_call", "call_id": "call_1", "name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}, {"type": "function_call_output", "call_id": "call_1", "output": "file body"}, {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]} ] }`, want: `{ "model": "up-model", "messages": [ {"role": "user", "content": "read a.txt"}, {"role": "assistant", "content": null, "tool_calls": [ {"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}} ]}, {"role": "tool", "tool_call_id": "call_1", "content": "file body"}, {"role": "assistant", "content": "done"} ] }`, }, { name: "consecutive assistant items merge into one message", req: `{ "input": [ {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "part1"}]}, {"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"}, {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "part2"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "next"}]}, {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "after"}]} ] }`, want: `{ "model": "up-model", "messages": [ {"role": "assistant", "content": "part1\n\npart2", "tool_calls": [ {"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}} ]}, {"role": "user", "content": "next"}, {"role": "assistant", "content": "after"} ] }`, }, { name: "reasoning items are dropped", req: `{ "input": [ {"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]} ] }`, want: `{"model": "up-model", "messages": [{"role": "user", "content": "hi"}]}`, }, { name: "function_call_output with array output", req: `{ "input": [ {"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"}, {"type": "function_call_output", "call_id": "c1", "output": [{"type": "output_text", "text": "line1"}, {"type": "output_text", "text": "line2"}]} ] }`, want: `{ "model": "up-model", "messages": [ {"role": "assistant", "content": null, "tool_calls": [ {"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}} ]}, {"role": "tool", "tool_call_id": "c1", "content": "line1line2"} ] }`, }, { name: "custom tools degrade to permissive function tools", req: `{ "input": "go", "tools": [ {"type": "custom", "name": "shell", "description": "run a command"}, {"type": "local_shell", "name": "local"} ] }`, want: `{ "model": "up-model", "messages": [{"role": "user", "content": "go"}], "tools": [ {"type": "function", "function": {"name": "shell", "description": "run a command", "parameters": {"type": "object", "additionalProperties": true}}}, {"type": "function", "function": {"name": "local", "parameters": {"type": "object", "additionalProperties": true}}} ] }`, }, { name: "function tools pass through with their schema", req: `{ "input": "go", "tools": [{"type": "function", "name": "f", "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}] }`, want: `{ "model": "up-model", "messages": [{"role": "user", "content": "go"}], "tools": [{"type": "function", "function": {"name": "f", "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}}] }`, }, { name: "streaming asks the upstream for a usage chunk", req: `{ "model": "m", "input": "hi", "stream": true, "max_output_tokens": 512, "temperature": 0.5, "top_p": 0.9, "store": false, "include": ["reasoning.encrypted_content"], "prompt_cache_key": "k", "parallel_tool_calls": true, "reasoning": {"effort": "high"}, "metadata": {"a": "b"}, "text": {"format": "json"} }`, want: `{ "model": "up-model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 512, "temperature": 0.5, "top_p": 0.9, "stream": true, "stream_options": {"include_usage": true} }`, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var req Request if err := json.Unmarshal([]byte(tc.req), &req); err != nil { t.Fatalf("unmarshal request: %v", err) } got, err := ConvertRequest(&req, "up-model") if err != nil { t.Fatalf("ConvertRequest: %v", err) } gotJSON, err := json.Marshal(got) if err != nil { t.Fatalf("marshal result: %v", err) } if want, gotNorm := normalizeJSON(t, tc.want), normalizeJSON(t, string(gotJSON)); gotNorm != want { t.Errorf("converted request mismatch\n got: %s\nwant: %s", gotJSON, want) } }) } } func TestConvertToolChoice(t *testing.T) { cases := []struct { raw string want string }{ {`"auto"`, `"auto"`}, {`"none"`, `"none"`}, {`"required"`, `"required"`}, {`{"type": "function", "name": "f"}`, `{"type": "function", "function": {"name": "f"}}`}, {`{"type": "allowed_tools", "tools": []}`, `null`}, {`"weird"`, `null`}, {``, `null`}, } for _, tc := range cases { var req Request if tc.raw != "" { if err := json.Unmarshal([]byte(`{"tool_choice": `+tc.raw+`}`), &req); err != nil { t.Fatalf("unmarshal %s: %v", tc.raw, err) } } got, err := convertToolChoice(req.ToolChoice) if err != nil { t.Fatalf("convertToolChoice(%s): %v", tc.raw, err) } gotJSON, _ := json.Marshal(got) if want := normalizeJSON(t, tc.want); string(gotJSON) != want { t.Errorf("tool_choice %s -> %s, want %s", tc.raw, gotJSON, want) } } } func TestMapStatus(t *testing.T) { cases := []struct { finish string wantStatus string wantReason string }{ {"stop", "completed", ""}, {"tool_calls", "completed", ""}, {"length", "incomplete", "max_output_tokens"}, {"content_filter", "incomplete", "content_filter"}, {"", "completed", ""}, } for _, tc := range cases { status, incomplete := MapStatus(tc.finish) if status != tc.wantStatus { t.Errorf("MapStatus(%q) status = %q, want %q", tc.finish, status, tc.wantStatus) } if tc.wantReason == "" { if incomplete != nil { t.Errorf("MapStatus(%q) incomplete = %v, want nil", tc.finish, incomplete) } } else if incomplete == nil || incomplete.Reason != tc.wantReason { t.Errorf("MapStatus(%q) incomplete = %v, want reason %q", tc.finish, incomplete, tc.wantReason) } } } func TestConvertResponseText(t *testing.T) { resp := chatResponse("stop", "hello", nil) out := ConvertResponse(resp, "gpt-5-codex") if out.ID != "resp_chatcmpl-1" || out.Object != "response" { t.Errorf("envelope = %+v", out) } if out.Status != "completed" || out.IncompleteDetails != nil { t.Errorf("status = %q incomplete = %v", out.Status, out.IncompleteDetails) } if out.Model != "gpt-5-codex" { t.Errorf("model = %q, want the requested model echoed", out.Model) } if len(out.Output) != 1 || out.Output[0].Type != "message" { t.Fatalf("output = %+v", out.Output) } msg := out.Output[0] if msg.Role != "assistant" || len(msg.Content) != 1 || msg.Content[0].Text != "hello" { t.Errorf("message item = %+v", msg) } if out.OutputText != "hello" { t.Errorf("output_text = %q", out.OutputText) } if out.Usage == nil || out.Usage.InputTokens != 9 || out.Usage.OutputTokens != 2 || out.Usage.TotalTokens != 11 { t.Errorf("usage = %+v", out.Usage) } } func TestConvertResponseToolCalls(t *testing.T) { resp := chatResponse("tool_calls", "", []chatcmpl.ToolCall{ {ID: "call_1", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "shell", Arguments: `{"cmd":"ls"}`}}, }) out := ConvertResponse(resp, "m") if len(out.Output) != 1 { t.Fatalf("output = %+v", out.Output) } call := out.Output[0] if call.Type != "function_call" || call.CallID != "call_1" || call.Name != "shell" || call.Arguments != `{"cmd":"ls"}` { t.Errorf("function_call item = %+v", call) } if out.Status != "completed" { t.Errorf("status = %q", out.Status) } } func TestConvertResponseIncomplete(t *testing.T) { out := ConvertResponse(chatResponse("length", "cut off", nil), "m") if out.Status != "incomplete" { t.Errorf("status = %q, want incomplete", out.Status) } if out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" { t.Errorf("incomplete_details = %+v", out.IncompleteDetails) } } func TestEstimateTokens(t *testing.T) { var req Request if err := json.Unmarshal([]byte(`{ "instructions": "be helpful", "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello there"}]}], "tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}] }`), &req); err != nil { t.Fatalf("unmarshal: %v", err) } chatReq, err := ConvertRequest(&req, "m") if err != nil { t.Fatalf("ConvertRequest: %v", err) } if got := EstimateTokens(chatReq); got <= 0 { t.Errorf("EstimateTokens = %d, want positive", got) } } func chatResponse(finish, text string, calls []chatcmpl.ToolCall) *chatcmpl.Response { return &chatcmpl.Response{ ID: "chatcmpl-1", Model: "up-model", Choices: []chatcmpl.Choice{{ Index: 0, Message: chatcmpl.Message{Role: "assistant", Content: text, ToolCalls: calls}, FinishReason: finish, }}, Usage: &chatcmpl.Usage{PromptTokens: 9, CompletionTokens: 2, TotalTokens: 11}, } }