diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index daf0d1c79..104a1ba4c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 7d16b7ebd..d65c1c0ae 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -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(["=", "!", "<=", "): 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): 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): unknown[] { +function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap): unknown[] { let changed = false; const output = value.map(item => { const next = normalizeOpenAIResponsesSchemaNode(item, cache); @@ -1032,11 +1058,19 @@ function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap): JsonObject { +function normalizeOpenAIResponsesSchemaMap( + schemaMap: JsonObject, + cache: WeakMap, + 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; diff --git a/packages/ai/test/openai-codex.test.ts b/packages/ai/test/openai-codex.test.ts index 2dde67365..d5a8aa680 100644 --- a/packages/ai/test/openai-codex.test.ts +++ b/packages/ai/test/openai-codex.test.ts @@ -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", () => { diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index caa79a80d..de62e5ea6 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -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;