Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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 }),
|
||||
})),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user