diff --git a/changelog.d/fixes/1556-openai-regex-lookaround.md b/changelog.d/fixes/1556-openai-regex-lookaround.md new file mode 100644 index 0000000000..7045d0917d --- /dev/null +++ b/changelog.d/fixes/1556-openai-regex-lookaround.md @@ -0,0 +1 @@ +- **fix(codex):** strip regex `pattern` lookaround (lookahead/lookbehind) from tool JSON Schemas on the Codex/OpenAI native passthrough path — previously only the translated-request path coerced tool schemas, so a `pattern` like `^(?=.*@).+$` reached OpenAI unmodified and was rejected with `regex lookaround is not supported`. (thanks @evinjohnn) (#7100) diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 3337000359..52d01e9d87 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -1,6 +1,8 @@ // Codex Responses-API tool normalization (hosted-tool passthrough + free-plan gating). // Extracted verbatim from codex.ts. Self-contained (console.debug only). +import { stripUnsupportedRegexPatterns } from "../../translator/helpers/schemaCoercion.ts"; + // Responses-API hosted tool types that OpenAI/Codex executes server-side. // These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — // e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or @@ -133,6 +135,11 @@ export function normalizeCodexTools( ? functionObject.strict : undefined; + // Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround + // (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error. + // Strip those before the schema reaches upstream (9router#1556). + const sanitizedParameters = stripUnsupportedRegexPatterns(parameters); + // Rewrite in-place to Responses format for (const key of Object.keys(tool)) { delete tool[key]; @@ -140,7 +147,7 @@ export function normalizeCodexTools( tool.type = "function"; tool.name = name.slice(0, 128); if (description) tool.description = description; - tool.parameters = parameters; + tool.parameters = sanitizedParameters; if (strict !== undefined) tool.strict = strict; validToolNames.add(name); diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 9be3d930b6..60ecfa44a7 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -24,6 +24,18 @@ const NUMERIC_SCHEMA_FIELDS = [ "multipleOf", ] as const; +// Fix (9router#1556): OpenAI/Codex's Responses API rejects JSON Schema `pattern` +// values that use regex lookaround (lookahead/lookbehind) with +// "Invalid JSON schema: regex lookaround is not supported.". IDE/SDK agent +// harnesses commonly emit lookahead patterns (e.g. `^(?=.*@).+$`), so any +// `pattern` field containing `(?=`, `(?!`, `(?<=`, or `(? [key, stripUnsupportedRegexPatterns(value)]) + ); +} + +/** + * Strip regex `pattern` constraints that use lookaround (lookahead/lookbehind), + * which OpenAI/Codex's Responses API rejects outright with a 400 + * ("Invalid JSON schema: regex lookaround is not supported."). Walks the same + * JSON Schema shape as `coerceSchemaNumericFields` (properties, items, + * anyOf/oneOf/allOf, $defs/definitions, etc). See 9router#1556. + */ +export function stripUnsupportedRegexPatterns(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripUnsupportedRegexPatterns(entry)); + } + if (!isPlainObject(schema)) return schema; + + const result: JsonRecord = { ...schema }; + + if (hasUnsupportedRegexLookaround(result.pattern)) { + delete result.pattern; + } + + for (const field of REGEX_STRIP_OBJECT_MAP_FIELDS) { + if (isPlainObject(result[field])) { + result[field] = stripRegexFromObjectMap(result[field]); + } + } + + for (const field of REGEX_STRIP_ARRAY_MAP_FIELDS) { + if (Array.isArray(result[field])) { + result[field] = (result[field] as unknown[]).map((entry) => + stripUnsupportedRegexPatterns(entry) + ); + } + } + + if (result.items !== undefined) { + result.items = stripUnsupportedRegexPatterns(result.items); + } + if (result.additionalProperties && typeof result.additionalProperties === "object") { + result.additionalProperties = stripUnsupportedRegexPatterns(result.additionalProperties); + } + if (isPlainObject(result.not)) { + result.not = stripUnsupportedRegexPatterns(result.not); + } + + return result; +} + export function sanitizeToolDescription(tool: unknown): unknown { if (!isPlainObject(tool)) return tool; diff --git a/tests/unit/codex-tools-regex-lookaround.test.ts b/tests/unit/codex-tools-regex-lookaround.test.ts new file mode 100644 index 0000000000..4778ec7ac7 --- /dev/null +++ b/tests/unit/codex-tools-regex-lookaround.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeCodexTools } from "../../open-sse/executors/codex/tools.ts"; + +// Port of 9router#1556: OpenAI/Codex Responses API rejects JSON Schema `pattern` +// fields containing regex lookaround (lookahead/lookbehind) with: +// "Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern." +// Clients (e.g. IDE agent harnesses) commonly emit lookahead patterns such as +// `^(?=.*@).+$` for "must contain an @". These must be stripped before the +// tool schema reaches the Codex/OpenAI Responses API. +test("normalizeCodexTools strips regex lookaround from function tool parameter patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + description: "Send an email", + parameters: { + type: "object", + properties: { + email: { + type: "string", + pattern: "^(?=.*@).+$", + }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const emailSchema = properties.email as Record; + + assert.equal( + emailSchema.pattern, + undefined, + "lookaround pattern must be stripped, not forwarded upstream" + ); +}); + +test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + parameters: { + type: "object", + properties: { + zip: { type: "string", pattern: "^[0-9]{5}$" }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const zipSchema = properties.zip as Record; + + assert.equal(zipSchema.pattern, "^[0-9]{5}$"); +});