mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(codex): strip regex lookaround from tool schema patterns (#7100)
* fix(codex): strip regex lookaround from tool schema patterns (port from 9router#1556) Codex/OpenAI's Responses API rejects JSON Schema pattern fields using regex lookaround (e.g. ^(?=.*@).+$) with a 400 'regex lookaround is not supported' error. The existing numeric-field sanitizer (coerceSchemaNumericFields) was only wired into the translated-request path (openai-to-claude.ts), not the native codex/openai passthrough path (normalizeCodexTools in open-sse/executors/codex/tools.ts), so lookahead/lookbehind patterns reached upstream unmodified and broke tool calls for clients that emit them (e.g. IDE agent harnesses validating an email field). Reported-by: evin (@evinjohnn) (https://github.com/decolua/9router/issues/1556) * chore(changelog): move #1556 entry to changelog.d fragment Consistency with the repo's canonical changelog.d/fixes/ workflow (avoids merge-storm re-conflicts from editing CHANGELOG.md directly). * refactor(codex): table-drive the regex-strip recursion to keep the complexity ratchet at baseline The #1556 lookaround strip walked every sub-schema field with its own copy-pasted if-block (properties / patternProperties / definitions / $defs, then prefixItems / anyOf / oneOf / allOf), pushing stripUnsupportedRegexPatterns past the cyclomatic threshold and check:complexity to 2057 > baseline 2056. Collapse the eight near-identical blocks into two loops over the field-name constants, with the object-map recursion factored into a helper. Same fields, same traversal order, same behavior — complexity is back at baseline 2056 and the #1556 regression tests still pass.
This commit is contained in:
committed by
GitHub
parent
f8ef562658
commit
fdabec6e59
1
changelog.d/fixes/1556-openai-regex-lookaround.md
Normal file
1
changelog.d/fixes/1556-openai-regex-lookaround.md
Normal file
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `(?<!` must be dropped
|
||||
// before the schema reaches the Codex/OpenAI upstream.
|
||||
const REGEX_LOOKAROUND_PATTERN = /\(\?<?[=!]/;
|
||||
|
||||
function hasUnsupportedRegexLookaround(pattern: unknown): boolean {
|
||||
return typeof pattern === "string" && REGEX_LOOKAROUND_PATTERN.test(pattern);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -81,6 +93,11 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
|
||||
delete result.default;
|
||||
}
|
||||
|
||||
// Fix (9router#1556): drop unsupported regex lookaround from `pattern`.
|
||||
if (hasUnsupportedRegexLookaround(result.pattern)) {
|
||||
delete result.pattern;
|
||||
}
|
||||
|
||||
for (const field of NUMERIC_SCHEMA_FIELDS) {
|
||||
if (field in result) {
|
||||
result[field] = coerceNumericString(result[field]);
|
||||
@@ -142,6 +159,70 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sub-schema maps keyed by property name (each value is itself walked recursively).
|
||||
const REGEX_STRIP_OBJECT_MAP_FIELDS = [
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"definitions",
|
||||
"$defs",
|
||||
] as const;
|
||||
|
||||
// Sub-schema lists (each entry is itself walked recursively).
|
||||
const REGEX_STRIP_ARRAY_MAP_FIELDS = ["prefixItems", "anyOf", "oneOf", "allOf"] as const;
|
||||
|
||||
/** Recursively strips unsupported regex lookaround from every value of an object map field. */
|
||||
function stripRegexFromObjectMap(record: JsonRecord): JsonRecord {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => [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;
|
||||
|
||||
|
||||
74
tests/unit/codex-tools-regex-lookaround.test.ts
Normal file
74
tests/unit/codex-tools-regex-lookaround.test.ts
Normal file
@@ -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<string, unknown> = {
|
||||
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<Record<string, unknown>>;
|
||||
const parameters = tools[0].parameters as Record<string, unknown>;
|
||||
const properties = parameters.properties as Record<string, unknown>;
|
||||
const emailSchema = properties.email as Record<string, unknown>;
|
||||
|
||||
assert.equal(
|
||||
emailSchema.pattern,
|
||||
undefined,
|
||||
"lookaround pattern must be stripped, not forwarded upstream"
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
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<Record<string, unknown>>;
|
||||
const parameters = tools[0].parameters as Record<string, unknown>;
|
||||
const properties = parameters.properties as Record<string, unknown>;
|
||||
const zipSchema = properties.zip as Record<string, unknown>;
|
||||
|
||||
assert.equal(zipSchema.pattern, "^[0-9]{5}$");
|
||||
});
|
||||
Reference in New Issue
Block a user