mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-15 12:06:25 +08:00
- Add integer field paths for memories, history, notes, web, and collaboration tool schemas - Support nested property paths and union types in parameter schema normalization Closes: #6255
220 lines
11 KiB
Go
220 lines
11 KiB
Go
package toolschema
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
func TestNormalizeCodexToolIntegerTypesSourceFields(t *testing.T) {
|
|
// Paths follow the input schemas and Deserialize types in codex-rs core and ext.
|
|
tests := []struct {
|
|
name string
|
|
fields []string
|
|
}{
|
|
{"test_sync_tool", []string{"barrier.properties.participants", "barrier.properties.timeout_ms"}},
|
|
{"create_goal", []string{"token_budget"}},
|
|
{"get_channels", []string{"limit"}},
|
|
{"list_threads", []string{"limit", "max_chars_per_post"}},
|
|
{"search_posts", []string{"limit", "max_chars_per_post"}},
|
|
{"read_thread", []string{"limit", "max_chars_per_post"}},
|
|
{"read_post", []string{"offset_chars", "limit_chars"}},
|
|
{"memories__list", []string{"max_results"}},
|
|
{"memories__read", []string{"line_offset", "max_lines"}},
|
|
{"memories__search", []string{"context_lines", "max_results"}},
|
|
{"history__list_windows", []string{"limit"}},
|
|
{"history__list_items", []string{"limit", "max_chars_per_item"}},
|
|
{"history__read_item", []string{"offset_chars", "limit_chars"}},
|
|
{"history__search_contents", []string{"limit"}},
|
|
{"notes__list_files_by_prefix", []string{"max_results"}},
|
|
{"notes__read_file", []string{"start_line", "stop_line", "start_line.anyOf.0", "stop_line.anyOf.0"}},
|
|
{"notes__search_contents", []string{"max_matches_per_file", "max_files"}},
|
|
{"image_gen__imagegen", []string{"num_last_images_to_include"}},
|
|
{"web__run", []string{
|
|
"search_query.items.properties.recency", "image_query.items.properties.recency",
|
|
"open.items.properties.lineno", "click.items.properties.id",
|
|
"screenshot.items.properties.pageno", "weather.items.properties.duration",
|
|
"sports.items.properties.num_games",
|
|
}},
|
|
{"collaboration__wait_agent", []string{"timeout_ms"}},
|
|
{"multi_agent_v1__wait_agent", []string{"timeout_ms"}},
|
|
{"functions__create_goal", []string{"token_budget"}},
|
|
{"collab__read_post", []string{"offset_chars", "limit_chars"}},
|
|
{"collaboration__get_channels", []string{"limit"}},
|
|
{"collaboration__list_threads", []string{"limit", "max_chars_per_post"}},
|
|
{"collaboration__search_posts", []string{"limit", "max_chars_per_post"}},
|
|
{"collaboration__read_thread", []string{"limit", "max_chars_per_post"}},
|
|
{"collaboration__read_post", []string{"offset_chars", "limit_chars"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
for _, typeJSON := range []string{`"number"`, `["number","integer","null"]`, `"integer"`, `"string"`} {
|
|
input := []byte(fmt.Sprintf(`{"tools":[{"type":"function","name":%q,"parameters":{"type":"object","properties":{"unrelated":{"type":"number","default":1.5}}},"output_schema":{"properties":{"wall_time_seconds":{"type":"number"}}}}],"input":[{"type":"function_call","arguments":"{\"limit\":1.5}"}]}`, tt.name))
|
|
for _, field := range tt.fields {
|
|
input = setIntegerTestRaw(t, input, "tools.0.parameters.properties."+field, `{"type":`+typeJSON+`,"description":"Keep metadata","minimum":0,"default":1}`)
|
|
}
|
|
for _, ua := range []string{"codex-tui/0.154.0", "curl/8.7.1", ""} {
|
|
var headers http.Header
|
|
if ua != "" {
|
|
headers = http.Header{"user-agent": []string{ua}}
|
|
}
|
|
want := bytes.Clone(input)
|
|
if ua == "codex-tui/0.154.0" {
|
|
wantType := typeJSON
|
|
if typeJSON == `"number"` {
|
|
wantType = `"integer"`
|
|
} else if typeJSON == `["number","integer","null"]` {
|
|
wantType = `["integer","null"]`
|
|
}
|
|
for _, field := range tt.fields {
|
|
want = setIntegerTestRaw(t, want, "tools.0.parameters.properties."+field+".type", wantType)
|
|
}
|
|
}
|
|
out := NormalizeCodexToolIntegerTypes(input, headers)
|
|
if !bytes.Equal(out, want) {
|
|
t.Fatalf("UA %q type %s: got %s, want %s", ua, typeJSON, out, want)
|
|
}
|
|
if again := NormalizeCodexToolIntegerTypes(out, headers); !bytes.Equal(again, out) {
|
|
t.Fatal("normalization is not idempotent")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCodexToolIntegerTypesNamespaceFormats(t *testing.T) {
|
|
schema := `{"type":"object","properties":{"line_offset":{"type":"number"},"max_lines":{"type":["number","null"]},"max_tokens":{"type":"number"}}}`
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
path string
|
|
}{
|
|
{"responses", `{"tools":[{"type":"function","name":"memories__read","parameters":%s}]}`, "tools.0.parameters"},
|
|
{"chat", `{"tools":[{"type":"function","function":{"name":"memories__read","parameters":%s}}]}`, "tools.0.function.parameters"},
|
|
{"claude", `{"tools":[{"name":"memories__read","input_schema":%s}]}`, "tools.0.input_schema"},
|
|
{"gemini", `{"tools":[{"function_declarations":[{"name":"memories__read","parameters":%s}]}]}`, "tools.0.function_declarations.0.parameters"},
|
|
{"gemini JSON schema", `{"tools":[{"functionDeclarations":[{"name":"memories__read","parametersJsonSchema":%s}]}]}`, "tools.0.functionDeclarations.0.parametersJsonSchema"},
|
|
{"namespace", `{"tools":[{"type":"namespace","name":"memories","tools":[{"type":"function","name":"read","parameters":%s}]}]}`, "tools.0.tools.0.parameters"},
|
|
{"additional namespace", `{"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"memories","tools":[{"type":"function","name":"read","parameters":%s}]}]}]}`, "input.0.tools.0.tools.0.parameters"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
input := []byte(fmt.Sprintf(tt.body, schema))
|
|
want := setIntegerTestRaw(t, input, tt.path+".properties.line_offset.type", `"integer"`)
|
|
want = setIntegerTestRaw(t, want, tt.path+".properties.max_lines.type", `["integer","null"]`)
|
|
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"Codex/1.0"}})
|
|
if !bytes.Equal(out, want) {
|
|
t.Fatalf("got %s, want %s", out, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCodexToolIntegerTypesPreservesUnprovenFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
namespace string
|
|
}{
|
|
{"unknown_tool", ""}, {"mcp__server__read_post", ""},
|
|
{"read", ""}, {"run", ""}, {"imagegen", ""},
|
|
{"read_post", "user_tools"}, {"wait_agent", "mcp__server"},
|
|
{"read", "skills"}, {"read", "user_tools"},
|
|
{"read_post", "multi_agent_v1"}, {"read_post", "arbitrary_collaboration"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.namespace+"/"+tt.name, func(t *testing.T) {
|
|
tool := fmt.Sprintf(`{"type":"function","name":%q,"parameters":{"properties":{"limit":{"type":"number"},"offset_chars":{"type":"number"},"line_offset":{"type":"number"},"timeout_ms":{"type":"number"}}}}`, tt.name)
|
|
if tt.namespace != "" {
|
|
tool = fmt.Sprintf(`{"type":"namespace","name":%q,"tools":[%s]}`, tt.namespace, tool)
|
|
}
|
|
input := []byte(`{"tools":[` + tool + `]}`)
|
|
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}})
|
|
if !bytes.Equal(out, input) {
|
|
t.Fatalf("unknown tool changed: %s", out)
|
|
}
|
|
})
|
|
}
|
|
input := []byte(`{"tools":[{"name":"test_sync_tool","parameters":{"properties":{"barrier.participants":{"type":"number"},"other":{"properties":{"participants":{"type":"number"}}},"barrier":{"properties":{"participants":{"type":"number"},"ratio":{"type":"number"}}}}}}]}`)
|
|
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}})
|
|
want := setIntegerTestRaw(t, input, "tools.0.parameters.properties.barrier.properties.participants.type", `"integer"`)
|
|
if !bytes.Equal(out, want) || !gjson.ValidBytes(out) {
|
|
t.Fatalf("nested path changed unrelated fields: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCodexToolIntegerTypesHistoryNotesSchemas(t *testing.T) {
|
|
// Parameter schemas copied from codex-rs/ext/history-notes/src/tools.rs:143-218.
|
|
fixture, errRead := os.ReadFile("testdata/history_notes_tools.json")
|
|
if errRead != nil {
|
|
t.Fatal(errRead)
|
|
}
|
|
if !gjson.ValidBytes(fixture) {
|
|
t.Fatal("invalid source schema fixture")
|
|
}
|
|
for _, namespace := range gjson.GetBytes(fixture, "tools").Array() {
|
|
tool := namespace.Get("tools.0")
|
|
name := namespace.Get("name").String() + "__" + tool.Get("name").String()
|
|
t.Run(name, func(t *testing.T) {
|
|
formats := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"namespace", `{"tools":[` + namespace.Raw + `]}`},
|
|
{"flat", fmt.Sprintf(`{"tools":[{"name":%q,"parameters":%s}]}`, name, tool.Get("parameters").Raw)},
|
|
{"additional", `{"input":[{"type":"additional_tools","tools":[` + namespace.Raw + `]}]}`},
|
|
}
|
|
for _, format := range formats {
|
|
t.Run(format.name, func(t *testing.T) {
|
|
// Simulate integer declarations degraded to number, retaining the real schema shape.
|
|
input := []byte(strings.ReplaceAll(format.body, `"type": "integer"`, `"type": "number"`))
|
|
for _, ua := range []string{"codex", "curl", ""} {
|
|
want := input
|
|
if ua == "codex" {
|
|
want = []byte(format.body)
|
|
}
|
|
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{ua}})
|
|
if !bytes.Equal(out, want) {
|
|
t.Fatalf("UA %q: got %s, want %s", ua, out, want)
|
|
}
|
|
if again := NormalizeCodexToolIntegerTypes(out, http.Header{"User-Agent": []string{ua}}); !bytes.Equal(again, out) {
|
|
t.Fatal("source schema is not stable")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
for _, unknownName := range []string{tool.Get("name").String(), "mcp__server__" + name, "user__" + name} {
|
|
input := []byte(fmt.Sprintf(`{"tools":[{"name":%q,"parameters":%s}]}`, unknownName, strings.ReplaceAll(tool.Get("parameters").Raw, `"type": "integer"`, `"type": "number"`)))
|
|
if out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}}); !bytes.Equal(out, input) {
|
|
t.Fatalf("unknown tool %q changed: %s", unknownName, out)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCodexToolIntegerTypesNotesExplicitUnionPaths(t *testing.T) {
|
|
input := []byte(`{"tools":[{"name":"notes__read_file","parameters":{"type":"object","properties":{"start_line":{"anyOf":[{"type":"number"},{"type":"null"}],"default":-3},"stop_line":{"anyOf":[{"type":"number"},{"type":"number"}],"default":-1},"ratio":{"anyOf":[{"type":"number"},{"type":"null"}]},"other":{"properties":{"start_line":{"anyOf":[{"type":"number"}]}}}},"required":["path"]}}],"input":[{"type":"function_call","arguments":"{\"start_line\":-3,\"stop_line\":-1}"}]}`)
|
|
want := setIntegerTestRaw(t, input, "tools.0.parameters.properties.start_line.anyOf.0.type", `"integer"`)
|
|
want = setIntegerTestRaw(t, want, "tools.0.parameters.properties.stop_line.anyOf.0.type", `"integer"`)
|
|
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}})
|
|
if !bytes.Equal(out, want) {
|
|
t.Fatalf("explicit union paths changed unrelated branches or signed values: %s", out)
|
|
}
|
|
}
|
|
|
|
func setIntegerTestRaw(t *testing.T, body []byte, path, value string) []byte {
|
|
t.Helper()
|
|
out, errSet := sjson.SetRawBytes(body, path, []byte(value))
|
|
if errSet != nil {
|
|
t.Fatal(errSet)
|
|
}
|
|
return out
|
|
}
|