Merge remote-tracking branch 'origin/main'

This commit is contained in:
can1357
2026-02-13 16:03:42 +01:00
+219 -4
View File
@@ -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<string, unknown>;
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<string, unknown>[] }[] | 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 }),
})),
},