UNPKG

@ai-sdk/google

Version:
447 lines (389 loc) 11.3 kB
import { UnsupportedFunctionalityError, type JSONSchema7, type JSONSchema7Definition, } from '@ai-sdk/provider'; type JSONSchema7WithDefinitions = JSONSchema7 & { $defs?: Record<string, JSONSchema7Definition>; }; type ReferenceContext = { definitions: Record<string, JSONSchema7Definition> | undefined; dollarDefinitions: Record<string, JSONSchema7Definition> | undefined; resolvingReferences: ReadonlySet<string>; }; const recursiveReferenceFunctionalityPrefix = 'recursive JSON Schema reference:'; export function isRecursiveJSONSchemaReferenceError( error: unknown, ): error is UnsupportedFunctionalityError { return ( UnsupportedFunctionalityError.isInstance(error) && error.functionality.startsWith(recursiveReferenceFunctionalityPrefix) ); } /** * Converts JSON Schema 7 to OpenAPI Schema 3.0 */ export function convertJSONSchemaToOpenAPISchema( jsonSchema: JSONSchema7Definition | undefined, isRoot = true, ): unknown { const rootSchema = typeof jsonSchema === 'object' ? (jsonSchema as JSONSchema7WithDefinitions) : undefined; return convertJSONSchemaDefinition(jsonSchema, isRoot, { definitions: rootSchema?.definitions, dollarDefinitions: rootSchema?.$defs, resolvingReferences: new Set(), }); } function convertJSONSchemaDefinition( jsonSchema: JSONSchema7Definition | undefined, isRoot: boolean, referenceContext: ReferenceContext, ): unknown { if (jsonSchema == null) { return undefined; } if (typeof jsonSchema === 'boolean') { return { type: 'boolean', properties: {} }; } if (jsonSchema.$ref != null) { return convertJSONSchemaReference({ jsonSchema, reference: jsonSchema.$ref, isRoot, referenceContext, }); } // Handle empty object schemas: undefined at root, preserved when nested if (isEmptyObjectSchema(jsonSchema)) { if (isRoot) { return undefined; } if (jsonSchema.description) { return { type: 'object', description: jsonSchema.description }; } return { type: 'object' }; } const { type, description, required, properties, items, allOf, anyOf, oneOf, format, const: constValue, minLength, enum: enumValues, } = jsonSchema; const result: Record<string, unknown> = {}; if (description) result.description = description; if (required) result.required = required; if (format) result.format = format; // Handle type if (type) { if (Array.isArray(type)) { const hasNull = type.includes('null'); const nonNullTypes = type.filter(t => t !== 'null'); if (nonNullTypes.length === 0) { // Only null type result.type = 'null'; } else { // One or more non-null types: always use anyOf result.anyOf = nonNullTypes.map(t => ({ type: t })); if (hasNull) { result.nullable = true; } } } else { result.type = type; } } const values = enumValues ?? (constValue !== undefined ? [constValue] : undefined); if (values !== undefined) { addEnumToSchema({ values, type, result }); } if (properties != null) { result.properties = Object.entries(properties).reduce( (acc, [key, value]) => { acc[key] = convertJSONSchemaDefinition(value, false, referenceContext); return acc; }, {} as Record<string, unknown>, ); } if (items) { result.items = Array.isArray(items) ? items.map(item => convertJSONSchemaDefinition(item, false, referenceContext), ) : convertJSONSchemaDefinition(items, false, referenceContext); } if (allOf) { result.allOf = allOf.map(item => convertJSONSchemaDefinition(item, false, referenceContext), ); } if (anyOf) { // Handle cases where anyOf includes a null type if ( anyOf.some( schema => typeof schema === 'object' && schema?.type === 'null', ) ) { const nonNullSchemas = anyOf.filter( schema => !(typeof schema === 'object' && schema?.type === 'null'), ); if (nonNullSchemas.length === 1) { // If there's only one non-null schema, convert it and make it nullable const converted = convertJSONSchemaDefinition( nonNullSchemas[0], false, referenceContext, ); if (typeof converted === 'object') { result.nullable = true; Object.assign(result, converted); } } else { // If there are multiple non-null schemas, keep them in anyOf result.anyOf = nonNullSchemas.map(item => convertJSONSchemaDefinition(item, false, referenceContext), ); result.nullable = true; } } else { result.anyOf = anyOf.map(item => convertJSONSchemaDefinition(item, false, referenceContext), ); } } if (oneOf) { result.oneOf = oneOf.map(item => convertJSONSchemaDefinition(item, false, referenceContext), ); } if (minLength !== undefined) { result.minLength = minLength; } return result; } function convertJSONSchemaReference({ jsonSchema, reference, isRoot, referenceContext, }: { jsonSchema: JSONSchema7; reference: string; isRoot: boolean; referenceContext: ReferenceContext; }): unknown { const { definition, referenceKey } = getReferencedDefinition( reference, referenceContext, ); if (referenceContext.resolvingReferences.has(referenceKey)) { throw new UnsupportedFunctionalityError({ functionality: `${recursiveReferenceFunctionalityPrefix} ${reference}`, message: 'Google schema conversion does not support recursive JSON Schema references.', }); } const resolvingReferences = new Set(referenceContext.resolvingReferences); resolvingReferences.add(referenceKey); // Inline references instead of emitting Google's `ref` / `defs` fields. // Those fields are supported by Vertex AI's Schema representation but are // rejected by the Gemini Developer API representation used by this shared // converter. const { $ref: _reference, ...siblingSchema } = jsonSchema; const resolvedSchema = typeof definition === 'boolean' ? definition ? siblingSchema : false : { ...definition, ...siblingSchema }; return convertJSONSchemaDefinition(resolvedSchema, isRoot, { ...referenceContext, resolvingReferences, }); } function getReferencedDefinition( reference: string, referenceContext: ReferenceContext, ): { definition: JSONSchema7Definition; referenceKey: string; } { const definitionSources = [ { prefix: '#/$defs/', definitions: referenceContext.dollarDefinitions, }, { prefix: '#/definitions/', definitions: referenceContext.definitions, }, ]; const source = definitionSources.find(({ prefix }) => reference.startsWith(prefix), ); const encodedDefinitionName = source ? reference.slice(source.prefix.length) : undefined; if ( source == null || encodedDefinitionName == null || encodedDefinitionName.length === 0 || encodedDefinitionName.includes('/') ) { throwUnsupportedReference(reference); } let decodedDefinitionName: string; try { decodedDefinitionName = decodeURIComponent(encodedDefinitionName); } catch { throwUnsupportedReference(reference); } if ( decodedDefinitionName.includes('/') || /~(?![01])/u.test(decodedDefinitionName) || source.definitions == null ) { throwUnsupportedReference(reference); } const definitionName = decodedDefinitionName.replace(/~[01]/g, match => match === '~1' ? '/' : '~', ); if ( !Object.prototype.hasOwnProperty.call(source.definitions, definitionName) ) { throwUnsupportedReference(reference); } return { definition: source.definitions[definitionName], referenceKey: `${source.prefix}${definitionName}`, }; } function throwUnsupportedReference(reference: string): never { throw new UnsupportedFunctionalityError({ functionality: `JSON Schema reference: ${reference}`, message: 'Google schema conversion only supports references to direct children of root-level $defs or definitions.', }); } type EnumValues = NonNullable<JSONSchema7['enum']>; type EnumType = 'string' | 'number' | 'integer' | 'boolean'; type GoogleEnumSchema = { type?: JSONSchema7['type']; enum?: JSONSchema7['enum']; format?: JSONSchema7['format']; anyOf?: JSONSchema7['anyOf']; nullable?: boolean; }; function addEnumToSchema({ values, type, result, }: { values: EnumValues; type: JSONSchema7['type']; result: GoogleEnumSchema; }) { const nullable = (Array.isArray(type) && type.includes('null')) || (type === undefined && values.includes(null)); // Gemini uses nullable instead of a null enum member. const enumValues = nullable ? values.filter(value => value !== null) : values; if (values.length > 0 && values.every(value => value === null)) { const typeAllowsNull = type === undefined || type === 'null' || (Array.isArray(type) && type.includes('null')); if (typeAllowsNull) { result.type = 'null'; if (Array.isArray(type)) { delete result.anyOf; } return; } } const enumType = getEnumType({ values: enumValues, type }); if (enumType === undefined) { throw new UnsupportedFunctionalityError({ functionality: 'JSON Schema enum with mixed or unsupported values', message: 'Google does not support this JSON Schema enum. Enum values must share one supported primitive type and match the schema type.', }); } result.type = enumType; // The earlier type-array conversion created anyOf. The enum gives us one // concrete value type, so store that type directly. if (Array.isArray(type)) { delete result.anyOf; } if (nullable) { result.nullable = true; } if (enumType === 'string') { result.enum = enumValues; } else { result.format = 'enum'; result.enum = enumValues.map(String); } } function getEnumType({ values, type, }: { values: EnumValues; type: JSONSchema7['type']; }): EnumType | undefined { if (values.length === 0) { return undefined; } const typeAllows = (enumType: EnumType) => type === undefined || type === enumType || (Array.isArray(type) && type.includes(enumType)); if ( typeAllows('string') && values.every(value => typeof value === 'string') ) { return 'string'; } if ( (typeAllows('number') || typeAllows('integer')) && values.every(value => typeof value === 'number' && Number.isFinite(value)) ) { if (typeAllows('number')) { return 'number'; } if (values.every(value => Number.isInteger(value))) { return 'integer'; } } if ( typeAllows('boolean') && values.every(value => typeof value === 'boolean') ) { return 'boolean'; } return undefined; } function isEmptyObjectSchema(jsonSchema: JSONSchema7Definition): boolean { return ( jsonSchema != null && typeof jsonSchema === 'object' && jsonSchema.type === 'object' && (jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) && !jsonSchema.additionalProperties ); }