From 4428942b846ab06afc1941b8c73f8cfe0f3c717c Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 10 Jun 2026 20:18:39 +0000 Subject: [PATCH] fix(ai): preserved constrained nullable unions Only flatten optional anyOf schemas when the union wrapper has no sibling constraints that would exclude null. Added a strict-schema regression for constrained anyOf properties so null remains an outer branch. --- packages/ai/src/utils/schema/normalize.ts | 9 ++++++++- packages/ai/test/schema-strict-mode.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index ac223b11b..26d90e12b 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -1437,6 +1437,13 @@ export function sanitizeSchemaForStrictMode( return sanitized; } +function canAppendNullVariantToAnyOf(schema: Record): boolean { + for (const key in schema) { + if (key !== "anyOf" && key !== "description") return false; + } + return true; +} + /** * Recursively enforces JSON Schema constraints required by OpenAI/Codex strict mode: * - `additionalProperties: false` on every object node @@ -1505,7 +1512,7 @@ function enforceStrictSchemaBody( strictProperties[key] = processed; continue; } - if (isJsonObject(processed) && Array.isArray(processed.anyOf)) { + if (isJsonObject(processed) && Array.isArray(processed.anyOf) && canAppendNullVariantToAnyOf(processed)) { strictProperties[key] = { ...processed, anyOf: [...processed.anyOf, { type: "null" }] }; continue; } diff --git a/packages/ai/test/schema-strict-mode.test.ts b/packages/ai/test/schema-strict-mode.test.ts index 6450c30b2..34909a84c 100644 --- a/packages/ai/test/schema-strict-mode.test.ts +++ b/packages/ai/test/schema-strict-mode.test.ts @@ -456,6 +456,25 @@ describe("enforceStrictSchema", () => { expect(branches.some(branch => Array.isArray(branch.anyOf))).toBe(false); }); + it("wraps constrained optional anyOf schemas so null is not blocked by sibling constraints", () => { + const strict = enforceStrictSchema({ + type: "object", + properties: { + choice: { + type: "string", + anyOf: [{ enum: ["a"] }, { enum: ["b"] }], + }, + }, + }); + const properties = strict.properties as Record>; + const branches = properties.choice.anyOf as Array>; + + expect(branches).toHaveLength(2); + expect(branches[0].type).toBe("string"); + expect(Array.isArray(branches[0].anyOf)).toBe(true); + expect(branches[1]).toEqual({ type: "null" }); + }); + it("never emits undefined as a schema type", () => { const schema = zodToWireSchema( z.object({