diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 7ede7de29..1e6a074f6 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -2,6 +2,8 @@ * Shared utilities for Google Generative AI and Google Cloud Code Assist providers. */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; +import type { AnySchema } from "ajv"; +import Ajv2020 from "ajv/dist/2020.js"; import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transform-messages"; @@ -257,7 +259,6 @@ const UNSUPPORTED_SCHEMA_FIELDS = new Set([ "$defs", "$dynamicRef", "$dynamicAnchor", - "format", "examples", "prefixItems", "unevaluatedProperties", @@ -282,6 +283,103 @@ interface SanitizeSchemaOptions { stripNullableKeyword: boolean; } +type JsonObject = Record; + +function isJsonObject(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function areJsonValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + for (let i = 0; i < left.length; i += 1) { + if (!areJsonValuesEqual(left[i], right[i])) { + return false; + } + } + return true; + } + if (!isJsonObject(left) || !isJsonObject(right)) { + return false; + } + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + for (const key of leftKeys) { + if (!(key in right) || !areJsonValuesEqual(left[key], right[key])) { + return false; + } + } + return true; +} + +function mergeCompatibleEnumSchemas(existing: unknown, incoming: unknown): JsonObject | null { + if (!isJsonObject(existing) || !isJsonObject(incoming)) { + return null; + } + const existingEnum = Array.isArray(existing.enum) ? existing.enum : null; + const incomingEnum = Array.isArray(incoming.enum) ? incoming.enum : null; + if (!existingEnum || !incomingEnum) { + return null; + } + if (!areJsonValuesEqual(existing.type, incoming.type)) { + return null; + } + const existingKeys = Object.keys(existing).filter(key => key !== "enum"); + const incomingKeys = Object.keys(incoming).filter(key => key !== "enum"); + if (existingKeys.length !== incomingKeys.length) { + return null; + } + for (const key of existingKeys) { + if (!(key in incoming) || !areJsonValuesEqual(existing[key], incoming[key])) { + return null; + } + } + + const mergedEnum = [...existingEnum]; + for (const enumValue of incomingEnum) { + if (!mergedEnum.some(existingValue => Object.is(existingValue, enumValue))) { + mergedEnum.push(enumValue); + } + } + return { + ...existing, + enum: mergedEnum, + }; +} + +function getAnyOfVariants(schema: unknown): unknown[] { + if (isJsonObject(schema) && Array.isArray(schema.anyOf)) { + return schema.anyOf; + } + return [schema]; +} + +function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { + if (areJsonValuesEqual(existing, incoming)) { + return existing; + } + const mergedEnumSchema = mergeCompatibleEnumSchemas(existing, incoming); + if (mergedEnumSchema !== null) { + return mergedEnumSchema; + } + + const mergedAnyOf = [...getAnyOfVariants(existing)]; + for (const variant of getAnyOfVariants(incoming)) { + if (!mergedAnyOf.some(existingVariant => areJsonValuesEqual(existingVariant, variant))) { + mergedAnyOf.push(variant); + } + } + return mergedAnyOf.length === 1 ? mergedAnyOf[0] : { anyOf: mergedAnyOf }; +} + function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unknown { if (Array.isArray(value)) { return value.map(entry => sanitizeSchemaImpl(entry, options)); @@ -381,6 +479,121 @@ export function sanitizeSchemaForCloudCodeAssistClaude(value: unknown): unknown }); } +/** + * 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; + if (variantType !== undefined && variantType !== "object") { + return schema; + } + if (entry.properties !== undefined && !isJsonObject(entry.properties)) { + return schema; + } + variants.push(entry); + } + + const mergedProperties: JsonObject = {}; + const ownProperties = isJsonObject(schema.properties) ? schema.properties : {}; + for (const [name, propertySchema] of Object.entries(ownProperties)) { + mergedProperties[name] = propertySchema; + } + + for (const variant of variants) { + const properties = isJsonObject(variant.properties) ? variant.properties : {}; + for (const [name, propertySchema] of Object.entries(properties)) { + const existingSchema = mergedProperties[name]; + mergedProperties[name] = + existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema); + } + } + + const nextSchema: JsonObject = {}; + for (const [key, entry] of Object.entries(schema)) { + if (key === combiner) continue; + nextSchema[key] = entry; + } + + nextSchema.type = "object"; + nextSchema.properties = mergedProperties; + return nextSchema; +} + +function normalizeSchemaForCloudCodeAssistClaude(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(entry => normalizeSchemaForCloudCodeAssistClaude(entry)); + } + if (!isJsonObject(value)) { + return value; + } + + const normalized: JsonObject = {}; + for (const [key, entry] of Object.entries(value)) { + normalized[key] = normalizeSchemaForCloudCodeAssistClaude(entry); + } + + const mergedAnyOf = mergeObjectCombinerVariants(normalized, "anyOf"); + return mergeObjectCombinerVariants(mergedAnyOf, "oneOf"); +} + +let cloudCodeAssistSchemaValidator: Ajv2020 | null = null; +function getCloudCodeAssistSchemaValidator(): Ajv2020 { + if (cloudCodeAssistSchemaValidator) { + return cloudCodeAssistSchemaValidator; + } + + cloudCodeAssistSchemaValidator = new Ajv2020({ + allErrors: true, + strict: false, + validateSchema: true, + }); + return cloudCodeAssistSchemaValidator; +} + +/** + * Keep validation synchronous in this request path. + */ +function isValidCloudCodeAssistClaudeSchema(schema: unknown): boolean { + try { + const result = getCloudCodeAssistSchemaValidator().validateSchema(schema as AnySchema); + return typeof result === "boolean" ? result : false; + } catch { + 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 prepareSchemaForCloudCodeAssistClaude(value: unknown): unknown { + const sanitized = sanitizeSchemaForCloudCodeAssistClaude(value); + const normalized = normalizeSchemaForCloudCodeAssistClaude(sanitized); + if (isValidCloudCodeAssistClaudeSchema(normalized)) { + return normalized; + } + return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA; +} + /** * Convert tools to Gemini function declarations format. * @@ -396,8 +609,10 @@ export function convertTools( ): { functionDeclarations: Record[] }[] | undefined { if (tools.length === 0) return undefined; - // Claude models on Cloud Code Assist need the legacy `parameters` field; - // the API translates it into Anthropic's `input_schema`. + /** + * Claude models on Cloud Code Assist need the legacy `parameters` field; + * the API translates it into Anthropic's `input_schema`. + */ const useParameters = model.id.startsWith("claude-"); return [ @@ -406,7 +621,7 @@ export function convertTools( name: tool.name, description: tool.description, ...(useParameters - ? { parameters: sanitizeSchemaForCloudCodeAssistClaude(tool.parameters) } + ? { parameters: prepareSchemaForCloudCodeAssistClaude(tool.parameters) } : { parametersJsonSchema: tool.parameters }), })), },