mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-16 04:28:34 +08:00
fix(codex): expand integer field normalization mappings for tools
- 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
This commit is contained in:
412
internal/client/codex/tool-schema/testdata/history_notes_tools.json
vendored
Normal file
412
internal/client/codex/tool-schema/testdata/history_notes_tools.json
vendored
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "history",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "list_windows",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of windows to return."
|
||||
},
|
||||
"agent_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent."
|
||||
},
|
||||
"recent_first": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to return the most recently created windows first."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "history",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "list_items",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of items to return."
|
||||
},
|
||||
"recent_first": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to return the most recently created items first."
|
||||
},
|
||||
"tool_namespace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Callable namespace to include. When set, non-tool messages are excluded."
|
||||
},
|
||||
"role": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"system",
|
||||
"developer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message role to include. Null or omission includes all roles."
|
||||
},
|
||||
"agent_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent."
|
||||
},
|
||||
"tool_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Callable tool name to include. When set, non-tool messages are excluded."
|
||||
},
|
||||
"window_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Full window ID. Null or omission includes all windows."
|
||||
},
|
||||
"max_chars_per_item": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum characters returned in each item's truncated_content."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "history",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_item",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent."
|
||||
},
|
||||
"item_id": {
|
||||
"type": "string",
|
||||
"description": "The short item ID is the suffix shown in the target item's trailing `[id: ...]` marker, printed after that item's content."
|
||||
},
|
||||
"offset_chars": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Zero-based character offset at which reading starts."
|
||||
},
|
||||
"limit_chars": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of characters to return."
|
||||
},
|
||||
"window_id": {
|
||||
"type": "string",
|
||||
"description": "Full window ID containing the item."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"item_id",
|
||||
"window_id"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "history",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "search_contents",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of matching items to return."
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"encrypted": true,
|
||||
"description": "Case-sensitive literal substring to find in item content."
|
||||
},
|
||||
"recent_first": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to return the most recently created matches first."
|
||||
},
|
||||
"tool_namespace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Callable namespace to include. When set, non-tool messages are excluded."
|
||||
},
|
||||
"role": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"system",
|
||||
"developer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message role to include. Null or omission includes all roles."
|
||||
},
|
||||
"agent_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent."
|
||||
},
|
||||
"tool_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Callable tool name to include. When set, non-tool messages are excluded."
|
||||
},
|
||||
"window_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Full window ID. Null or omission includes all windows."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "notes",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "list_files_by_prefix",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prefix": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Note path prefix to list."
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of files to return."
|
||||
},
|
||||
"file_order_by": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
],
|
||||
"description": "Field used to order files."
|
||||
},
|
||||
"file_order": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ascending",
|
||||
"descending"
|
||||
],
|
||||
"description": "Direction used to order files."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "notes",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Note file path to read."
|
||||
},
|
||||
"start_line": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "First line to return, inclusive and 1-based. Negative values count backward from the final line."
|
||||
},
|
||||
"stop_line": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Last line to return, inclusive and 1-based. Negative values count backward from the final line."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "notes",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "search_contents",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_matches_per_file": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of matching lines returned per file."
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"encrypted": true,
|
||||
"description": "Case-sensitive literal substring to find in note lines."
|
||||
},
|
||||
"recent_file_first": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to order matching files by creation time, newest first."
|
||||
},
|
||||
"max_files": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of matching files returned."
|
||||
},
|
||||
"path_prefix": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Note path prefix to search."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func headerValueCaseInsensitive(headers http.Header, name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Keys are explicit schema paths relative to parameters.properties, not recursive field names.
|
||||
var codexClientToolIntegerFields = map[string]map[string]struct{}{
|
||||
"exec_command": {
|
||||
"yield_time_ms": struct{}{},
|
||||
@@ -60,10 +61,85 @@ var codexClientToolIntegerFields = map[string]map[string]struct{}{
|
||||
"limit": struct{}{},
|
||||
},
|
||||
"test_sync_tool": {
|
||||
"sleep_before_ms": struct{}{},
|
||||
"sleep_after_ms": struct{}{},
|
||||
"participants": struct{}{},
|
||||
"timeout_ms": struct{}{},
|
||||
"sleep_before_ms": struct{}{},
|
||||
"sleep_after_ms": struct{}{},
|
||||
"participants": struct{}{},
|
||||
"timeout_ms": struct{}{},
|
||||
"barrier.properties.participants": struct{}{},
|
||||
"barrier.properties.timeout_ms": struct{}{},
|
||||
},
|
||||
"create_goal": {
|
||||
"token_budget": struct{}{},
|
||||
},
|
||||
"get_channels": {
|
||||
"limit": struct{}{},
|
||||
},
|
||||
"list_threads": {
|
||||
"limit": struct{}{},
|
||||
"max_chars_per_post": struct{}{},
|
||||
},
|
||||
"search_posts": {
|
||||
"limit": struct{}{},
|
||||
"max_chars_per_post": struct{}{},
|
||||
},
|
||||
"read_thread": {
|
||||
"limit": struct{}{},
|
||||
"max_chars_per_post": struct{}{},
|
||||
},
|
||||
"read_post": {
|
||||
"offset_chars": struct{}{},
|
||||
"limit_chars": struct{}{},
|
||||
},
|
||||
"memories__list": {
|
||||
"max_results": struct{}{},
|
||||
},
|
||||
"memories__read": {
|
||||
"line_offset": struct{}{},
|
||||
"max_lines": struct{}{},
|
||||
},
|
||||
"memories__search": {
|
||||
"context_lines": struct{}{},
|
||||
"max_results": struct{}{},
|
||||
},
|
||||
"history__list_windows": {
|
||||
"limit": struct{}{},
|
||||
},
|
||||
"history__list_items": {
|
||||
"limit": struct{}{},
|
||||
"max_chars_per_item": struct{}{},
|
||||
},
|
||||
"history__read_item": {
|
||||
"offset_chars": struct{}{},
|
||||
"limit_chars": struct{}{},
|
||||
},
|
||||
"history__search_contents": {
|
||||
"limit": struct{}{},
|
||||
},
|
||||
"notes__list_files_by_prefix": {
|
||||
"max_results": struct{}{},
|
||||
},
|
||||
"notes__read_file": {
|
||||
"start_line": struct{}{},
|
||||
"stop_line": struct{}{},
|
||||
// Codex declares signed line numbers in the first nullable union branch.
|
||||
"start_line.anyOf.0": struct{}{},
|
||||
"stop_line.anyOf.0": struct{}{},
|
||||
},
|
||||
"notes__search_contents": {
|
||||
"max_matches_per_file": struct{}{},
|
||||
"max_files": struct{}{},
|
||||
},
|
||||
"image_gen__imagegen": {
|
||||
"num_last_images_to_include": struct{}{},
|
||||
},
|
||||
"web__run": {
|
||||
"search_query.items.properties.recency": struct{}{},
|
||||
"image_query.items.properties.recency": struct{}{},
|
||||
"open.items.properties.lineno": struct{}{},
|
||||
"click.items.properties.id": struct{}{},
|
||||
"screenshot.items.properties.pageno": struct{}{},
|
||||
"weather.items.properties.duration": struct{}{},
|
||||
"sports.items.properties.num_games": struct{}{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -74,6 +150,13 @@ func matchCodexTargetTool(toolName string) map[string]struct{} {
|
||||
} else if strings.HasPrefix(baseName, "collab__") {
|
||||
baseName = strings.TrimPrefix(baseName, "collab__")
|
||||
}
|
||||
switch baseName {
|
||||
case "multi_agent_v1__wait_agent", "collaboration__wait_agent":
|
||||
baseName = "wait_agent"
|
||||
case "collaboration__get_channels", "collaboration__list_threads", "collaboration__search_posts",
|
||||
"collaboration__read_thread", "collaboration__read_post":
|
||||
baseName = strings.TrimPrefix(baseName, "collaboration__")
|
||||
}
|
||||
return codexClientToolIntegerFields[baseName]
|
||||
}
|
||||
|
||||
@@ -96,9 +179,9 @@ func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]stru
|
||||
if !typeVal.Exists() {
|
||||
continue
|
||||
}
|
||||
escapedKey := escapeCodexSjsonKey(fieldName)
|
||||
typePath := "properties." + fieldName + ".type"
|
||||
if typeVal.Type == gjson.String && typeVal.String() == "number" {
|
||||
if updated, errSet := sjson.SetBytes(rawParams, "properties."+escapedKey+".type", "integer"); errSet == nil {
|
||||
if updated, errSet := sjson.SetBytes(rawParams, typePath, "integer"); errSet == nil {
|
||||
rawParams = updated
|
||||
changed = true
|
||||
}
|
||||
@@ -119,7 +202,7 @@ func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]stru
|
||||
}
|
||||
}
|
||||
if hasNumber {
|
||||
if updated, errSet := sjson.SetBytes(rawParams, "properties."+escapedKey+".type", newTypes); errSet == nil {
|
||||
if updated, errSet := sjson.SetBytes(rawParams, typePath, newTypes); errSet == nil {
|
||||
rawParams = updated
|
||||
changed = true
|
||||
}
|
||||
@@ -142,7 +225,7 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
|
||||
// 1. Process top-level tools
|
||||
toolsResult := gjson.GetBytes(body, "tools")
|
||||
if toolsResult.IsArray() {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(toolsResult); ok {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(toolsResult, ""); ok {
|
||||
if out, errSet := sjson.SetRawBytes(body, "tools", updated); errSet == nil {
|
||||
body = out
|
||||
changed = true
|
||||
@@ -157,7 +240,7 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
|
||||
if item.Get("type").String() == "additional_tools" {
|
||||
addTools := item.Get("tools")
|
||||
if addTools.IsArray() {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(addTools); ok {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(addTools, ""); ok {
|
||||
path := fmt.Sprintf("input.%d.tools", idx)
|
||||
if out, errSet := sjson.SetRawBytes(body, path, updated); errSet == nil {
|
||||
body = out
|
||||
@@ -175,14 +258,14 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
func normalizeToolIntegerTypesInArray(tools gjson.Result) ([]byte, bool) {
|
||||
func normalizeToolIntegerTypesInArray(tools gjson.Result, namespace string) ([]byte, bool) {
|
||||
if !tools.IsArray() {
|
||||
return nil, false
|
||||
}
|
||||
var out []byte
|
||||
offset := 0
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
updated, changed := normalizeToolIntegerTypesInElement(tool)
|
||||
updated, changed := normalizeToolIntegerTypesInElement(tool, namespace)
|
||||
if !changed {
|
||||
return true
|
||||
}
|
||||
@@ -201,15 +284,22 @@ func normalizeToolIntegerTypesInArray(tools gjson.Result) ([]byte, bool) {
|
||||
return append(out, tools.Raw[offset:]...), true
|
||||
}
|
||||
|
||||
func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) {
|
||||
func normalizeToolIntegerTypesInElement(tool gjson.Result, namespace string) ([]byte, bool) {
|
||||
toolRaw := []byte(tool.Raw)
|
||||
changed := false
|
||||
|
||||
// Handle namespace tools
|
||||
if tool.Get("type").String() == "namespace" {
|
||||
if namespace != "" {
|
||||
return nil, false
|
||||
}
|
||||
namespace = tool.Get("name").String()
|
||||
if namespace == "" {
|
||||
return nil, false
|
||||
}
|
||||
nested := tool.Get("tools")
|
||||
if nested.IsArray() {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(nested); ok {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(nested, namespace); ok {
|
||||
if out, errSet := sjson.SetRawBytes(toolRaw, "tools", updated); errSet == nil {
|
||||
toolRaw = out
|
||||
changed = true
|
||||
@@ -223,7 +313,7 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) {
|
||||
for _, declKey := range []string{"function_declarations", "functionDeclarations"} {
|
||||
decls := tool.Get(declKey)
|
||||
if decls.IsArray() {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(decls); ok {
|
||||
if updated, ok := normalizeToolIntegerTypesInArray(decls, namespace); ok {
|
||||
if out, errSet := sjson.SetRawBytes(toolRaw, declKey, updated); errSet == nil {
|
||||
toolRaw = out
|
||||
changed = true
|
||||
@@ -256,6 +346,9 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
if namespace != "" {
|
||||
toolName = namespace + "__" + toolName
|
||||
}
|
||||
targetFields := matchCodexTargetTool(toolName)
|
||||
if len(targetFields) == 0 {
|
||||
return nil, false
|
||||
@@ -272,10 +365,3 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) {
|
||||
}
|
||||
return updatedTool, true
|
||||
}
|
||||
|
||||
func escapeCodexSjsonKey(key string) string {
|
||||
key = strings.ReplaceAll(key, `\`, `\\`)
|
||||
key = strings.ReplaceAll(key, `.`, `\.`)
|
||||
key = strings.ReplaceAll(key, `:`, `\:`)
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user