Merge remote-tracking branch 'origin/farm/e27b29db/strip-openai-regex-lookaround'

This commit is contained in:
can1357
2026-06-16 17:59:24 +02:00
4 changed files with 142 additions and 6 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses/Codex tool schema normalization stripping provider-rejected regex lookaround patterns from MCP tool parameter schemas. ([#2784](https://github.com/can1357/oh-my-pi/issues/2784))
## [16.0.2] - 2026-06-16
### Added
+40 -6
View File
@@ -936,8 +936,24 @@ export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject
* `normalizeSchemaFor*` dispatcher naming used elsewhere in this module.
*/
export const normalizeSchemaForOpenAIResponses: (schema: JsonObject) => JsonObject = sanitizeSchemaForOpenAIResponses;
const OPENAI_UNSUPPORTED_REGEX_LOOKAROUNDS = new Set(["=", "!", "<=", "<!"]);
function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonObject, JsonObject>): unknown {
function hasOpenAIUnsupportedRegexLookaround(pattern: string): boolean {
let groupStart = pattern.indexOf("(?");
while (groupStart !== -1) {
let escapes = 0;
for (let i = groupStart - 1; i >= 0 && pattern[i] === "\\"; i--) escapes++;
if (escapes % 2 === 0) {
const operator =
pattern[groupStart + 2] === "<" ? pattern.slice(groupStart + 2, groupStart + 4) : pattern[groupStart + 2];
if (OPENAI_UNSUPPORTED_REGEX_LOOKAROUNDS.has(operator)) return true;
}
groupStart = pattern.indexOf("(?", groupStart + 2);
}
return false;
}
function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonObject, unknown>): unknown {
if (!isJsonObject(value)) return value;
// `{}` (empty JSON Schema) ≡ `true` (JSON Schema draft 2020-12 §4.3.1).
@@ -973,11 +989,21 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
changed = true;
continue;
}
if (
key === "pattern" &&
typeof value.pattern === "string" &&
hasOpenAIUnsupportedRegexLookaround(value.pattern)
) {
changed = true;
continue;
}
const child = value[key];
let next: unknown = child;
if (OPENAI_RESPONSES_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
next = normalizeOpenAIResponsesSchemaMap(child, cache);
if (key === "patternProperties" && isJsonObject(child)) {
next = normalizeOpenAIResponsesSchemaMap(child, cache, true);
} else if (OPENAI_RESPONSES_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
next = normalizeOpenAIResponsesSchemaMap(child, cache, false);
} else if (OPENAI_RESPONSES_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) {
next = normalizeOpenAIResponsesSchemaArray(child, cache);
} else if (OPENAI_RESPONSES_SCHEMA_VALUE_KEYS.has(key) && isJsonObject(child)) {
@@ -1008,7 +1034,7 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
// the seeded partial and set `changed = true` for that node, so a node
// that finishes with `changed === false` is provably non-cyclic and
// referentially equal to its input.
const result = changed ? output : value;
const result = changed ? (isJsonObjectEmpty(output) ? true : output) : value;
cache.set(value, result);
return result;
}
@@ -1022,7 +1048,7 @@ function declaresObjectType(type: unknown): boolean {
return false;
}
function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<JsonObject, JsonObject>): unknown[] {
function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<JsonObject, unknown>): unknown[] {
let changed = false;
const output = value.map(item => {
const next = normalizeOpenAIResponsesSchemaNode(item, cache);
@@ -1032,11 +1058,19 @@ function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<Js
return changed ? output : value;
}
function normalizeOpenAIResponsesSchemaMap(schemaMap: JsonObject, cache: WeakMap<JsonObject, JsonObject>): JsonObject {
function normalizeOpenAIResponsesSchemaMap(
schemaMap: JsonObject,
cache: WeakMap<JsonObject, unknown>,
stripUnsupportedRegexKeys: boolean,
): JsonObject {
let changed = false;
const output: JsonObject = {};
for (const key in schemaMap) {
if (!Object.hasOwn(schemaMap, key)) continue;
if (stripUnsupportedRegexKeys && hasOpenAIUnsupportedRegexLookaround(key)) {
changed = true;
continue;
}
const child = schemaMap[key];
const next = normalizeOpenAIResponsesSchemaNode(child, cache);
if (next !== child) changed = true;
+60
View File
@@ -41,6 +41,66 @@ describe("openai-codex tool schemas", () => {
parameters: { type: "object", properties: {} },
});
});
it("strips MCP regex lookaround patterns from function parameters", () => {
const tools: Tool[] = [
{
name: "get_design_context",
description: "Get Figma design context",
parameters: {
type: "object",
properties: {
fileKey: { type: "string", pattern: "^(?!undefined$|null$)" },
},
propertyNames: { pattern: "^(?!undefined$|null$)" },
},
},
];
const converted = convertOpenAICodexResponsesTools(tools, createCodexModel("gpt-5.5"));
expect(converted[0]).toEqual({
type: "function",
name: "get_design_context",
description: "Get Figma design context",
parameters: {
type: "object",
properties: {
fileKey: { type: "string" },
},
propertyNames: true,
},
});
});
it("strips MCP regex lookaround patternProperties from function parameters", () => {
const tools: Tool[] = [
{
name: "read_dynamic_values",
description: "Read dynamic values",
parameters: {
type: "object",
patternProperties: {
"^(?!secret_)": { type: "string" },
"^public_": { type: "string" },
},
},
},
];
const converted = convertOpenAICodexResponsesTools(tools, createCodexModel("gpt-5.5"));
expect(converted[0]).toEqual({
type: "function",
name: "read_dynamic_values",
description: "Read dynamic values",
parameters: {
type: "object",
patternProperties: {
"^public_": { type: "string" },
},
properties: {},
},
});
});
});
describe("openai-codex request transformer", () => {
@@ -519,6 +519,44 @@ describe("sanitizeSchemaForOpenAIResponses", () => {
});
});
it("strips regex lookaround patterns unsupported by OpenAI Responses", () => {
const schema = {
type: "object",
properties: {
fileKey: { type: "string", pattern: "^(?!undefined$|null$)" },
ending: { type: "string", pattern: "(?<=/)node$" },
slug: { type: "string", pattern: "^[a-z0-9_-]+$" },
literal: { type: "string", pattern: "\\(?!literal" },
patternOnly: { pattern: "^(?!bad$)" },
"^(?!property-name)": { type: "string" },
},
patternProperties: {
"^(?!secret_)": { type: "string" },
"(?<=/)node$": { type: "string" },
"^x-": { type: "object" },
"\\(?!literal": { type: "string" },
},
propertyNames: { pattern: "^(?!invalid$)" },
};
expect(sanitizeSchemaForOpenAIResponses(schema)).toEqual({
type: "object",
properties: {
fileKey: { type: "string" },
ending: { type: "string" },
slug: { type: "string", pattern: "^[a-z0-9_-]+$" },
literal: { type: "string", pattern: "\\(?!literal" },
patternOnly: true,
"^(?!property-name)": { type: "string" },
},
patternProperties: {
"^x-": { type: "object", properties: {} },
"\\(?!literal": { type: "string" },
},
propertyNames: true,
});
});
it("preserves non-array oneOf payloads verbatim instead of dropping them", () => {
const malformed = { type: "object", oneOf: { type: "object" } } as unknown as Record<string, unknown>;