Skip to content
33 changes: 33 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
28 changes: 28 additions & 0 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 [{
Expand Down
15 changes: 10 additions & 5 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" },
};
}

Expand Down
165 changes: 157 additions & 8 deletions src/adapters/responses-tool-schema.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<string, unknown>): 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.
Expand Down Expand Up @@ -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; } });
}
}
Expand All @@ -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<string, unknown> = Object.create(null) as Record<string, unknown>;
frame.assign(out);

for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
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<string, unknown>),
assign: v => { out[key] = v; },
});
}
}

return dropped === 0 ? node : result;
}
Loading
Loading