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 @@
1package helps1package helps
2 2
3import (3import (
4 "bytes"
5 "encoding/json"
6 "io"
4 "math/big"7 "math/big"
5 "strconv"8 "strconv"
6 "strings"9 "strings"
@@ -8,6 +11,8 @@ import ( · 1 unmodified line
8 log "github.com/sirupsen/logrus"11 log "github.com/sirupsen/logrus"
9 "github.com/tidwall/gjson"12 "github.com/tidwall/gjson"
10 "github.com/tidwall/sjson"13 "github.com/tidwall/sjson"
14
15 "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
11)16)
12 17
13const (18const (
@@ -89,7 +94,7 @@ func normalizeCodexTool(tool gjson.Result) ([]byte, bool) { · 75 unmodified lines
89 return nil, false94 return nil, false
90 }95 }
91 96
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())
93 return updatedTool, true98 return updatedTool, true
94}99}
95 100
@@ -97,6 +102,12 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 1 unmodified line
97 rawParams := []byte(params.Raw)102 rawParams := []byte(params.Raw)
98 changed := false103 changed := false
99 104
105 if sanitizedParams, patternChanged := stripIncompatiblePatternsFromJSON(rawParams); patternChanged {
106 rawParams = sanitizedParams
107 changed = true
108 params = gjson.ParseBytes(rawParams)
109 }
110
100 properties := params.Get("properties")111 properties := params.Get("properties")
101 if properties.Exists() && properties.IsObject() {112 if properties.Exists() && properties.IsObject() {
102 for propName, propVal := range properties.Map() {113 for propName, propVal := range properties.Map() {
@@ -115,6 +126,98 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 12 unmodified lines
115 return rawParams, changed126 return rawParams, changed
116}127}
117 128
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).
133func 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
161func 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
118func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) {221func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) {
119 if !prop.IsObject() {222 if !prop.IsObject() {
120 return nil, false223 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
491 t.Fatalf("expected action.enum with 8 items")491 t.Fatalf("expected action.enum with 8 items")
492 }492 }
493}493}
494
495func 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
544func 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
592func 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
675func 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
693func 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
743func 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
665 return m665 return m
666}666}
667 667
668// normalizeToolParameters ensures object schemas contain at least an empty properties map668// normalizeToolParameters ensures object schemas contain at least an empty properties map,
669// and strips dialect keywords ($schema, $id) from schema objects.669// strips dialect keywords ($schema, $id), and drops regex patterns containing unsupported
670// Unicode property escapes (\p{...} / \P{...}) that cause upstream schema validation failures.
670func normalizeToolParameters(raw string) string {671func normalizeToolParameters(raw string) string {
671 raw = strings.TrimSpace(raw)672 raw = strings.TrimSpace(raw)
672 if raw == "" || raw == "null" || !gjson.Valid(raw) {673 if raw == "" || raw == "null" || !gjson.Valid(raw) {
@@ -721,8 +722,25 @@ func stripDialectKeywordsFromSchema(v any) { · 48 unmodified lines
721 case map[string]any:722 case map[string]any:
722 delete(schema, "$schema")723 delete(schema, "$schema")
723 delete(schema, "$id")724 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 }
724 739
725 for _, mapKey := range codexSchemaMapKeywords {740 for _, mapKey := range codexSchemaMapKeywords {
741 if mapKey == "patternProperties" {
742 continue
743 }
726 if subMap, ok := schema[mapKey].(map[string]any); ok {744 if subMap, ok := schema[mapKey].(map[string]any); ok {
727 for _, subSchema := range subMap {745 for _, subSchema := range subMap {
728 stripDialectKeywordsFromSchema(subSchema)746 stripDialectKeywordsFromSchema(subSchema)
@@ -749,36 +767,12 @@ func stripDialectKeywordsFromSchema(v any) { · 20 unmodified lines
749 }767 }
750}768}
751 769
752// codexSchemaMapKeywords holds JSON Schema keywords whose values are maps of770// codexSchemaMapKeywords and codexSchemaValueKeywords reference the unified JSON Schema keywords
753// subschemas; codexSchemaValueKeywords holds keywords with a single nested771// declared in internal/util.
754// schema or a list of schemas.772var (
755var codexSchemaMapKeywords = [...]string{773 codexSchemaMapKeywords = util.SchemaMapKeywords
756 "properties",774 codexSchemaValueKeywords = util.SchemaValueKeywords
757 "$defs",775)
758 "definitions",
759 "patternProperties",
760 "dependentSchemas",
761 "dependencies",
762}
763
764var 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}
782 776
783// codexSchemaMissesRequired reports whether a JSON Schema has any declared777// codexSchemaMissesRequired reports whether a JSON Schema has any declared
784// property missing from its sibling required list (recursively). OpenAI778// 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
1124 t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw)1124 t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw)
1125 }1125 }
1126}1126}
1127
1128func 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
1230func 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
388 value["properties"] = map[string]any{}388 value["properties"] = map[string]any{}
389 }389 }
390 }390 }
391 for key, child := range value {391 if patternVal, ok := value["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
392 value[key] = normalizeObjectSchemaProperties(child)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 }
393 }428 }
394 return value429 return value
395 case []any:430 case []any:
internal/translator/openai/claude/openai_claude_request_test.go+139−0
@@ -1017,3 +1017,142 @@ func TestConvertClaudeRequestToOpenAI_ToolWithoutInputSchemaDefaultsParameters(t · 1016 unmodified lines
1017 }1017 }
1018 }1018 }
1019}1019}
1020
1021func 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
1074func 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
1123func 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
120 root["required"] = requiredRaw120 root["required"] = requiredRaw
121 }121 }
122}122}
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.
128func 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.
144var 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.
154var 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
112 })112 })
113 }113 }
114}114}
115
116func 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}