320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
// Cloud Code Assist API rejects a subset of JSON Schema keywords.
|
|
// This module scrubs/normalizes tool schemas to keep Gemini happy.
|
|
|
|
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
|
|
export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
"patternProperties",
|
|
"additionalProperties",
|
|
"$schema",
|
|
"$id",
|
|
"$ref",
|
|
"$defs",
|
|
"definitions",
|
|
// Non-standard (OpenAPI) keyword; Claude validators reject it.
|
|
"examples",
|
|
|
|
// Cloud Code Assist appears to validate tool schemas more strictly/quirkily than
|
|
// draft 2020-12 in practice; these constraints frequently trigger 400s.
|
|
"minLength",
|
|
"maxLength",
|
|
"minimum",
|
|
"maximum",
|
|
"multipleOf",
|
|
"pattern",
|
|
"format",
|
|
"minItems",
|
|
"maxItems",
|
|
"uniqueItems",
|
|
"minProperties",
|
|
"maxProperties",
|
|
]);
|
|
|
|
// Check if an anyOf/oneOf array contains only literal values that can be flattened.
|
|
// TypeBox Type.Literal generates { const: "value", type: "string" }.
|
|
// Some schemas may use { enum: ["value"], type: "string" }.
|
|
// Both patterns are flattened to { type: "string", enum: ["a", "b", ...] }.
|
|
function tryFlattenLiteralAnyOf(variants: unknown[]): { type: string; enum: unknown[] } | null {
|
|
if (variants.length === 0) return null;
|
|
|
|
const allValues: unknown[] = [];
|
|
let commonType: string | null = null;
|
|
|
|
for (const variant of variants) {
|
|
if (!variant || typeof variant !== "object") return null;
|
|
const v = variant as Record<string, unknown>;
|
|
|
|
let literalValue: unknown;
|
|
if ("const" in v) {
|
|
literalValue = v.const;
|
|
} else if (Array.isArray(v.enum) && v.enum.length === 1) {
|
|
literalValue = v.enum[0];
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
const variantType = typeof v.type === "string" ? v.type : null;
|
|
if (!variantType) return null;
|
|
if (commonType === null) commonType = variantType;
|
|
else if (commonType !== variantType) return null;
|
|
|
|
allValues.push(literalValue);
|
|
}
|
|
|
|
if (commonType && allValues.length > 0) return { type: commonType, enum: allValues };
|
|
return null;
|
|
}
|
|
|
|
function isNullSchema(variant: unknown): boolean {
|
|
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
|
|
return false;
|
|
}
|
|
const record = variant as Record<string, unknown>;
|
|
if ("const" in record && record.const === null) return true;
|
|
if (Array.isArray(record.enum) && record.enum.length === 1) {
|
|
return record.enum[0] === null;
|
|
}
|
|
const typeValue = record.type;
|
|
if (typeValue === "null") return true;
|
|
if (Array.isArray(typeValue) && typeValue.length === 1 && typeValue[0] === "null") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stripNullVariants(variants: unknown[]): {
|
|
variants: unknown[];
|
|
stripped: boolean;
|
|
} {
|
|
if (variants.length === 0) return { variants, stripped: false };
|
|
const nonNull = variants.filter((variant) => !isNullSchema(variant));
|
|
return {
|
|
variants: nonNull,
|
|
stripped: nonNull.length !== variants.length,
|
|
};
|
|
}
|
|
|
|
type SchemaDefs = Map<string, unknown>;
|
|
|
|
function extendSchemaDefs(
|
|
defs: SchemaDefs | undefined,
|
|
schema: Record<string, unknown>,
|
|
): SchemaDefs | undefined {
|
|
const defsEntry =
|
|
schema.$defs && typeof schema.$defs === "object" && !Array.isArray(schema.$defs)
|
|
? (schema.$defs as Record<string, unknown>)
|
|
: undefined;
|
|
const legacyDefsEntry =
|
|
schema.definitions &&
|
|
typeof schema.definitions === "object" &&
|
|
!Array.isArray(schema.definitions)
|
|
? (schema.definitions as Record<string, unknown>)
|
|
: undefined;
|
|
|
|
if (!defsEntry && !legacyDefsEntry) return defs;
|
|
|
|
const next = defs ? new Map(defs) : new Map<string, unknown>();
|
|
if (defsEntry) {
|
|
for (const [key, value] of Object.entries(defsEntry)) next.set(key, value);
|
|
}
|
|
if (legacyDefsEntry) {
|
|
for (const [key, value] of Object.entries(legacyDefsEntry)) next.set(key, value);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function decodeJsonPointerSegment(segment: string): string {
|
|
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
}
|
|
|
|
function tryResolveLocalRef(ref: string, defs: SchemaDefs | undefined): unknown {
|
|
if (!defs) return undefined;
|
|
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
|
|
if (!match) return undefined;
|
|
const name = decodeJsonPointerSegment(match[1] ?? "");
|
|
if (!name) return undefined;
|
|
return defs.get(name);
|
|
}
|
|
|
|
function cleanSchemaForGeminiWithDefs(
|
|
schema: unknown,
|
|
defs: SchemaDefs | undefined,
|
|
refStack: Set<string> | undefined,
|
|
): unknown {
|
|
if (!schema || typeof schema !== "object") return schema;
|
|
if (Array.isArray(schema)) {
|
|
return schema.map((item) => cleanSchemaForGeminiWithDefs(item, defs, refStack));
|
|
}
|
|
|
|
const obj = schema as Record<string, unknown>;
|
|
const nextDefs = extendSchemaDefs(defs, obj);
|
|
|
|
const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined;
|
|
if (refValue) {
|
|
if (refStack?.has(refValue)) return {};
|
|
|
|
const resolved = tryResolveLocalRef(refValue, nextDefs);
|
|
if (resolved) {
|
|
const nextRefStack = refStack ? new Set(refStack) : new Set<string>();
|
|
nextRefStack.add(refValue);
|
|
|
|
const cleaned = cleanSchemaForGeminiWithDefs(resolved, nextDefs, nextRefStack);
|
|
if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) {
|
|
return cleaned;
|
|
}
|
|
|
|
const result: Record<string, unknown> = {
|
|
...(cleaned as Record<string, unknown>),
|
|
};
|
|
for (const key of ["description", "title", "default"]) {
|
|
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
for (const key of ["description", "title", "default"]) {
|
|
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
|
|
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
|
|
let cleanedAnyOf = hasAnyOf
|
|
? (obj.anyOf as unknown[]).map((variant) =>
|
|
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
|
)
|
|
: undefined;
|
|
let cleanedOneOf = hasOneOf
|
|
? (obj.oneOf as unknown[]).map((variant) =>
|
|
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
|
)
|
|
: undefined;
|
|
|
|
if (hasAnyOf) {
|
|
const { variants: nonNullVariants, stripped } = stripNullVariants(cleanedAnyOf ?? []);
|
|
if (stripped) cleanedAnyOf = nonNullVariants;
|
|
|
|
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
|
|
if (flattened) {
|
|
const result: Record<string, unknown> = {
|
|
type: flattened.type,
|
|
enum: flattened.enum,
|
|
};
|
|
for (const key of ["description", "title", "default"]) {
|
|
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
|
}
|
|
return result;
|
|
}
|
|
if (stripped && nonNullVariants.length === 1) {
|
|
const lone = nonNullVariants[0];
|
|
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
|
|
const result: Record<string, unknown> = {
|
|
...(lone as Record<string, unknown>),
|
|
};
|
|
for (const key of ["description", "title", "default"]) {
|
|
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
|
}
|
|
return result;
|
|
}
|
|
return lone;
|
|
}
|
|
}
|
|
|
|
if (hasOneOf) {
|
|
const { variants: nonNullVariants, stripped } = stripNullVariants(cleanedOneOf ?? []);
|
|
if (stripped) cleanedOneOf = nonNullVariants;
|
|
|
|
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
|
|
if (flattened) {
|
|
const result: Record<string, unknown> = {
|
|
type: flattened.type,
|
|
enum: flattened.enum,
|
|
};
|
|
for (const key of ["description", "title", "default"]) {
|
|
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
|
}
|
|
return result;
|
|
}
|
|
if (stripped && nonNullVariants.length === 1) {
|
|
const lone = nonNullVariants[0];
|
|
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
|
|
const result: Record<string, unknown> = {
|
|
...(lone as Record<string, unknown>),
|
|
};
|
|
for (const key of ["description", "title", "default"]) {
|
|
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
|
}
|
|
return result;
|
|
}
|
|
return lone;
|
|
}
|
|
}
|
|
|
|
const cleaned: Record<string, unknown> = {};
|
|
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
|
|
|
|
if (key === "const") {
|
|
cleaned.enum = [value];
|
|
continue;
|
|
}
|
|
|
|
if (key === "type" && (hasAnyOf || hasOneOf)) continue;
|
|
if (
|
|
key === "type" &&
|
|
Array.isArray(value) &&
|
|
value.every((entry) => typeof entry === "string")
|
|
) {
|
|
const types = value.filter((entry) => entry !== "null");
|
|
cleaned.type = types.length === 1 ? types[0] : types;
|
|
continue;
|
|
}
|
|
|
|
if (key === "properties" && value && typeof value === "object") {
|
|
const props = value as Record<string, unknown>;
|
|
cleaned[key] = Object.fromEntries(
|
|
Object.entries(props).map(([k, v]) => [
|
|
k,
|
|
cleanSchemaForGeminiWithDefs(v, nextDefs, refStack),
|
|
]),
|
|
);
|
|
} else if (key === "items" && value) {
|
|
if (Array.isArray(value)) {
|
|
cleaned[key] = value.map((entry) =>
|
|
cleanSchemaForGeminiWithDefs(entry, nextDefs, refStack),
|
|
);
|
|
} else if (typeof value === "object") {
|
|
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
|
|
} else {
|
|
cleaned[key] = value;
|
|
}
|
|
} else if (key === "anyOf" && Array.isArray(value)) {
|
|
cleaned[key] =
|
|
cleanedAnyOf ??
|
|
value.map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack));
|
|
} else if (key === "oneOf" && Array.isArray(value)) {
|
|
cleaned[key] =
|
|
cleanedOneOf ??
|
|
value.map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack));
|
|
} else if (key === "allOf" && Array.isArray(value)) {
|
|
cleaned[key] = value.map((variant) =>
|
|
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
|
);
|
|
} else {
|
|
cleaned[key] = value;
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
export function cleanSchemaForGemini(schema: unknown): unknown {
|
|
if (!schema || typeof schema !== "object") return schema;
|
|
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
|
|
|
|
const defs = extendSchemaDefs(undefined, schema as Record<string, unknown>);
|
|
return cleanSchemaForGeminiWithDefs(schema, defs, undefined);
|
|
}
|