From 89a47c8567a18136c1157771d02eee4806baf492 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 4 Jun 2026 10:09:43 +0000 Subject: [PATCH] fix(ai): preserved nullable enum tool schemas Distributed enum constraints across strict-mode type-array branches and kept null in collapsed nullable scalar enum wire schemas. Added regression coverage for the OpenAI Responses MCP nullable enum path.\n\nFixes #1835 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/utils/schema/normalize.ts | 43 +++++++++++++- packages/ai/src/utils/schema/wire.ts | 21 ++++++- packages/ai/test/schema-strict-mode.test.ts | 65 +++++++++++++++++++++ packages/ai/test/schema-wire.test.ts | 24 ++++++++ 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ca22febb6..750552d64 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Responses strict-mode tool schema normalization for nullable enum MCP parameters so enum constraints are distributed to matching `anyOf` branches instead of being copied onto the `null` branch. ([#1835](https://github.com/can1357/oh-my-pi/issues/1835)) + ## [15.9.0] - 2026-06-04 ### Fixed diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 1b21afd67..0199d712c 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -1036,6 +1036,36 @@ function primitiveJsonTypeOf(value: unknown): StrictPrimitiveType | undefined { return undefined; } } +function jsonSchemaTypeAcceptsValue(type: string, value: unknown): boolean { + switch (type) { + case "null": + return value === null; + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "array": + return Array.isArray(value); + case "object": + return isJsonObject(value); + default: + return true; + } +} + +function narrowEnumToType(schema: Record, type: string): boolean { + const enumValues = schema.enum; + if (!Array.isArray(enumValues)) return true; + + const narrowed = enumValues.filter(value => jsonSchemaTypeAcceptsValue(type, value)); + if (narrowed.length === 0) return false; + if (narrowed.length !== enumValues.length) schema.enum = narrowed; + return true; +} /** * Returns the primitive `type` keyword that fully describes the constraint @@ -1250,7 +1280,8 @@ export function sanitizeSchemaForStrictMode( // `enforceStrictSchema` and the typical OpenAI strict-mode "description // on the union" shape. const { description, ...variantBase } = sanitizedWithoutType; - const variants = typeVariants.map(variantType => { + const variants: Record[] = []; + for (const variantType of typeVariants) { const variantSchema: Record = { ...variantBase, type: variantType }; if (variantType !== "object") { delete variantSchema.properties; @@ -1260,8 +1291,14 @@ export function sanitizeSchemaForStrictMode( if (variantType !== "array") { delete variantSchema.items; } - return sanitizeSchemaForStrictMode(variantSchema, epoch, cache, root); - }); + if (!narrowEnumToType(variantSchema, variantType)) continue; + variants.push(sanitizeSchemaForStrictMode(variantSchema, epoch, cache, root)); + } + + if (variants.length === 0) { + cache.set(schema, sanitizedWithoutType); + return sanitizedWithoutType; + } if (variants.length === 1) { const sole = variants[0] as Record; diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index 6e9ff6802..9c618afd3 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -128,6 +128,23 @@ function hasIntegerType(type: unknown): boolean { return type === "integer" || (Array.isArray(type) && type.includes("integer")); } +function copyNullableScalarConstraints(schema: Record, scalarVariant: Record): void { + for (const key in scalarVariant) { + if (key === "type" || key === "enum" || key === "const" || Object.hasOwn(schema, key)) continue; + schema[key] = scalarVariant[key]; + } + + if (Object.hasOwn(scalarVariant, "const")) { + schema.enum = [scalarVariant.const, null]; + return; + } + + const enumValues = scalarVariant.enum; + if (Array.isArray(enumValues)) { + schema.enum = enumValues.includes(null) ? enumValues : [...enumValues, null]; + } +} + function rewriteNullableScalarAnyOf(schema: Record): void { if (hasSchemaDefiningSibling(schema)) return; const variants = schema.anyOf; @@ -150,9 +167,7 @@ function rewriteNullableScalarAnyOf(schema: Record): void { if (!sawNull || !scalarVariant || !scalarType) return; delete schema.anyOf; - for (const key in scalarVariant) { - if (key !== "type" && !Object.hasOwn(schema, key)) schema[key] = scalarVariant[key]; - } + copyNullableScalarConstraints(schema, scalarVariant); schema.type = [scalarType, "null"]; } diff --git a/packages/ai/test/schema-strict-mode.test.ts b/packages/ai/test/schema-strict-mode.test.ts index 462eaecba..994813b37 100644 --- a/packages/ai/test/schema-strict-mode.test.ts +++ b/packages/ai/test/schema-strict-mode.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "bun:test"; import type { Tool, ToolCall } from "@oh-my-pi/pi-ai/types"; import { + adaptSchemaForStrict, enforceStrictSchema, isJsonSchemaValueValid, isValidJsonSchema, + sanitizeSchemaForOpenAIResponses, sanitizeSchemaForStrictMode, + toolWireSchema, tryEnforceStrictSchema, zodToWireSchema, } from "@oh-my-pi/pi-ai/utils/schema"; @@ -86,6 +89,68 @@ describe("sanitizeSchemaForStrictMode", () => { expect((objectVariant as Record).properties).toEqual({ data: { type: "string" } }); }); + it("distributes enum values to matching nullable type-array branches", () => { + const sanitized = sanitizeSchemaForStrictMode({ + type: ["string", "null"], + enum: ["javascript", "python", null], + description: "guide", + }); + + expect(sanitized).toEqual({ + anyOf: [ + { enum: ["javascript", "python"], type: "string" }, + { enum: [null], type: "null" }, + ], + description: "guide", + }); + }); + + it("drops type-array branches that cannot satisfy enum constraints", () => { + const sanitized = sanitizeSchemaForStrictMode({ + type: ["string", "null"], + enum: ["javascript", "python"], + }); + + expect(sanitized).toEqual({ + enum: ["javascript", "python"], + type: "string", + }); + }); + + it("keeps OpenAI Responses strict schemas valid for nullable MCP enum parameters", () => { + const guideEnum = ["javascript", "python"]; + const raw = { + type: "object", + properties: { + guide: { + anyOf: [{ type: "string", enum: guideEnum, description: "guide" }, { type: "null" }], + default: null, + description: "guide", + }, + }, + required: ["guide"], + additionalProperties: false, + }; + + const wired = toolWireSchema({ + name: "mcp__sentry_search_docs", + description: "", + parameters: structuredClone(raw), + }); + const responses = sanitizeSchemaForOpenAIResponses(wired); + const strict = adaptSchemaForStrict(responses, true); + const properties = strict.schema.properties as Record>; + + expect(strict.strict).toBe(true); + expect(properties.guide).toEqual({ + anyOf: [ + { enum: ["javascript", "python"], type: "string" }, + { enum: [null], type: "null" }, + ], + description: "guide (default: null)", + }); + }); + it("keeps existing anyOf constraints inside each normalized type variant", () => { const schema = { type: ["object", "null"], diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index 7d7efe449..128a90e59 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -101,6 +101,30 @@ describe("zodToWireSchema — nullable scalar normalization", () => { }); }); + it("preserves null semantics when rewriting nullable scalar enum anyOf", () => { + const wire = toolWireSchema({ + name: "mcp__sentry_search_docs", + description: "", + parameters: { + type: "object", + properties: { + guide: { + anyOf: [{ type: "string", enum: ["javascript", "python"], description: "guide" }, { type: "null" }], + }, + }, + required: ["guide"], + }, + async execute() {}, + } as unknown as Tool); + const guide = (wire.properties as Record).guide as Record; + + expect(guide).toEqual({ + type: ["string", "null"], + enum: ["javascript", "python", null], + description: "guide", + }); + }); + it("keeps nullable integers free of Zod safe-integer bounds", () => { const schema = z.object({ count: z.number().int().nullable() }); const wire = zodToWireSchema(schema);