mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-09 17:42:08 +08:00
- Normalize complex constant `oneOf` and `anyOf` tool parameter schemas into equivalent enums to prevent upstream aborts. - Escape property keys containing dots and colons during schema updates to prevent invalid path splitting. - Identify terminal `response.incomplete` events with zero output tokens and no content as upstream failures. Closes: #5551
268 lines
7.3 KiB
Go
268 lines
7.3 KiB
Go
package helps
|
|
|
|
import (
|
|
"math/big"
|
|
"strconv"
|
|
"strings"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const (
|
|
// codexComplexUnionBranchThreshold is the minimum number of union branches (oneOf / anyOf)
|
|
// required before considering a pure constant union eligible for semantic enum normalization.
|
|
codexComplexUnionBranchThreshold = 8
|
|
)
|
|
|
|
// NormalizeCodexToolSchemas inspects function tools in a Codex request payload
|
|
// and simplifies pure constant union combinations (e.g. large oneOf branch sets
|
|
// representing enums with descriptions, as emitted by MCP servers) into semantically
|
|
// equivalent enum lists.
|
|
// Only unions mathematically proven to be semantically equivalent to enum definitions
|
|
// are modified; all other structures, property names, types, and constraints remain untouched.
|
|
func NormalizeCodexToolSchemas(body []byte) []byte {
|
|
tools := gjson.GetBytes(body, "tools")
|
|
if !tools.Exists() || !tools.IsArray() || len(tools.Array()) == 0 {
|
|
return body
|
|
}
|
|
|
|
toolsArray := tools.Array()
|
|
changed := false
|
|
for i, tool := range toolsArray {
|
|
updatedTool, toolChanged := normalizeCodexTool(tool)
|
|
if toolChanged {
|
|
var errSet error
|
|
body, errSet = sjson.SetRawBytes(body, "tools."+strconv.Itoa(i), updatedTool)
|
|
if errSet == nil {
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if changed {
|
|
log.Debugf("codex: normalized tool schemas to prevent upstream failure")
|
|
}
|
|
return body
|
|
}
|
|
|
|
func normalizeCodexTool(tool gjson.Result) ([]byte, bool) {
|
|
toolType := tool.Get("type").String()
|
|
// Handle namespace tools (e.g. multi-agent nested tools)
|
|
if toolType == "namespace" {
|
|
nestedTools := tool.Get("tools")
|
|
if nestedTools.IsArray() && len(nestedTools.Array()) > 0 {
|
|
changed := false
|
|
raw := []byte(tool.Raw)
|
|
for j, nestedTool := range nestedTools.Array() {
|
|
updatedNested, nestedChanged := normalizeCodexTool(nestedTool)
|
|
if nestedChanged {
|
|
var errSet error
|
|
raw, errSet = sjson.SetRawBytes(raw, "tools."+strconv.Itoa(j), updatedNested)
|
|
if errSet == nil {
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
return raw, changed
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
if toolType != "function" && toolType != "custom" {
|
|
return nil, false
|
|
}
|
|
|
|
params := tool.Get("parameters")
|
|
if !params.Exists() || !params.IsObject() {
|
|
return nil, false
|
|
}
|
|
|
|
rawTool := []byte(tool.Raw)
|
|
updatedParams, paramsChanged := normalizeCodexParameters(params)
|
|
if !paramsChanged {
|
|
return nil, false
|
|
}
|
|
|
|
updatedTool, errSet := sjson.SetRawBytes(rawTool, "parameters", updatedParams)
|
|
if errSet != nil {
|
|
return nil, false
|
|
}
|
|
|
|
log.Debugf("codex: simplified complex schema unions for tool %s to avoid upstream abort", tool.Get("name").String())
|
|
return updatedTool, true
|
|
}
|
|
|
|
func normalizeCodexParameters(params gjson.Result) ([]byte, bool) {
|
|
rawParams := []byte(params.Raw)
|
|
changed := false
|
|
|
|
properties := params.Get("properties")
|
|
if properties.Exists() && properties.IsObject() {
|
|
for propName, propVal := range properties.Map() {
|
|
updatedProp, propChanged := normalizeCodexPropertySchema(propVal)
|
|
if propChanged {
|
|
escapedKey := escapeCodexSjsonKey(propName)
|
|
var errSet error
|
|
rawParams, errSet = sjson.SetRawBytes(rawParams, "properties."+escapedKey, updatedProp)
|
|
if errSet == nil {
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return rawParams, changed
|
|
}
|
|
|
|
func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) {
|
|
if !prop.IsObject() {
|
|
return nil, false
|
|
}
|
|
|
|
hasOneOf := prop.Get("oneOf").Exists()
|
|
hasAnyOf := prop.Get("anyOf").Exists()
|
|
// If both oneOf and anyOf are present on the same property, leave untouched to preserve compound constraints
|
|
if hasOneOf && hasAnyOf {
|
|
return nil, false
|
|
}
|
|
|
|
unionName := ""
|
|
if hasOneOf {
|
|
unionName = "oneOf"
|
|
} else if hasAnyOf {
|
|
unionName = "anyOf"
|
|
} else {
|
|
return nil, false
|
|
}
|
|
|
|
union := prop.Get(unionName)
|
|
if !union.IsArray() || len(union.Array()) < codexComplexUnionBranchThreshold {
|
|
return nil, false
|
|
}
|
|
|
|
branches := union.Array()
|
|
constRawValues := make([]string, 0, len(branches))
|
|
constSemanticKeys := make([]string, 0, len(branches))
|
|
seenSemanticKeys := make(map[string]struct{}, len(branches))
|
|
pureConsts := true
|
|
|
|
for _, branch := range branches {
|
|
canonicalKey, rawJSON, ok := isPureConstBranch(branch)
|
|
if !ok {
|
|
pureConsts = false
|
|
break
|
|
}
|
|
if _, seen := seenSemanticKeys[canonicalKey]; seen {
|
|
// Duplicate semantic value in oneOf violates exclusivity; keep original schema
|
|
pureConsts = false
|
|
break
|
|
}
|
|
seenSemanticKeys[canonicalKey] = struct{}{}
|
|
constSemanticKeys = append(constSemanticKeys, canonicalKey)
|
|
constRawValues = append(constRawValues, rawJSON)
|
|
}
|
|
|
|
// Only transform if every branch is proven to be a pure, unique const definition
|
|
if !pureConsts || len(constRawValues) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
rawProp := []byte(prop.Raw)
|
|
existingEnum := prop.Get("enum")
|
|
if existingEnum.Exists() && existingEnum.IsArray() {
|
|
existingEnumKeys := make([]string, 0, len(existingEnum.Array()))
|
|
for _, v := range existingEnum.Array() {
|
|
key, ok := canonicalJSONValueKey(v)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
existingEnumKeys = append(existingEnumKeys, key)
|
|
}
|
|
// Only remove the redundant union if existing enum is proven semantically identical
|
|
if equalCanonicalSets(existingEnumKeys, constSemanticKeys) {
|
|
rawProp, _ = sjson.DeleteBytes(rawProp, unionName)
|
|
return rawProp, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// Migrate the pure const union to an enum using raw JSON tokens to avoid any numeric precision loss
|
|
rawEnumJSON := []byte("[" + strings.Join(constRawValues, ",") + "]")
|
|
rawProp, errEnum := sjson.SetRawBytes(rawProp, "enum", rawEnumJSON)
|
|
if errEnum != nil {
|
|
return nil, false
|
|
}
|
|
rawProp, _ = sjson.DeleteBytes(rawProp, unionName)
|
|
return rawProp, true
|
|
}
|
|
|
|
func isPureConstBranch(branch gjson.Result) (canonicalKey string, rawJSON string, ok bool) {
|
|
if !branch.IsObject() {
|
|
return "", "", false
|
|
}
|
|
constVal := branch.Get("const")
|
|
if !constVal.Exists() {
|
|
return "", "", false
|
|
}
|
|
// Verify no other schema validation constraints exist in this branch
|
|
for key := range branch.Map() {
|
|
if key != "const" && key != "description" && key != "title" {
|
|
return "", "", false
|
|
}
|
|
}
|
|
key, ok := canonicalJSONValueKey(constVal)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
return key, constVal.Raw, true
|
|
}
|
|
|
|
func canonicalJSONValueKey(val gjson.Result) (string, bool) {
|
|
switch val.Type {
|
|
case gjson.String:
|
|
return "s:" + val.String(), true
|
|
case gjson.Number:
|
|
raw := strings.TrimSpace(val.Raw)
|
|
var r big.Rat
|
|
if _, ok := r.SetString(raw); ok {
|
|
return "n:" + r.RatString(), true
|
|
}
|
|
return "n:" + raw, true
|
|
case gjson.True:
|
|
return "b:true", true
|
|
case gjson.False:
|
|
return "b:false", true
|
|
case gjson.Null:
|
|
return "null", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func equalCanonicalSets(a []string, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
setA := make(map[string]struct{}, len(a))
|
|
for _, v := range a {
|
|
setA[v] = struct{}{}
|
|
}
|
|
for _, v := range b {
|
|
if _, ok := setA[v]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return len(setA) == len(a)
|
|
}
|
|
|
|
// escapeCodexSjsonKey escapes dots, colons, and backslashes in property keys so that sjson treats
|
|
// keys containing dots (e.g. "my.field") or colons (e.g. ":action") as a single literal key rather
|
|
// than nested paths or control syntax.
|
|
func escapeCodexSjsonKey(key string) string {
|
|
key = strings.ReplaceAll(key, `\`, `\\`)
|
|
key = strings.ReplaceAll(key, `.`, `\.`)
|
|
key = strings.ReplaceAll(key, `:`, `\:`)
|
|
return key
|
|
}
|