feat(ai): added unified normalize flow for Google/CCA schema handling
- Implemented a unified normalization flow by switching Google/CCA handling to normalizeSchemaForGoogle/CCA. - Added normalize.ts with recursive node normalization, nullable-union checks, and combiner collapsing. - Removed sanitize-google.ts and normalize-cca.ts, replacing them with normalize exports in schema indexes. - Added spill-to-description utilities with spill/paren modes and `$defs` exclusion for unsupported fields. - Updated MCP bridge and schema tests to use normalizeSchemaFor* APIs with expanded compatibility checks. - Documented normalization behavior changes and breaking rename in constraints and package changelog files.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai";
|
||||
import { normalizeSchemaForGoogle } from "@oh-my-pi/pi-ai";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { createTools, HIDDEN_TOOLS, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
/**
|
||||
* Problematic JSON Schema features that cause issues with various providers.
|
||||
*
|
||||
* These are checked AFTER sanitization (sanitizeSchemaForGoogle) is applied,
|
||||
* These are checked AFTER sanitization (normalizeSchemaForGoogle) is applied,
|
||||
* so features like `const` that are transformed by sanitization are not flagged.
|
||||
*
|
||||
* Prohibited (error):
|
||||
@@ -32,7 +32,7 @@ const PROHIBITED_KEYS = new Set([
|
||||
"prefixItems",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"const", // Should be converted to enum by sanitizeSchemaForGoogle
|
||||
"const", // Should be converted to enum by normalizeSchemaForGoogle
|
||||
"examples",
|
||||
]);
|
||||
|
||||
@@ -61,7 +61,9 @@ function validateSchema(schema: unknown, path = "root"): SchemaViolation[] {
|
||||
|
||||
const obj = schema as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
for (const key in obj) {
|
||||
if (!Object.hasOwn(obj, key)) continue;
|
||||
const value = obj[key];
|
||||
const currentPath = `${path}.${key}`;
|
||||
|
||||
if (PROHIBITED_KEYS.has(key)) {
|
||||
@@ -113,22 +115,22 @@ function createTestSession(): ToolSession {
|
||||
};
|
||||
}
|
||||
|
||||
describe("sanitizeSchemaForGoogle", () => {
|
||||
describe("normalizeSchemaForGoogle", () => {
|
||||
it("converts const to enum", () => {
|
||||
const schema = { type: "string", const: "active" };
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
expect(sanitized).toEqual({ type: "string", enum: ["active"] });
|
||||
});
|
||||
|
||||
it("merges const into existing enum", () => {
|
||||
const schema = { type: "string", const: "active", enum: ["inactive"] };
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
expect(sanitized).toEqual({ type: "string", enum: ["inactive", "active"] });
|
||||
});
|
||||
|
||||
it("does not duplicate const in enum", () => {
|
||||
const schema = { type: "string", const: "active", enum: ["active", "inactive"] };
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
expect(sanitized).toEqual({ type: "string", enum: ["active", "inactive"] });
|
||||
});
|
||||
|
||||
@@ -139,7 +141,7 @@ describe("sanitizeSchemaForGoogle", () => {
|
||||
{ type: "string", const: "dir" },
|
||||
],
|
||||
};
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
// anyOf with all const values should collapse into a single enum
|
||||
expect(sanitized).toEqual({
|
||||
type: "string",
|
||||
@@ -159,7 +161,7 @@ describe("sanitizeSchemaForGoogle", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const props = sanitized.properties as Record<string, unknown>;
|
||||
const nested = props.nested as Record<string, unknown>;
|
||||
const nestedProps = nested.properties as Record<string, unknown>;
|
||||
@@ -175,11 +177,11 @@ describe("sanitizeSchemaForGoogle", () => {
|
||||
description: "A description",
|
||||
minLength: 1,
|
||||
};
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
expect(sanitized).toEqual({
|
||||
type: "string",
|
||||
enum: ["value"],
|
||||
description: "A description",
|
||||
description: "A description\n\n{minLength: 1}",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,17 +190,17 @@ describe("sanitizeSchemaForGoogle", () => {
|
||||
type: "array",
|
||||
items: { type: "string", const: "only" },
|
||||
};
|
||||
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const items = sanitized.items as Record<string, unknown>;
|
||||
expect(items.const).toBeUndefined();
|
||||
expect(items.enum).toEqual(["only"]);
|
||||
});
|
||||
|
||||
it("passes through primitives unchanged", () => {
|
||||
expect(sanitizeSchemaForGoogle("string")).toBe("string");
|
||||
expect(sanitizeSchemaForGoogle(123)).toBe(123);
|
||||
expect(sanitizeSchemaForGoogle(true)).toBe(true);
|
||||
expect(sanitizeSchemaForGoogle(null)).toBe(null);
|
||||
expect(normalizeSchemaForGoogle("string")).toBe("string");
|
||||
expect(normalizeSchemaForGoogle(123)).toBe(123);
|
||||
expect(normalizeSchemaForGoogle(true)).toBe(true);
|
||||
expect(normalizeSchemaForGoogle(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves property names that match schema keywords (e.g., 'pattern')", () => {
|
||||
@@ -210,7 +212,7 @@ describe("sanitizeSchemaForGoogle", () => {
|
||||
},
|
||||
required: ["pattern"],
|
||||
};
|
||||
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const props = sanitized.properties as Record<string, unknown>;
|
||||
expect(props.pattern).toEqual({ type: "string", description: "The search pattern" });
|
||||
expect(props.format).toEqual({ type: "string", description: "Output format" });
|
||||
@@ -224,7 +226,7 @@ describe("sanitizeSchemaForGoogle", () => {
|
||||
format: "email",
|
||||
minLength: 1,
|
||||
};
|
||||
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
|
||||
expect(sanitized.pattern).toBeUndefined();
|
||||
expect(sanitized.format).toBeUndefined();
|
||||
expect(sanitized.minLength).toBeUndefined();
|
||||
@@ -244,7 +246,7 @@ describe("tool schema validation (post-sanitization)", () => {
|
||||
if (!schema) continue;
|
||||
|
||||
// Apply the same sanitization that happens before sending to providers
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
const violations = validateSchema(sanitized, tool.name);
|
||||
const errors = violations.filter(v => v.severity === "error");
|
||||
|
||||
@@ -270,14 +272,15 @@ describe("tool schema validation (post-sanitization)", () => {
|
||||
it("hidden tools also have valid sanitized schemas", async () => {
|
||||
const session = createTestSession();
|
||||
|
||||
for (const [name, factory] of Object.entries(HIDDEN_TOOLS)) {
|
||||
const tool = await factory(session);
|
||||
for (const name in HIDDEN_TOOLS) {
|
||||
if (!Object.hasOwn(HIDDEN_TOOLS, name)) continue;
|
||||
const tool = await HIDDEN_TOOLS[name](session);
|
||||
if (!tool) continue;
|
||||
|
||||
const schema = tool.parameters;
|
||||
if (!schema) continue;
|
||||
|
||||
const sanitized = sanitizeSchemaForGoogle(schema);
|
||||
const sanitized = normalizeSchemaForGoogle(schema);
|
||||
const violations = validateSchema(sanitized, name);
|
||||
const errors = violations.filter(v => v.severity === "error");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user