AI网关:Responses直通codex兼容与流式升级回退
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -43,6 +44,9 @@ func TestRespPassthroughValidate(t *testing.T) {
|
||||
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
||||
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
||||
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, true},
|
||||
{"namespace放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "namespace", Name: "multi_agent_v1"}}}, false},
|
||||
{"custom放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "custom", Name: "run_script"}}}, false},
|
||||
{"tool_search放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "tool_search"}}}, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -56,10 +60,13 @@ func TestRespPassthroughValidate(t *testing.T) {
|
||||
|
||||
func TestRespPassthroughBody(t *testing.T) {
|
||||
raw := []byte(`{"model":"m","input":"hi","stream":true,"store":true,"max_output_tokens":128,"custom_field":{"a":1.5}}`)
|
||||
out, err := RespPassthroughBody(raw)
|
||||
out, compat, err := RespPassthroughBody(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if len(compat.Dropped) != 0 || len(compat.Flattened) != 0 || len(compat.NsRefs) != 0 {
|
||||
t.Errorf("无 codex 工具不应有兼容改写: %+v", compat)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(out, &body); err != nil {
|
||||
t.Fatalf("unmarshal out: %v", err)
|
||||
@@ -76,11 +83,206 @@ func TestRespPassthroughBody(t *testing.T) {
|
||||
if !strings.Contains(string(out), `"custom_field"`) {
|
||||
t.Errorf("未知字段应保留: %s", out)
|
||||
}
|
||||
if _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
||||
if _, _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
||||
t.Error("非法 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespQualifyNsName 断言限定名生成规则与 CLIProxyAPI 对齐。
|
||||
func TestRespQualifyNsName(t *testing.T) {
|
||||
tests := []struct{ name, ns, tool, want string }{
|
||||
{"常规拼接", "multi_agent_v1", "spawn_agent", "multi_agent_v1__spawn_agent"},
|
||||
{"mcp子工具不加前缀", "mcp__sites", "mcp__sites__create_site", "mcp__sites__create_site"},
|
||||
{"ns以双下划线结尾", "codex_app__", "update", "codex_app__update"},
|
||||
{"已带前缀不重复", "ns1", "ns1__tool", "ns1__tool"},
|
||||
{"空ns原样", "", "tool", "tool"},
|
||||
{"空名返回空", "ns1", "", ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := respQualifyNsName(test.ns, test.tool); got != test.want {
|
||||
t.Fatalf("respQualifyNsName(%q,%q) = %q, want %q", test.ns, test.tool, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespPassthroughBodyCompatTools 断言 codex 工具兼容改写:namespace 拍平为
|
||||
// 限定名 function 并建映射,web_search 降权剥离,剔空连删 tool_choice。
|
||||
func TestRespPassthroughBodyCompatTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantFlattened []string
|
||||
wantDropped []string
|
||||
wantRefs map[string]RespNsRef
|
||||
wantContains []string
|
||||
wantAbsent []string
|
||||
}{
|
||||
{
|
||||
"namespace拍平保留function",
|
||||
`{"model":"m","tools":[{"type":"function","name":"exec"},{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}]}`,
|
||||
[]string{"namespace:multi_agent_v1"},
|
||||
nil,
|
||||
map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}},
|
||||
[]string{`"name":"multi_agent_v1__spawn_agent"`, `"name":"exec"`},
|
||||
[]string{`"type":"namespace"`},
|
||||
},
|
||||
{
|
||||
"mcp子工具保名并补parameters",
|
||||
`{"model":"m","tools":[{"type":"namespace","name":"mcp__sites","tools":[{"type":"function","name":"mcp__sites__create"}]}]}`,
|
||||
[]string{"namespace:mcp__sites"},
|
||||
nil,
|
||||
map[string]RespNsRef{"mcp__sites__create": {Namespace: "mcp__sites", Name: "mcp__sites__create"}},
|
||||
[]string{`"name":"mcp__sites__create"`, `"parameters":{"properties":{},"type":"object"}`},
|
||||
[]string{`"type":"namespace"`},
|
||||
},
|
||||
{
|
||||
"非function子工具剥离",
|
||||
`{"model":"m","tools":[{"type":"namespace","name":"ns1","tools":[{"type":"custom","name":"patch"},{"type":"function","name":"run"}]}]}`,
|
||||
[]string{"namespace:ns1"},
|
||||
[]string{"ns1.patch(type=custom)"},
|
||||
map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}},
|
||||
[]string{`"name":"ns1__run"`},
|
||||
[]string{"patch"},
|
||||
},
|
||||
{
|
||||
"剔空连删tool_choice",
|
||||
`{"model":"m","tool_choice":"auto","parallel_tool_calls":false,"tools":[{"type":"web_search","external_web_access":false}]}`,
|
||||
nil,
|
||||
[]string{"web_search(external_web_access=false)"},
|
||||
nil,
|
||||
nil,
|
||||
[]string{`"tools"`, `"tool_choice"`, `"parallel_tool_calls"`},
|
||||
},
|
||||
{
|
||||
"web_search全访问仅删键",
|
||||
`{"model":"m","tools":[{"type":"web_search","external_web_access":true}]}`,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
[]string{`"tools":[{"type":"web_search"}]`},
|
||||
[]string{"external_web_access"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out, compat, err := RespPassthroughBody([]byte(test.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if !slices.Equal(compat.Flattened, test.wantFlattened) {
|
||||
t.Errorf("Flattened = %v, want %v", compat.Flattened, test.wantFlattened)
|
||||
}
|
||||
if !slices.Equal(compat.Dropped, test.wantDropped) {
|
||||
t.Errorf("Dropped = %v, want %v", compat.Dropped, test.wantDropped)
|
||||
}
|
||||
for q, ref := range test.wantRefs {
|
||||
if compat.NsRefs[q] != ref {
|
||||
t.Errorf("NsRefs[%s] = %+v, want %+v", q, compat.NsRefs[q], ref)
|
||||
}
|
||||
}
|
||||
for _, s := range test.wantContains {
|
||||
if !strings.Contains(string(out), s) {
|
||||
t.Errorf("应包含 %s: %s", s, out)
|
||||
}
|
||||
}
|
||||
for _, s := range test.wantAbsent {
|
||||
if strings.Contains(string(out), s) {
|
||||
t.Errorf("不应包含 %s: %s", s, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatHistoryAndToolChoice 断言多轮历史 function_call 与对象 tool_choice
|
||||
// 的 namespace 字段被重限定并删除。
|
||||
func TestRespCompatHistoryAndToolChoice(t *testing.T) {
|
||||
raw := `{"model":"m",
|
||||
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"multi_agent_v1"},
|
||||
"input":[
|
||||
{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"c1","arguments":"{}"},
|
||||
{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"c1","output":"ok"}
|
||||
],
|
||||
"tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{}}]}]}`
|
||||
out, _, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
s := string(out)
|
||||
if !strings.Contains(s, `"tool_choice":{"name":"multi_agent_v1__spawn_agent","type":"function"}`) {
|
||||
t.Errorf("tool_choice 应限定化: %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"namespace":"multi_agent_v1"`) {
|
||||
t.Errorf("namespace 字段应全部删除: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"name":"exec"`) {
|
||||
t.Errorf("无 namespace 的历史项应原样: %s", s)
|
||||
}
|
||||
if c := strings.Count(s, "multi_agent_v1__spawn_agent"); c != 3 {
|
||||
t.Errorf("限定名应出现 3 次(tools/input/tool_choice), got %d: %s", c, s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespRestoreNamespace 断言非流式响应把限定名还原为短名+namespace 双字段。
|
||||
func TestRespRestoreNamespace(t *testing.T) {
|
||||
compat := RespCompat{NsRefs: map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}}}
|
||||
payload := []byte(`{"id":"r1","output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"c1","arguments":"{}"},{"type":"message","content":[]}],"usage":{"total_tokens":7}}`)
|
||||
out := RespRestoreToolCalls(payload, compat)
|
||||
s := string(out)
|
||||
if !strings.Contains(s, `"name":"spawn_agent"`) || !strings.Contains(s, `"namespace":"multi_agent_v1"`) {
|
||||
t.Errorf("应还原短名并补 namespace: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"total_tokens":7`) {
|
||||
t.Errorf("其余字段应保真: %s", s)
|
||||
}
|
||||
for name, payload := range map[string][]byte{
|
||||
"未命中原样": []byte(`{"output":[{"type":"function_call","name":"other","call_id":"c"}]}`),
|
||||
"非法JSON": []byte(`xx`),
|
||||
} {
|
||||
if got := RespRestoreToolCalls(payload, compat); string(got) != string(payload) {
|
||||
t.Errorf("%s: 应返回原字节, got %s", name, got)
|
||||
}
|
||||
}
|
||||
if got := RespRestoreToolCalls(payload, RespCompat{}); string(got) != string(payload) {
|
||||
t.Error("无需还原时应返回原字节")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespRestoreNamespaceEvent 断言流式事件还原覆盖 item 与 response.output
|
||||
// 两个位置,无关事件保持未改动。
|
||||
func TestRespRestoreNamespaceEvent(t *testing.T) {
|
||||
compat := RespCompat{NsRefs: map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}}}
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantChanged bool
|
||||
wantSub string
|
||||
}{
|
||||
{"output_item.done", `{"type":"response.output_item.done","item":{"type":"function_call","name":"ns1__run","call_id":"c1"}}`, true, `"namespace":"ns1"`},
|
||||
{"completed快照", `{"type":"response.completed","response":{"output":[{"type":"function_call","name":"ns1__run"}],"usage":{"total_tokens":1}}}`, true, `"name":"run"`},
|
||||
{"文本增量不动", `{"type":"response.output_text.delta","delta":"hi"}`, false, ""},
|
||||
{"未命中不动", `{"type":"response.output_item.done","item":{"type":"function_call","name":"exec"}}`, false, ""},
|
||||
{"非法JSON不动", `not-json`, false, ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out, changed := RespRestoreToolCallsEvent([]byte(test.data), compat)
|
||||
if changed != test.wantChanged {
|
||||
t.Fatalf("changed = %v, want %v", changed, test.wantChanged)
|
||||
}
|
||||
if !changed && string(out) != test.data {
|
||||
t.Fatalf("未改动应返回原字节: %s", out)
|
||||
}
|
||||
if test.wantSub != "" && !strings.Contains(string(out), test.wantSub) {
|
||||
t.Fatalf("应包含 %s: %s", test.wantSub, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespPassthroughUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -126,3 +328,166 @@ func TestRespStreamCompletedUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
||||
func TestRespDisableStream(t *testing.T) {
|
||||
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
||||
if err != nil {
|
||||
t.Fatalf("RespDisableStream: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), `"stream":false`) || !strings.Contains(string(out), `"max_output_tokens":64`) {
|
||||
t.Fatalf("字段不符: %s", out)
|
||||
}
|
||||
if _, err := RespDisableStream([]byte("x")); err == nil {
|
||||
t.Error("非法 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespSynthSSEEvents 断言合成事件序列:created(in_progress,空 output)→
|
||||
// 逐项 output_item.done → completed(完整响应)。
|
||||
func TestRespSynthSSEEvents(t *testing.T) {
|
||||
payload := []byte(`{"id":"r1","status":"completed","output":[{"type":"reasoning","summary":[]},{"type":"function_call","name":"run","call_id":"c1"}],"usage":{"total_tokens":9}}`)
|
||||
events, err := RespSynthSSEEvents(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("RespSynthSSEEvents: %v", err)
|
||||
}
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("事件数 = %d, want 4", len(events))
|
||||
}
|
||||
first := string(events[0])
|
||||
if !strings.Contains(first, `"type":"response.created"`) || !strings.Contains(first, `"status":"in_progress"`) || !strings.Contains(first, `"output":[]`) {
|
||||
t.Errorf("created 事件不符: %s", first)
|
||||
}
|
||||
if !strings.Contains(string(events[1]), `"type":"response.output_item.done"`) || !strings.Contains(string(events[1]), `"output_index":0`) {
|
||||
t.Errorf("item.done 事件不符: %s", events[1])
|
||||
}
|
||||
if !strings.Contains(string(events[2]), `"name":"run"`) {
|
||||
t.Errorf("第二项应为 function_call: %s", events[2])
|
||||
}
|
||||
last := string(events[3])
|
||||
if !strings.Contains(last, `"type":"response.completed"`) || !strings.Contains(last, `"status":"completed"`) ||
|
||||
!strings.Contains(last, `"total_tokens":9`) || !strings.Contains(last, `"name":"run"`) {
|
||||
t.Errorf("completed 事件不符: %s", last)
|
||||
}
|
||||
if _, err := RespSynthSSEEvents([]byte("x")); err == nil {
|
||||
t.Error("非法 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatCustomTools 断言 custom 工具转换:apply_patch 丢弃,其余转
|
||||
// function 并补 input 包装 schema、记入 CustomNames;format 字段移除。
|
||||
func TestRespCompatCustomTools(t *testing.T) {
|
||||
raw := `{"model":"m","tools":[
|
||||
{"type":"custom","name":"apply_patch","description":"edit files"},
|
||||
{"type":"custom","name":"run_script","description":"run it","format":{"type":"grammar"}},
|
||||
{"type":"function","name":"exec","parameters":{}}]}`
|
||||
out, compat, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if !slices.Equal(compat.Dropped, []string{"custom:apply_patch"}) {
|
||||
t.Errorf("Dropped = %v", compat.Dropped)
|
||||
}
|
||||
if !slices.Equal(compat.Converted, []string{"custom:run_script"}) {
|
||||
t.Errorf("Converted = %v", compat.Converted)
|
||||
}
|
||||
if _, ok := compat.CustomNames["run_script"]; !ok {
|
||||
t.Errorf("CustomNames 缺 run_script: %v", compat.CustomNames)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "apply_patch") || strings.Contains(s, `"type":"custom"`) || strings.Contains(s, "grammar") {
|
||||
t.Errorf("apply_patch/custom/format 应消失: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"required":["input"]`) || !strings.Contains(s, `"input":{"type":"string"}`) {
|
||||
t.Errorf("run_script 应补 input 包装 schema: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatCustomHistory 断言 input 历史的 custom_tool_call(_output) 转换
|
||||
// 与 arguments 包装三分支。
|
||||
func TestRespCompatCustomHistory(t *testing.T) {
|
||||
raw := `{"model":"m","input":[
|
||||
{"type":"custom_tool_call","call_id":"c1","name":"run_script","input":"plain text"},
|
||||
{"type":"custom_tool_call","call_id":"c2","name":"run_script","input":"{\"a\":1}"},
|
||||
{"type":"custom_tool_call","call_id":"c3","name":"run_script"},
|
||||
{"type":"custom_tool_call_output","call_id":"c1","output":"done"},
|
||||
{"type":"custom_tool_call_output","call_id":"c2","output":{"ok":true}}]}`
|
||||
out, _, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "custom_tool_call") {
|
||||
t.Fatalf("custom_tool_call 系应全部转换: %s", s)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"arguments":"{\"input\":\"plain text\"}"`,
|
||||
`"arguments":"{\"a\":1}"`,
|
||||
`"arguments":"{}"`,
|
||||
`"output":"done"`,
|
||||
`"output":"{\"ok\":true}"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("应包含 %s: %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespRestoreCustomToolCalls 断言响应侧回转:命中 CustomNames 的
|
||||
// function_call 变 custom_tool_call 且 input 解包;流式事件同样生效。
|
||||
func TestRespRestoreCustomToolCalls(t *testing.T) {
|
||||
compat := RespCompat{CustomNames: map[string]struct{}{"run_script": {}}}
|
||||
payload := []byte(`{"output":[{"type":"function_call","name":"run_script","call_id":"c1","arguments":"{\"input\":\"echo hi\"}"},{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"}]}`)
|
||||
s := string(RespRestoreToolCalls(payload, compat))
|
||||
if !strings.Contains(s, `"type":"custom_tool_call"`) || !strings.Contains(s, `"input":"echo hi"`) {
|
||||
t.Errorf("应回转 custom_tool_call 并解包 input: %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"arguments":"{\"input\":\"echo hi\"}"`) {
|
||||
t.Errorf("arguments 应删除: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"name":"exec"`) || strings.Count(s, "custom_tool_call") != 1 {
|
||||
t.Errorf("非转换名不应动: %s", s)
|
||||
}
|
||||
ev := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"run_script","arguments":"not-json"}}`)
|
||||
got, changed := RespRestoreToolCallsEvent(ev, compat)
|
||||
if !changed || !strings.Contains(string(got), `"input":"not-json"`) {
|
||||
t.Errorf("非 JSON arguments 应整串作 input: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespUnwrapCustomInput 断言解包分支:input 键取值、非串取原文、其余整串。
|
||||
func TestRespUnwrapCustomInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args any
|
||||
want string
|
||||
}{
|
||||
{"包装取input", `{"input":"hello"}`, "hello"},
|
||||
{"input非串取原文", `{"input":{"x":1}}`, `{"x":1}`},
|
||||
{"无input键整串", `{"cmd":"ls"}`, `{"cmd":"ls"}`},
|
||||
{"非JSON整串", "raw text", "raw text"},
|
||||
{"非串类型空串", 42, ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := respUnwrapCustomInput(test.args); got != test.want {
|
||||
t.Fatalf("respUnwrapCustomInput(%v) = %q, want %q", test.args, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatToolSearchDrop 断言 tool_search 剥离(拍平后语义冗余,上游不识别)。
|
||||
func TestRespCompatToolSearchDrop(t *testing.T) {
|
||||
raw := `{"model":"m","tools":[{"type":"tool_search"},{"type":"function","name":"exec"}]}`
|
||||
out, compat, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if !slices.Equal(compat.Dropped, []string{"tool_search"}) {
|
||||
t.Errorf("Dropped = %v", compat.Dropped)
|
||||
}
|
||||
if strings.Contains(string(out), "tool_search") || !strings.Contains(string(out), `"name":"exec"`) {
|
||||
t.Errorf("tool_search 应剥离且 function 保留: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user