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:
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user