History

Merge pull request #5677 from sususu98/fix/codex-tool-schema-pattern

fix(translator): strip unsupported unicode property escape patterns from tool schemas

Luis Pater committed 2 months ago

Commit db011592 parents b064b83 37ce3688 files · +854 −35Browse files

Unified · Split

8 files
  1. internal/runtime/executor/helps/codex_tool_schema.go +104 −1
  2. internal/runtime/executor/helps/codex_tool_schema_test.go +283 −0
  3. internal/translator/codex/claude/codex_claude_request.go +26 −32
  4. internal/translator/codex/claude/codex_claude_request_test.go +138 −0
  5. internal/translator/openai/claude/openai_claude_request.go +37 −2
  6. internal/translator/openai/claude/openai_claude_request_test.go +139 −0
  7. internal/util/claude_schema.go +49 −0
  8. internal/util/claude_schema_test.go +78 −0
internal/runtime/executor/helps/codex_tool_schema.go+104−1
@@ -1,6 +1,9 @@
11 package helps
22
33 import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
47 "math/big"
58 "strconv"
69 "strings"
@@ -8,6 +11,8 @@ import ( · 1 unmodified line
811 log "github.com/sirupsen/logrus"
912 "github.com/tidwall/gjson"
1013 "github.com/tidwall/sjson"
14+
15+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
1116 )
1217
1318 const (
@@ -89,7 +94,7 @@ func normalizeCodexTool(tool gjson.Result) ([]byte, bool) { · 75 unmodified lines
8994 return nil, false
9095 }
9196
92− log.Debugf("codex: simplified complex schema unions for tool %s to avoid upstream abort", tool.Get("name").String())
97+ log.Debugf("codex: normalized schema for tool %s to avoid upstream abort", tool.Get("name").String())
9398 return updatedTool, true
9499 }
95100
@@ -97,6 +102,12 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 1 unmodified line
97102 rawParams := []byte(params.Raw)
98103 changed := false
99104
105+ if sanitizedParams, patternChanged := stripIncompatiblePatternsFromJSON(rawParams); patternChanged {
106+ rawParams = sanitizedParams
107+ changed = true
108+ params = gjson.ParseBytes(rawParams)
109+ }
110+
100111 properties := params.Get("properties")
101112 if properties.Exists() && properties.IsObject() {
102113 for propName, propVal := range properties.Map() {
@@ -115,6 +126,98 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 12 unmodified lines
115126 return rawParams, changed
116127 }
117128
129+// stripIncompatiblePatternsFromJSON recursively removes pattern attributes containing
130+// unsupported Unicode property escapes (\p{...} / \P{...}) from parameter schemas.
131+// It is schema-aware: only subschemas under known JSON Schema keyword locations are visited,
132+// preventing accidental deletion of 'pattern' keys inside user data (e.g. description, default, enum).
133+func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) {
134+ rawStr := string(raw)
135+ if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) {
136+ return raw, false
137+ }
138+ var root any
139+ dec := json.NewDecoder(bytes.NewReader(raw))
140+ dec.UseNumber()
141+ if err := dec.Decode(&root); err != nil || root == nil {
142+ return raw, false
143+ }
144+ // Verify no trailing garbage
145+ var dummy any
146+ if err := dec.Decode(&dummy); err != io.EOF {
147+ return raw, false
148+ }
149+ if !stripIncompatiblePatterns(root) {
150+ return raw, false
151+ }
152+ var buf bytes.Buffer
153+ enc := json.NewEncoder(&buf)
154+ enc.SetEscapeHTML(false)
155+ if err := enc.Encode(root); err != nil {
156+ return raw, false
157+ }
158+ return bytes.TrimSpace(buf.Bytes()), true
159+}
160+
161+func stripIncompatiblePatterns(v any) bool {
162+ changed := false
163+ switch schema := v.(type) {
164+ case map[string]any:
165+ if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
166+ delete(schema, "pattern")
167+ changed = true
168+ }
169+
170+ // Inspect regex keys under patternProperties
171+ if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
172+ for patternKey, subSchema := range patternProps {
173+ if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
174+ delete(patternProps, patternKey)
175+ changed = true
176+ } else if stripIncompatiblePatterns(subSchema) {
177+ changed = true
178+ }
179+ }
180+ }
181+
182+ for _, mapKey := range util.SchemaMapKeywords {
183+ if mapKey == "patternProperties" {
184+ continue
185+ }
186+ if subMap, ok := schema[mapKey].(map[string]any); ok {
187+ for _, subSchema := range subMap {
188+ if stripIncompatiblePatterns(subSchema) {
189+ changed = true
190+ }
191+ }
192+ }
193+ }
194+
195+ for _, valKey := range util.SchemaValueKeywords {
196+ if val, exists := schema[valKey]; exists {
197+ switch sub := val.(type) {
198+ case map[string]any:
199+ if stripIncompatiblePatterns(sub) {
200+ changed = true
201+ }
202+ case []any:
203+ for _, item := range sub {
204+ if stripIncompatiblePatterns(item) {
205+ changed = true
206+ }
207+ }
208+ }
209+ }
210+ }
211+ case []any:
212+ for _, item := range schema {
213+ if stripIncompatiblePatterns(item) {
214+ changed = true
215+ }
216+ }
217+ }
218+ return changed
219+}
220+
118221 func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) {
119222 if !prop.IsObject() {
120223 return nil, false
internal/runtime/executor/helps/codex_tool_schema_test.go+283−0
@@ -491,3 +491,286 @@ func TestNormalizeCodexToolSchemas_NamespaceToolSimplified(t *testing.T) { · 490 unmodified lines
491491 t.Fatalf("expected action.enum with 8 items")
492492 }
493493 }
494+
495+func TestNormalizeCodexToolSchemas_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) {
496+ input := []byte(`{
497+ "model": "gpt-5.6",
498+ "tools": [{
499+ "type": "function",
500+ "name": "Artifact",
501+ "parameters": {
502+ "type": "object",
503+ "properties": {
504+ "field": {
505+ "type": "string",
506+ "description": "field to edit",
507+ "pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$"
508+ },
509+ "asset_id": {
510+ "type": "string",
511+ "pattern": "^[0-9a-f]{32}$"
512+ }
513+ },
514+ "required": ["field"]
515+ }
516+ }]
517+ }`)
518+
519+ out := NormalizeCodexToolSchemas(input)
520+
521+ tool := gjson.GetBytes(out, "tools.0")
522+ params := tool.Get("parameters")
523+
524+ // Unsupported pattern must be removed
525+ if params.Get("properties.field.pattern").Exists() {
526+ t.Errorf("expected properties.field.pattern to be removed, got: %s", params.Get("properties.field.pattern").Raw)
527+ }
528+ if got := params.Get("properties.field.type").String(); got != "string" {
529+ t.Errorf("expected properties.field.type == 'string', got %q", got)
530+ }
531+
532+ // Valid pattern must be preserved
533+ if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" {
534+ t.Errorf("expected properties.asset_id.pattern preserved, got %q", got)
535+ }
536+
537+ // Idempotence test
538+ outAgain := NormalizeCodexToolSchemas(out)
539+ if string(outAgain) != string(out) {
540+ t.Errorf("expected NormalizeCodexToolSchemas to be idempotent")
541+ }
542+}
543+
544+func TestNormalizeCodexToolSchemas_PreservesNonSchemaPatternKeys(t *testing.T) {
545+ // A property whose default, enum, or description metadata contains a nested object
546+ // with a 'pattern' key must NOT be mutated, because it is user data, not a schema.
547+ input := []byte(`{
548+ "model": "gpt-5.6",
549+ "tools": [{
550+ "type": "function",
551+ "name": "config_tool",
552+ "parameters": {
553+ "type": "object",
554+ "properties": {
555+ "regex_config": {
556+ "type": "object",
557+ "default": {
558+ "pattern": "\\p{L}+"
559+ },
560+ "enum": [
561+ {"pattern": "\\p{N}+"}
562+ ]
563+ },
564+ "real_schema": {
565+ "type": "string",
566+ "pattern": "\\p{L}+"
567+ }
568+ }
569+ }
570+ }]
571+ }`)
572+
573+ out := NormalizeCodexToolSchemas(input)
574+
575+ tool := gjson.GetBytes(out, "tools.0")
576+ params := tool.Get("parameters")
577+
578+ // Real schema pattern must be removed
579+ if params.Get("properties.real_schema.pattern").Exists() {
580+ t.Errorf("expected real_schema.pattern to be removed, got: %s", params.Get("properties.real_schema").Raw)
581+ }
582+
583+ // User data under default and enum must be PRESERVED
584+ if got := params.Get("properties.regex_config.default.pattern").String(); got != `\p{L}+` {
585+ t.Errorf("expected default.pattern preserved, got %q", got)
586+ }
587+ if got := params.Get("properties.regex_config.enum.0.pattern").String(); got != `\p{N}+` {
588+ t.Errorf("expected enum.0.pattern preserved, got %q", got)
589+ }
590+}
591+
592+func TestNormalizeCodexToolSchemas_CoversAllSchemaKeywordLocations(t *testing.T) {
593+ input := []byte(`{
594+ "model": "gpt-5.6",
595+ "tools": [{
596+ "type": "function",
597+ "name": "deep_tool",
598+ "parameters": {
599+ "type": "object",
600+ "$defs": {
601+ "custom_type": {
602+ "type": "string",
603+ "pattern": "\\p{L}+"
604+ }
605+ },
606+ "additionalProperties": {
607+ "type": "string",
608+ "pattern": "\\p{N}+"
609+ },
610+ "patternProperties": {
611+ "^s_": {
612+ "type": "string",
613+ "pattern": "\\p{M}+"
614+ }
615+ },
616+ "if": {
617+ "properties": {
618+ "flag": {
619+ "type": "string",
620+ "pattern": "\\p{P}+"
621+ }
622+ }
623+ },
624+ "then": {
625+ "properties": {
626+ "val": {
627+ "type": "string",
628+ "pattern": "\\p{S}+"
629+ }
630+ }
631+ },
632+ "else": {
633+ "properties": {
634+ "other": {
635+ "type": "string",
636+ "pattern": "\\p{Z}+"
637+ }
638+ }
639+ }
640+ }
641+ }]
642+ }`)
643+
644+ out := NormalizeCodexToolSchemas(input)
645+
646+ tool := gjson.GetBytes(out, "tools.0")
647+ params := tool.Get("parameters")
648+
649+ // All subschemas in schema-aware locations must have their incompatible patterns stripped
650+ if params.Get("$defs.custom_type.pattern").Exists() {
651+ t.Errorf("expected $defs.custom_type.pattern to be removed")
652+ }
653+ if params.Get("additionalProperties.pattern").Exists() {
654+ t.Errorf("expected additionalProperties.pattern to be removed")
655+ }
656+ if params.Get("patternProperties.^s_.pattern").Exists() {
657+ t.Errorf("expected patternProperties.^s_.pattern to be removed")
658+ }
659+ if params.Get("if.properties.flag.pattern").Exists() {
660+ t.Errorf("expected if.properties.flag.pattern to be removed")
661+ }
662+ if params.Get("then.properties.val.pattern").Exists() {
663+ t.Errorf("expected then.properties.val.pattern to be removed")
664+ }
665+ if params.Get("else.properties.other.pattern").Exists() {
666+ t.Errorf("expected else.properties.other.pattern to be removed")
667+ }
668+
669+ // Subschema types must be preserved
670+ if got := params.Get("$defs.custom_type.type").String(); got != "string" {
671+ t.Errorf("expected $defs.custom_type.type == 'string', got %q", got)
672+ }
673+}
674+
675+func TestNormalizeCodexToolSchemas_MalformedOrEmptyParametersFallback(t *testing.T) {
676+ // Malformed JSON, non-object parameters, null, and empty payloads must not panic
677+ cases := [][]byte{
678+ []byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":null}]}`),
679+ []byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":"not_an_object"}]}`),
680+ []byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":{"type":"object"}}]}`),
681+ []byte(`{"model":"gpt-5.6","tools":[]}`),
682+ []byte(`{"model":"gpt-5.6"}`),
683+ }
684+
685+ for i, c := range cases {
686+ out := NormalizeCodexToolSchemas(c)
687+ if len(out) == 0 {
688+ t.Errorf("case %d: unexpected empty output", i)
689+ }
690+ }
691+}
692+
693+func TestNormalizeCodexToolSchemas_JSONUnicodeEscapeBypassPrevention(t *testing.T) {
694+ // Patterns encoded using JSON Unicode escapes (e.g. \u005c for '\' or \u0070 for 'p')
695+ // decode to \p{...} / \P{...} and must not be skipped by the fast-path check.
696+ input := []byte(`{
697+ "model": "gpt-5.6",
698+ "tools": [{
699+ "type": "function",
700+ "name": "escape_bypass_tool",
701+ "parameters": {
702+ "type": "object",
703+ "properties": {
704+ "p1": {
705+ "type": "string",
706+ "pattern": "\u005c\u0070{L}+"
707+ },
708+ "p2": {
709+ "type": "string",
710+ "pattern": "\u005cp{Cc}"
711+ },
712+ "p3": {
713+ "type": "string",
714+ "pattern": "\u005c\u0050{N}+"
715+ },
716+ "valid": {
717+ "type": "string",
718+ "pattern": "^[0-9a-f]{32}$"
719+ }
720+ }
721+ }
722+ }]
723+ }`)
724+
725+ out := NormalizeCodexToolSchemas(input)
726+ tool := gjson.GetBytes(out, "tools.0")
727+ params := tool.Get("parameters")
728+
729+ if params.Get("properties.p1.pattern").Exists() {
730+ t.Errorf("expected properties.p1.pattern (\\u0070) to be removed, got: %s", params.Get("properties.p1.pattern").Raw)
731+ }
732+ if params.Get("properties.p2.pattern").Exists() {
733+ t.Errorf("expected properties.p2.pattern (\\u005c) to be removed, got: %s", params.Get("properties.p2.pattern").Raw)
734+ }
735+ if params.Get("properties.p3.pattern").Exists() {
736+ t.Errorf("expected properties.p3.pattern (\\u0050) to be removed, got: %s", params.Get("properties.p3.pattern").Raw)
737+ }
738+ if got := params.Get("properties.valid.pattern").String(); got != "^[0-9a-f]{32}$" {
739+ t.Errorf("expected valid.pattern to be preserved, got %q", got)
740+ }
741+}
742+
743+func TestNormalizeCodexToolSchemas_PatternPropertiesKeySanitization(t *testing.T) {
744+ input := []byte(`{
745+ "model": "gpt-5.6",
746+ "tools": [{
747+ "type": "function",
748+ "name": "pattern_props_tool",
749+ "parameters": {
750+ "type": "object",
751+ "patternProperties": {
752+ "^\\\\p{L}+$": {
753+ "type": "string"
754+ },
755+ "^[a-z]+$": {
756+ "type": "number"
757+ }
758+ }
759+ }
760+ }]
761+ }`)
762+
763+ out := NormalizeCodexToolSchemas(input)
764+ tool := gjson.GetBytes(out, "tools.0")
765+ params := tool.Get("parameters")
766+
767+ // Key with \p{L}+ must be removed
768+ patternProps := params.Get("patternProperties").Map()
769+ if _, exists := patternProps[`^\p{L}+$`]; exists {
770+ t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw)
771+ }
772+ // Safe key must be preserved
773+ if _, exists := patternProps[`^[a-z]+$`]; !exists {
774+ t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw)
775+ }
776+}
internal/translator/codex/claude/codex_claude_request.go+26−32
@@ -665,8 +665,9 @@ func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string · 664 unmodified lines
665665 return m
666666 }
667667
668−// normalizeToolParameters ensures object schemas contain at least an empty properties map
669−// and strips dialect keywords ($schema, $id) from schema objects.
668+// normalizeToolParameters ensures object schemas contain at least an empty properties map,
669+// strips dialect keywords ($schema, $id), and drops regex patterns containing unsupported
670+// Unicode property escapes (\p{...} / \P{...}) that cause upstream schema validation failures.
670671 func normalizeToolParameters(raw string) string {
671672 raw = strings.TrimSpace(raw)
672673 if raw == "" || raw == "null" || !gjson.Valid(raw) {
@@ -721,8 +722,25 @@ func stripDialectKeywordsFromSchema(v any) { · 48 unmodified lines
721722 case map[string]any:
722723 delete(schema, "$schema")
723724 delete(schema, "$id")
725+ if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
726+ delete(schema, "pattern")
727+ }
728+
729+ // Inspect regex keys under patternProperties
730+ if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
731+ for patternKey, subSchema := range patternProps {
732+ if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
733+ delete(patternProps, patternKey)
734+ } else {
735+ stripDialectKeywordsFromSchema(subSchema)
736+ }
737+ }
738+ }
724739
725740 for _, mapKey := range codexSchemaMapKeywords {
741+ if mapKey == "patternProperties" {
742+ continue
743+ }
726744 if subMap, ok := schema[mapKey].(map[string]any); ok {
727745 for _, subSchema := range subMap {
728746 stripDialectKeywordsFromSchema(subSchema)
@@ -749,36 +767,12 @@ func stripDialectKeywordsFromSchema(v any) { · 20 unmodified lines
749767 }
750768 }
751769
752−// codexSchemaMapKeywords holds JSON Schema keywords whose values are maps of
753−// subschemas; codexSchemaValueKeywords holds keywords with a single nested
754−// schema or a list of schemas.
755−var codexSchemaMapKeywords = [...]string{
756− "properties",
757− "$defs",
758− "definitions",
759− "patternProperties",
760− "dependentSchemas",
761− "dependencies",
762−}
763−
764−var codexSchemaValueKeywords = [...]string{
765− "items",
766− "prefixItems",
767− "contains",
768− "additionalProperties",
769− "propertyNames",
770− "unevaluatedProperties",
771− "unevaluatedItems",
772− "additionalItems",
773− "contentSchema",
774− "anyOf",
775− "oneOf",
776− "allOf",
777− "not",
778− "if",
779− "then",
780− "else",
781−}
770+// codexSchemaMapKeywords and codexSchemaValueKeywords reference the unified JSON Schema keywords
771+// declared in internal/util.
772+var (
773+ codexSchemaMapKeywords = util.SchemaMapKeywords
774+ codexSchemaValueKeywords = util.SchemaValueKeywords
775+)
782776
783777 // codexSchemaMissesRequired reports whether a JSON Schema has any declared
784778 // property missing from its sibling required list (recursively). OpenAI
internal/translator/codex/claude/codex_claude_request_test.go+138−0
@@ -1124,3 +1124,141 @@ func TestConvertClaudeRequestToCodex_StripsNestedToolSchemaMeta(t *testing.T) { · 1123 unmodified lines
11241124 t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw)
11251125 }
11261126 }
1127+
1128+func TestConvertClaudeRequestToCodex_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) {
1129+ inputJSON := `{
1130+ "model": "gpt-5.6",
1131+ "messages": [{"role": "user", "content": "hello"}],
1132+ "tools": [{
1133+ "name": "Artifact",
1134+ "description": "Render an HTML file to an Artifact",
1135+ "input_schema": {
1136+ "type": "object",
1137+ "properties": {
1138+ "field": {
1139+ "type": "string",
1140+ "description": "field to replace",
1141+ "pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$"
1142+ },
1143+ "asset_id": {
1144+ "type": "string",
1145+ "pattern": "^[0-9a-f]{32}$"
1146+ },
1147+ "lookahead_safe": {
1148+ "type": "string",
1149+ "pattern": "^(?!__.*__$).{1,200}$"
1150+ },
1151+ "literal_p": {
1152+ "type": "string",
1153+ "pattern": "^\\\\p{Cc}$"
1154+ },
1155+ "nested": {
1156+ "type": "object",
1157+ "properties": {
1158+ "inner_field": {
1159+ "type": "string",
1160+ "pattern": "\\P{L}+"
1161+ }
1162+ }
1163+ },
1164+ "union_field": {
1165+ "anyOf": [
1166+ {
1167+ "type": "string",
1168+ "pattern": "\\p{N}+"
1169+ },
1170+ {
1171+ "type": "null"
1172+ }
1173+ ]
1174+ }
1175+ },
1176+ "required": ["field"]
1177+ }
1178+ }]
1179+ }`
1180+
1181+ translated := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false)
1182+ tools := gjson.GetBytes(translated, "tools").Array()
1183+ if len(tools) == 0 {
1184+ t.Fatalf("expected tools in translated payload, got: %s", translated)
1185+ }
1186+ params := tools[0].Get("parameters")
1187+
1188+ // The \p{...} pattern on field must be removed to avoid Python re failure upstream
1189+ if params.Get("properties.field.pattern").Exists() {
1190+ t.Errorf("expected properties.field.pattern to be removed, got %v", params.Get("properties.field.pattern").Raw)
1191+ }
1192+ // Other attributes on field must be preserved
1193+ if got := params.Get("properties.field.type").String(); got != "string" {
1194+ t.Errorf("expected properties.field.type == 'string', got %q", got)
1195+ }
1196+ if got := params.Get("properties.field.description").String(); got != "field to replace" {
1197+ t.Errorf("expected properties.field.description == 'field to replace', got %q", got)
1198+ }
1199+
1200+ // Valid patterns without unescaped \p must be preserved
1201+ if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" {
1202+ t.Errorf("expected asset_id.pattern preserved, got %q", got)
1203+ }
1204+ if got := params.Get("properties.lookahead_safe.pattern").String(); got != "^(?!__.*__$).{1,200}$" {
1205+ t.Errorf("expected lookahead_safe.pattern preserved, got %q", got)
1206+ }
1207+ if got := params.Get("properties.literal_p.pattern").String(); got != "^\\\\p{Cc}$" {
1208+ t.Errorf("expected literal_p.pattern preserved, got %q", got)
1209+ }
1210+
1211+ // Nested object with \P{L}+ must have its pattern removed
1212+ if params.Get("properties.nested.properties.inner_field.pattern").Exists() {
1213+ t.Errorf("expected properties.nested.properties.inner_field.pattern to be removed, got %v", params.Get("properties.nested.properties.inner_field.pattern").Raw)
1214+ }
1215+ if got := params.Get("properties.nested.properties.inner_field.type").String(); got != "string" {
1216+ t.Errorf("expected nested inner_field.type preserved, got %q", got)
1217+ }
1218+
1219+ // Union anyOf with \p{N}+ must have its pattern removed
1220+ if params.Get("properties.union_field.anyOf.0.pattern").Exists() {
1221+ t.Errorf("expected union_field.anyOf.0.pattern to be removed, got %v", params.Get("properties.union_field.anyOf.0.pattern").Raw)
1222+ }
1223+
1224+ // Required array must be preserved
1225+ if got := params.Get("required.0").String(); got != "field" {
1226+ t.Errorf("expected required.0 == 'field', got %q", got)
1227+ }
1228+}
1229+
1230+func TestConvertClaudeRequestToCodex_StripsPatternPropertiesIncompatibleKeys(t *testing.T) {
1231+ inputJSON := `{
1232+ "model": "gpt-5.6",
1233+ "messages": [{"role": "user", "content": "hello"}],
1234+ "tools": [{
1235+ "name": "pattern_tool",
1236+ "input_schema": {
1237+ "type": "object",
1238+ "patternProperties": {
1239+ "^\\\\p{L}+$": {
1240+ "type": "string"
1241+ },
1242+ "^[a-z]+$": {
1243+ "type": "number"
1244+ }
1245+ }
1246+ }
1247+ }]
1248+ }`
1249+
1250+ translated := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false)
1251+ tools := gjson.GetBytes(translated, "tools").Array()
1252+ if len(tools) == 0 {
1253+ t.Fatalf("expected tools in translated payload, got: %s", translated)
1254+ }
1255+ params := tools[0].Get("parameters")
1256+
1257+ patternProps := params.Get("patternProperties").Map()
1258+ if _, exists := patternProps[`^\p{L}+$`]; exists {
1259+ t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw)
1260+ }
1261+ if _, exists := patternProps[`^[a-z]+$`]; !exists {
1262+ t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw)
1263+ }
1264+}
internal/translator/openai/claude/openai_claude_request.go+37−2
@@ -388,8 +388,43 @@ func normalizeObjectSchemaProperties(schema any) any { · 387 unmodified lines
388388 value["properties"] = map[string]any{}
389389 }
390390 }
391− for key, child := range value {
392− value[key] = normalizeObjectSchemaProperties(child)
391+ if patternVal, ok := value["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
392+ delete(value, "pattern")
393+ }
394+
395+ // Inspect regex keys under patternProperties
396+ if patternProps, ok := value["patternProperties"].(map[string]any); ok {
397+ for patternKey, subSchema := range patternProps {
398+ if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
399+ delete(patternProps, patternKey)
400+ } else {
401+ patternProps[patternKey] = normalizeObjectSchemaProperties(subSchema)
402+ }
403+ }
404+ }
405+
406+ for _, mapKey := range util.SchemaMapKeywords {
407+ if mapKey == "patternProperties" {
408+ continue
409+ }
410+ if subMap, ok := value[mapKey].(map[string]any); ok {
411+ for subKey, subSchema := range subMap {
412+ subMap[subKey] = normalizeObjectSchemaProperties(subSchema)
413+ }
414+ }
415+ }
416+
417+ for _, valKey := range util.SchemaValueKeywords {
418+ if val, exists := value[valKey]; exists {
419+ switch sub := val.(type) {
420+ case map[string]any:
421+ value[valKey] = normalizeObjectSchemaProperties(sub)
422+ case []any:
423+ for i, item := range sub {
424+ sub[i] = normalizeObjectSchemaProperties(item)
425+ }
426+ }
427+ }
393428 }
394429 return value
395430 case []any:
internal/translator/openai/claude/openai_claude_request_test.go+139−0
@@ -1017,3 +1017,142 @@ func TestConvertClaudeRequestToOpenAI_ToolWithoutInputSchemaDefaultsParameters(t · 1016 unmodified lines
10171017 }
10181018 }
10191019 }
1020+
1021+func TestConvertClaudeRequestToOpenAI_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) {
1022+ inputJSON := []byte(`{
1023+ "model": "gpt-5.6",
1024+ "messages": [{"role": "user", "content": "hello"}],
1025+ "tools": [{
1026+ "name": "Artifact",
1027+ "description": "Render an HTML file to an Artifact",
1028+ "input_schema": {
1029+ "type": "object",
1030+ "properties": {
1031+ "field": {
1032+ "type": "string",
1033+ "description": "field to replace",
1034+ "pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$"
1035+ },
1036+ "asset_id": {
1037+ "type": "string",
1038+ "pattern": "^[0-9a-f]{32}$"
1039+ },
1040+ "lookahead_safe": {
1041+ "type": "string",
1042+ "pattern": "^(?!__.*__$).{1,200}$"
1043+ }
1044+ }
1045+ }
1046+ }]
1047+ }`)
1048+
1049+ output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
1050+ outputJSON := gjson.ParseBytes(output)
1051+
1052+ params := outputJSON.Get("tools.0.function.parameters")
1053+ if !params.Exists() {
1054+ t.Fatalf("expected function.parameters in output: %s", output)
1055+ }
1056+
1057+ // Incompatible \p{...} pattern must be stripped
1058+ if params.Get("properties.field.pattern").Exists() {
1059+ t.Errorf("expected properties.field.pattern to be removed, got: %s", params.Get("properties.field.pattern").Raw)
1060+ }
1061+ if got := params.Get("properties.field.type").String(); got != "string" {
1062+ t.Errorf("expected properties.field.type == 'string', got %q", got)
1063+ }
1064+
1065+ // Valid patterns must remain intact
1066+ if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" {
1067+ t.Errorf("expected properties.asset_id.pattern preserved, got %q", got)
1068+ }
1069+ if got := params.Get("properties.lookahead_safe.pattern").String(); got != "^(?!__.*__$).{1,200}$" {
1070+ t.Errorf("expected properties.lookahead_safe.pattern preserved, got %q", got)
1071+ }
1072+}
1073+
1074+func TestConvertClaudeRequestToOpenAI_PreservesNonSchemaPatternKeys(t *testing.T) {
1075+ inputJSON := []byte(`{
1076+ "model": "gpt-5.6",
1077+ "messages": [{"role": "user", "content": "hello"}],
1078+ "tools": [{
1079+ "name": "config_tool",
1080+ "input_schema": {
1081+ "type": "object",
1082+ "properties": {
1083+ "regex_config": {
1084+ "type": "object",
1085+ "default": {
1086+ "pattern": "\\p{L}+"
1087+ },
1088+ "enum": [
1089+ {"pattern": "\\p{N}+"}
1090+ ]
1091+ },
1092+ "real_schema": {
1093+ "type": "string",
1094+ "pattern": "\\p{L}+"
1095+ }
1096+ }
1097+ }
1098+ }]
1099+ }`)
1100+
1101+ output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
1102+ outputJSON := gjson.ParseBytes(output)
1103+
1104+ params := outputJSON.Get("tools.0.function.parameters")
1105+ if !params.Exists() {
1106+ t.Fatalf("expected function.parameters in output: %s", output)
1107+ }
1108+
1109+ // Real schema pattern must be removed
1110+ if params.Get("properties.real_schema.pattern").Exists() {
1111+ t.Errorf("expected real_schema.pattern to be removed, got: %s", params.Get("properties.real_schema").Raw)
1112+ }
1113+
1114+ // User data under default and enum must be PRESERVED
1115+ if got := params.Get("properties.regex_config.default.pattern").String(); got != `\p{L}+` {
1116+ t.Errorf("expected default.pattern preserved, got %q", got)
1117+ }
1118+ if got := params.Get("properties.regex_config.enum.0.pattern").String(); got != `\p{N}+` {
1119+ t.Errorf("expected enum.0.pattern preserved, got %q", got)
1120+ }
1121+}
1122+
1123+func TestConvertClaudeRequestToOpenAI_StripsPatternPropertiesIncompatibleKeys(t *testing.T) {
1124+ inputJSON := []byte(`{
1125+ "model": "gpt-5.6",
1126+ "messages": [{"role": "user", "content": "hello"}],
1127+ "tools": [{
1128+ "name": "pattern_tool",
1129+ "input_schema": {
1130+ "type": "object",
1131+ "patternProperties": {
1132+ "^\\\\p{L}+$": {
1133+ "type": "string"
1134+ },
1135+ "^[a-z]+$": {
1136+ "type": "number"
1137+ }
1138+ }
1139+ }
1140+ }]
1141+ }`)
1142+
1143+ output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
1144+ outputJSON := gjson.ParseBytes(output)
1145+
1146+ params := outputJSON.Get("tools.0.function.parameters")
1147+ if !params.Exists() {
1148+ t.Fatalf("expected function.parameters in output: %s", output)
1149+ }
1150+
1151+ patternProps := params.Get("patternProperties").Map()
1152+ if _, exists := patternProps[`^\p{L}+$`]; exists {
1153+ t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw)
1154+ }
1155+ if _, exists := patternProps[`^[a-z]+$`]; !exists {
1156+ t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw)
1157+ }
1158+}
internal/util/claude_schema.go+49−0
@@ -120,3 +120,52 @@ func mergeClaudeSchemaRequired(root map[string]json.RawMessage, branchRequired j · 119 unmodified lines
120120 root["required"] = requiredRaw
121121 }
122122 }
123+
124+// HasUnsupportedUnicodePropertyEscape reports whether a regular expression string
125+// contains unescaped Unicode property escape sequences (\p{...} or \P{...}).
126+// Python's built-in re module (and schema validators relying on it) fails compilation
127+// with "bad escape \p" on these sequences.
128+func HasUnsupportedUnicodePropertyEscape(pattern string) bool {
129+ for i := 0; i < len(pattern); i++ {
130+ if pattern[i] != '\\' {
131+ continue
132+ }
133+ if i+2 < len(pattern) &&
134+ (pattern[i+1] == 'p' || pattern[i+1] == 'P') &&
135+ pattern[i+2] == '{' {
136+ return true
137+ }
138+ i++ // skip the escaped character (including escaped backslash)
139+ }
140+ return false
141+}
142+
143+// SchemaMapKeywords lists JSON Schema keywords whose values are maps of subschemas.
144+var SchemaMapKeywords = [...]string{
145+ "properties",
146+ "$defs",
147+ "definitions",
148+ "patternProperties",
149+ "dependentSchemas",
150+ "dependencies",
151+}
152+
153+// SchemaValueKeywords lists JSON Schema keywords with a single nested subschema or a slice of subschemas.
154+var SchemaValueKeywords = [...]string{
155+ "items",
156+ "prefixItems",
157+ "contains",
158+ "additionalProperties",
159+ "propertyNames",
160+ "unevaluatedProperties",
161+ "unevaluatedItems",
162+ "additionalItems",
163+ "contentSchema",
164+ "anyOf",
165+ "oneOf",
166+ "allOf",
167+ "not",
168+ "if",
169+ "then",
170+ "else",
171+}
internal/util/claude_schema_test.go+78−0
@@ -112,3 +112,81 @@ func TestNormalizeClaudeToolInputSchema(t *testing.T) { · 111 unmodified lines
112112 })
113113 }
114114 }
115+
116+func TestHasUnsupportedUnicodePropertyEscape(t *testing.T) {
117+ tests := []struct {
118+ name string
119+ pattern string
120+ want bool
121+ }{
122+ {
123+ name: "Artifact regex with multiple \\p escapes",
124+ pattern: `^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\./[\]]{1,200}$`,
125+ want: true,
126+ },
127+ {
128+ name: "Uppercase \\P property escape",
129+ pattern: `^\P{L}+$`,
130+ want: true,
131+ },
132+ {
133+ name: "Escaped backslash before p is literal and safe",
134+ pattern: `^\\p{Cc}$`,
135+ want: false,
136+ },
137+ {
138+ name: "Three backslashes means literal backslash plus real \\p escape",
139+ pattern: `^\\\p{Cc}$`,
140+ want: true,
141+ },
142+ {
143+ name: "Four backslashes means two literal backslashes",
144+ pattern: `^\\\\p{Cc}$`,
145+ want: false,
146+ },
147+ {
148+ name: "Standard hex character pattern",
149+ pattern: `^[0-9a-f]{32}$`,
150+ want: false,
151+ },
152+ {
153+ name: "Negative lookahead without unicode properties",
154+ pattern: `^(?!__.*__$).{1,200}$`,
155+ want: false,
156+ },
157+ {
158+ name: "Backreference safe in Python re",
159+ pattern: `^(a)\1$`,
160+ want: false,
161+ },
162+ {
163+ name: "Trailing single backslash",
164+ pattern: `abc\`,
165+ want: false,
166+ },
167+ {
168+ name: "Backslash followed by p without brace",
169+ pattern: `\p`,
170+ want: false,
171+ },
172+ {
173+ name: "Backslash followed by p and brace without close",
174+ pattern: `\p{`,
175+ want: true,
176+ },
177+ {
178+ name: "Empty string",
179+ pattern: ``,
180+ want: false,
181+ },
182+ }
183+
184+ for _, tt := range tests {
185+ t.Run(tt.name, func(t *testing.T) {
186+ got := HasUnsupportedUnicodePropertyEscape(tt.pattern)
187+ if got != tt.want {
188+ t.Errorf("HasUnsupportedUnicodePropertyEscape(%q) = %v, want %v", tt.pattern, got, tt.want)
189+ }
190+ })
191+ }
192+}