## New: schema compatibility validation API
Add `validateSchemaCompatibility(schema, provider)` in
`packages/ai/src/utils/schema/compatibility.ts` that performs a static
audit of a JSON Schema against three provider targets:
- `openai-strict`: checks forbidden keys, required/properties symmetry,
additionalProperties constraint, and that every node declares a type,
combinator, or $ref
- `google`: checks unsupported keyword set and array-valued type
- `cloud-code-assist-claude`: checks forbidden keywords, array type,
null type, nullable keyword, and combiner presence; also validates via
AJV 2020 draft
Add `validateStrictSchemaEnforcement(original, result)` to assert the
fail-open contract: when strict enforcement succeeds the output must pass
openai-strict validation; when it fails the output must be the original
schema object (same reference).
Export both functions and their types from `./utils/schema/index.ts`.
## New: shared constants in fields.ts
Extract `COMBINATOR_KEYS` (`anyOf`, `allOf`, `oneOf`) and add
`CCA_UNSUPPORTED_SCHEMA_FIELDS` as exported constants, eliminating the
local duplicate in `strict-mode.ts` and providing a canonical field set
for Cloud Code Assist (much narrower than the Google set — CCA supports
validation keywords like `additionalProperties`, `minLength`,
`pattern`, etc.).
## Fix: cycle detection in all recursive schema traversals
All recursive walkers now carry a `WeakSet<object>` guard. Previously any
schema with a reference cycle (or a schema object that appears at two
nodes in the tree) would cause an infinite loop or a stack overflow:
- `sanitizeSchemaForStrictMode` / `enforceStrictSchema`
- `normalizeSchemaForCloudCodeAssistClaude`
- `normalizeNullablePropertiesForCloudCodeAssist`
- `stripResidualCombiners`
- `sanitizeSchemaImpl` (Google sanitizer)
- `hasResidualCloudCodeAssistIncompatibilities`
`hasResidualCloudCodeAssistIncompatibilities` previously returned `true`
for already-visited nodes, producing false positives that forced the CCA
fallback schema on valid (but multiply-referenced) schemas. It now
correctly returns `false`.
## Fix: stripResidualCombiners iterates to fixpoint
The previous single-pass approach missed chained combiner reductions
where one collapsed variant exposed another reducible combiner. The
rewriter now loops until no further reduction occurs.
## Fix: mergeObjectCombinerVariants required-field computation
The merged object schema now takes the intersection of all variants'
`required` arrays, then unions in own-level required properties that
exist in the merged schema. Previously the `required` field was silently
dropped from the flattened schema, making all properties effectively
optional.
## Fix: sanitizeSchemaForGoogle improvements
- Type inference for const-collapsed enums: type is derived from all
variants (must unanimously agree), falling back to inference from enum
values; mixed null/non-null infers the non-null scalar type and sets
`nullable: true`
- Const→enum deduplication now uses deep structural equality instead of
`Object.is`
- Recursion spreads the full options object so new fields (`unsupportedFields`,
`seen`) are not silently dropped when descending into sub-schemas
- Array-valued `type` is filtered to strings before processing
- Removed incorrect stripping of `additionalProperties: false` (the
field is valid and should be preserved)
- Parameterized `unsupportedFields` in `SanitizeSchemaOptions` enables
code reuse between the Google and CCA sanitizers
## Fix: sanitizeSchemaForStrictMode / enforceStrictSchema
- `nullable: true` is now stripped during sanitization and expanded into
`anyOf: [schema, {type: "null"}]` in the enforcer output, matching
what OpenAI strict mode requires
- Type inference: `type: "array"` is inferred when `items` is present;
a scalar type is inferred from uniform `enum` values
- Const→enum merge uses deep equality to avoid duplicate entries when
both `const` and `enum` exist with the same value
- `additionalProperties` is now dropped unconditionally in sanitization
(previously only object-valued `additionalProperties` was recursed;
non-object values were passed through)
- `enforceStrictSchema` recurses into `$defs` and `definitions` blocks
- `enforceStrictSchema` handles tuple-style `items` arrays
- `enforceStrictSchema` skips double-wrapping: optional properties
already expressed as `anyOf: [..., {type: "null"}]` are not wrapped again
- `tryEnforceStrictSchema` now caches results in a `WeakMap` keyed on
the input schema object to avoid redundant work on repeated calls
## Fix: mergeCompatibleEnumSchemas deep equality
Uses `areJsonValuesEqual` instead of `Object.is` when deduplicating
enum members, so structurally equal objects are not duplicated.
## New: test coverage
- `packages/ai/test/schema-normalization.test.ts`: comprehensive unit
tests for strict mode, Google, and Cloud Code Assist normalization
- `packages/ai/test/schema-compatibility.test.ts`: unit tests for all
three provider targets in the new compatibility validator
- `packages/coding-agent/test/tools/provider-schema-compatibility.test.ts`:
integration test that instantiates every builtin and hidden tool, runs
their parameter schemas through all three provider pipelines, and
asserts zero compatibility violations
137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import {
|
|
adaptSchemaForStrict,
|
|
prepareSchemaForCCA,
|
|
type SchemaCompatibilityResult,
|
|
sanitizeSchemaForGoogle,
|
|
validateSchemaCompatibility,
|
|
validateStrictSchemaEnforcement,
|
|
} from "@oh-my-pi/pi-ai/utils/schema";
|
|
|
|
function hasRule(result: SchemaCompatibilityResult, rule: string): boolean {
|
|
return result.violations.some(violation => violation.rule === rule);
|
|
}
|
|
|
|
describe("schema compatibility validation", () => {
|
|
it("validates strict-mode schemas after enforcement", () => {
|
|
const strictResult = adaptSchemaForStrict(
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
requiredText: { type: "string" },
|
|
optionalCount: { type: "number" },
|
|
},
|
|
required: ["requiredText"],
|
|
},
|
|
true,
|
|
);
|
|
|
|
expect(strictResult.strict).toBe(true);
|
|
|
|
const compatibility = validateSchemaCompatibility(strictResult.schema, "openai-strict");
|
|
expect(compatibility.compatible).toBe(true);
|
|
expect(compatibility.violations).toEqual([]);
|
|
});
|
|
|
|
it("validates strict-mode fail-open contract when enforcement falls back", () => {
|
|
const originalSchema = {
|
|
type: "object",
|
|
properties: {
|
|
broken: {
|
|
type: "array",
|
|
items: {},
|
|
},
|
|
},
|
|
required: ["broken"],
|
|
} as Record<string, unknown>;
|
|
|
|
const strictResult = adaptSchemaForStrict(originalSchema, true);
|
|
expect(strictResult.strict).toBe(false);
|
|
|
|
const compatibility = validateStrictSchemaEnforcement(originalSchema, strictResult);
|
|
expect(compatibility.compatible).toBe(true);
|
|
expect(compatibility.violations).toEqual([]);
|
|
});
|
|
|
|
it("reports strict-mode violations for incompatible schemas", () => {
|
|
const compatibility = validateSchemaCompatibility(
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
},
|
|
required: [],
|
|
},
|
|
"openai-strict",
|
|
);
|
|
|
|
expect(compatibility.compatible).toBe(false);
|
|
expect(hasRule(compatibility, "strict-object-required")).toBe(true);
|
|
});
|
|
|
|
it("validates Google-compatible schemas after sanitization", () => {
|
|
const sanitized = sanitizeSchemaForGoogle({
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
pattern: { type: "string", description: "property name that matches schema keyword" },
|
|
status: { type: ["string", "null"], const: "active" },
|
|
},
|
|
required: ["status"],
|
|
});
|
|
|
|
const compatibility = validateSchemaCompatibility(sanitized, "google");
|
|
expect(compatibility.compatible).toBe(true);
|
|
expect(compatibility.violations).toEqual([]);
|
|
});
|
|
|
|
it("reports Google violations for unsupported schema features", () => {
|
|
const compatibility = validateSchemaCompatibility(
|
|
{
|
|
type: ["string", "null"],
|
|
additionalProperties: false,
|
|
},
|
|
"google",
|
|
);
|
|
|
|
expect(compatibility.compatible).toBe(false);
|
|
expect(hasRule(compatibility, "google-type-array")).toBe(true);
|
|
expect(hasRule(compatibility, "google-forbidden-key")).toBe(true);
|
|
});
|
|
|
|
it("validates Cloud Code Assist Claude schemas after normalization", () => {
|
|
const prepared = prepareSchemaForCCA({
|
|
type: "object",
|
|
properties: {
|
|
mode: { anyOf: [{ const: "fast" }, { const: "safe" }, { type: "null" }] },
|
|
},
|
|
required: ["mode"],
|
|
});
|
|
|
|
const compatibility = validateSchemaCompatibility(prepared, "cloud-code-assist-claude");
|
|
expect(compatibility.compatible).toBe(true);
|
|
expect(compatibility.violations).toEqual([]);
|
|
});
|
|
|
|
it("reports Cloud Code Assist residual incompatibilities", () => {
|
|
const compatibility = validateSchemaCompatibility(
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
value: {
|
|
nullable: true,
|
|
anyOf: [{ type: "string" }, { type: "null" }],
|
|
},
|
|
},
|
|
required: ["value"],
|
|
},
|
|
"cloud-code-assist-claude",
|
|
);
|
|
|
|
expect(compatibility.compatible).toBe(false);
|
|
expect(hasRule(compatibility, "cca-nullable-key")).toBe(true);
|
|
expect(hasRule(compatibility, "cca-combiner")).toBe(true);
|
|
expect(hasRule(compatibility, "cca-null-type")).toBe(true);
|
|
});
|
|
});
|