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