fix(translator): strip unsupported unicode property escape patterns from tool schemas
Unified · Split
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | package helps |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 5 | + "encoding/json" | |
| 6 | + "io" | |
| 4 | 7 | "math/big" |
| 5 | 8 | "strconv" |
| 6 | 9 | "strings" |
| @@ -8,6 +11,8 @@ import ( · 1 unmodified line | ||
| 8 | 11 | log "github.com/sirupsen/logrus" |
| 9 | 12 | "github.com/tidwall/gjson" |
| 10 | 13 | "github.com/tidwall/sjson" |
| 14 | + | |
| 15 | + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" | |
| 11 | 16 | ) |
| 12 | 17 | |
| 13 | 18 | const ( |
| @@ -89,7 +94,7 @@ func normalizeCodexTool(tool gjson.Result) ([]byte, bool) { · 75 unmodified lines | ||
| 89 | 94 | 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 | 98 | return updatedTool, true |
| 94 | 99 | } |
| 95 | 100 | |
| @@ -97,6 +102,12 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 1 unmodified line | ||
| 97 | 102 | rawParams := []byte(params.Raw) |
| 98 | 103 | 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 | 111 | properties := params.Get("properties") |
| 101 | 112 | if properties.Exists() && properties.IsObject() { |
| 102 | 113 | for propName, propVal := range properties.Map() { |
| @@ -115,6 +126,98 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 12 unmodified lines | ||
| 115 | 126 | 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). | |
| 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 | + | |
| 118 | 221 | func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) { |
| 119 | 222 | if !prop.IsObject() { |
| 120 | 223 | return nil, false |
| @@ -491,3 +491,286 @@ func TestNormalizeCodexToolSchemas_NamespaceToolSimplified(t *testing.T) { · 490 unmodified lines | ||
| 491 | 491 | t.Fatalf("expected action.enum with 8 items") |
| 492 | 492 | } |
| 493 | 493 | } |
| 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 | +} | |
| @@ -665,8 +665,9 @@ func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string · 664 unmodified lines | ||
| 665 | 665 | return m |
| 666 | 666 | } |
| 667 | 667 | |
| 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. | |
| 670 | 671 | func normalizeToolParameters(raw string) string { |
| 671 | 672 | raw = strings.TrimSpace(raw) |
| 672 | 673 | if raw == "" || raw == "null" || !gjson.Valid(raw) { |
| @@ -721,8 +722,25 @@ func stripDialectKeywordsFromSchema(v any) { · 48 unmodified lines | ||
| 721 | 722 | case map[string]any: |
| 722 | 723 | delete(schema, "$schema") |
| 723 | 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 | 740 | for _, mapKey := range codexSchemaMapKeywords { |
| 741 | + if mapKey == "patternProperties" { | |
| 742 | + continue | |
| 743 | + } | |
| 726 | 744 | if subMap, ok := schema[mapKey].(map[string]any); ok { |
| 727 | 745 | for _, subSchema := range subMap { |
| 728 | 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 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 | +) | |
| 782 | 776 | |
| 783 | 777 | // codexSchemaMissesRequired reports whether a JSON Schema has any declared |
| 784 | 778 | // property missing from its sibling required list (recursively). OpenAI |
| @@ -1124,3 +1124,141 @@ func TestConvertClaudeRequestToCodex_StripsNestedToolSchemaMeta(t *testing.T) { · 1123 unmodified lines | ||
| 1124 | 1124 | t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw) |
| 1125 | 1125 | } |
| 1126 | 1126 | } |
| 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 | +} | |
| @@ -388,8 +388,43 @@ func normalizeObjectSchemaProperties(schema any) any { · 387 unmodified lines | ||
| 388 | 388 | value["properties"] = map[string]any{} |
| 389 | 389 | } |
| 390 | 390 | } |
| 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 | + } | |
| 393 | 428 | } |
| 394 | 429 | return value |
| 395 | 430 | case []any: |
| @@ -1017,3 +1017,142 @@ func TestConvertClaudeRequestToOpenAI_ToolWithoutInputSchemaDefaultsParameters(t · 1016 unmodified lines | ||
| 1017 | 1017 | } |
| 1018 | 1018 | } |
| 1019 | 1019 | } |
| 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 | +} | |
| @@ -120,3 +120,52 @@ func mergeClaudeSchemaRequired(root map[string]json.RawMessage, branchRequired j · 119 unmodified lines | ||
| 120 | 120 | 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. | |
| 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 | +} | |
| @@ -112,3 +112,81 @@ func TestNormalizeClaudeToolInputSchema(t *testing.T) { · 111 unmodified lines | ||
| 112 | 112 | }) |
| 113 | 113 | } |
| 114 | 114 | } |
| 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 | +} | |