diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 0c3cb32697..42b151d779 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -542,6 +542,39 @@ Replay preserves non-hidden signed blocks (including empty thinking) and opaque role; `tool_result` without `tool_use_id`; `tool_use` without id/name; named `tool_choice` without name. +### Unicode-property patterns in tool schemas + +A JSON Schema `pattern` written for JavaScript may use Unicode property escapes such as +`\p{Cc}` or `\P{L}`. OpenAI-family backends validate `pattern` by compiling it with Python's +`re`, which does not support those escapes, and a schema they cannot compile is refused whole — +so a single such pattern on one built-in tool fails every request in the session, not just calls +to that tool. + +To keep those sessions working, the `openai-chat` and `openai-responses` adapter paths omit the +regexes that use Unicode property escapes when translating tool schemas: a `pattern` value, and a +`patternProperties` key, which the destination compiles the same way. Everything else on the tool +is preserved: sibling constraints such as `minLength`, `enum`, or `format`, the `required` list, +other `patternProperties` entries, and any property a caller happened to name `pattern`. A regex +that Python can compile, including one using lookaheads, is passed through unchanged. + +A `patternProperties` key is only omitted where doing so cannot narrow the object. Dropping a +matcher moves the keys it covered to `additionalProperties`, so on an open object those keys stay +admissible and the tool merely loses a constraint. On a closed object — `additionalProperties: +false`, `additionalProperties` set to a schema, or `unevaluatedProperties: false` — the same drop +would forbid or re-constrain those keys, and a dictionary tool whose only matcher was regex-keyed +would admit nothing once `minProperties` is 1. Such an object is sent exactly as written: a +destination that compiles ECMA regexes still accepts it, and one that cannot reports the regex +itself rather than receiving a schema no argument can satisfy. + +This is normalization on the selected adapter path, not a provider-wide guarantee. Provider +configuration and authentication are untouched, and a provider on a different adapter is +unaffected. + +It is a compatibility measure, not a claim that every custom OpenAI-compatible backend rejects +these patterns. What it costs is worth knowing: an omitted regex is not preserved anywhere and is +not enforced upstream, so a tool implementation should validate its own inputs rather than relying +on the schema to reject a malformed argument. + ## Outbound translation (Responses → Messages SSE) | Responses event | Messages SSE | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 90857854f0..1c3da9c30e 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -425,6 +425,34 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. **오류 조건(400):** 잘못된 JSON, 누락되거나 빈 `model`, 누락되거나 빈 `messages`, 지원하지 않는 role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 이름 지정 `tool_choice`예요. +### 도구 스키마의 유니코드 속성 패턴 + +자바스크립트 기준으로 작성한 JSON Schema `pattern`에는 `\p{Cc}`나 `\P{L}` 같은 유니코드 속성 +이스케이프가 들어갈 수 있어요. OpenAI 계열 백엔드는 `pattern`을 파이썬 `re`로 컴파일해 검사하는데 +`re`는 이 이스케이프를 지원하지 않고, 컴파일하지 못한 스키마는 통째로 거절해요. 그래서 내장 도구 +하나에 그런 패턴이 하나만 있어도 그 도구 호출뿐 아니라 세션의 모든 요청이 실패해요. + +이 상황을 피하려고 `openai-chat`·`openai-responses` 어댑터 경로는 도구 스키마를 변환할 때 유니코드 속성 +이스케이프를 쓰는 정규식을 빼요. `pattern` 값과, 백엔드가 똑같이 컴파일하는 `patternProperties` 키가 +대상이에요. 나머지는 그대로 둬요. `minLength`·`enum`·`format` 같은 형제 제약, `required` 목록, 다른 +`patternProperties` 항목, 호출자가 마침 `pattern`이라고 이름 붙인 속성 모두 보존해요. 파이썬이 컴파일할 +수 있는 정규식은 lookahead를 쓰더라도 그대로 전달해요. + +`patternProperties` 키는 빼도 객체가 좁아지지 않을 때만 빼요. 매처를 빼면 그 매처가 담당하던 키가 +`additionalProperties` 쪽으로 넘어가요. 열린 객체에서는 그 키가 그대로 허용되니 제약 하나만 사라져요. +반대로 닫힌 객체 — `additionalProperties: false`, `additionalProperties`가 스키마인 경우, +`unevaluatedProperties: false` — 에서는 같은 조작이 그 키를 금지하거나 다른 제약으로 바꿔 버려요. 매처가 +하나뿐인 사전형 도구라면 `minProperties`가 1일 때 아무 객체도 통과하지 못하게 돼요. 그런 객체는 작성된 +그대로 보내요. ECMA 정규식을 컴파일하는 백엔드는 계속 정상 동작하고, 컴파일하지 못하는 백엔드는 어떤 +인자로도 만족할 수 없는 스키마를 받는 대신 문제의 정규식을 그대로 알려 줘요. + +이건 선택된 어댑터 경로에서 일어나는 정규화이지 프로바이더 전체에 대한 보장이 아니에요. 프로바이더 설정과 +인증은 건드리지 않고, 다른 어댑터를 쓰는 프로바이더는 영향을 받지 않아요. + +호환성을 위한 조치일 뿐, 모든 OpenAI 호환 백엔드가 이런 패턴을 거절한다고 확인한 건 아니에요. 대가는 +알아 두는 게 좋아요. 빠진 정규식은 어디에도 보존되지 않고 상위에서 강제되지도 않으니, 도구 구현이 +스키마의 거절에 기대지 말고 입력을 직접 검증해야 해요. + ## 출력 변환(Responses → Messages SSE) | Responses 이벤트 | Messages SSE | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 0abb3277ae..4ba654487c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -27,7 +27,7 @@ import { type ResolvedFastPolicy, } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; -import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; +import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "./responses-tool-schema"; import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter"; import { isXaiSchemaTarget, @@ -1331,7 +1331,7 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig : moonshotTarget ? normalizeMoonshotToolParameters(t.parameters) : ensureRootObjectType(t.parameters); - const parameters = stripResponsesOnlyEncryptedMarker(normalized); + const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized)); if (parameters === undefined) return []; return [{ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d60bd6ce1f..c4aa523ee6 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -25,6 +25,7 @@ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-com import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeResponsesCodeMode } from "./responses-code-mode"; +import { stripUnicodePropertyPatterns } from "./responses-tool-schema"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { @@ -649,14 +650,18 @@ function mapRoutedResponsesReasoningEffort( function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; + // Runs for every Responses destination, forward auth included: the ChatGPT backend is where + // the `\p{…}` rejection was observed, and it reaches this function through the same seam. + const compatible = stripUnicodePropertyPatterns(tool); + const source = isPlainObject(compatible) ? compatible : tool; if (xaiTarget) { - const parameters = normalizeXaiToolParameters(isPlainObject(tool.parameters) ? tool.parameters : {}); - return parameters === undefined ? undefined : { ...tool, parameters }; + const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {}); + return parameters === undefined ? undefined : { ...source, parameters }; } - if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool; + if (isPlainObject(source.parameters) && source.parameters.type === "object") return source; return { - ...tool, - parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" }, + ...source, + parameters: { ...(isPlainObject(source.parameters) ? source.parameters : {}), type: "object" }, }; } diff --git a/src/adapters/responses-tool-schema.ts b/src/adapters/responses-tool-schema.ts index 0c02c589aa..d5c9da56ad 100644 --- a/src/adapters/responses-tool-schema.ts +++ b/src/adapters/responses-tool-schema.ts @@ -1,8 +1,7 @@ -// Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on -// collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an -// annotation for the ChatGPT backend only, so translated provider schemas must -// drop it without removing properties or definitions literally named `encrypted`. -const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([ +// Keys whose *children's names* are caller-chosen rather than schema keywords, and keys whose +// values are literal payloads rather than schemas. Shared by both strippers below: each one has +// to tell "the keyword `x`" apart from "a property someone named `x`". +const SCHEMA_NAME_BAG_KEYS = new Set([ "properties", "patternProperties", "$defs", @@ -11,9 +10,40 @@ const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([ "dependentSchemas", "dependentRequired", ]); -const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]); +const SCHEMA_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]); /** + * `patternProperties` is the one name bag whose keys are not just names: each key is itself a + * regex the destination compiles. So the Unicode-property problem applies to the key as well as + * to a `pattern` value, and a bag copied verbatim would still fail the whole schema. + */ +const PATTERN_KEYED_BAG_KEY = "patternProperties"; + +/** + * Whether dropping a `patternProperties` entry from this object only ever widens what it admits. + * + * Removing a matcher moves the keys it covered to whatever `additionalProperties` says. When the + * object is open — the keyword absent, or `true` — those keys become unconstrained, so every + * argument the original accepted is still accepted and the drop is a pure loss of validation. + * + * When the object is closed the same drop narrows it instead. With `additionalProperties: false` + * the covered keys become forbidden outright, and an object whose only matcher was regex-keyed + * then admits nothing at all once `minProperties` is 1 — a dictionary tool silently becomes an + * empty-object-only tool. A schema for `additionalProperties` is refused for the same reason: + * the covered keys would have to satisfy it instead of their own value schema. + * `unevaluatedProperties` closes an object the same way, so it is treated the same. + */ +function patternPropertyDropOnlyWidens(node: Record): boolean { + const open = (value: unknown): boolean => value === undefined || value === true; + return open(node.additionalProperties) && open(node.unevaluatedProperties); +} + +/** + * Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on collaboration tool + * schemas (openai/codex 5f4d06ef; issue #85). It is an annotation for the ChatGPT backend only, + * so translated provider schemas must drop it without removing properties or definitions + * literally named `encrypted`. + * * The schema is caller-supplied, so its nesting depth is attacker-influenced. Native recursion * would turn a deep schema into a stack overflow that takes down the request path, so this walks * an explicit stack instead: depth costs heap, which is bounded and recoverable. @@ -52,11 +82,11 @@ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = fal // Inside a name bag every key is a caller-chosen name, so `encrypted` here is data. stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } }); } else if (key !== "encrypted") { - if (ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)) { + if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) { // Literal payloads are values, not schemas: an `encrypted` key inside them is data. out[key] = value; } else { - const childInNameBag = ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key); + const childInNameBag = SCHEMA_NAME_BAG_KEYS.has(key); stack.push({ node: value, inNameBag: childInNameBag, assign: v => { out[key] = v; } }); } } @@ -65,3 +95,122 @@ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = fal return result; } + +/** + * `\p{…}` is an escape only when the backslash introducing it is itself unescaped: in `\\p{2}` + * the pair is a literal backslash and the `p{2}` that follows is an ordinary quantified `p`, + * which Python compiles fine. Scanning for the raw substring would misread that as a property + * escape and discard a working pattern. + */ +function usesUnicodePropertyEscape(pattern: string): boolean { + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] !== "\\") continue; + const next = pattern[i + 1]; + if (next === "\\") { + i++; + continue; + } + if ((next === "p" || next === "P") && pattern[i + 2] === "{") return true; + } + return false; +} + +/** + * ECMA-262 regexes may use Unicode property escapes (`\p{Cc}`, `\P{L}`); Python's `re` cannot + * compile them. OpenAI-family upstreams validate a function tool's JSON Schema `pattern` by + * compiling it with `re`, so a schema authored in JavaScript is refused whole, before routing: + * + * Invalid schema for function 'Artifact': + * '^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$' is not a 'regex'. + * + * A client that ships such a pattern on a built-in tool therefore loses every request, not just + * the calls to that tool — Claude Code 2.1.265 does exactly this on its `Artifact` tool. + * Dropping only the patterns the destination cannot compile keeps the tool's shape while letting + * the request through, the same trade the Kiro adapter makes for the validation keywords Bedrock + * rejects. What is given up is bounded: an upstream that enforces `pattern` does so under strict + * Structured Outputs, and a pattern this function drops is one that upstream could not have + * compiled in the first place — it refuses the whole schema before any argument is generated. So + * the choice is a dropped constraint versus no request at all, not a silently weakened one that + * would otherwise have been enforced. + * + * Returns `node` itself when nothing was dropped, so callers can use identity to tell whether + * the schema changed. Walks an explicit stack for the same reason as the stripper above. + * + * Two shapes carry an uncompilable regex: a `pattern` value, and a `patternProperties` key. The + * key case matters because the destination compiles those keys too, so preserving one would fail + * the schema exactly as a `pattern` value would. + * + * The two are not equally safe to drop, so "keeps the tool's shape" holds only where the drop + * widens. A `pattern` value is a constraint on a value that is admitted either way. A + * `patternProperties` key decides which keys exist at all, so removing it from a closed object + * narrows the object instead of relaxing it, and a dictionary tool whose only matcher was + * regex-keyed would become an empty-object-only tool. Those objects are therefore left exactly + * as the caller wrote them: a destination that compiles ECMA regexes still accepts them, and one + * that does not reports the uncompilable regex itself, which is the honest outcome. See + * {@link patternPropertyDropOnlyWidens}. + */ +export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false): unknown { + type Assign = (value: unknown) => void; + interface Frame { node: unknown; inNameBag: boolean; dropUncompilableKeys?: boolean; assign: Assign } + + let result: unknown; + let dropped = 0; + const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + const current = frame.node; + + if (Array.isArray(current)) { + const out: unknown[] = new Array(current.length); + frame.assign(out); + // Array items are schemas in their own right, never a name bag. + for (let i = current.length - 1; i >= 0; i--) { + stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } }); + } + continue; + } + if (!current || typeof current !== "object") { + frame.assign(current); + continue; + } + + // A schema name may be `__proto__`; a null-prototype record keeps it as data. + const out: Record = Object.create(null) as Record; + frame.assign(out); + + for (const [key, value] of Object.entries(current as Record)) { + if (frame.inNameBag) { + if (frame.dropUncompilableKeys && usesUnicodePropertyEscape(key)) { + // The key is the matcher here, so an uncompilable key takes its schema with it. + // Keeping the entry would fail the whole schema exactly as a pattern value does. + // Only reached when the enclosing object is open, so this cannot narrow it. + dropped++; + continue; + } + // Inside a name bag every key is a caller-chosen name, so `pattern` here is a property + // name; its value is still a schema and is walked as one. + stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } }); + continue; + } + if (key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) { + dropped++; + continue; + } + if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) { + // Literal payloads are values, not schemas: a `pattern` key inside them is data. + out[key] = value; + continue; + } + stack.push({ + node: value, + inNameBag: SCHEMA_NAME_BAG_KEYS.has(key), + dropUncompilableKeys: key === PATTERN_KEYED_BAG_KEY + && patternPropertyDropOnlyWidens(current as Record), + assign: v => { out[key] = v; }, + }); + } + } + + return dropped === 0 ? node : result; +} diff --git a/tests/adapters/openai/openai-chat-hardening.test.ts b/tests/adapters/openai/openai-chat-hardening.test.ts index c5050a3ced..0ad51ab246 100644 --- a/tests/adapters/openai/openai-chat-hardening.test.ts +++ b/tests/adapters/openai/openai-chat-hardening.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; -import { stripResponsesOnlyEncryptedMarker } from "../../../src/adapters/responses-tool-schema"; +import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../../../src/adapters/responses-tool-schema"; import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests } from "../../../src/lib/debug-settings"; import { routeModel } from "../../../src/router"; @@ -286,6 +286,291 @@ describe("openai-chat request hardening", () => { }); }); +describe("unicode property-escape pattern stripping", () => { + // Claude Code 2.1.265 ships this on the `field` parameter of its built-in Artifact tool. + const artifactFieldPattern = '^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}"\\\\./[\\]]{1,200}$'; + + test("drops a pattern Python `re` cannot compile and keeps one it can", () => { + const stripped = stripUnicodePropertyPatterns({ + type: "object", + properties: { + field: { type: "string", pattern: artifactFieldPattern, description: "keep me" }, + // Python `re` supports lookaheads, so this one is compilable and must survive. + collection: { type: "string", pattern: "^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$" }, + plain: { type: "string", pattern: "^[a-z0-9_-]{1,64}$" }, + }, + }) as Record>>; + + expect(stripped.properties.field.pattern).toBeUndefined(); + expect(stripped.properties.field.type).toBe("string"); + expect(stripped.properties.field.description).toBe("keep me"); + expect(stripped.properties.collection.pattern).toBe("^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$"); + expect(stripped.properties.plain.pattern).toBe("^[a-z0-9_-]{1,64}$"); + }); + + test("an escaped backslash before `p{` is a literal, not a property escape", () => { + // `\\p{2}` is a literal backslash followed by a quantified `p`; Python compiles it, so a + // substring scan for `\p{` would throw away a working pattern. + const before = { type: "string", pattern: "^\\\\p{2}$" }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("`\\P{…}` is dropped as well as `\\p{…}`", () => { + const stripped = stripUnicodePropertyPatterns({ type: "string", pattern: "^\\P{L}+$" }) as Record; + expect(stripped.pattern).toBeUndefined(); + expect(stripped.type).toBe("string"); + }); + + test("a property or literal payload named `pattern` is data, not a keyword", () => { + const before = { + type: "object", + properties: { + // A caller-chosen property name that happens to be `pattern`: its schema survives whole. + pattern: { type: "string", pattern: "^[a-z]+$" }, + }, + $defs: { pattern: { type: "string" } }, + patternProperties: { "^x-": { type: "string" } }, + const: { pattern: artifactFieldPattern }, + default: { pattern: artifactFieldPattern }, + enum: [{ pattern: artifactFieldPattern }], + examples: [{ pattern: artifactFieldPattern }], + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("returns the input itself when nothing is dropped", () => { + const before = { type: "object", properties: { a: { type: "string" } } }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("a patternProperties key is a regex too, so an uncompilable one is dropped with its schema", () => { + // The destination compiles these keys exactly as it compiles a `pattern` value, so copying + // the key verbatim would still fail the whole schema and lose every request. + const stripped = stripUnicodePropertyPatterns({ + type: "object", + patternProperties: { + "^\\p{L}+$": { type: "string" }, + "^\\P{N}+$": { type: "string" }, + // Python `re` compiles these, so they survive with their schemas intact. + "^x-": { type: "string", description: "keep me" }, + "^(?!__).+$": { type: "number" }, + }, + }) as Record>>; + + // Key order is not part of the schema contract, so compare the set. The point is which + // matchers survive and that their schemas come through intact. + expect(Object.keys(stripped.patternProperties).sort()).toEqual(["^(?!__).+$", "^x-"].sort()); + expect(stripped.patternProperties["^x-"].description).toBe("keep me"); + expect(stripped.patternProperties["^(?!__).+$"].type).toBe("number"); + }); + + test("an ordinary name bag keeps a property literally named like a property escape", () => { + // Only `patternProperties` keys are matchers. Elsewhere the key is just a name, so a + // property called `\\p{L}` is data and must survive. + const before = { + type: "object", + properties: { "\\p{L}": { type: "string" } }, + $defs: { "\\p{L}": { type: "string" } }, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("a nested patternProperties inside properties is still key-checked", () => { + const stripped = stripUnicodePropertyPatterns({ + type: "object", + properties: { + nested: { + type: "object", + patternProperties: { "^\\p{Lu}$": { type: "string" }, "^ok$": { type: "string" } }, + }, + }, + }) as Record>>>; + + expect(Object.keys(stripped.properties.nested.patternProperties).sort()).toEqual(["^ok$"]); + expect(stripped.properties.nested.patternProperties["^ok$"].type).toBe("string"); + }); + + test("a closed object keeps its regex matcher, because dropping it would narrow the object", () => { + // `additionalProperties: false` means the matcher decides which keys exist at all. Dropping + // it would forbid every key it covered, and with `minProperties: 1` the object could then + // admit nothing — a dictionary tool turned into an empty-object-only tool. Leaving it alone + // keeps the schema valid on a destination that compiles ECMA regexes, and lets one that + // cannot report the regex itself. + const before = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + minProperties: 1, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("`additionalProperties` as a schema also blocks the drop", () => { + // The covered keys would have to satisfy that schema instead of their own value schema, + // which is a different constraint rather than a relaxed one. + const before = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: { type: "number" }, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("`unevaluatedProperties: false` closes the object the same way", () => { + const before = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + unevaluatedProperties: false, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("an explicitly open object still drops the uncompilable matcher", () => { + // `additionalProperties: true` leaves the covered keys admissible, so the drop only removes + // validation and the object stays satisfiable. + const stripped = stripUnicodePropertyPatterns({ + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" }, "^x-": { type: "string" } }, + additionalProperties: true, + }) as Record>; + + expect(Object.keys(stripped.patternProperties)).toEqual(["^x-"]); + expect(stripped.additionalProperties).toBe(true); + }); + + test("closing is decided per object, so an open sibling still drops", () => { + const stripped = stripUnicodePropertyPatterns({ + type: "object", + properties: { + closed: { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + }, + open: { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" }, "^ok$": { type: "string" } }, + }, + }, + }) as Record>>>; + + expect(Object.keys(stripped.properties.closed.patternProperties)).toEqual(["^\\p{L}+$"]); + expect(Object.keys(stripped.properties.open.patternProperties)).toEqual(["^ok$"]); + }); + + test("a deeply nested schema is stripped without exhausting the stack", () => { + // Same reasoning as the encrypted-marker walk: schema depth is caller-controlled. + const depth = 50_000; + const root: Record = { type: "object", pattern: artifactFieldPattern }; + let cursor = root; + for (let i = 0; i < depth; i++) { + const child: Record = { type: "object", pattern: artifactFieldPattern }; + cursor.properties = { pattern: child }; + cursor = child; + } + + const stripped = stripUnicodePropertyPatterns(root) as Record; + expect(stripped.pattern).toBeUndefined(); + let walk = stripped; + for (let i = 0; i < depth; i++) { + // Each level keeps the property literally named `pattern` and drops the keyword. + walk = (walk.properties as Record>).pattern; + expect(walk.pattern).toBeUndefined(); + expect(walk.type).toBe("object"); + } + }); + + test("the chat wire drops the uncompilable pattern and keeps the compilable sibling", () => { + // The tests above call the helper directly, so they stay green even if the + // chat serializer stops calling it. This one goes through buildRequest and + // asserts the bytes a provider would receive, which is the seam that was + // actually broken: toolsToChatFormat in src/adapters/openai-chat.ts. + const compilableSibling = "^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$"; + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "make an artifact", timestamp: 0 }], + tools: [{ + name: "Artifact", + namespace: "collaboration", + description: "Create an artifact", + parameters: { + type: "object", + properties: { + field: { type: "string", pattern: artifactFieldPattern, description: "Artifact field" }, + collection: { type: "string", pattern: compilableSibling }, + }, + required: ["field"], + }, + }], + }, + }); + + const body = JSON.parse(request.body) as { + tools: Array<{ function: { parameters: { properties: Record>; required: string[] } } }>; + }; + const wire = body.tools[0].function.parameters; + + expect(wire.properties.field.pattern).toBeUndefined(); + expect(wire.properties.field.type).toBe("string"); + expect(wire.properties.field.description).toBe("Artifact field"); + expect(wire.properties.collection.pattern).toBe(compilableSibling); + expect(wire.required).toEqual(["field"]); + }); + + test("chat wire: a closed dictionary tool passes through, never becoming an empty-object tool", () => { + // This seam normalizes for every destination, including ones that compile ECMA regexes. + // Dropping the matcher would leave `additionalProperties: false` forbidding the keys it + // covered, and `minProperties: 1` would then admit nothing at all. + const parameters = { + type: "object", + description: "Arbitrary letter-keyed labels", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + minProperties: 1, + }; + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "label it", timestamp: 0 }], + tools: [{ name: "Label", namespace: "collaboration", description: "Label things", parameters }], + }, + }); + + const body = JSON.parse(request.body) as { tools: Array<{ function: { parameters: unknown } }> }; + // Byte-identical to what the caller supplied: matcher, closure and lower bound all intact. + expect(body.tools[0].function.parameters).toEqual(parameters); + }); + + test("chat wire: an open dictionary tool still drops the uncompilable matcher", () => { + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "label it", timestamp: 0 }], + tools: [{ + name: "Label", + namespace: "collaboration", + description: "Label things", + parameters: { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" }, "^x-": { type: "string" } }, + minProperties: 1, + }, + }], + }, + }); + + const body = JSON.parse(request.body) as { + tools: Array<{ function: { parameters: { patternProperties: Record; minProperties: number } } }>; + }; + const wire = body.tools[0].function.parameters; + // Open object: the covered keys stay admissible through the default `additionalProperties`, + // so the tool is still satisfiable after the drop. + expect(Object.keys(wire.patternProperties)).toEqual(["^x-"]); + expect(wire.minProperties).toBe(1); + }); +}); + describe("openai-chat non-stream response hardening", () => { test("surfaces an upstream error envelope message", async () => { const adapter = createOpenAIChatAdapter(provider()); diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index df2d064cab..c1416e84b3 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1461,6 +1461,77 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]?.parameters).toEqual({ ...parameters, type: "object" }); }); + test("drops unicode property-escape patterns on the codex forward path", () => { + // Claude Code 2.1.265 puts `\p{Cc}` in the `pattern` of its built-in Artifact tool. The + // ChatGPT backend compiles `pattern` with Python `re`, which has no property escapes, and + // answers "Invalid schema for function 'Artifact': … is not a 'regex'" — so every request + // from such a client fails, whether or not the tool is ever called. + const field = '^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}"\\\\./[\\]]{1,200}$'; + const collection = "^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$"; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [], + tools: [{ + type: "function", + name: "Artifact", + parameters: { + type: "object", + properties: { + field: { type: "string", pattern: field }, + collection: { type: "string", pattern: collection }, + }, + }, + }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Array<{ name: string; parameters: { properties: Record> } }>; + }; + const properties = body.tools[0]?.parameters.properties; + + expect(body.tools).toHaveLength(1); + expect(body.tools[0]?.name).toBe("Artifact"); + expect(properties.field.pattern).toBeUndefined(); + expect(properties.field.type).toBe("string"); + // Lookaheads compile under Python `re`, so only the incompatible pattern is dropped. + expect(properties.collection.pattern).toBe(collection); + }); + + test("leaves a closed regex-keyed object alone on the codex forward path", () => { + // Dropping this matcher would leave `additionalProperties: false` forbidding every key it + // covered, and `minProperties: 1` would make the object admit nothing — a dictionary tool + // silently reduced to an empty-object-only tool. The schema goes out as written instead, so + // a destination that compiles ECMA regexes still works and one that cannot names the regex. + const parameters = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + minProperties: 1, + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [], + tools: [{ type: "function", name: "Label", parameters }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Array<{ name: string; parameters: unknown }>; + }; + + expect(body.tools).toHaveLength(1); + expect(body.tools[0]?.parameters).toEqual(parameters); + }); + test("model reasoning-summary opt-out strips unsupported delivery fields (#323)", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses",