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
+6
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Renamed public schema utilities in `@oh-my-pi/pi-ai/utils/schema` by replacing `sanitizeSchemaForGoogle`, `sanitizeSchemaForCCA`, `prepareSchemaForCCA`, and `sanitizeSchemaForMCP` with `normalizeSchemaForGoogle`, `normalizeSchemaForCCA`, and `normalizeSchemaForMCP`
- Added MCP schema normalization via `normalizeSchemaForMCP` for compatibility checks
### Changed
@@ -12,6 +16,8 @@
### Fixed
- Fixed Gemini CLI / Antigravity tool schema normalization to run the full Cloud Code Assist pipeline, matching shared Google schema handling for union/object merging and nullable extraction
- Fixed stripped validation hints to be preserved as description spill text (`{key: value}` blocks) when `normalizeSchemaForGoogle` and `normalizeSchemaForCCA` drop unsupported schema keywords
- Fixed `sanitizeSchemaForGoogle` to collapse nullability forms (`type:'null'` and null-bearing `anyOf` variants) into `nullable` while preserving remaining variants
- Fixed `sanitizeSchemaForGoogle` to inline local `$defs` references instead of dropping `$ref`/`$defs` structure during Google schema sanitization
- Fixed `normalizeAnthropicToolSchema` to handle self-referential schemas without infinite recursion
+1 -17
View File
@@ -59,6 +59,7 @@ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { notifyProviderResponse } from "../utils/provider-response";
import { isCopilotTransientModelError } from "../utils/retry";
import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
import { spillToDescription } from "../utils/schema/spill";
import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
import {
buildCopilotDynamicHeaders,
@@ -2144,23 +2145,6 @@ function isJsonSchemaObjectNode(schema: Record<string, unknown>): boolean {
return false;
}
/**
* Demote unsupported JSON Schema keywords into the node's `description` so the model
* still gets the constraint as a natural-language hint after we strip it from the wire
* schema. Mirrors the trailing description-spill in the Anthropic Python SDK's
* `lib/_parse/_transform.py::transform_schema`, formatted as `{key: value, ...}`.
*
* `entries` are applied in order and only when the value is not `undefined`; an empty
* input is a no-op so callers can pass the same set unconditionally.
*/
function spillToDescription(node: Record<string, unknown>, entries: Array<[string, unknown]>): void {
const spilled = entries.filter(([, value]) => value !== undefined);
if (spilled.length === 0) return;
const formatted = `{${spilled.map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join(", ")}}`;
const existing = typeof node.description === "string" ? node.description : "";
node.description = existing ? `${existing}\n\n${formatted}` : formatted;
}
/**
* Pick the principal non-null scalar type from a `type` keyword. Anthropic accepts
* `type` as either a single string or an array (e.g. `["number", "null"]` for a
@@ -24,7 +24,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
import { refreshAntigravityToken } from "../utils/oauth/google-antigravity";
import { refreshGoogleCloudToken } from "../utils/oauth/google-gemini-cli";
import { sanitizeSchemaForCCA } from "../utils/schema";
import { normalizeSchemaForCCA } from "../utils/schema";
import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers";
import {
convertMessages,
@@ -688,7 +688,7 @@ function normalizeAntigravityTools(
const { parametersJsonSchema, ...rest } = declaration;
return {
...rest,
parameters: sanitizeSchemaForCCA(parametersJsonSchema),
parameters: normalizeSchemaForCCA(parametersJsonSchema),
};
}),
}));
+3 -3
View File
@@ -30,11 +30,11 @@ import type {
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { prepareSchemaForCCA, sanitizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
import { transformMessages } from "./transform-messages";
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
export { sanitizeSchemaForGoogle };
export { normalizeSchemaForGoogle };
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
@@ -340,7 +340,7 @@ export function convertTools(
name: tool.name,
description: tool.description || "",
...(useParameters
? { parameters: prepareSchemaForCCA(toolWireSchema(tool)) }
? { parameters: normalizeSchemaForCCA(toolWireSchema(tool)) }
: { parametersJsonSchema: toolWireSchema(tool) }),
})),
},
+17 -13
View File
@@ -5,8 +5,7 @@ This document is the operational contract for schema normalization/strictness in
## Scope
- Applies to provider-facing tool schemas produced by:
- `sanitize-google.ts`
- `normalize-cca.ts`
- `normalize.ts`
- `strict-mode.ts`
- `adapt.ts`
- `fields.ts`
@@ -57,9 +56,9 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
---
## 2) Google Gemini / Vertex / Gemini CLI (`sanitizeSchemaForGoogle`)
## 2) Google Gemini / Vertex / Gemini CLI (`normalizeSchemaForGoogle`)
Schemas sent on Google JSON Schema path MUST follow:
Schemas sent on the Google JSON Schema path MUST follow:
1. **Unsupported JSON Schema keywords are stripped (except property names under `properties`)**
- Unsupported keys (`UNSUPPORTED_SCHEMA_FIELDS`):
@@ -70,6 +69,7 @@ Schemas sent on Google JSON Schema path MUST follow:
- `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`
- `pattern`, `format`
- Important: keys inside a `properties` object are treated as property names and MUST NOT be stripped by keyword match.
- Human-meaningful stripped keys (`pattern`, `format`, min/max constraints, `default`, `examples`, etc.) are appended to the sibling `description` as an Anthropic-style spill block: `{pattern: "^foo$", minimum: 0}`. Structural/meta keys such as `$ref`, `$defs`, and `additionalProperties` are not spilled.
2. **`type` arrays are normalized to scalar type + nullable marker**
- `type: ["T", "null"]` becomes `type: "T"` and `nullable: true`.
@@ -78,25 +78,25 @@ Schemas sent on Google JSON Schema path MUST follow:
3. **`const` is converted to `enum`**
- If `const` exists, schema uses/merges `enum` with the const value.
4. **`additionalProperties: false` is removed**
- This value is stripped during sanitization for Google compatibility.
4. **Object schemas get an explicit properties map**
- `{ "type": "object" }` becomes `{ "type": "object", "properties": {} }`.
---
## 3) Claude via Cloud Code Assist (`prepareSchemaForCCA`)
## 3) Claude via Cloud Code Assist (`normalizeSchemaForCCA`)
For Cloud Code Assist Claude tool declarations, schema MUST satisfy stricter constraints than generic Google path.
### 3.1 Transport contract
1. **Use legacy `parameters` field** (not `parametersJsonSchema`) for CCA Claude.
2. CCA path uses `sanitizeSchemaForCCA` + normalization pipeline.
2. CCA path uses the full `normalizeSchemaForCCA` pipeline.
### 3.2 Sanitization contract
1. Start with Google sanitizer behavior.
1. Start with Google unsupported-key stripping behavior.
2. **`nullable` keyword MUST be stripped** in CCA Claude path.
3. `type: ["T", "null"]` becomes `type: "T"` with no `nullable` marker.
4. Human-meaningful stripped keys are appended to `description` with the same spill format used by the Google dispatcher.
### 3.3 Combiner/union normalization contract
@@ -145,10 +145,10 @@ If any remain, schema is incompatible.
- Emit `strict: true` only when effective strict enforcement succeeded.
- **Google Gemini/Vertex/Gemini CLI (non-CCA Claude)**:
- Use Google sanitizer and send schema on `parametersJsonSchema` path.
- Use `normalizeSchemaForGoogle` and send schema on `parametersJsonSchema` path.
- **Cloud Code Assist Claude models (`model.id` starts with `claude-`)**:
- Use CCA preparation pipeline and send sanitized normalized schema in `parameters`.
- Use `normalizeSchemaForCCA` and send sanitized normalized schema in `parameters`.
---
@@ -158,5 +158,9 @@ When adding/changing provider adapters:
1. Any new unsupported keyword MUST be added to the appropriate set in `fields.ts`.
2. Any new normalization rule MUST include regression tests under `packages/ai/test`.
3. Never bypass adapter helpers (`adaptSchemaForStrict`, `sanitizeSchemaForGoogle`, `prepareSchemaForCCA`) in provider code.
3. Never bypass adapter helpers (`adaptSchemaForStrict`, `normalizeSchemaForGoogle`, `normalizeSchemaForCCA`, `normalizeSchemaForMCP`) in provider code.
4. If a provider rejects schema with partial support, prefer deterministic per-tool fallback over request-wide failure.
## 6) Gemini CLI / Antigravity CCA parity
The Gemini CLI / Antigravity Claude path MUST run the same full `normalizeSchemaForCCA` pipeline as the shared Google Claude path. It MUST NOT call only the first keyword-stripping pass, because that leaves object combiners, nullable unions, residual combiners, and fallback gating inconsistent between transports.
@@ -11,8 +11,8 @@ import { isJsonObject, type JsonObject } from "./types";
* Schema compatibility audits.
*
* Each provider has a different idea of what JSON Schema features it accepts
* for tool definitions. The sanitizers in `normalize-cca`, `sanitize-google`,
* and `strict-mode` rewrite incoming schemas to fit. This module is the
* for tool definitions. The normalizers in `normalize.ts`, `strict-mode`,
* and `adapt.ts` rewrite incoming schemas to fit. This module is the
* *audit* counterpart: it walks a (presumably already-sanitized) schema and
* reports any feature the target provider would reject. Tests use it to lock
* down the contract; the runtime uses it to fail-open with diagnostic logs
+26 -2
View File
@@ -11,7 +11,7 @@
/**
* Google Generative AI unsupported schema fields.
* Stripped during sanitizeSchemaForGoogle / sanitizeSchemaForCCA.
* Stripped during normalizeSchemaForGoogle / normalizeSchemaForCCA.
*/
export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
$schema: true,
@@ -38,6 +38,30 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
format: true,
};
/**
* Human-meaningful validation/decorative keywords that can be preserved in a
* sibling description when a provider-specific normalizer strips them from the
* wire schema.
*/
export const LIFTABLE_TO_DESCRIPTION_FIELDS: Record<string, true> = {
pattern: true,
format: true,
minLength: true,
maxLength: true,
minimum: true,
maximum: true,
exclusiveMinimum: true,
exclusiveMaximum: true,
multipleOf: true,
minItems: true,
maxItems: true,
uniqueItems: true,
minProperties: true,
maxProperties: true,
default: true,
examples: true,
};
/**
* Non-structural schema keys stripped during OpenAI strict mode sanitization.
* These are decorative/validation-only keywords that don't affect the structural
@@ -146,7 +170,7 @@ export const CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS: Record<string, true> = {
/**
* Combinator keys used across schema sanitization modules.
* Defined once to avoid duplication in strict-mode.ts and normalize-cca.ts.
* Defined once to avoid duplication in strict-mode.ts and normalize.ts.
*/
export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const;
+2 -2
View File
@@ -6,8 +6,8 @@ export * from "./equality";
export * from "./fields";
export * from "./json-schema-validator";
export * from "./meta-validator";
export * from "./normalize-cca";
export * from "./sanitize-google";
export * from "./normalize";
export * from "./spill";
export * from "./strict-mode";
export * from "./types";
export * from "./wire";
@@ -1,490 +0,0 @@
/**
* Cloud Code Assist (CCA) for Claude rejects most JSON Schema combinator and
* nullable shapes. This module is the multi-pass rewriter that turns whatever
* the tool author authored into the narrow subset CCA accepts:
*
* 1. `sanitizeSchemaForCCA` — strip Google-incompatible keywords, normalize
* `type: [..., "null"]` arrays into a scalar + nullable.
* 2. `mergeObjectCombinerVariants` — collapse `anyOf` of object variants
* into a single merged object.
* 3. `collapseMixedTypeCombinerVariants` — `anyOf` of distinct scalar types
* collapses to the first non-null type (lossy, intentional).
* 4. `collapseSameTypeCombinerVariants` — `anyOf` of variants with one
* shared type collapses to that variant (lossy, intentional).
* 5. `stripResidualCombiners` — fixpoint loop applying 3+4 to combiners that
* pass-1 merging produced from inside merged subtrees.
* 6. `normalizeNullablePropertiesForCloudCodeAssist` — extract `nullable: T`
* from `anyOf:[T,null]`-shaped property schemas and demote those keys
* from `required`.
*
* If any incompatibility survives, we ship a stub `{type:"object",properties:{}}`
* fallback for that tool — CCA will accept the call but the model will see no
* arguments documented. Better than rejecting the whole turn.
*/
import { logger } from "@oh-my-pi/pi-utils";
import { areJsonValuesEqual, mergePropertySchemas } from "./equality";
import { CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS } from "./fields";
import { isValidJsonSchema } from "./meta-validator";
import { sanitizeSchemaForCCA } from "./sanitize-google";
import { epochNext, once } from "./stamps";
import type { JsonObject } from "./types";
import { isJsonObject } from "./types";
/** Copy all keys from a schema except the specified combiner key. */
export function copySchemaWithout(schema: JsonObject, combiner: string): JsonObject {
const { [combiner]: _, ...rest } = schema;
return rest;
}
/**
* Claude via Cloud Code Assist (`parameters` path) can reject schemas that keep
* object variant combiners, so flatten object-only unions into one object shape.
*/
function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
return schema;
}
const variants: JsonObject[] = [];
for (const entry of variantsRaw) {
if (!isJsonObject(entry)) {
return schema;
}
const variantType = entry.type;
const hasObjectShape =
isJsonObject(entry.properties) ||
Array.isArray(entry.required) ||
Object.hasOwn(entry, "additionalProperties");
if (variantType === undefined && !hasObjectShape) {
return schema;
}
if (variantType !== undefined && variantType !== "object") {
return schema;
}
if (entry.properties !== undefined && !isJsonObject(entry.properties)) {
return schema;
}
if (entry.required !== undefined && !Array.isArray(entry.required)) {
return schema;
}
variants.push(entry);
}
const mergedProperties: JsonObject = {};
const ownProperties = isJsonObject(schema.properties) ? schema.properties : {};
for (const name in ownProperties) {
mergedProperties[name] = ownProperties[name];
}
for (const variant of variants) {
const properties = isJsonObject(variant.properties) ? variant.properties : {};
for (const name in properties) {
const propertySchema = properties[name];
const existingSchema = mergedProperties[name];
mergedProperties[name] =
existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema);
}
}
const nextSchema = copySchemaWithout(schema, combiner);
nextSchema.type = "object";
nextSchema.properties = mergedProperties;
// Compute the `required` set for the merged object. We intersect each
// variant's required keys (a property is only required if every variant
// required it) and then union in the parent's own required keys for
// properties that lived on the parent. Filter against `mergedProperties`
// so we never reference a key that does not exist on the result.
let requiredIntersection: string[] | undefined;
for (const variant of variants) {
const variantRequired = Array.isArray(variant.required)
? variant.required.filter((r): r is string => typeof r === "string")
: [];
if (requiredIntersection === undefined) {
requiredIntersection = [...variantRequired];
} else {
const reqSet = new Set(variantRequired);
requiredIntersection = requiredIntersection.filter(r => reqSet.has(r));
}
}
const parentRequired = Array.isArray(schema.required)
? schema.required.filter((r): r is string => typeof r === "string")
: [];
const safeRequired = new Set<string>();
for (const name of requiredIntersection ?? []) {
if (name in mergedProperties) safeRequired.add(name);
}
for (const name of parentRequired) {
if (name in ownProperties && name in mergedProperties) {
safeRequired.add(name);
}
}
// Emit required in property-insertion order so the wire payload is stable.
const requiredInPropertyOrder: string[] = [];
for (const name in mergedProperties) {
if (safeRequired.has(name)) requiredInPropertyOrder.push(name);
}
if (requiredInPropertyOrder.length > 0) {
nextSchema.required = requiredInPropertyOrder;
} else {
delete nextSchema.required;
}
return nextSchema;
}
/**
* Collapse anyOf/oneOf with distinct typed variants into a single-type schema.
* Picks the first non-null type as a scalar. This is lossy for multi-type unions
* (e.g., string|number|null narrows to string), but CCA requires a scalar type field
* and an uncollapsed anyOf would be rejected by the CCA API at runtime.
*/
function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
return schema;
}
const seenTypes = new Set<string>();
const variantTypes: string[] = [];
const mergedVariantFields: JsonObject = {};
for (const entry of variantsRaw) {
if (!isJsonObject(entry) || typeof entry.type !== "string") {
return schema;
}
const variantType = entry.type;
if (seenTypes.has(variantType)) {
return schema;
}
const allowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[variantType];
if (!allowedKeys) {
return schema;
}
for (const key in entry) {
const variantValue = entry[key];
if (key === "type") continue;
if (!(key in allowedKeys) && !(key in CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS)) {
return schema;
}
const existingValue = mergedVariantFields[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
return schema;
}
mergedVariantFields[key] = variantValue;
}
seenTypes.add(variantType);
variantTypes.push(variantType);
}
if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) {
return schema;
}
const nextSchema = copySchemaWithout(schema, combiner);
const nonNullTypes = variantTypes.filter(t => t !== "null");
// Lossy: when multiple non-null types exist we pick the first. CCA requires
// a scalar type and keeping the anyOf would cause an API rejection at runtime.
nextSchema.type = nonNullTypes[0] ?? variantTypes[0];
for (const key in mergedVariantFields) {
const value = mergedVariantFields[key];
const existingValue = nextSchema[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
return schema;
}
if (existingValue === undefined) {
nextSchema[key] = value;
}
}
return nextSchema;
}
/**
* Collapse anyOf/oneOf where all variants share the same primitive type.
* E.g. anyOf: [{type: "string", desc: "A"}, {type: "string", desc: "B"}] -> {type: "string", desc: "A"}
* Claude via CCA rejects any remaining anyOf/oneOf, so pick first variant.
* Note: constraints from non-first variants are silently dropped.
*/
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
let commonType: string | undefined;
let firstEntry: JsonObject | undefined;
for (const entry of variantsRaw) {
if (!isJsonObject(entry) || typeof entry.type !== "string") return schema;
if (commonType === undefined) {
commonType = entry.type;
firstEntry = entry;
} else if (entry.type !== commonType) return schema;
}
if (!firstEntry) return schema;
const nextSchema = copySchemaWithout(schema, combiner);
for (const key in firstEntry) {
if (!(key in nextSchema)) nextSchema[key] = firstEntry[key];
}
return nextSchema;
}
/**
* Recursively strip any remaining anyOf/oneOf that collapseSameTypeCombinerVariants can handle.
* This is needed because mergeObjectCombinerVariants can create new anyOf in merged
* properties AFTER the recursive normalization pass has already processed children.
*/
export function stripResidualCombiners(value: unknown, epoch: number = epochNext()): unknown {
if (Array.isArray(value)) {
if (!once(value, epoch)) return [];
return value.map(entry => stripResidualCombiners(entry, epoch));
}
if (!isJsonObject(value)) return value;
if (!once(value, epoch)) return {};
const result: JsonObject = {};
for (const key in value) {
result[key] = stripResidualCombiners(value[key], epoch);
}
let current: JsonObject = result;
let changed = true;
while (changed) {
changed = false;
for (const combiner of ["anyOf", "oneOf"] as const) {
const sameType = collapseSameTypeCombinerVariants(current, combiner);
if (sameType !== current) {
current = sameType;
changed = true;
}
const mixed = collapseMixedTypeCombinerVariants(current, combiner);
if (mixed !== current) {
current = mixed;
changed = true;
}
}
}
return current;
}
function normalizeSchemaForCCA(value: unknown, epoch: number = epochNext()): unknown {
if (Array.isArray(value)) {
if (!once(value, epoch)) return [];
return value.map(entry => normalizeSchemaForCCA(entry, epoch));
}
if (!isJsonObject(value)) {
return value;
}
if (!once(value, epoch)) return {};
const normalized: JsonObject = {};
for (const key in value) {
normalized[key] = normalizeSchemaForCCA(value[key], epoch);
}
const mergedAnyOf = mergeObjectCombinerVariants(normalized, "anyOf");
const collapsedAnyOf = collapseMixedTypeCombinerVariants(mergedAnyOf, "anyOf");
const sameTypeAnyOf = collapseSameTypeCombinerVariants(collapsedAnyOf, "anyOf");
const mergedOneOf = mergeObjectCombinerVariants(sameTypeAnyOf, "oneOf");
const collapsedOneOf = collapseMixedTypeCombinerVariants(mergedOneOf, "oneOf");
return collapseSameTypeCombinerVariants(collapsedOneOf, "oneOf");
}
interface NullableExtractionResult {
schema: unknown;
nullable: boolean;
}
function extractNullableUnionSchema(schema: unknown): NullableExtractionResult {
if (!isJsonObject(schema)) {
return { schema, nullable: false };
}
if (schema.nullable === true) {
const nextSchema = { ...schema };
delete nextSchema.nullable;
return { schema: nextSchema, nullable: true };
}
if (Array.isArray(schema.type)) {
const typeVariants = schema.type.filter((entry): entry is string => typeof entry === "string");
const nonNullTypes = typeVariants.filter(entry => entry !== "null");
if (typeVariants.includes("null") && nonNullTypes.length === 1) {
const nextSchema = { ...schema, type: nonNullTypes[0] };
return { schema: nextSchema, nullable: true };
}
}
for (const combiner of ["anyOf", "oneOf"] as const) {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw)) continue;
let hasNullVariant = false;
const nonNullVariants: unknown[] = [];
for (const variant of variantsRaw) {
if (isJsonObject(variant) && variant.type === "null") {
let keyCount = 0;
for (const _k in variant) {
if (++keyCount > 1) break;
}
if (keyCount === 1) {
hasNullVariant = true;
continue;
}
}
nonNullVariants.push(variant);
}
if (!hasNullVariant || nonNullVariants.length !== 1 || !isJsonObject(nonNullVariants[0])) {
continue;
}
const nextSchema = copySchemaWithout(schema, combiner);
const nonNullVariant = nonNullVariants[0];
for (const key in nonNullVariant) {
const value = nonNullVariant[key];
const existingValue = nextSchema[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
return { schema, nullable: false };
}
if (existingValue === undefined) {
nextSchema[key] = value;
}
}
return { schema: nextSchema, nullable: true };
}
return { schema, nullable: false };
}
interface NullableNormalizationResult {
schema: unknown;
nullable: boolean;
}
function normalizeNullablePropertiesForCloudCodeAssist(
value: unknown,
isPropertySchema = false,
epoch: number = epochNext(),
): NullableNormalizationResult {
if (Array.isArray(value)) {
if (!once(value, epoch)) {
return { schema: [], nullable: false };
}
return {
schema: value.map(entry => normalizeNullablePropertiesForCloudCodeAssist(entry, false, epoch).schema),
nullable: false,
};
}
if (!isJsonObject(value)) {
return { schema: value, nullable: false };
}
if (!once(value, epoch)) {
return { schema: {}, nullable: false };
}
const normalized: JsonObject = {};
for (const key in value) {
normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(value[key], false, epoch).schema;
}
if (isJsonObject(normalized.properties)) {
const properties = normalized.properties;
const required = new Set(
Array.isArray(normalized.required)
? normalized.required.filter((entry): entry is string => typeof entry === "string")
: [],
);
const nextProperties: JsonObject = {};
for (const name in properties) {
const normalizedProperty = normalizeNullablePropertiesForCloudCodeAssist(properties[name], true, epoch);
nextProperties[name] = normalizedProperty.schema;
if (normalizedProperty.nullable) {
required.delete(name);
}
}
normalized.properties = nextProperties;
if (Array.isArray(normalized.required)) {
normalized.required = Array.from(required);
}
}
if (!isPropertySchema) {
return { schema: normalized, nullable: false };
}
return extractNullableUnionSchema(normalized);
}
/**
* Keep validation synchronous in this request path.
* Replaces the previous AJV-based meta-schema check with a tiny
* structural validator that catches the failure modes the CCA pipeline
* actually produces.
*/
function isValidCCASchema(schema: unknown): boolean {
return isValidJsonSchema(schema);
}
/** See COMBINATOR_KEYS in fields.ts — CCA forbids all three combiners. */
const CCA_FORBIDDEN_COMBINERS: Record<string, true> = { anyOf: true, oneOf: true, allOf: true };
function hasResidualCloudCodeAssistIncompatibilities(value: unknown, epoch: number = epochNext()): boolean {
if (Array.isArray(value)) {
if (!once(value, epoch)) return false;
return value.some(entry => hasResidualCloudCodeAssistIncompatibilities(entry, epoch));
}
if (!isJsonObject(value)) {
return false;
}
if (!once(value, epoch)) {
return false;
}
if (Array.isArray(value.type) || value.type === "null") {
return true;
}
if (Object.hasOwn(value, "nullable")) {
return true;
}
for (const combiner in CCA_FORBIDDEN_COMBINERS) {
if (Array.isArray(value[combiner])) {
return true;
}
}
for (const k in value) {
if (hasResidualCloudCodeAssistIncompatibilities(value[k], epoch)) {
return true;
}
}
return false;
}
const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = {
type: "object",
properties: {},
} as const;
/**
* Prepare schema for Claude on Cloud Code Assist:
* sanitize -> normalize union objects -> validate -> fallback.
*
* Fallback is per-tool and fail-open to avoid rejecting the entire request when
* one tool schema is invalid.
*/
export function prepareSchemaForCCA(value: unknown): unknown {
const sanitized = sanitizeSchemaForCCA(value);
const pass1 = normalizeSchemaForCCA(sanitized);
// Second pass: strip anyOf/oneOf created by mergeObjectCombinerVariants during pass1
const normalized = stripResidualCombiners(pass1);
const nullableNormalized = normalizeNullablePropertiesForCloudCodeAssist(normalized).schema;
if (hasResidualCloudCodeAssistIncompatibilities(nullableNormalized)) {
logger.debug("CCA schema has residual incompatibilities, using fallback");
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
}
if (isValidCCASchema(nullableNormalized)) {
return nullableNormalized;
}
logger.debug("CCA schema failed validation, using fallback");
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
}
+848
View File
@@ -0,0 +1,848 @@
/**
* Provider-specific JSON Schema normalization used in the request path.
*
* Google's Schema proto, Cloud Code Assist's Claude bridge, and MCP/AJV
* validation all reject different subsets of standard JSON Schema. This module
* exposes one option-driven core plus thin dispatchers that pin the option set
* for each target.
*/
import { logger } from "@oh-my-pi/pi-utils";
import { dereferenceJsonSchema } from "./dereference";
import { upgradeJsonSchemaTo202012 } from "./draft";
import { areJsonValuesEqual, mergePropertySchemas } from "./equality";
import {
CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS,
CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS,
LIFTABLE_TO_DESCRIPTION_FIELDS,
UNSUPPORTED_SCHEMA_FIELDS,
} from "./fields";
import { isValidJsonSchema } from "./meta-validator";
import { type DescriptionSpillFormat, spillToDescription } from "./spill";
import { epochNext, once } from "./stamps";
import type { JsonObject } from "./types";
import { isJsonObject } from "./types";
export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners";
export interface NormalizeSchemaOptions {
unsupportedFields: (key: string) => boolean;
normalizeFieldNames: boolean;
collapseNullFields: boolean;
normalizeTypeArrayToNullable: boolean;
stripNullableKeyword: boolean;
autoPropertyOrdering: boolean;
ensureObjectProperties: boolean;
liftStrippedToDescription:
| false
| {
keys?: (key: string) => boolean;
format?: DescriptionSpillFormat;
};
mergeObjectCombiners: boolean;
collapseSameTypeCombiners: boolean;
collapseMixedTypeCombiners: boolean;
stripResidualCombinersFixpoint: boolean;
extractNullableFromUnions: boolean;
rejectResidualIncompatibilities?: ReadonlyArray<ResidualSchemaIncompatibility>;
validateAndFallback?: { fallback: unknown };
}
interface NormalizeSchemaWalkOptions extends NormalizeSchemaOptions {
insideProperties: boolean;
epoch: number;
}
interface ResidualIncompatibilityChecks {
typeArray: boolean;
typeNull: boolean;
nullable: boolean;
combiners: boolean;
}
const SNAKE_TO_CAMEL_RENAMES = new Map<string, string>([
["additional_properties", "additionalProperties"],
["any_of", "anyOf"],
["prefix_items", "prefixItems"],
["property_ordering", "propertyOrdering"],
]);
const JSON_SCHEMA_COMBINERS = ["anyOf", "oneOf"] as const;
const CCA_FORBIDDEN_COMBINERS = new Set(["anyOf", "oneOf", "allOf"]);
const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = {
type: "object",
properties: {},
} as const;
function isGoogleUnsupportedSchemaField(key: string): boolean {
return Object.hasOwn(UNSUPPORTED_SCHEMA_FIELDS, key);
}
function isMcpUnsupportedSchemaField(key: string): boolean {
return key === "$schema";
}
function isDefaultLiftableToDescriptionField(key: string): boolean {
return Object.hasOwn(LIFTABLE_TO_DESCRIPTION_FIELDS, key);
}
/**
* Returns `obj` unchanged when no renamable key is present; otherwise returns
* a fresh shallow-copy with snake_case keys rewritten. The collision rule
* matches upstream (`pop(from)` → `set(to)`): snake_case wins over an
* existing camelCase entry, matching python-genai/_transformers.py:751.
*/
function applySnakeCaseRenames(obj: JsonObject): JsonObject {
let needsRename = false;
for (const k in obj) {
if (!Object.hasOwn(obj, k)) continue;
if (SNAKE_TO_CAMEL_RENAMES.has(k)) {
needsRename = true;
break;
}
}
if (!needsRename) return obj;
const out: JsonObject = {};
for (const k in obj) {
if (!Object.hasOwn(obj, k)) continue;
const renamed = SNAKE_TO_CAMEL_RENAMES.get(k);
if (renamed !== undefined) {
out[renamed] = obj[k];
} else if (!outHasOwn(out, k)) {
out[k] = obj[k];
}
}
return out;
}
/**
* `handle_null_fields` (python-genai/_transformers.py:584-640) applied at the
* parent level BEFORE child recursion — matches upstream's call order at
* `process_schema` line 768. Returns a new object when changes apply, the
* original reference otherwise (zero-allocation fast path).
*/
function preHandleNullFields(obj: JsonObject): JsonObject {
if (obj.type === "null") {
const out: JsonObject = {};
for (const k in obj) {
if (!Object.hasOwn(obj, k) || k === "type") continue;
out[k] = obj[k];
}
out.nullable = true;
return out;
}
if (!Array.isArray(obj.anyOf)) return obj;
const variants = obj.anyOf as unknown[];
let sawNull = false;
const kept: unknown[] = [];
for (const v of variants) {
if (isJsonObject(v) && v.type === "null") {
sawNull = true;
continue;
}
kept.push(v);
}
if (!sawNull) return obj;
const out: JsonObject = {};
for (const k in obj) {
if (Object.hasOwn(obj, k)) out[k] = obj[k];
}
out.nullable = true;
if (kept.length === 0) {
delete out.anyOf;
} else if (kept.length === 1 && isJsonObject(kept[0])) {
delete out.anyOf;
const only = kept[0];
for (const k in only) {
if (Object.hasOwn(only, k) && !outHasOwn(out, k)) out[k] = only[k];
}
} else {
out.anyOf = kept;
}
return out;
}
function outHasOwn(obj: JsonObject, key: string): boolean {
return Object.hasOwn(obj, key);
}
function inferJsonSchemaTypeFromValue(value: unknown): string | undefined {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
switch (typeof value) {
case "string":
return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "object":
return "object";
default:
return undefined;
}
}
function pushEnumValue(values: unknown[], value: unknown): void {
if (!values.some(existing => areJsonValuesEqual(existing, value))) {
values.push(value);
}
}
function pushStrippedDescriptionEntry(
spill: Array<[string, unknown]> | undefined,
key: string,
value: unknown,
options: NormalizeSchemaWalkOptions,
): Array<[string, unknown]> | undefined {
const lift = options.liftStrippedToDescription;
if (!lift) return spill;
const isLiftable = lift.keys ?? isDefaultLiftableToDescriptionField;
if (!isLiftable(key)) return spill;
const next = spill ?? [];
next.push([key, value]);
return next;
}
function applyDescriptionSpill(
result: JsonObject,
spill: Array<[string, unknown]> | undefined,
options: NormalizeSchemaWalkOptions,
): void {
const lift = options.liftStrippedToDescription;
if (!lift || spill === undefined) return;
spillToDescription(result, spill, lift.format ?? "spill");
}
function normalizeSchemaNode(value: unknown, options: NormalizeSchemaWalkOptions): unknown {
if (Array.isArray(value)) {
if (!once(value, options.epoch)) return [];
return value.map(entry => normalizeSchemaNode(entry, options));
}
if (!isJsonObject(value)) {
return value;
}
if (!once(value, options.epoch)) return {};
let obj = options.normalizeFieldNames && !options.insideProperties ? applySnakeCaseRenames(value) : value;
if (options.collapseNullFields && !options.insideProperties) {
obj = preHandleNullFields(obj);
}
const result: JsonObject = {};
let spill: Array<[string, unknown]> | undefined;
for (const combiner of JSON_SCHEMA_COMBINERS) {
if (!Array.isArray(obj[combiner])) continue;
const variants = obj[combiner] as JsonObject[];
const allHaveConst = variants.every(v => isJsonObject(v) && "const" in v);
if (!allHaveConst || variants.length === 0) continue;
const dedupedEnum: unknown[] = [];
for (const variant of variants) {
pushEnumValue(dedupedEnum, variant.const);
}
result.enum = dedupedEnum;
const explicitTypes = variants
.map(variant => variant.type)
.filter((variantType): variantType is string => typeof variantType === "string");
const allHaveSameExplicitType =
explicitTypes.length === variants.length &&
explicitTypes.every(variantType => variantType === explicitTypes[0]);
if (allHaveSameExplicitType && explicitTypes[0]) {
result.type = explicitTypes[0];
} else {
const inferredTypes = dedupedEnum
.map(enumValue => inferJsonSchemaTypeFromValue(enumValue))
.filter((inferredType): inferredType is string => inferredType !== undefined);
const inferredTypeSet = new Set(inferredTypes);
if (inferredTypeSet.size === 1) {
result.type = inferredTypes[0];
} else {
const nonNullInferredTypes = inferredTypes.filter(inferredType => inferredType !== "null");
const nonNullTypeSet = new Set(nonNullInferredTypes);
if (inferredTypes.includes("null") && nonNullTypeSet.size === 1) {
result.type = nonNullInferredTypes[0];
if (!options.stripNullableKeyword) {
result.nullable = true;
}
}
}
}
for (const key in obj) {
if (!Object.hasOwn(obj, key) || key === combiner || outHasOwn(result, key)) continue;
const entry = obj[key];
if (!options.insideProperties && options.unsupportedFields(key)) {
spill = pushStrippedDescriptionEntry(spill, key, entry, options);
continue;
}
if (options.stripNullableKeyword && key === "nullable") continue;
result[key] = normalizeSchemaNode(entry, {
...options,
insideProperties: key === "properties",
});
}
applyDescriptionSpill(result, spill, options);
return applyNodePostProcessing(result, options);
}
let constValue: unknown;
for (const key in obj) {
if (!Object.hasOwn(obj, key)) continue;
const entry = obj[key];
if (!options.insideProperties && options.unsupportedFields(key)) {
spill = pushStrippedDescriptionEntry(spill, key, entry, options);
continue;
}
if (options.stripNullableKeyword && key === "nullable") continue;
if (key === "const") {
constValue = entry;
continue;
}
result[key] = normalizeSchemaNode(entry, {
...options,
insideProperties: key === "properties",
});
}
if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) {
const types = (result.type as unknown[]).filter((t): t is string => typeof t === "string");
const nonNull = types.filter(t => t !== "null");
if (types.includes("null") && !options.stripNullableKeyword) {
result.nullable = true;
}
result.type = nonNull[0] ?? types[0];
}
if (constValue !== undefined) {
const existingEnum = Array.isArray(result.enum) ? result.enum : [];
pushEnumValue(existingEnum, constValue);
result.enum = existingEnum;
if (!result.type) {
result.type = inferJsonSchemaTypeFromValue(constValue);
}
}
if (options.collapseNullFields && result.type === "null") {
delete result.type;
if (!options.stripNullableKeyword) result.nullable = true;
}
if (
options.autoPropertyOrdering &&
result.type === "object" &&
!outHasOwn(result, "propertyOrdering") &&
isJsonObject(result.properties)
) {
const props = result.properties;
const keys: string[] = [];
for (const k in props) {
if (Object.hasOwn(props, k)) keys.push(k);
}
if (keys.length > 1) result.propertyOrdering = keys;
}
if (options.ensureObjectProperties && result.type === "object" && !outHasOwn(result, "properties")) {
result.properties = {};
}
applyDescriptionSpill(result, spill, options);
return applyNodePostProcessing(result, options);
}
function applyNodePostProcessing(schema: JsonObject, options: NormalizeSchemaWalkOptions): JsonObject {
let current = schema;
for (const combiner of JSON_SCHEMA_COMBINERS) {
if (options.mergeObjectCombiners) current = mergeObjectCombinerVariants(current, combiner);
if (options.collapseMixedTypeCombiners) current = collapseMixedTypeCombinerVariants(current, combiner);
if (options.collapseSameTypeCombiners) current = collapseSameTypeCombinerVariants(current, combiner);
}
return current;
}
/** Copy all keys from a schema except the specified combiner key. */
export function copySchemaWithout(schema: JsonObject, combiner: string): JsonObject {
const { [combiner]: _, ...rest } = schema;
return rest;
}
function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
return schema;
}
const variants: JsonObject[] = [];
for (const entry of variantsRaw) {
if (!isJsonObject(entry)) {
return schema;
}
const variantType = entry.type;
const hasObjectShape =
isJsonObject(entry.properties) ||
Array.isArray(entry.required) ||
Object.hasOwn(entry, "additionalProperties");
if (variantType === undefined && !hasObjectShape) {
return schema;
}
if (variantType !== undefined && variantType !== "object") {
return schema;
}
if (entry.properties !== undefined && !isJsonObject(entry.properties)) {
return schema;
}
if (entry.required !== undefined && !Array.isArray(entry.required)) {
return schema;
}
variants.push(entry);
}
const mergedProperties: JsonObject = {};
const ownProperties = isJsonObject(schema.properties) ? schema.properties : {};
for (const name in ownProperties) {
if (Object.hasOwn(ownProperties, name)) mergedProperties[name] = ownProperties[name];
}
for (const variant of variants) {
const properties = isJsonObject(variant.properties) ? variant.properties : {};
for (const name in properties) {
if (!Object.hasOwn(properties, name)) continue;
const propertySchema = properties[name];
const existingSchema = mergedProperties[name];
mergedProperties[name] =
existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema);
}
}
const nextSchema = copySchemaWithout(schema, combiner);
nextSchema.type = "object";
nextSchema.properties = mergedProperties;
let requiredIntersection: string[] | undefined;
for (const variant of variants) {
const variantRequired = Array.isArray(variant.required)
? variant.required.filter((r): r is string => typeof r === "string")
: [];
if (requiredIntersection === undefined) {
requiredIntersection = [...variantRequired];
} else {
const reqSet = new Set(variantRequired);
requiredIntersection = requiredIntersection.filter(r => reqSet.has(r));
}
}
const parentRequired = Array.isArray(schema.required)
? schema.required.filter((r): r is string => typeof r === "string")
: [];
const safeRequired = new Set<string>();
for (const name of requiredIntersection ?? []) {
if (Object.hasOwn(mergedProperties, name)) safeRequired.add(name);
}
for (const name of parentRequired) {
if (Object.hasOwn(ownProperties, name) && Object.hasOwn(mergedProperties, name)) {
safeRequired.add(name);
}
}
const requiredInPropertyOrder: string[] = [];
for (const name in mergedProperties) {
if (Object.hasOwn(mergedProperties, name) && safeRequired.has(name)) requiredInPropertyOrder.push(name);
}
if (requiredInPropertyOrder.length > 0) {
nextSchema.required = requiredInPropertyOrder;
} else {
delete nextSchema.required;
}
return nextSchema;
}
function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
return schema;
}
const seenTypes = new Set<string>();
const variantTypes: string[] = [];
const mergedVariantFields: JsonObject = {};
for (const entry of variantsRaw) {
if (!isJsonObject(entry) || typeof entry.type !== "string") {
return schema;
}
const variantType = entry.type;
if (seenTypes.has(variantType)) {
return schema;
}
const allowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[variantType];
if (!allowedKeys) {
return schema;
}
for (const key in entry) {
if (!Object.hasOwn(entry, key)) continue;
const variantValue = entry[key];
if (key === "type") continue;
if (!Object.hasOwn(allowedKeys, key) && !Object.hasOwn(CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, key)) {
return schema;
}
const existingValue = mergedVariantFields[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
return schema;
}
mergedVariantFields[key] = variantValue;
}
seenTypes.add(variantType);
variantTypes.push(variantType);
}
if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) {
return schema;
}
const nextSchema = copySchemaWithout(schema, combiner);
const nonNullTypes = variantTypes.filter(t => t !== "null");
nextSchema.type = nonNullTypes[0] ?? variantTypes[0];
for (const key in mergedVariantFields) {
if (!Object.hasOwn(mergedVariantFields, key)) continue;
const value = mergedVariantFields[key];
const existingValue = nextSchema[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
return schema;
}
if (existingValue === undefined) {
nextSchema[key] = value;
}
}
return nextSchema;
}
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
let commonType: string | undefined;
let firstEntry: JsonObject | undefined;
for (const entry of variantsRaw) {
if (!isJsonObject(entry) || typeof entry.type !== "string") return schema;
if (commonType === undefined) {
commonType = entry.type;
firstEntry = entry;
} else if (entry.type !== commonType) return schema;
}
if (!firstEntry) return schema;
const nextSchema = copySchemaWithout(schema, combiner);
for (const key in firstEntry) {
if (Object.hasOwn(firstEntry, key) && !outHasOwn(nextSchema, key)) nextSchema[key] = firstEntry[key];
}
return nextSchema;
}
/**
* Recursively strip any remaining anyOf/oneOf that same-type or mixed-type
* collapse can handle. This is needed because object-combiner merging can
* create new anyOf in merged subtrees after child normalization already ran.
*/
export function stripResidualCombiners(value: unknown, epoch: number = epochNext()): unknown {
if (Array.isArray(value)) {
if (!once(value, epoch)) return [];
return value.map(entry => stripResidualCombiners(entry, epoch));
}
if (!isJsonObject(value)) return value;
if (!once(value, epoch)) return {};
const result: JsonObject = {};
for (const key in value) {
if (Object.hasOwn(value, key)) result[key] = stripResidualCombiners(value[key], epoch);
}
let current: JsonObject = result;
let changed = true;
while (changed) {
changed = false;
for (const combiner of JSON_SCHEMA_COMBINERS) {
const sameType = collapseSameTypeCombinerVariants(current, combiner);
if (sameType !== current) {
current = sameType;
changed = true;
}
const mixed = collapseMixedTypeCombinerVariants(current, combiner);
if (mixed !== current) {
current = mixed;
changed = true;
}
}
}
return current;
}
interface NullableExtractionResult {
schema: unknown;
nullable: boolean;
}
function extractNullableUnionSchema(schema: unknown): NullableExtractionResult {
if (!isJsonObject(schema)) {
return { schema, nullable: false };
}
if (schema.nullable === true) {
const nextSchema = { ...schema };
delete nextSchema.nullable;
return { schema: nextSchema, nullable: true };
}
if (Array.isArray(schema.type)) {
const typeVariants = schema.type.filter((entry): entry is string => typeof entry === "string");
const nonNullTypes = typeVariants.filter(entry => entry !== "null");
if (typeVariants.includes("null") && nonNullTypes.length === 1) {
const nextSchema = { ...schema, type: nonNullTypes[0] };
return { schema: nextSchema, nullable: true };
}
}
for (const combiner of JSON_SCHEMA_COMBINERS) {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw)) continue;
let hasNullVariant = false;
const nonNullVariants: unknown[] = [];
for (const variant of variantsRaw) {
if (isJsonObject(variant) && variant.type === "null") {
let keyCount = 0;
for (const k in variant) {
if (!Object.hasOwn(variant, k)) continue;
if (++keyCount > 1) break;
}
if (keyCount === 1) {
hasNullVariant = true;
continue;
}
}
nonNullVariants.push(variant);
}
if (!hasNullVariant || nonNullVariants.length !== 1 || !isJsonObject(nonNullVariants[0])) {
continue;
}
const nextSchema = copySchemaWithout(schema, combiner);
const nonNullVariant = nonNullVariants[0];
for (const key in nonNullVariant) {
if (!Object.hasOwn(nonNullVariant, key)) continue;
const value = nonNullVariant[key];
const existingValue = nextSchema[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
return { schema, nullable: false };
}
if (existingValue === undefined) {
nextSchema[key] = value;
}
}
return { schema: nextSchema, nullable: true };
}
return { schema, nullable: false };
}
interface NullableNormalizationResult {
schema: unknown;
nullable: boolean;
}
function normalizeNullablePropertiesForCloudCodeAssist(
value: unknown,
isPropertySchema = false,
epoch: number = epochNext(),
): NullableNormalizationResult {
if (Array.isArray(value)) {
if (!once(value, epoch)) {
return { schema: [], nullable: false };
}
return {
schema: value.map(entry => normalizeNullablePropertiesForCloudCodeAssist(entry, false, epoch).schema),
nullable: false,
};
}
if (!isJsonObject(value)) {
return { schema: value, nullable: false };
}
if (!once(value, epoch)) {
return { schema: {}, nullable: false };
}
const normalized: JsonObject = {};
for (const key in value) {
if (Object.hasOwn(value, key))
normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(value[key], false, epoch).schema;
}
if (isJsonObject(normalized.properties)) {
const properties = normalized.properties;
const required = new Set(
Array.isArray(normalized.required)
? normalized.required.filter((entry): entry is string => typeof entry === "string")
: [],
);
const nextProperties: JsonObject = {};
for (const name in properties) {
if (!Object.hasOwn(properties, name)) continue;
const normalizedProperty = normalizeNullablePropertiesForCloudCodeAssist(properties[name], true, epoch);
nextProperties[name] = normalizedProperty.schema;
if (normalizedProperty.nullable) {
required.delete(name);
}
}
normalized.properties = nextProperties;
if (Array.isArray(normalized.required)) {
normalized.required = Array.from(required);
}
}
if (!isPropertySchema) {
return { schema: normalized, nullable: false };
}
return extractNullableUnionSchema(normalized);
}
function createResidualIncompatibilityChecks(
checks: ReadonlyArray<ResidualSchemaIncompatibility> | undefined,
): ResidualIncompatibilityChecks | undefined {
if (!checks || checks.length === 0) return undefined;
const result: ResidualIncompatibilityChecks = {
typeArray: false,
typeNull: false,
nullable: false,
combiners: false,
};
for (const check of checks) {
switch (check) {
case "type-array":
result.typeArray = true;
break;
case "type-null":
result.typeNull = true;
break;
case "nullable":
result.nullable = true;
break;
case "combiners":
result.combiners = true;
break;
}
}
return result;
}
function hasResidualSchemaIncompatibilities(
value: unknown,
checks: ResidualIncompatibilityChecks,
epoch: number = epochNext(),
): boolean {
if (Array.isArray(value)) {
if (!once(value, epoch)) return false;
return value.some(entry => hasResidualSchemaIncompatibilities(entry, checks, epoch));
}
if (!isJsonObject(value)) {
return false;
}
if (!once(value, epoch)) {
return false;
}
if (checks.typeArray && Array.isArray(value.type)) return true;
if (checks.typeNull && value.type === "null") return true;
if (checks.nullable && Object.hasOwn(value, "nullable")) return true;
if (checks.combiners) {
for (const combiner of CCA_FORBIDDEN_COMBINERS) {
if (Array.isArray(value[combiner])) return true;
}
}
for (const k in value) {
if (!Object.hasOwn(value, k)) continue;
if (hasResidualSchemaIncompatibilities(value[k], checks, epoch)) {
return true;
}
}
return false;
}
export function normalizeSchema(value: unknown, options: NormalizeSchemaOptions): unknown {
const upgraded = upgradeJsonSchemaTo202012(value);
const dereferenced = dereferenceJsonSchema(upgraded);
let normalized = normalizeSchemaNode(dereferenced, {
...options,
insideProperties: false,
epoch: epochNext(),
});
if (options.stripResidualCombinersFixpoint) {
normalized = stripResidualCombiners(normalized);
}
if (options.extractNullableFromUnions) {
normalized = normalizeNullablePropertiesForCloudCodeAssist(normalized).schema;
}
const residualChecks = createResidualIncompatibilityChecks(options.rejectResidualIncompatibilities);
if (residualChecks && hasResidualSchemaIncompatibilities(normalized, residualChecks)) {
logger.debug("Schema has residual provider incompatibilities, using fallback");
return options.validateAndFallback?.fallback ?? normalized;
}
if (options.validateAndFallback && !isValidJsonSchema(normalized)) {
logger.debug("Schema failed validation, using fallback");
return options.validateAndFallback.fallback;
}
return normalized;
}
export function normalizeSchemaForGoogle(value: unknown): unknown {
return normalizeSchema(value, {
unsupportedFields: isGoogleUnsupportedSchemaField,
normalizeFieldNames: true,
collapseNullFields: true,
normalizeTypeArrayToNullable: true,
stripNullableKeyword: false,
autoPropertyOrdering: true,
ensureObjectProperties: true,
liftStrippedToDescription: { format: "spill" },
mergeObjectCombiners: false,
collapseSameTypeCombiners: false,
collapseMixedTypeCombiners: false,
stripResidualCombinersFixpoint: false,
extractNullableFromUnions: false,
});
}
export function normalizeSchemaForCCA(value: unknown): unknown {
return normalizeSchema(value, {
unsupportedFields: isGoogleUnsupportedSchemaField,
normalizeFieldNames: true,
collapseNullFields: false,
normalizeTypeArrayToNullable: true,
stripNullableKeyword: true,
autoPropertyOrdering: false,
ensureObjectProperties: true,
liftStrippedToDescription: { format: "spill" },
mergeObjectCombiners: true,
collapseSameTypeCombiners: true,
collapseMixedTypeCombiners: true,
stripResidualCombinersFixpoint: true,
extractNullableFromUnions: true,
rejectResidualIncompatibilities: ["type-array", "type-null", "nullable", "combiners"],
validateAndFallback: { fallback: CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA },
});
}
export function normalizeSchemaForMCP(value: unknown): unknown {
return normalizeSchema(value, {
unsupportedFields: isMcpUnsupportedSchemaField,
normalizeFieldNames: false,
collapseNullFields: false,
normalizeTypeArrayToNullable: false,
stripNullableKeyword: true,
autoPropertyOrdering: false,
ensureObjectProperties: false,
liftStrippedToDescription: false,
mergeObjectCombiners: false,
collapseSameTypeCombiners: false,
collapseMixedTypeCombiners: false,
stripResidualCombinersFixpoint: false,
extractNullableFromUnions: false,
});
}
@@ -1,421 +0,0 @@
/**
* Provider-specific JSON Schema sanitizers used in the request path.
*
* Google's Schema proto, Cloud Code Assist's Claude bridge, and MCP/AJV
* validation all reject different subsets of standard JSON Schema. Rather
* than ship three near-identical walkers, this module exposes a shared
* `sanitizeSchemaImpl` parameterised by an options bag, plus three thin
* wrappers that fix the option set for each target.
*/
import { dereferenceJsonSchema } from "./dereference";
import { upgradeJsonSchemaTo202012 } from "./draft";
import { areJsonValuesEqual } from "./equality";
import { UNSUPPORTED_SCHEMA_FIELDS } from "./fields";
import { epochNext, once } from "./stamps";
/**
* Options that pin the behavior of `sanitizeSchemaImpl`.
*
* - `insideProperties`: true when we are walking the children of a `properties`
* object. Keys at that level are property *names*, not JSON-Schema keywords —
* so the "strip unsupported keyword" rule must not apply.
* - `normalizeTypeArrayToNullable`: convert `type: ["string","null"]` to
* `type: "string"` + `nullable: true`. Required for Google's proto; left off
* for MCP which keeps standard JSON Schema shapes.
* - `stripNullableKeyword`: remove `nullable` entirely. CCA forbids the
* keyword; Google keeps it.
* - `unsupportedFields`: provider-specific keyword blacklist.
* - `epoch`: shared cycle guard (see `stamps.ts`).
*/
interface SanitizeSchemaOptions {
insideProperties: boolean;
normalizeTypeArrayToNullable: boolean;
stripNullableKeyword: boolean;
unsupportedFields: Record<string, true>;
epoch: number;
/**
* Apply snake_case → camelCase field renames at every node. Mirrors
* python-genai/_transformers.py:745-752. Safe to enable for any provider
* that consumes camelCase JSON Schema.
*/
normalizeFieldNames: boolean;
/**
* Apply `handle_null_fields` (python-genai/_transformers.py:584-640):
* `{type:'null'}` → `{nullable:true}` and collapse `anyOf` null variants
* into a nullable parent (flattening single-survivor unions). Google-only.
* The CCA pipeline relies on the un-collapsed `anyOf` / `type:null` shape
* surviving sanitize so it can make its own required/optional decisions.
*/
collapseNullFields: boolean;
/**
* Auto-populate `propertyOrdering` on multi-property objects. Mirrors
* python-genai/_transformers.py:817-822 (Gemini structured-output ordering).
*/
autoPropertyOrdering: boolean;
}
/**
* Spelling normalization applied at the top of every schema node before any
* other processing. Mirrors python-genai/_transformers.py:745-752 — accepts
* snake_case spellings that pydantic / hand-written dicts may use and rewrites
* them to the canonical camelCase form. Insertion order is preserved.
*/
const SNAKE_TO_CAMEL_RENAMES: Record<string, string> = {
additional_properties: "additionalProperties",
any_of: "anyOf",
prefix_items: "prefixItems",
property_ordering: "propertyOrdering",
};
/**
* Returns `obj` unchanged when no renamable key is present; otherwise returns
* a fresh shallow-copy with snake_case keys rewritten. The collision rule
* matches upstream (`pop(from)` → `set(to)`): snake_case wins over an
* existing camelCase entry, matching python-genai/_transformers.py:751.
*/
function applySnakeCaseRenames(obj: Record<string, unknown>): Record<string, unknown> {
let needsRename = false;
for (const k in SNAKE_TO_CAMEL_RENAMES) {
if (Object.hasOwn(obj, k)) {
needsRename = true;
break;
}
}
if (!needsRename) return obj;
const out: Record<string, unknown> = {};
for (const k in obj) {
const renamed = SNAKE_TO_CAMEL_RENAMES[k];
if (renamed !== undefined && Object.hasOwn(obj, k)) {
out[renamed] = obj[k];
} else if (!(k in SNAKE_TO_CAMEL_RENAMES) && !Object.hasOwn(out, k)) {
out[k] = obj[k];
}
}
// Copy non-renamed keys that the loop skipped because of the rename guard.
for (const k in obj) {
if (k in SNAKE_TO_CAMEL_RENAMES) continue;
if (!Object.hasOwn(out, k)) out[k] = obj[k];
}
return out;
}
/**
* `handle_null_fields` (python-genai/_transformers.py:584-640) applied at the
* parent level BEFORE child recursion — matches upstream's call order at
* `process_schema` line 768. Returns a new object when changes apply, the
* original reference otherwise (zero-allocation fast path).
*
* Rules:
* - `{type:'null'}` → `{nullable:true}` (drop type, preserve siblings).
* - `anyOf` containing any `{type:'null'}` variant → set `nullable:true` on
* parent and drop those variants. If a single non-null variant remains,
* flatten its keys into the parent and drop `anyOf` (upstream lines 636-640).
*/
function preHandleNullFields(obj: Record<string, unknown>): Record<string, unknown> {
if (obj.type === "null") {
const out: Record<string, unknown> = {};
for (const k in obj) {
if (Object.hasOwn(obj, k) && k !== "type") out[k] = obj[k];
}
out.nullable = true;
return out;
}
if (!Array.isArray(obj.anyOf)) return obj;
const variants = obj.anyOf as unknown[];
let sawNull = false;
const kept: unknown[] = [];
for (const v of variants) {
if (v && typeof v === "object" && !Array.isArray(v) && (v as Record<string, unknown>).type === "null") {
sawNull = true;
continue;
}
kept.push(v);
}
if (!sawNull) return obj;
const out: Record<string, unknown> = {};
for (const k in obj) {
if (Object.hasOwn(obj, k)) out[k] = obj[k];
}
out.nullable = true;
if (kept.length === 0) {
delete out.anyOf;
} else if (kept.length === 1) {
delete out.anyOf;
const only = kept[0] as Record<string, unknown>;
for (const k in only) {
if (Object.hasOwn(only, k) && !(k in out)) out[k] = only[k];
}
} else {
out.anyOf = kept;
}
return out;
}
function inferJsonSchemaTypeFromValue(value: unknown): string | undefined {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
switch (typeof value) {
case "string":
return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "object":
return "object";
default:
return undefined;
}
}
function pushEnumValue(values: unknown[], value: unknown): void {
if (!values.some(existing => areJsonValuesEqual(existing, value))) {
values.push(value);
}
}
/**
* Generic sanitizer core. Two phases:
* 1. If a combiner (`anyOf`/`oneOf`) holds variants that are all `const`
* values, collapse it into an `enum`. Google/CCA do not accept
* `const`-in-combinator unions but do accept enums.
* 2. Otherwise, walk the schema, stripping disallowed keywords and
* recursing into children. Standalone `const` values are converted to
* single-entry `enum` arrays.
* Cycle-safe via `once(epoch)`; cycles short-circuit to `{}`/`[]`.
*/
function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unknown {
if (Array.isArray(value)) {
if (!once(value, options.epoch)) return [];
return value.map(entry => sanitizeSchemaImpl(entry, options));
}
if (!value || typeof value !== "object") {
return value;
}
if (!once(value as object, options.epoch)) return {};
let obj =
options.normalizeFieldNames && !options.insideProperties
? applySnakeCaseRenames(value as Record<string, unknown>)
: (value as Record<string, unknown>);
if (options.collapseNullFields && !options.insideProperties) {
obj = preHandleNullFields(obj);
}
const result: Record<string, unknown> = {};
for (const combiner of ["anyOf", "oneOf"] as const) {
if (Array.isArray(obj[combiner])) {
const variants = obj[combiner] as Record<string, unknown>[];
const allHaveConst = variants.every(v => v && typeof v === "object" && "const" in v);
if (allHaveConst && variants.length > 0) {
// Step 1a: collect deduped enum values from every variant's const.
const dedupedEnum: unknown[] = [];
for (const variant of variants) {
pushEnumValue(dedupedEnum, variant.const);
}
result.enum = dedupedEnum;
const explicitTypes = variants
.map(variant => variant.type)
.filter((variantType): variantType is string => typeof variantType === "string");
const allHaveSameExplicitType =
explicitTypes.length === variants.length &&
explicitTypes.every(variantType => variantType === explicitTypes[0]);
// Step 1b: pick a `type` for the synthesized enum. Prefer an explicit
// type declared on every variant; otherwise infer from the values
// themselves. Mixed types stay un-typed (Google accepts a bare enum).
if (allHaveSameExplicitType && explicitTypes[0]) {
result.type = explicitTypes[0];
} else {
const inferredTypes = dedupedEnum
.map(enumValue => inferJsonSchemaTypeFromValue(enumValue))
.filter((inferredType): inferredType is string => inferredType !== undefined);
const inferredTypeSet = new Set(inferredTypes);
if (inferredTypeSet.size === 1) {
result.type = inferredTypes[0];
} else {
const nonNullInferredTypes = inferredTypes.filter(inferredType => inferredType !== "null");
const nonNullTypeSet = new Set(nonNullInferredTypes);
// nullable + single non-null type: collapse to scalar + nullable marker.
if (inferredTypes.includes("null") && nonNullTypeSet.size === 1) {
result.type = nonNullInferredTypes[0];
if (!options.stripNullableKeyword) {
result.nullable = true;
}
}
}
}
// Step 1c: pull non-combiner siblings (description, etc.) through.
// Copy description and other top-level fields (not the combiner)
for (const key in obj) {
const entry = obj[key];
if (key !== combiner && !(key in result)) {
result[key] = sanitizeSchemaImpl(entry, {
...options,
insideProperties: key === "properties",
});
}
}
return result;
}
}
}
// Phase 2: not a const-combiner — process keys one by one.
let constValue: unknown;
for (const key in obj) {
const entry = obj[key];
// Only strip unsupported schema keywords when NOT inside "properties" object
// Inside "properties", keys are property names (e.g., "pattern") not schema keywords
if (!options.insideProperties && key in options.unsupportedFields) continue;
if (options.stripNullableKeyword && key === "nullable") continue;
if (key === "const") {
// `const` is converted to a single-entry `enum` after the loop so the
// `type` inference can use it.
constValue = entry;
continue;
}
// When key is "properties", child keys are property names, not schema keywords
result[key] = sanitizeSchemaImpl(entry, {
...options,
insideProperties: key === "properties",
});
}
// Normalize array-valued "type" (e.g. ["string", "null"]) to a single type + nullable.
// Google's Schema proto expects type to be a single enum string, not an array.
if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) {
const types = (result.type as unknown[]).filter((t): t is string => typeof t === "string");
const nonNull = types.filter(t => t !== "null");
if (types.includes("null") && !options.stripNullableKeyword) {
result.nullable = true;
}
result.type = nonNull[0] ?? types[0];
}
if (constValue !== undefined) {
// Convert const to enum, merging with existing enum if present
const existingEnum = Array.isArray(result.enum) ? result.enum : [];
pushEnumValue(existingEnum, constValue);
result.enum = existingEnum;
if (!result.type) {
result.type = inferJsonSchemaTypeFromValue(constValue);
}
}
// Defensive post-pass: covers cases where `type` became `'null'` AFTER
// child processing (e.g. `type: ['null']` collapsed via normalizeTypeArrayToNullable).
// Mirrors python-genai/_transformers.py:628-630.
if (options.collapseNullFields && result.type === "null") {
delete result.type;
if (!options.stripNullableKeyword) result.nullable = true;
}
// Auto-populate `propertyOrdering` for objects with >1 property. Mirrors
// python-genai/_transformers.py:817-822. Insertion order of `properties`
// determines the emitted ordering, matching JS object-key iteration order.
if (
options.autoPropertyOrdering &&
result.type === "object" &&
!Object.hasOwn(result, "propertyOrdering") &&
result.properties &&
typeof result.properties === "object" &&
!Array.isArray(result.properties)
) {
const props = result.properties as Record<string, unknown>;
const keys: string[] = [];
for (const k in props) {
if (Object.hasOwn(props, k)) keys.push(k);
}
if (keys.length > 1) result.propertyOrdering = keys;
}
// Ensure object schemas have a properties field (some LLM providers require it)
if (result.type === "object" && !("properties" in result)) {
result.properties = {};
}
return result;
}
/**
* Sanitize a JSON Schema for Google's generative AI APIs by stripping unsupported
* JSON Schema keywords and normalizing representable nullable/type patterns.
*
* Draft-07-shaped schemas are upgraded to 2020-12 before provider-specific
* unsupported keywords are stripped. `$ref` is still stripped as unsupported;
* callers that need references preserved must dereference before this path.
*/
export function sanitizeSchemaForGoogle(value: unknown): unknown {
const upgraded = upgradeJsonSchemaTo202012(value);
// Mirror python-genai/_transformers.py:754-766: inline `$defs` so the
// downstream walk sees the resolved schema instead of dropping `$ref`.
const dereferenced = dereferenceJsonSchema(upgraded);
return sanitizeSchemaImpl(dereferenced, {
insideProperties: false,
normalizeTypeArrayToNullable: true,
stripNullableKeyword: false,
unsupportedFields: UNSUPPORTED_SCHEMA_FIELDS,
epoch: epochNext(),
normalizeFieldNames: true,
collapseNullFields: true,
autoPropertyOrdering: true,
});
}
/**
* Sanitize a JSON Schema for Cloud Code Assist Claude.
* Starts from Google sanitizer behavior, then strips `nullable` markers.
*
* Draft-07-shaped schemas are upgraded to 2020-12 before provider-specific
* unsupported keywords are stripped. `$ref` is still stripped as unsupported;
* callers that need references preserved must dereference before this path.
*/
export function sanitizeSchemaForCCA(value: unknown): unknown {
const upgraded = upgradeJsonSchemaTo202012(value);
const dereferenced = dereferenceJsonSchema(upgraded);
return sanitizeSchemaImpl(dereferenced, {
insideProperties: false,
normalizeTypeArrayToNullable: true,
stripNullableKeyword: true,
unsupportedFields: UNSUPPORTED_SCHEMA_FIELDS,
epoch: epochNext(),
normalizeFieldNames: true,
// Leave null-field collapse to the CCA-specific pipeline downstream,
// which needs the un-collapsed `anyOf` / `type:null` shape to make
// per-property required/optional decisions.
collapseNullFields: false,
// CCA pipeline assembles its own ordering separately; leave off here.
autoPropertyOrdering: false,
});
}
/**
* Fields stripped for MCP/AJV compatibility.
* Only `$schema` — AJV throws on unrecognised meta-schema URIs
* (e.g. draft 2020-12 emitted by schemars 1.x / rmcp 0.15+).
*/
const MCP_UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = { $schema: true };
/**
* Sanitize a JSON Schema for MCP tool parameter validation (AJV compatibility).
*
* Strips only the minimal set of fields that cause AJV validation errors:
* - `$schema`: AJV throws on unknown meta-schema URIs.
* - `nullable`: OpenAPI 3.0 extension, not standard JSON Schema.
*
* Unlike the Google/CCA sanitizers this preserves validation keywords
* (`pattern`, `format`, `additionalProperties`, etc.) and `$ref`/`$defs`.
*/
export function sanitizeSchemaForMCP(value: unknown): unknown {
// Upgrade before dereferencing so legacy `definitions` refs become the
// canonical `$defs` form, then inline refs for providers that drop `$defs`.
const upgraded = upgradeJsonSchemaTo202012(value);
const dereferenced = dereferenceJsonSchema(upgraded);
return sanitizeSchemaImpl(dereferenced, {
insideProperties: false,
normalizeTypeArrayToNullable: false,
stripNullableKeyword: true,
unsupportedFields: MCP_UNSUPPORTED_SCHEMA_FIELDS,
epoch: epochNext(),
normalizeFieldNames: false,
collapseNullFields: false,
autoPropertyOrdering: false,
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { JsonObject } from "./types";
export type DescriptionSpillFormat = "spill" | "paren";
function formatSpillValue(value: unknown): string {
return JSON.stringify(value);
}
function formatParenValue(value: unknown): string {
return typeof value === "string" ? value : JSON.stringify(value);
}
/**
* Demote stripped JSON Schema keywords into a node's `description` so the model
* still receives the constraint as natural-language context after the wire
* schema drops it.
*/
export function spillToDescription(
node: JsonObject,
entries: ReadonlyArray<readonly [string, unknown]>,
format: DescriptionSpillFormat = "spill",
): void {
let spilled: Array<readonly [string, unknown]> | undefined;
for (const entry of entries) {
if (entry[1] === undefined) continue;
if (spilled === undefined) spilled = [];
spilled.push(entry);
}
if (spilled === undefined || spilled.length === 0) return;
const existing = typeof node.description === "string" ? node.description : "";
if (format === "paren") {
let suffix = "";
for (const [key, value] of spilled) {
suffix += ` (${key}: ${formatParenValue(value)})`;
}
node.description = `${existing}${suffix}`;
return;
}
const formatted = `{${spilled.map(([key, value]) => `${key}: ${formatSpillValue(value)}`).join(", ")}}`;
node.description = existing ? `${existing}\n\n${formatted}` : formatted;
}
+24 -24
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "bun:test";
import { convertTools } from "@oh-my-pi/pi-ai/providers/google-shared";
import type { Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types";
import { prepareSchemaForCCA, sanitizeSchemaForCCA, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema";
import { normalizeSchemaForCCA, normalizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema";
function createModel(id: string): Model<"google-gemini-cli"> {
return {
@@ -37,7 +37,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
// normalizeTypeArrayToNullable converts type array to scalar + nullable,
// then stripNullableKeyword removes the nullable marker.
expect(sanitizeSchemaForCCA(schema)).toEqual({
expect(normalizeSchemaForCCA(schema)).toEqual({
type: "object",
properties: {
value: {
@@ -59,7 +59,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
},
} as unknown;
expect(sanitizeSchemaForCCA(schema)).toEqual({
expect(normalizeSchemaForCCA(schema)).toEqual({
type: "object",
properties: {
env: {
@@ -290,7 +290,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
required: ["mode"],
} as unknown;
expect(prepareSchemaForCCA(parameters)).toEqual({
expect(normalizeSchemaForCCA(parameters)).toEqual({
type: "object",
properties: {},
});
@@ -305,7 +305,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
},
} as unknown;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
type: "object",
properties: {
value: {
@@ -320,11 +320,11 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
/**
* Tests ported from python-genai's `process_schema`/`handle_null_fields`
* coverage in google/genai/tests/transformers/test_schema.py. The Python
* suite is the canonical regression set for the rules our `sanitizeSchemaForGoogle`
* suite is the canonical regression set for the rules our `normalizeSchemaForGoogle`
* mirrors (snake_case field renames, null-field collapsing, const→enum,
* propertyOrdering propagation, $ref cycle handling).
*/
describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () => {
describe("normalizeSchemaForGoogle parity with python-genai process_schema", () => {
// Mirrors python-genai test_schema.py::test_schema_with_no_null_fields_is_unchanged
it("leaves anyOf alone when no variant has type null", () => {
const schema = {
@@ -333,7 +333,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
title: "Total Area Sq Mi",
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
anyOf: [{ type: "integer" }, { type: "number" }],
default: "null",
title: "Total Area Sq Mi",
@@ -355,7 +355,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
required: ["name"],
} as const;
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
const props = sanitized.properties as Record<string, Record<string, unknown>>;
expect(props.population?.nullable).toBe(true);
expect(props.population?.type).toBe("integer");
@@ -376,7 +376,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
required: ["name", "restaurants_per_capita"],
} as const;
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
const props = sanitized.properties as Record<string, Record<string, unknown>>;
// snake_case any_of must be rewritten to camelCase anyOf.
expect(props.restaurants_per_capita?.anyOf).toEqual([{ type: "integer" }, { type: "number" }]);
@@ -426,12 +426,12 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
} as const;
// fruit alone is the only top-level property; auto-ordering does not fire.
expect(sanitizeSchemaForGoogle(dictSchema)).toEqual(dictSchema);
expect(normalizeSchemaForGoogle(dictSchema)).toEqual(dictSchema);
});
// Mirrors python-genai test_schema.py::test_process_schema_converts_const_to_enum
it("converts const to a singleton enum", () => {
const sanitized = sanitizeSchemaForGoogle({ type: "string", const: "FOO" });
const sanitized = normalizeSchemaForGoogle({ type: "string", const: "FOO" });
expect(sanitized).toEqual({ type: "string", enum: ["FOO"] });
});
@@ -440,7 +440,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
// the value as a singleton enum. Google's Schema proto accepts numeric enums
// and we prefer permissive normalization over surfacing a transformer-level error.
it("accepts non-string const as a singleton enum (intentional deviation from upstream raise)", () => {
const sanitized = sanitizeSchemaForGoogle({ type: "integer", const: 123 }) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle({ type: "integer", const: 123 }) as Record<string, unknown>;
expect(sanitized.enum).toEqual([123]);
expect(sanitized.type).toBe("integer");
});
@@ -460,7 +460,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
},
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
type: "object",
properties: {
foo: { type: "string" },
@@ -483,7 +483,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
},
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
type: "array",
items: {
type: "object",
@@ -512,7 +512,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
},
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
type: "object",
properties: {
xyz: {
@@ -544,7 +544,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
],
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
anyOf: [
{
type: "object",
@@ -576,7 +576,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
},
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
type: "object",
properties: {
recursive: {
@@ -600,7 +600,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
propertyOrdering: [...custom],
} as const;
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
expect(sanitized.propertyOrdering).toEqual(custom);
});
@@ -619,7 +619,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
},
} as const;
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
expect(sanitized.propertyOrdering).toEqual([
"name",
"population",
@@ -642,7 +642,7 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
property_ordering: ["bar", "foo"],
} as const;
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
expect(sanitized.propertyOrdering).toEqual(["bar", "foo"]);
expect(sanitized.property_ordering).toBeUndefined();
});
@@ -654,20 +654,20 @@ describe("sanitizeSchemaForGoogle parity with python-genai process_schema", () =
any_of: [{ type: "integer" }, { type: "number" }],
} as const;
const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle(schema) as Record<string, unknown>;
expect(sanitized.anyOf).toEqual([{ type: "integer" }, { type: "number" }]);
expect(sanitized.any_of).toBeUndefined();
});
// Covers python-genai _transformers.py:628-630 bare {type:'null'} flatten.
it("rewrites a bare {type:'null'} schema as {nullable:true}", () => {
expect(sanitizeSchemaForGoogle({ type: "null" })).toEqual({ nullable: true });
expect(normalizeSchemaForGoogle({ type: "null" })).toEqual({ nullable: true });
});
// Covers python-genai _transformers.py:631-640 single-non-null anyOf flatten.
it("flattens anyOf:[X, {type:'null'}] into X + nullable", () => {
expect(
sanitizeSchemaForGoogle({
normalizeSchemaForGoogle({
anyOf: [{ type: "string", title: "Name" }, { type: "null" }],
}),
).toEqual({ type: "string", title: "Name", nullable: true });
@@ -1,9 +1,9 @@
import { describe, expect, it } from "bun:test";
import {
adaptSchemaForStrict,
prepareSchemaForCCA,
normalizeSchemaForCCA,
normalizeSchemaForGoogle,
type SchemaCompatibilityResult,
sanitizeSchemaForGoogle,
validateSchemaCompatibility,
validateStrictSchemaEnforcement,
} from "@oh-my-pi/pi-ai/utils/schema";
@@ -70,7 +70,7 @@ describe("schema compatibility validation", () => {
});
it("validates Google-compatible schemas after sanitization", () => {
const sanitized = sanitizeSchemaForGoogle({
const sanitized = normalizeSchemaForGoogle({
type: "object",
additionalProperties: false,
properties: {
@@ -100,7 +100,7 @@ describe("schema compatibility validation", () => {
});
it("validates Cloud Code Assist Claude schemas after normalization", () => {
const prepared = prepareSchemaForCCA({
const prepared = normalizeSchemaForCCA({
type: "object",
properties: {
mode: { anyOf: [{ const: "fast" }, { const: "safe" }, { type: "null" }] },
+150 -23
View File
@@ -1,10 +1,13 @@
import { describe, expect, it } from "bun:test";
import { buildRequest } from "@oh-my-pi/pi-ai/providers/google-gemini-cli";
import { convertTools } from "@oh-my-pi/pi-ai/providers/google-shared";
import type { Context, Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types";
import {
enforceStrictSchema,
mergeCompatibleEnumSchemas,
prepareSchemaForCCA,
sanitizeSchemaForCCA,
sanitizeSchemaForGoogle,
normalizeSchemaForCCA,
normalizeSchemaForGoogle,
normalizeSchemaForMCP,
sanitizeSchemaForStrictMode,
schemaNeedsDraft202012Upgrade,
stripResidualCombiners,
@@ -12,6 +15,26 @@ import {
upgradeJsonSchemaTo202012,
} from "@oh-my-pi/pi-ai/utils/schema";
function createGoogleCliModel(id: string): Model<"google-gemini-cli"> {
return {
id,
name: id,
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://example.com",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 8192,
};
}
// ---------------------------------------------------------------------------
// mergeCompatibleEnumSchemas
// ---------------------------------------------------------------------------
@@ -155,12 +178,12 @@ describe("upgradeJsonSchemaTo202012", () => {
});
// ---------------------------------------------------------------------------
// sanitizeSchemaForGoogle
// normalizeSchemaForGoogle
// ---------------------------------------------------------------------------
describe("sanitizeSchemaForGoogle", () => {
describe("normalizeSchemaForGoogle", () => {
it("sets object type when converting an object const to an enum entry", () => {
const sanitized = sanitizeSchemaForGoogle({
const sanitized = normalizeSchemaForGoogle({
const: { a: 1 },
});
@@ -172,7 +195,7 @@ describe("sanitizeSchemaForGoogle", () => {
});
it("deduplicates a deep-equal object const against an existing enum entry", () => {
const sanitized = sanitizeSchemaForGoogle({
const sanitized = normalizeSchemaForGoogle({
type: "object",
enum: [{ a: 1 }],
const: { a: 1 },
@@ -186,7 +209,7 @@ describe("sanitizeSchemaForGoogle", () => {
});
it("does not stamp a wrong scalar type when const variants span multiple primitive types", () => {
const sanitized = sanitizeSchemaForGoogle({
const sanitized = normalizeSchemaForGoogle({
anyOf: [
{ const: "A", type: "string" },
{ const: 1, type: "number" },
@@ -201,7 +224,7 @@ describe("sanitizeSchemaForGoogle", () => {
it("collapses inferred null type to nullable when const is null", () => {
// After python-genai parity (handle_null_fields), bare `type: 'null'` is
// folded into `nullable: true` so the schema is OpenAPI-compatible.
const sanitized = sanitizeSchemaForGoogle({ const: null }) as Record<string, unknown>;
const sanitized = normalizeSchemaForGoogle({ const: null }) as Record<string, unknown>;
expect(sanitized.type).toBeUndefined();
expect(sanitized.nullable).toBe(true);
@@ -209,7 +232,7 @@ describe("sanitizeSchemaForGoogle", () => {
});
it("preserves a property schema literally named additionalProperties inside properties", () => {
const sanitized = sanitizeSchemaForGoogle({
const sanitized = normalizeSchemaForGoogle({
type: "object",
properties: {
additionalProperties: false,
@@ -231,7 +254,7 @@ describe("sanitizeSchemaForGoogle", () => {
required: ["additionalProperties"],
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual(schema);
expect(normalizeSchemaForGoogle(schema)).toEqual(schema);
});
it("inlines local $ref / $defs entries for Google compatibility", () => {
@@ -255,7 +278,7 @@ describe("sanitizeSchemaForGoogle", () => {
},
} as const;
expect(sanitizeSchemaForGoogle(schema)).toEqual({
expect(normalizeSchemaForGoogle(schema)).toEqual({
type: "object",
properties: {
user: {
@@ -269,6 +292,43 @@ describe("sanitizeSchemaForGoogle", () => {
required: ["user"],
});
});
it("lifts stripped validation keywords into description", () => {
const normalized = normalizeSchemaForGoogle({
type: "string",
pattern: "^\\d+$",
minLength: 1,
maxLength: 8,
description: "ID",
}) as Record<string, unknown>;
expect(normalized.pattern).toBeUndefined();
expect(normalized.minLength).toBeUndefined();
expect(normalized.maxLength).toBeUndefined();
expect(normalized.description).toBe('ID\n\n{pattern: "^\\\\d+$", minLength: 1, maxLength: 8}');
});
});
// ---------------------------------------------------------------------------
// normalizeSchemaForMCP
// ---------------------------------------------------------------------------
describe("normalizeSchemaForMCP", () => {
it("keeps validation keywords without mutating description", () => {
const normalized = normalizeSchemaForMCP({
type: "string",
pattern: "^\\d+$",
minLength: 1,
description: "ID",
}) as Record<string, unknown>;
expect(normalized).toEqual({
type: "string",
pattern: "^\\d+$",
minLength: 1,
description: "ID",
});
});
});
// ---------------------------------------------------------------------------
@@ -411,12 +471,12 @@ describe("stripResidualCombiners", () => {
});
// ---------------------------------------------------------------------------
// sanitizeSchemaForCCA and prepareSchemaForCCA
// normalizeSchemaForCCA
// ---------------------------------------------------------------------------
describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
describe("normalizeSchemaForCCA", () => {
it("collapses same-type anyOf variants when mixed-type collapse bails out", () => {
const prepared = prepareSchemaForCCA({
const prepared = normalizeSchemaForCCA({
type: "object",
properties: {
value: {
@@ -437,7 +497,7 @@ describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
});
it("applies Google unsupported-key stripping before CCA-specific normalization", () => {
const sanitized = sanitizeSchemaForCCA({
const sanitized = normalizeSchemaForCCA({
type: "object",
additionalProperties: false,
properties: {
@@ -463,14 +523,81 @@ describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
},
name: {
type: "string",
description: '{minLength: 2, pattern: "^[a-z]+$"}',
},
},
required: ["config", "name"],
});
});
it("lifts stripped validation keywords into description", () => {
const normalized = normalizeSchemaForCCA({
type: "string",
pattern: "^\\d+$",
minLength: 1,
maxLength: 8,
description: "ID",
}) as Record<string, unknown>;
expect(normalized.pattern).toBeUndefined();
expect(normalized.minLength).toBeUndefined();
expect(normalized.maxLength).toBeUndefined();
expect(normalized.description).toBe('ID\n\n{pattern: "^\\\\d+$", minLength: 1, maxLength: 8}');
});
it("uses the same merged object output in shared and gemini-cli Antigravity paths", () => {
const parameters = {
anyOf: [
{
type: "object",
properties: {
shared: { type: "string" },
a: { type: "string" },
},
required: ["shared"],
},
{
type: "object",
properties: {
shared: { type: "string" },
b: { type: "number" },
},
required: ["shared"],
},
],
} as TJsonSchema;
const tools: Tool[] = [{ name: "merge_test", description: "Merge test", parameters }];
const sharedTools = convertTools(tools, createGoogleCliModel("claude-sonnet-4-5"));
const sharedDeclaration = sharedTools?.[0]?.functionDeclarations[0] as Record<string, unknown>;
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools,
};
const antigravityRequest = buildRequest(createGoogleCliModel("gemini-2.5-pro"), context, "project", {}, true);
const antigravityDeclaration = antigravityRequest.request.tools?.[0]?.functionDeclarations[0] as Record<
string,
unknown
>;
const expected = {
type: "object",
properties: {
shared: { type: "string" },
a: { type: "string" },
b: { type: "number" },
},
required: ["shared"],
};
expect(sharedDeclaration.parameters).toEqual(expected);
expect(antigravityDeclaration.parameters).toEqual(expected);
expect(antigravityDeclaration.parameters).toEqual(sharedDeclaration.parameters);
expect(antigravityDeclaration.parametersJsonSchema).toBeUndefined();
});
it("does not retain stale required keys after an object-union anyOf merge", () => {
const prepared = prepareSchemaForCCA({
const prepared = normalizeSchemaForCCA({
required: ["a"],
anyOf: [
{
@@ -523,7 +650,7 @@ describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
required: ["profile"],
} as const;
const normalized = prepareSchemaForCCA(schema) as {
const normalized = normalizeSchemaForCCA(schema) as {
properties?: {
profile?: {
type?: string;
@@ -546,8 +673,8 @@ describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
};
(circular.properties as Record<string, unknown>).self = circular;
expect(() => prepareSchemaForCCA(circular)).not.toThrow();
expect(prepareSchemaForCCA(circular)).toEqual({
expect(() => normalizeSchemaForCCA(circular)).not.toThrow();
expect(normalizeSchemaForCCA(circular)).toEqual({
type: "object",
properties: {
self: {},
@@ -560,7 +687,7 @@ describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
type: "invalid-type-token",
} as Record<string, unknown>;
expect(prepareSchemaForCCA(ajvInvalid)).toEqual({
expect(normalizeSchemaForCCA(ajvInvalid)).toEqual({
type: "object",
properties: {},
});
@@ -568,7 +695,7 @@ describe("sanitizeSchemaForCCA and prepareSchemaForCCA", () => {
});
// ---------------------------------------------------------------------------
// Circular schema safety (sanitizeSchemaForGoogle + sanitizeSchemaForStrictMode)
// Circular schema safety (normalizeSchemaForGoogle + sanitizeSchemaForStrictMode)
// ---------------------------------------------------------------------------
describe("circular schema safety", () => {
@@ -579,7 +706,7 @@ describe("circular schema safety", () => {
};
(circular.properties as Record<string, unknown>).self = circular;
expect(() => sanitizeSchemaForGoogle(circular)).not.toThrow();
expect(() => normalizeSchemaForGoogle(circular)).not.toThrow();
expect(() => sanitizeSchemaForStrictMode(circular)).not.toThrow();
});
});
+3 -3
View File
@@ -5,7 +5,7 @@
*/
import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { TSchema } from "@oh-my-pi/pi-ai";
import { sanitizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
import { untilAborted } from "@oh-my-pi/pi-utils";
import type { SourceMeta } from "../capability/types";
import type {
@@ -231,7 +231,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
this.name = createMCPToolName(connection.name, tool.name);
this.label = `${connection.name}/${tool.name}`;
this.description = tool.description ?? `MCP tool from ${connection.name}`;
this.parameters = sanitizeSchemaForMCP(tool.inputSchema) as TSchema;
this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
this.mcpToolName = tool.name;
this.mcpServerName = connection.name;
}
@@ -324,7 +324,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
this.name = createMCPToolName(serverName, tool.name);
this.label = `${serverName}/${tool.name}`;
this.description = tool.description ?? `MCP tool from ${serverName}`;
this.parameters = sanitizeSchemaForMCP(tool.inputSchema) as TSchema;
this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
this.mcpToolName = tool.name;
this.mcpServerName = serverName;
this.#fallbackProvider = source?.provider;
@@ -1,10 +1,10 @@
import { describe, expect, it } from "bun:test";
import {
adaptSchemaForStrict,
prepareSchemaForCCA,
normalizeSchemaForCCA,
normalizeSchemaForGoogle,
type SchemaCompatibilityProvider,
type SchemaCompatibilityResult,
sanitizeSchemaForGoogle,
toolWireSchema,
validateSchemaCompatibility,
validateStrictSchemaEnforcement,
@@ -103,16 +103,16 @@ describe("builtin tool schemas provider compatibility", () => {
}
try {
const googleSchema = sanitizeSchemaForGoogle(schema);
const googleSchema = normalizeSchemaForGoogle(schema);
const googleCompatibility = validateSchemaCompatibility(googleSchema, "google");
if (!googleCompatibility.compatible) {
failures.push(formatCompatibilityIssues(name, "google", googleCompatibility));
}
} catch (error) {
failures.push(`${name} (google): sanitizeSchemaForGoogle threw: ${String(error)}`);
failures.push(`${name} (google): normalizeSchemaForGoogle threw: ${String(error)}`);
}
const cloudCodeAssistSchema = prepareSchemaForCCA(schema);
const cloudCodeAssistSchema = normalizeSchemaForCCA(schema);
const cloudCodeAssistCompatibility = validateSchemaCompatibility(
cloudCodeAssistSchema,
"cloud-code-assist-claude",
@@ -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");