mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-11 06:37:47 +08:00
Merge pull request #5677 from sususu98/fix/codex-tool-schema-pattern
fix(translator): strip unsupported unicode property escape patterns from tool schemas
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package helps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -8,6 +11,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -89,7 +94,7 @@ func normalizeCodexTool(tool gjson.Result) ([]byte, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
log.Debugf("codex: simplified complex schema unions for tool %s to avoid upstream abort", tool.Get("name").String())
|
||||
log.Debugf("codex: normalized schema for tool %s to avoid upstream abort", tool.Get("name").String())
|
||||
return updatedTool, true
|
||||
}
|
||||
|
||||
@@ -97,6 +102,12 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) {
|
||||
rawParams := []byte(params.Raw)
|
||||
changed := false
|
||||
|
||||
if sanitizedParams, patternChanged := stripIncompatiblePatternsFromJSON(rawParams); patternChanged {
|
||||
rawParams = sanitizedParams
|
||||
changed = true
|
||||
params = gjson.ParseBytes(rawParams)
|
||||
}
|
||||
|
||||
properties := params.Get("properties")
|
||||
if properties.Exists() && properties.IsObject() {
|
||||
for propName, propVal := range properties.Map() {
|
||||
@@ -115,6 +126,98 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) {
|
||||
return rawParams, changed
|
||||
}
|
||||
|
||||
// stripIncompatiblePatternsFromJSON recursively removes pattern attributes containing
|
||||
// unsupported Unicode property escapes (\p{...} / \P{...}) from parameter schemas.
|
||||
// It is schema-aware: only subschemas under known JSON Schema keyword locations are visited,
|
||||
// preventing accidental deletion of 'pattern' keys inside user data (e.g. description, default, enum).
|
||||
func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) {
|
||||
rawStr := string(raw)
|
||||
if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) {
|
||||
return raw, false
|
||||
}
|
||||
var root any
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&root); err != nil || root == nil {
|
||||
return raw, false
|
||||
}
|
||||
// Verify no trailing garbage
|
||||
var dummy any
|
||||
if err := dec.Decode(&dummy); err != io.EOF {
|
||||
return raw, false
|
||||
}
|
||||
if !stripIncompatiblePatterns(root) {
|
||||
return raw, false
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(root); err != nil {
|
||||
return raw, false
|
||||
}
|
||||
return bytes.TrimSpace(buf.Bytes()), true
|
||||
}
|
||||
|
||||
func stripIncompatiblePatterns(v any) bool {
|
||||
changed := false
|
||||
switch schema := v.(type) {
|
||||
case map[string]any:
|
||||
if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
|
||||
delete(schema, "pattern")
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Inspect regex keys under patternProperties
|
||||
if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
|
||||
for patternKey, subSchema := range patternProps {
|
||||
if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
|
||||
delete(patternProps, patternKey)
|
||||
changed = true
|
||||
} else if stripIncompatiblePatterns(subSchema) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, mapKey := range util.SchemaMapKeywords {
|
||||
if mapKey == "patternProperties" {
|
||||
continue
|
||||
}
|
||||
if subMap, ok := schema[mapKey].(map[string]any); ok {
|
||||
for _, subSchema := range subMap {
|
||||
if stripIncompatiblePatterns(subSchema) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, valKey := range util.SchemaValueKeywords {
|
||||
if val, exists := schema[valKey]; exists {
|
||||
switch sub := val.(type) {
|
||||
case map[string]any:
|
||||
if stripIncompatiblePatterns(sub) {
|
||||
changed = true
|
||||
}
|
||||
case []any:
|
||||
for _, item := range sub {
|
||||
if stripIncompatiblePatterns(item) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range schema {
|
||||
if stripIncompatiblePatterns(item) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) {
|
||||
if !prop.IsObject() {
|
||||
return nil, false
|
||||
|
||||
@@ -491,3 +491,286 @@ func TestNormalizeCodexToolSchemas_NamespaceToolSimplified(t *testing.T) {
|
||||
t.Fatalf("expected action.enum with 8 items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexToolSchemas_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "Artifact",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "field to edit",
|
||||
"pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$"
|
||||
},
|
||||
"asset_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32}$"
|
||||
}
|
||||
},
|
||||
"required": ["field"]
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out := NormalizeCodexToolSchemas(input)
|
||||
|
||||
tool := gjson.GetBytes(out, "tools.0")
|
||||
params := tool.Get("parameters")
|
||||
|
||||
// Unsupported pattern must be removed
|
||||
if params.Get("properties.field.pattern").Exists() {
|
||||
t.Errorf("expected properties.field.pattern to be removed, got: %s", params.Get("properties.field.pattern").Raw)
|
||||
}
|
||||
if got := params.Get("properties.field.type").String(); got != "string" {
|
||||
t.Errorf("expected properties.field.type == 'string', got %q", got)
|
||||
}
|
||||
|
||||
// Valid pattern must be preserved
|
||||
if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" {
|
||||
t.Errorf("expected properties.asset_id.pattern preserved, got %q", got)
|
||||
}
|
||||
|
||||
// Idempotence test
|
||||
outAgain := NormalizeCodexToolSchemas(out)
|
||||
if string(outAgain) != string(out) {
|
||||
t.Errorf("expected NormalizeCodexToolSchemas to be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexToolSchemas_PreservesNonSchemaPatternKeys(t *testing.T) {
|
||||
// A property whose default, enum, or description metadata contains a nested object
|
||||
// with a 'pattern' key must NOT be mutated, because it is user data, not a schema.
|
||||
input := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "config_tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"regex_config": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"pattern": "\\p{L}+"
|
||||
},
|
||||
"enum": [
|
||||
{"pattern": "\\p{N}+"}
|
||||
]
|
||||
},
|
||||
"real_schema": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{L}+"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out := NormalizeCodexToolSchemas(input)
|
||||
|
||||
tool := gjson.GetBytes(out, "tools.0")
|
||||
params := tool.Get("parameters")
|
||||
|
||||
// Real schema pattern must be removed
|
||||
if params.Get("properties.real_schema.pattern").Exists() {
|
||||
t.Errorf("expected real_schema.pattern to be removed, got: %s", params.Get("properties.real_schema").Raw)
|
||||
}
|
||||
|
||||
// User data under default and enum must be PRESERVED
|
||||
if got := params.Get("properties.regex_config.default.pattern").String(); got != `\p{L}+` {
|
||||
t.Errorf("expected default.pattern preserved, got %q", got)
|
||||
}
|
||||
if got := params.Get("properties.regex_config.enum.0.pattern").String(); got != `\p{N}+` {
|
||||
t.Errorf("expected enum.0.pattern preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexToolSchemas_CoversAllSchemaKeywordLocations(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "deep_tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"custom_type": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{L}+"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{N}+"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^s_": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{M}+"
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"properties": {
|
||||
"flag": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{P}+"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{S}+"
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": {
|
||||
"properties": {
|
||||
"other": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{Z}+"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out := NormalizeCodexToolSchemas(input)
|
||||
|
||||
tool := gjson.GetBytes(out, "tools.0")
|
||||
params := tool.Get("parameters")
|
||||
|
||||
// All subschemas in schema-aware locations must have their incompatible patterns stripped
|
||||
if params.Get("$defs.custom_type.pattern").Exists() {
|
||||
t.Errorf("expected $defs.custom_type.pattern to be removed")
|
||||
}
|
||||
if params.Get("additionalProperties.pattern").Exists() {
|
||||
t.Errorf("expected additionalProperties.pattern to be removed")
|
||||
}
|
||||
if params.Get("patternProperties.^s_.pattern").Exists() {
|
||||
t.Errorf("expected patternProperties.^s_.pattern to be removed")
|
||||
}
|
||||
if params.Get("if.properties.flag.pattern").Exists() {
|
||||
t.Errorf("expected if.properties.flag.pattern to be removed")
|
||||
}
|
||||
if params.Get("then.properties.val.pattern").Exists() {
|
||||
t.Errorf("expected then.properties.val.pattern to be removed")
|
||||
}
|
||||
if params.Get("else.properties.other.pattern").Exists() {
|
||||
t.Errorf("expected else.properties.other.pattern to be removed")
|
||||
}
|
||||
|
||||
// Subschema types must be preserved
|
||||
if got := params.Get("$defs.custom_type.type").String(); got != "string" {
|
||||
t.Errorf("expected $defs.custom_type.type == 'string', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexToolSchemas_MalformedOrEmptyParametersFallback(t *testing.T) {
|
||||
// Malformed JSON, non-object parameters, null, and empty payloads must not panic
|
||||
cases := [][]byte{
|
||||
[]byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":null}]}`),
|
||||
[]byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":"not_an_object"}]}`),
|
||||
[]byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":{"type":"object"}}]}`),
|
||||
[]byte(`{"model":"gpt-5.6","tools":[]}`),
|
||||
[]byte(`{"model":"gpt-5.6"}`),
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
out := NormalizeCodexToolSchemas(c)
|
||||
if len(out) == 0 {
|
||||
t.Errorf("case %d: unexpected empty output", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexToolSchemas_JSONUnicodeEscapeBypassPrevention(t *testing.T) {
|
||||
// Patterns encoded using JSON Unicode escapes (e.g. \u005c for '\' or \u0070 for 'p')
|
||||
// decode to \p{...} / \P{...} and must not be skipped by the fast-path check.
|
||||
input := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "escape_bypass_tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"p1": {
|
||||
"type": "string",
|
||||
"pattern": "\u005c\u0070{L}+"
|
||||
},
|
||||
"p2": {
|
||||
"type": "string",
|
||||
"pattern": "\u005cp{Cc}"
|
||||
},
|
||||
"p3": {
|
||||
"type": "string",
|
||||
"pattern": "\u005c\u0050{N}+"
|
||||
},
|
||||
"valid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out := NormalizeCodexToolSchemas(input)
|
||||
tool := gjson.GetBytes(out, "tools.0")
|
||||
params := tool.Get("parameters")
|
||||
|
||||
if params.Get("properties.p1.pattern").Exists() {
|
||||
t.Errorf("expected properties.p1.pattern (\\u0070) to be removed, got: %s", params.Get("properties.p1.pattern").Raw)
|
||||
}
|
||||
if params.Get("properties.p2.pattern").Exists() {
|
||||
t.Errorf("expected properties.p2.pattern (\\u005c) to be removed, got: %s", params.Get("properties.p2.pattern").Raw)
|
||||
}
|
||||
if params.Get("properties.p3.pattern").Exists() {
|
||||
t.Errorf("expected properties.p3.pattern (\\u0050) to be removed, got: %s", params.Get("properties.p3.pattern").Raw)
|
||||
}
|
||||
if got := params.Get("properties.valid.pattern").String(); got != "^[0-9a-f]{32}$" {
|
||||
t.Errorf("expected valid.pattern to be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexToolSchemas_PatternPropertiesKeySanitization(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "pattern_props_tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^\\\\p{L}+$": {
|
||||
"type": "string"
|
||||
},
|
||||
"^[a-z]+$": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out := NormalizeCodexToolSchemas(input)
|
||||
tool := gjson.GetBytes(out, "tools.0")
|
||||
params := tool.Get("parameters")
|
||||
|
||||
// Key with \p{L}+ must be removed
|
||||
patternProps := params.Get("patternProperties").Map()
|
||||
if _, exists := patternProps[`^\p{L}+$`]; exists {
|
||||
t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw)
|
||||
}
|
||||
// Safe key must be preserved
|
||||
if _, exists := patternProps[`^[a-z]+$`]; !exists {
|
||||
t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,8 +665,9 @@ func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string
|
||||
return m
|
||||
}
|
||||
|
||||
// normalizeToolParameters ensures object schemas contain at least an empty properties map
|
||||
// and strips dialect keywords ($schema, $id) from schema objects.
|
||||
// normalizeToolParameters ensures object schemas contain at least an empty properties map,
|
||||
// strips dialect keywords ($schema, $id), and drops regex patterns containing unsupported
|
||||
// Unicode property escapes (\p{...} / \P{...}) that cause upstream schema validation failures.
|
||||
func normalizeToolParameters(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" || !gjson.Valid(raw) {
|
||||
@@ -721,8 +722,25 @@ func stripDialectKeywordsFromSchema(v any) {
|
||||
case map[string]any:
|
||||
delete(schema, "$schema")
|
||||
delete(schema, "$id")
|
||||
if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
|
||||
delete(schema, "pattern")
|
||||
}
|
||||
|
||||
// Inspect regex keys under patternProperties
|
||||
if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
|
||||
for patternKey, subSchema := range patternProps {
|
||||
if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
|
||||
delete(patternProps, patternKey)
|
||||
} else {
|
||||
stripDialectKeywordsFromSchema(subSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, mapKey := range codexSchemaMapKeywords {
|
||||
if mapKey == "patternProperties" {
|
||||
continue
|
||||
}
|
||||
if subMap, ok := schema[mapKey].(map[string]any); ok {
|
||||
for _, subSchema := range subMap {
|
||||
stripDialectKeywordsFromSchema(subSchema)
|
||||
@@ -749,36 +767,12 @@ func stripDialectKeywordsFromSchema(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// codexSchemaMapKeywords holds JSON Schema keywords whose values are maps of
|
||||
// subschemas; codexSchemaValueKeywords holds keywords with a single nested
|
||||
// schema or a list of schemas.
|
||||
var codexSchemaMapKeywords = [...]string{
|
||||
"properties",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"patternProperties",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
}
|
||||
|
||||
var codexSchemaValueKeywords = [...]string{
|
||||
"items",
|
||||
"prefixItems",
|
||||
"contains",
|
||||
"additionalProperties",
|
||||
"propertyNames",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"additionalItems",
|
||||
"contentSchema",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"allOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
}
|
||||
// codexSchemaMapKeywords and codexSchemaValueKeywords reference the unified JSON Schema keywords
|
||||
// declared in internal/util.
|
||||
var (
|
||||
codexSchemaMapKeywords = util.SchemaMapKeywords
|
||||
codexSchemaValueKeywords = util.SchemaValueKeywords
|
||||
)
|
||||
|
||||
// codexSchemaMissesRequired reports whether a JSON Schema has any declared
|
||||
// property missing from its sibling required list (recursively). OpenAI
|
||||
|
||||
@@ -1124,3 +1124,141 @@ func TestConvertClaudeRequestToCodex_StripsNestedToolSchemaMeta(t *testing.T) {
|
||||
t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToCodex_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-5.6",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"name": "Artifact",
|
||||
"description": "Render an HTML file to an Artifact",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "field to replace",
|
||||
"pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$"
|
||||
},
|
||||
"asset_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32}$"
|
||||
},
|
||||
"lookahead_safe": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!__.*__$).{1,200}$"
|
||||
},
|
||||
"literal_p": {
|
||||
"type": "string",
|
||||
"pattern": "^\\\\p{Cc}$"
|
||||
},
|
||||
"nested": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inner_field": {
|
||||
"type": "string",
|
||||
"pattern": "\\P{L}+"
|
||||
}
|
||||
}
|
||||
},
|
||||
"union_field": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "\\p{N}+"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["field"]
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
translated := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false)
|
||||
tools := gjson.GetBytes(translated, "tools").Array()
|
||||
if len(tools) == 0 {
|
||||
t.Fatalf("expected tools in translated payload, got: %s", translated)
|
||||
}
|
||||
params := tools[0].Get("parameters")
|
||||
|
||||
// The \p{...} pattern on field must be removed to avoid Python re failure upstream
|
||||
if params.Get("properties.field.pattern").Exists() {
|
||||
t.Errorf("expected properties.field.pattern to be removed, got %v", params.Get("properties.field.pattern").Raw)
|
||||
}
|
||||
// Other attributes on field must be preserved
|
||||
if got := params.Get("properties.field.type").String(); got != "string" {
|
||||
t.Errorf("expected properties.field.type == 'string', got %q", got)
|
||||
}
|
||||
if got := params.Get("properties.field.description").String(); got != "field to replace" {
|
||||
t.Errorf("expected properties.field.description == 'field to replace', got %q", got)
|
||||
}
|
||||
|
||||
// Valid patterns without unescaped \p must be preserved
|
||||
if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" {
|
||||
t.Errorf("expected asset_id.pattern preserved, got %q", got)
|
||||
}
|
||||
if got := params.Get("properties.lookahead_safe.pattern").String(); got != "^(?!__.*__$).{1,200}$" {
|
||||
t.Errorf("expected lookahead_safe.pattern preserved, got %q", got)
|
||||
}
|
||||
if got := params.Get("properties.literal_p.pattern").String(); got != "^\\\\p{Cc}$" {
|
||||
t.Errorf("expected literal_p.pattern preserved, got %q", got)
|
||||
}
|
||||
|
||||
// Nested object with \P{L}+ must have its pattern removed
|
||||
if params.Get("properties.nested.properties.inner_field.pattern").Exists() {
|
||||
t.Errorf("expected properties.nested.properties.inner_field.pattern to be removed, got %v", params.Get("properties.nested.properties.inner_field.pattern").Raw)
|
||||
}
|
||||
if got := params.Get("properties.nested.properties.inner_field.type").String(); got != "string" {
|
||||
t.Errorf("expected nested inner_field.type preserved, got %q", got)
|
||||
}
|
||||
|
||||
// Union anyOf with \p{N}+ must have its pattern removed
|
||||
if params.Get("properties.union_field.anyOf.0.pattern").Exists() {
|
||||
t.Errorf("expected union_field.anyOf.0.pattern to be removed, got %v", params.Get("properties.union_field.anyOf.0.pattern").Raw)
|
||||
}
|
||||
|
||||
// Required array must be preserved
|
||||
if got := params.Get("required.0").String(); got != "field" {
|
||||
t.Errorf("expected required.0 == 'field', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToCodex_StripsPatternPropertiesIncompatibleKeys(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-5.6",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"name": "pattern_tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^\\\\p{L}+$": {
|
||||
"type": "string"
|
||||
},
|
||||
"^[a-z]+$": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
translated := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false)
|
||||
tools := gjson.GetBytes(translated, "tools").Array()
|
||||
if len(tools) == 0 {
|
||||
t.Fatalf("expected tools in translated payload, got: %s", translated)
|
||||
}
|
||||
params := tools[0].Get("parameters")
|
||||
|
||||
patternProps := params.Get("patternProperties").Map()
|
||||
if _, exists := patternProps[`^\p{L}+$`]; exists {
|
||||
t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw)
|
||||
}
|
||||
if _, exists := patternProps[`^[a-z]+$`]; !exists {
|
||||
t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,8 +388,43 @@ func normalizeObjectSchemaProperties(schema any) any {
|
||||
value["properties"] = map[string]any{}
|
||||
}
|
||||
}
|
||||
for key, child := range value {
|
||||
value[key] = normalizeObjectSchemaProperties(child)
|
||||
if patternVal, ok := value["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
|
||||
delete(value, "pattern")
|
||||
}
|
||||
|
||||
// Inspect regex keys under patternProperties
|
||||
if patternProps, ok := value["patternProperties"].(map[string]any); ok {
|
||||
for patternKey, subSchema := range patternProps {
|
||||
if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
|
||||
delete(patternProps, patternKey)
|
||||
} else {
|
||||
patternProps[patternKey] = normalizeObjectSchemaProperties(subSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, mapKey := range util.SchemaMapKeywords {
|
||||
if mapKey == "patternProperties" {
|
||||
continue
|
||||
}
|
||||
if subMap, ok := value[mapKey].(map[string]any); ok {
|
||||
for subKey, subSchema := range subMap {
|
||||
subMap[subKey] = normalizeObjectSchemaProperties(subSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, valKey := range util.SchemaValueKeywords {
|
||||
if val, exists := value[valKey]; exists {
|
||||
switch sub := val.(type) {
|
||||
case map[string]any:
|
||||
value[valKey] = normalizeObjectSchemaProperties(sub)
|
||||
case []any:
|
||||
for i, item := range sub {
|
||||
sub[i] = normalizeObjectSchemaProperties(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
case []any:
|
||||
|
||||
@@ -1017,3 +1017,142 @@ func TestConvertClaudeRequestToOpenAI_ToolWithoutInputSchemaDefaultsParameters(t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"name": "Artifact",
|
||||
"description": "Render an HTML file to an Artifact",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "field to replace",
|
||||
"pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$"
|
||||
},
|
||||
"asset_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32}$"
|
||||
},
|
||||
"lookahead_safe": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!__.*__$).{1,200}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
params := outputJSON.Get("tools.0.function.parameters")
|
||||
if !params.Exists() {
|
||||
t.Fatalf("expected function.parameters in output: %s", output)
|
||||
}
|
||||
|
||||
// Incompatible \p{...} pattern must be stripped
|
||||
if params.Get("properties.field.pattern").Exists() {
|
||||
t.Errorf("expected properties.field.pattern to be removed, got: %s", params.Get("properties.field.pattern").Raw)
|
||||
}
|
||||
if got := params.Get("properties.field.type").String(); got != "string" {
|
||||
t.Errorf("expected properties.field.type == 'string', got %q", got)
|
||||
}
|
||||
|
||||
// Valid patterns must remain intact
|
||||
if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" {
|
||||
t.Errorf("expected properties.asset_id.pattern preserved, got %q", got)
|
||||
}
|
||||
if got := params.Get("properties.lookahead_safe.pattern").String(); got != "^(?!__.*__$).{1,200}$" {
|
||||
t.Errorf("expected properties.lookahead_safe.pattern preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_PreservesNonSchemaPatternKeys(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"name": "config_tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"regex_config": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"pattern": "\\p{L}+"
|
||||
},
|
||||
"enum": [
|
||||
{"pattern": "\\p{N}+"}
|
||||
]
|
||||
},
|
||||
"real_schema": {
|
||||
"type": "string",
|
||||
"pattern": "\\p{L}+"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
params := outputJSON.Get("tools.0.function.parameters")
|
||||
if !params.Exists() {
|
||||
t.Fatalf("expected function.parameters in output: %s", output)
|
||||
}
|
||||
|
||||
// Real schema pattern must be removed
|
||||
if params.Get("properties.real_schema.pattern").Exists() {
|
||||
t.Errorf("expected real_schema.pattern to be removed, got: %s", params.Get("properties.real_schema").Raw)
|
||||
}
|
||||
|
||||
// User data under default and enum must be PRESERVED
|
||||
if got := params.Get("properties.regex_config.default.pattern").String(); got != `\p{L}+` {
|
||||
t.Errorf("expected default.pattern preserved, got %q", got)
|
||||
}
|
||||
if got := params.Get("properties.regex_config.enum.0.pattern").String(); got != `\p{N}+` {
|
||||
t.Errorf("expected enum.0.pattern preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_StripsPatternPropertiesIncompatibleKeys(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gpt-5.6",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"name": "pattern_tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^\\\\p{L}+$": {
|
||||
"type": "string"
|
||||
},
|
||||
"^[a-z]+$": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
params := outputJSON.Get("tools.0.function.parameters")
|
||||
if !params.Exists() {
|
||||
t.Fatalf("expected function.parameters in output: %s", output)
|
||||
}
|
||||
|
||||
patternProps := params.Get("patternProperties").Map()
|
||||
if _, exists := patternProps[`^\p{L}+$`]; exists {
|
||||
t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw)
|
||||
}
|
||||
if _, exists := patternProps[`^[a-z]+$`]; !exists {
|
||||
t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,3 +120,52 @@ func mergeClaudeSchemaRequired(root map[string]json.RawMessage, branchRequired j
|
||||
root["required"] = requiredRaw
|
||||
}
|
||||
}
|
||||
|
||||
// HasUnsupportedUnicodePropertyEscape reports whether a regular expression string
|
||||
// contains unescaped Unicode property escape sequences (\p{...} or \P{...}).
|
||||
// Python's built-in re module (and schema validators relying on it) fails compilation
|
||||
// with "bad escape \p" on these sequences.
|
||||
func HasUnsupportedUnicodePropertyEscape(pattern string) bool {
|
||||
for i := 0; i < len(pattern); i++ {
|
||||
if pattern[i] != '\\' {
|
||||
continue
|
||||
}
|
||||
if i+2 < len(pattern) &&
|
||||
(pattern[i+1] == 'p' || pattern[i+1] == 'P') &&
|
||||
pattern[i+2] == '{' {
|
||||
return true
|
||||
}
|
||||
i++ // skip the escaped character (including escaped backslash)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SchemaMapKeywords lists JSON Schema keywords whose values are maps of subschemas.
|
||||
var SchemaMapKeywords = [...]string{
|
||||
"properties",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"patternProperties",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
}
|
||||
|
||||
// SchemaValueKeywords lists JSON Schema keywords with a single nested subschema or a slice of subschemas.
|
||||
var SchemaValueKeywords = [...]string{
|
||||
"items",
|
||||
"prefixItems",
|
||||
"contains",
|
||||
"additionalProperties",
|
||||
"propertyNames",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"additionalItems",
|
||||
"contentSchema",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"allOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
}
|
||||
|
||||
@@ -112,3 +112,81 @@ func TestNormalizeClaudeToolInputSchema(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnsupportedUnicodePropertyEscape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Artifact regex with multiple \\p escapes",
|
||||
pattern: `^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\./[\]]{1,200}$`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Uppercase \\P property escape",
|
||||
pattern: `^\P{L}+$`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Escaped backslash before p is literal and safe",
|
||||
pattern: `^\\p{Cc}$`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Three backslashes means literal backslash plus real \\p escape",
|
||||
pattern: `^\\\p{Cc}$`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Four backslashes means two literal backslashes",
|
||||
pattern: `^\\\\p{Cc}$`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Standard hex character pattern",
|
||||
pattern: `^[0-9a-f]{32}$`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Negative lookahead without unicode properties",
|
||||
pattern: `^(?!__.*__$).{1,200}$`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Backreference safe in Python re",
|
||||
pattern: `^(a)\1$`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Trailing single backslash",
|
||||
pattern: `abc\`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Backslash followed by p without brace",
|
||||
pattern: `\p`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Backslash followed by p and brace without close",
|
||||
pattern: `\p{`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
pattern: ``,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := HasUnsupportedUnicodePropertyEscape(tt.pattern)
|
||||
if got != tt.want {
|
||||
t.Errorf("HasUnsupportedUnicodePropertyEscape(%q) = %v, want %v", tt.pattern, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user