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
This commit is contained in:
roboomp
2026-05-17 10:55:29 +00:00
parent 03623bdf5e
commit 74461e6f53
2 changed files with 92 additions and 2 deletions
+35 -2
View File
@@ -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<JsonO
const cached = cache.get(value);
if (cached) return cached;
// Seed the cache with the in-flight `output` BEFORE recursing so that a
// child re-entering this node mid-walk gets the partial back instead of
// triggering an infinite recursion. A cycle hitting this seeded entry
// forces `changed = true` below (the cached partial is referentially
// distinct from `value`), which is why the final `cache.set(value, result)`
// never silently overwrites the seed with `value` on a cyclic input.
const output: JsonObject = {};
cache.set(value, output);
let changed = false;
for (const key in value) {
if (!Object.hasOwn(value, key)) continue;
if (key === "oneOf") {
// Drop only well-formed `oneOf` arrays here; they are re-emitted as
// `anyOf` after the loop so any neighboring `anyOf` entries can be
// concatenated. A non-array `oneOf` is malformed for the wire but
// still preserved verbatim so callers can see the original payload
// instead of having it silently disappear.
if (key === "oneOf" && Array.isArray(value.oneOf)) {
changed = true;
continue;
}
@@ -936,16 +953,32 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
: rewrittenOneOf;
}
if (value.type === "object" && !Object.hasOwn(value, "properties")) {
// Draft 2020-12 lets `type` be an array (e.g. `["object", "null"]`); treat
// any variant that includes "object" as an object position for the
// properties requirement.
if (declaresObjectType(value.type) && !Object.hasOwn(value, "properties")) {
output.properties = {};
changed = true;
}
// Safe to overwrite the seed: any cyclic re-entry above already observed
// 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;
cache.set(value, result);
return result;
}
function declaresObjectType(type: unknown): boolean {
if (type === "object") return true;
if (!Array.isArray(type)) return false;
for (const variant of type) {
if (variant === "object") return true;
}
return false;
}
function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<JsonObject, JsonObject>): unknown[] {
let changed = false;
const output = value.map(item => {
@@ -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<string, unknown>;
expect(sanitizeSchemaForOpenAIResponses(malformed)).toEqual({
type: "object",
oneOf: { type: "object" },
properties: {},
});
});
it("does not recurse infinitely on self-referential object schemas", () => {
const circular: Record<string, unknown> = { type: "object", properties: {} };
(circular.properties as Record<string, unknown>).self = circular;
const sanitized = sanitizeSchemaForOpenAIResponses(circular);
const properties = (sanitized as { properties: Record<string, unknown> }).properties;
expect(properties.self).toBe(sanitized as unknown as object);
expect((sanitized as { type: unknown }).type).toBe("object");
});
});
// ---------------------------------------------------------------------------