Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re

requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body = util.FixCodexToolSchemas(body)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.SetBytes(body, "stream", true)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
Expand Down Expand Up @@ -216,6 +217,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au

requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body = util.FixCodexToolSchemas(body)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
Expand Down
104 changes: 104 additions & 0 deletions internal/util/codex_schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Package util provides utility functions for the CLI Proxy API server.
package util

import (
"strconv"

"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

// FixCodexToolSchemas fixes tool schemas in a Codex API request body.
// It adds missing "items" to array-type schemas which OpenAI's strict validation requires.
func FixCodexToolSchemas(body []byte) []byte {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return body
}

for i, tool := range tools.Array() {
if tool.Get("type").String() != "function" {
continue
}

var params gjson.Result
var setPath string

// Support both Chat Completions (function.parameters) and Responses API (parameters)
if tool.Get("function.parameters").Exists() {
params = tool.Get("function.parameters")
setPath = "tools." + strconv.Itoa(i) + ".function.parameters"
} else if tool.Get("parameters").Exists() {
params = tool.Get("parameters")
setPath = "tools." + strconv.Itoa(i) + ".parameters"
} else {
continue
}

fixed := addMissingArrayItems(params.Raw)
if fixed != params.Raw {
body, _ = sjson.SetRawBytes(body, setPath, []byte(fixed))
}
}
return body
}

// addMissingArrayItems adds a default "items" schema to arrays that are missing it.
func addMissingArrayItems(jsonStr string) string {
paths := findArrayTypePaths(gjson.Parse(jsonStr), "")
for _, p := range paths {
itemsPath := p + ".items"
if p == "" {
itemsPath = "items"
}
items := gjson.Get(jsonStr, itemsPath)
// Add items if missing or null
if !items.Exists() || items.Type == gjson.Null {
jsonStr, _ = sjson.SetRaw(jsonStr, itemsPath, `{}`)
}
}
return jsonStr
}

// isArrayType checks if a node's type indicates an array (string or array containing "array").
func isArrayType(node gjson.Result) bool {
typeVal := node.Get("type")
if typeVal.IsArray() {
for _, t := range typeVal.Array() {
if t.String() == "array" {
return true
}
}
return false
}
return typeVal.String() == "array"
}

// findArrayTypePaths recursively finds all paths where type="array".
func findArrayTypePaths(node gjson.Result, path string) []string {
var paths []string

if node.IsObject() {
if isArrayType(node) {
paths = append(paths, path)
}
node.ForEach(func(key, value gjson.Result) bool {
newPath := key.String()
if path != "" {
newPath = path + "." + key.String()
}
paths = append(paths, findArrayTypePaths(value, newPath)...)
return true
})
} else if node.IsArray() {
for i, elem := range node.Array() {
newPath := strconv.Itoa(i)
if path != "" {
newPath = path + "." + strconv.Itoa(i)
}
paths = append(paths, findArrayTypePaths(elem, newPath)...)
}
}

return paths
}
Comment on lines +78 to +104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The recursive implementation of findArrayTypePaths can be inefficient due to the use of append(slice, anotherSlice...) within the recursion. Each call creates a new slice, leading to multiple allocations. A more performant and arguably clearer approach is to use a closure to append to a single paths slice, avoiding the overhead of repeated slice allocations.

// findArrayTypePaths recursively finds all paths where type="array".
func findArrayTypePaths(node gjson.Result, path string) []string {
	var paths []string
	var find func(gjson.Result, string)

	find = func(n gjson.Result, p string) {
		if n.IsObject() {
			if isArrayType(n) {
				paths = append(paths, p)
			}
			n.ForEach(func(key, value gjson.Result) bool {
				childPath := key.String()
				if p != "" {
					childPath = p + "." + childPath
				}
				find(value, childPath)
				return true
			})
		} else if n.IsArray() {
			for i, elem := range n.Array() {
				childPath := strconv.Itoa(i)
				if p != "" {
					childPath = p + "." + childPath
				}
				find(elem, childPath)
			}
		}
	}

	find(node, path)
	return paths
}

76 changes: 76 additions & 0 deletions internal/util/codex_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package util

import (
"testing"

"github.com/tidwall/gjson"
)

func TestFixCodexToolSchemas_AddsMissingItems(t *testing.T) {
input := `{"tools":[{"type":"function","parameters":{"type":"object","properties":{"options":{"type":"array"}}}}]}`
result := FixCodexToolSchemas([]byte(input))

items := gjson.GetBytes(result, "tools.0.parameters.properties.options.items")
if !items.Exists() {
t.Error("expected items to be added to array schema")
}
}

func TestFixCodexToolSchemas_PreservesExistingItems(t *testing.T) {
input := `{"tools":[{"type":"function","parameters":{"type":"object","properties":{"options":{"type":"array","items":{"type":"string"}}}}}]}`
result := FixCodexToolSchemas([]byte(input))

itemsType := gjson.GetBytes(result, "tools.0.parameters.properties.options.items.type").String()
if itemsType != "string" {
t.Errorf("expected existing items to be preserved, got type=%s", itemsType)
}
}

func TestFixCodexToolSchemas_HandlesAnyOf(t *testing.T) {
input := `{"tools":[{"type":"function","parameters":{"anyOf":[{"type":"array"}]}}]}`
result := FixCodexToolSchemas([]byte(input))

items := gjson.GetBytes(result, "tools.0.parameters.anyOf.0.items")
if !items.Exists() {
t.Error("expected items to be added to array schema inside anyOf")
}
}

func TestFixCodexToolSchemas_NoTools(t *testing.T) {
input := `{"model":"gpt-5"}`
result := FixCodexToolSchemas([]byte(input))

if string(result) != input {
t.Error("expected unchanged output when no tools present")
}
}

func TestFixCodexToolSchemas_ChatCompletionsFormat(t *testing.T) {
input := `{"tools":[{"type":"function","function":{"name":"test","parameters":{"type":"object","properties":{"items":{"type":"array"}}}}}]}`
result := FixCodexToolSchemas([]byte(input))

items := gjson.GetBytes(result, "tools.0.function.parameters.properties.items.items")
if !items.Exists() {
t.Error("expected items to be added to array schema in function.parameters")
}
}

func TestFixCodexToolSchemas_NullableArrayType(t *testing.T) {
input := `{"tools":[{"type":"function","parameters":{"type":"object","properties":{"data":{"type":["array","null"]}}}}]}`
result := FixCodexToolSchemas([]byte(input))

items := gjson.GetBytes(result, "tools.0.parameters.properties.data.items")
if !items.Exists() {
t.Error("expected items to be added to nullable array schema")
}
}

func TestFixCodexToolSchemas_NullItems(t *testing.T) {
input := `{"tools":[{"type":"function","parameters":{"type":"object","properties":{"list":{"type":"array","items":null}}}}]}`
result := FixCodexToolSchemas([]byte(input))

items := gjson.GetBytes(result, "tools.0.parameters.properties.list.items")
if items.Type == gjson.Null {
t.Error("expected null items to be replaced with empty object")
}
}