CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page
This commit is contained in:
1 parent
404704171d
commit
5112f7ca06
30 files changed
+3539
-72
No files matched your search
@@ -0,0 +1,354 @@
|
||||
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},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user