Files
CrossRelay/internal/responses/convert_test.go
T

355 lines
11 KiB
Go

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},
}
}