From e7200c2e4a7fc0c9aac5aa2753fdab3454e53b68 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 16 May 2026 19:04:09 +0200 Subject: [PATCH] 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. --- packages/ai/CHANGELOG.md | 6 + packages/ai/src/providers/anthropic.ts | 18 +- .../ai/src/providers/google-gemini-cli.ts | 4 +- packages/ai/src/providers/google-shared.ts | 6 +- packages/ai/src/utils/schema/CONSTRAINTS.md | 30 +- packages/ai/src/utils/schema/compatibility.ts | 4 +- packages/ai/src/utils/schema/fields.ts | 28 +- packages/ai/src/utils/schema/index.ts | 4 +- packages/ai/src/utils/schema/normalize-cca.ts | 490 ---------- packages/ai/src/utils/schema/normalize.ts | 848 ++++++++++++++++++ .../ai/src/utils/schema/sanitize-google.ts | 421 --------- packages/ai/src/utils/schema/spill.ts | 43 + packages/ai/test/google-tool-schema.test.ts | 48 +- packages/ai/test/schema-compatibility.test.ts | 8 +- packages/ai/test/schema-normalization.test.ts | 173 +++- packages/coding-agent/src/mcp/tool-bridge.ts | 6 +- .../provider-schema-compatibility.test.ts | 10 +- .../test/tools/schema-validation.test.ts | 49 +- 18 files changed, 1162 insertions(+), 1034 deletions(-) delete mode 100644 packages/ai/src/utils/schema/normalize-cca.ts create mode 100644 packages/ai/src/utils/schema/normalize.ts delete mode 100644 packages/ai/src/utils/schema/sanitize-google.ts create mode 100644 packages/ai/src/utils/schema/spill.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b2b18deec..1a424198b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 56a8caec4..5b472f066 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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): 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, 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 diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index c8fe2ea3f..1e9c29951 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -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), }; }), })); diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 14ca2e50e..1226eef3b 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -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) }), })), }, diff --git a/packages/ai/src/utils/schema/CONSTRAINTS.md b/packages/ai/src/utils/schema/CONSTRAINTS.md index fa03e2540..da5e95b35 100644 --- a/packages/ai/src/utils/schema/CONSTRAINTS.md +++ b/packages/ai/src/utils/schema/CONSTRAINTS.md @@ -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. diff --git a/packages/ai/src/utils/schema/compatibility.ts b/packages/ai/src/utils/schema/compatibility.ts index d52fe3f84..8ede79204 100644 --- a/packages/ai/src/utils/schema/compatibility.ts +++ b/packages/ai/src/utils/schema/compatibility.ts @@ -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 diff --git a/packages/ai/src/utils/schema/fields.ts b/packages/ai/src/utils/schema/fields.ts index d5f0dc46d..55457d0ef 100644 --- a/packages/ai/src/utils/schema/fields.ts +++ b/packages/ai/src/utils/schema/fields.ts @@ -11,7 +11,7 @@ /** * Google Generative AI unsupported schema fields. - * Stripped during sanitizeSchemaForGoogle / sanitizeSchemaForCCA. + * Stripped during normalizeSchemaForGoogle / normalizeSchemaForCCA. */ export const UNSUPPORTED_SCHEMA_FIELDS: Record = { $schema: true, @@ -38,6 +38,30 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record = { 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 = { + 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 = { /** * 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; diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts index d7c93e675..fe5784258 100644 --- a/packages/ai/src/utils/schema/index.ts +++ b/packages/ai/src/utils/schema/index.ts @@ -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"; diff --git a/packages/ai/src/utils/schema/normalize-cca.ts b/packages/ai/src/utils/schema/normalize-cca.ts deleted file mode 100644 index fcf9fe61f..000000000 --- a/packages/ai/src/utils/schema/normalize-cca.ts +++ /dev/null @@ -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(); - 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(); - 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 = { 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; -} diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts new file mode 100644 index 000000000..e3cd04a10 --- /dev/null +++ b/packages/ai/src/utils/schema/normalize.ts @@ -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; + 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([ + ["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(); + 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(); + 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 | 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, + }); +} diff --git a/packages/ai/src/utils/schema/sanitize-google.ts b/packages/ai/src/utils/schema/sanitize-google.ts deleted file mode 100644 index 0a1aa80b4..000000000 --- a/packages/ai/src/utils/schema/sanitize-google.ts +++ /dev/null @@ -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; - 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 = { - 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): Record { - 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 = {}; - 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): Record { - if (obj.type === "null") { - const out: Record = {}; - 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).type === "null") { - sawNull = true; - continue; - } - kept.push(v); - } - if (!sawNull) return obj; - const out: Record = {}; - 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; - 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) - : (value as Record); - if (options.collapseNullFields && !options.insideProperties) { - obj = preHandleNullFields(obj); - } - const result: Record = {}; - for (const combiner of ["anyOf", "oneOf"] as const) { - if (Array.isArray(obj[combiner])) { - const variants = obj[combiner] as Record[]; - 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; - 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 = { $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, - }); -} diff --git a/packages/ai/src/utils/schema/spill.ts b/packages/ai/src/utils/schema/spill.ts new file mode 100644 index 000000000..09d4dfe5e --- /dev/null +++ b/packages/ai/src/utils/schema/spill.ts @@ -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, + format: DescriptionSpillFormat = "spill", +): void { + let spilled: Array | 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; +} diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index dfcd7637d..54cb3709f 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; const props = sanitized.properties as Record>; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; const props = sanitized.properties as Record>; // 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; + const sanitized = normalizeSchemaForGoogle({ type: "integer", const: 123 }) as Record; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; 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 }); diff --git a/packages/ai/test/schema-compatibility.test.ts b/packages/ai/test/schema-compatibility.test.ts index c268a45f7..db8287211 100644 --- a/packages/ai/test/schema-compatibility.test.ts +++ b/packages/ai/test/schema-compatibility.test.ts @@ -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" }] }, diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index d8c597101..d4161f4ea 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -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; + const sanitized = normalizeSchemaForGoogle({ const: null }) as Record; 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; + + 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; + + 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; + + 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; + + 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).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; - 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).self = circular; - expect(() => sanitizeSchemaForGoogle(circular)).not.toThrow(); + expect(() => normalizeSchemaForGoogle(circular)).not.toThrow(); expect(() => sanitizeSchemaForStrictMode(circular)).not.toThrow(); }); }); diff --git a/packages/coding-agent/src/mcp/tool-bridge.ts b/packages/coding-agent/src/mcp/tool-bridge.ts index 811bc4cff..0340d0df1 100644 --- a/packages/coding-agent/src/mcp/tool-bridge.ts +++ b/packages/coding-agent/src/mcp/tool-bridge.ts @@ -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 { 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 { 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; diff --git a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts index 558eba703..f5dd5f11e 100644 --- a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts +++ b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts @@ -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", diff --git a/packages/coding-agent/test/tools/schema-validation.test.ts b/packages/coding-agent/test/tools/schema-validation.test.ts index 5bf9b118e..4bfdc2ae1 100644 --- a/packages/coding-agent/test/tools/schema-validation.test.ts +++ b/packages/coding-agent/test/tools/schema-validation.test.ts @@ -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; - 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; const props = sanitized.properties as Record; const nested = props.nested as Record; const nestedProps = nested.properties as Record; @@ -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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; const items = sanitized.items as Record; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; const props = sanitized.properties as Record; 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; + const sanitized = normalizeSchemaForGoogle(schema) as Record; 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");