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:
can1357
2026-05-16 19:04:09 +02:00
parent 959cd42798
commit e7200c2e4a
18 changed files with 1162 additions and 1034 deletions
@@ -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");