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
This commit is contained in:
roboomp
2026-06-04 10:09:43 +00:00
parent f6fca1f5cd
commit 89a47c8567
5 changed files with 151 additions and 6 deletions
+4
View File
@@ -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
+40 -3
View File
@@ -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<string, unknown>, 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<string, unknown>[] = [];
for (const variantType of typeVariants) {
const variantSchema: Record<string, unknown> = { ...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<string, unknown>;
+18 -3
View File
@@ -128,6 +128,23 @@ function hasIntegerType(type: unknown): boolean {
return type === "integer" || (Array.isArray(type) && type.includes("integer"));
}
function copyNullableScalarConstraints(schema: Record<string, unknown>, scalarVariant: Record<string, unknown>): 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<string, unknown>): void {
if (hasSchemaDefiningSibling(schema)) return;
const variants = schema.anyOf;
@@ -150,9 +167,7 @@ function rewriteNullableScalarAnyOf(schema: Record<string, unknown>): 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"];
}
@@ -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<string, unknown>).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<string, Record<string, unknown>>;
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"],
+24
View File
@@ -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<string, unknown>).guide as Record<string, unknown>;
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);