From 74461e6f53d3a8a99c5e1e4b4a352af330665a1a Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 17 May 2026 10:55:29 +0000 Subject: [PATCH] fix(ai): broadened openai schema sanitization coverage Added dependencies and contentSchema to the OpenAI Responses schema-position sets so draft-04..07 dependencies maps and draft 2019-09 contentSchema nodes also get the properties-on-object normalization. Recognized array-form type declarations that include object. Preserved malformed non-array oneOf payloads instead of dropping them. Documented the cycle-safe cache seed and added a self-referential regression test. Refs #1147 --- packages/ai/src/utils/schema/normalize.ts | 37 +++++++++++- packages/ai/test/schema-normalization.test.ts | 57 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 689c5ab3b..befaf81a0 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -857,6 +857,11 @@ const OPENAI_RESPONSES_SCHEMA_ARRAY_KEYS = new Set(["anyOf", "oneOf", "allOf", " const OPENAI_RESPONSES_SCHEMA_MAP_KEYS = new Set([ "properties", "patternProperties", + // `dependencies` is the Draft-04..07 schema-valued form; older MCP servers + // still emit `{ dependencies: { foo: { type: "object" } } }`. String-array + // branches per key pass through `normalizeOpenAIResponsesSchemaNode` + // untouched because non-objects return as-is. + "dependencies", "dependentSchemas", "$defs", "definitions", @@ -865,6 +870,7 @@ const OPENAI_RESPONSES_SCHEMA_VALUE_KEYS = new Set([ "items", "additionalItems", "contains", + "contentSchema", "propertyNames", "if", "then", @@ -903,13 +909,24 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap): unknown[] { let changed = false; const output = value.map(item => { diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index c1a0a6657..a1e871b81 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -387,6 +387,63 @@ describe("sanitizeSchemaForOpenAIResponses", () => { anyOf: [{ type: "object", properties: {} }], }); }); + + it("adds empty properties under draft-07 dependencies and draft 2019-09 contentSchema", () => { + const schema = { + type: "object", + properties: { + body: { + type: "string", + contentSchema: { type: "object" }, + }, + }, + dependencies: { + body: { type: "object" }, + other: ["body"], + }, + }; + + expect(sanitizeSchemaForOpenAIResponses(schema)).toEqual({ + type: "object", + properties: { + body: { + type: "string", + contentSchema: { type: "object", properties: {} }, + }, + }, + dependencies: { + body: { type: "object", properties: {} }, + other: ["body"], + }, + }); + }); + + it("adds empty properties when `type` is a draft 2020-12 array including object", () => { + expect(sanitizeSchemaForOpenAIResponses({ type: ["object", "null"] })).toEqual({ + type: ["object", "null"], + properties: {}, + }); + }); + + it("preserves non-array oneOf payloads verbatim instead of dropping them", () => { + const malformed = { type: "object", oneOf: { type: "object" } } as unknown as Record; + + expect(sanitizeSchemaForOpenAIResponses(malformed)).toEqual({ + type: "object", + oneOf: { type: "object" }, + properties: {}, + }); + }); + + it("does not recurse infinitely on self-referential object schemas", () => { + const circular: Record = { type: "object", properties: {} }; + (circular.properties as Record).self = circular; + + const sanitized = sanitizeSchemaForOpenAIResponses(circular); + const properties = (sanitized as { properties: Record }).properties; + expect(properties.self).toBe(sanitized as unknown as object); + expect((sanitized as { type: unknown }).type).toBe("object"); + }); }); // ---------------------------------------------------------------------------