@genkit-ai/core
Version:
Genkit AI framework core libraries.
354 lines (321 loc) • 9.92 kB
text/typescript
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Validator } from '@cfworker/json-schema';
import Ajv, { type ErrorObject, type JSONSchemaType } from 'ajv';
import addFormats from 'ajv-formats';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { getGenkitRuntimeConfig } from './config.js';
import { GenkitError } from './error.js';
import type { Registry } from './registry.js';
const ajv = new Ajv();
addFormats(ajv);
export { z }; // provide a consistent zod to use throughout genkit
/**
* JSON schema.
*/
export type JSONSchema = JSONSchemaType<any> | any;
const jsonSchemas = new WeakMap<z.ZodTypeAny, JSONSchema>();
const ajvValidators = new WeakMap<JSONSchema, ReturnType<typeof ajv.compile>>();
const cfWorkerValidators = new WeakMap<JSONSchema, Validator>();
const schemaAnnotations = new WeakMap<z.ZodTypeAny, Record<string, any>>();
/**
* Annotates a Zod schema with UI-specific metadata.
*
* NOTE: It's typically recommended to use x-genkit-* (or similar) as the prefix.
*/
export function annotateSchema<T extends z.ZodTypeAny>(
schema: T,
annotations: Record<string, any>
): T {
const current = schemaAnnotations.get(schema) || {};
schemaAnnotations.set(schema, { ...current, ...annotations });
return schema;
}
/**
* Wrapper object for various ways schema can be provided.
*/
export interface ProvidedSchema {
jsonSchema?: JSONSchema;
schema?: z.ZodTypeAny;
}
/**
* Schema validation error.
*/
export class ValidationError extends GenkitError {
constructor({
data,
errors,
schema,
}: {
data: any;
errors: ValidationErrorDetail[];
schema: JSONSchema;
}) {
super({
status: 'INVALID_ARGUMENT',
message: `Schema validation failed. Parse Errors:\n\n${errors.map((e) => `- ${e.path}: ${e.message}`).join('\n')}\n\nProvided data:\n\n${JSON.stringify(data, null, 2)}\n\nRequired JSON schema:\n\n${JSON.stringify(schema, null, 2)}`,
detail: { errors, schema },
});
}
}
/**
* Converts a Zod schema into a JSON schema, utilizing an in-memory cache for known objects.
* @param options Provide a json schema and/or zod schema. JSON schema has priority.
* @returns A JSON schema.
*/
export function toJsonSchema({
jsonSchema,
schema,
}: ProvidedSchema): JSONSchema | undefined {
// if neither jsonSchema or schema is present return undefined
if (!jsonSchema && !schema) return null;
if (jsonSchema) return jsonSchema;
if (jsonSchemas.has(schema!)) return jsonSchemas.get(schema!)!;
const outSchema = zodToJsonSchema(schema!, {
removeAdditionalStrategy: 'strict',
});
const annotatedSchema = applyAnnotations(schema!, outSchema as JSONSchema);
jsonSchemas.set(schema!, annotatedSchema);
return annotatedSchema;
}
/**
* Recursively applies annotations to a JSON schema by walking the Zod tree
* and matching it against the JSON schema structure.
*
* Note: This currently does not resolve JSON schema `$ref` nodes. Annotations
* on recursive schemas (using `z.lazy`) may not be correctly applied to the
* referenced definitions.
*/
function applyAnnotations(schema: z.ZodTypeAny, json: any): any {
if (!json || typeof json !== 'object') return json;
const annotationsToApply: Record<string, any>[] = [];
let current = schema;
// Collect all annotations in the hierarchy (outer to inner)
while (current) {
const ann = schemaAnnotations.get(current);
if (ann) annotationsToApply.push(ann);
if (
current instanceof z.ZodOptional ||
current instanceof z.ZodNullable ||
current instanceof z.ZodDefault ||
current instanceof z.ZodEffects
) {
current = (current as any)._def.innerType || (current as any)._def.schema;
} else {
break;
}
}
// Resolve annotations (outer-most last so it wins)
const resolvedAnnotations: Record<string, any> = {};
for (let i = annotationsToApply.length - 1; i >= 0; i--) {
Object.assign(resolvedAnnotations, annotationsToApply[i]);
}
for (const key in resolvedAnnotations) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
console.warn(
`Annotation key "${key}" conflicts with existing JSON schema property and will be ignored.`
);
continue;
}
json[key] = resolvedAnnotations[key];
}
const inner = current;
if (inner instanceof z.ZodObject && json.properties) {
for (const key in inner.shape) {
if (json.properties[key]) {
applyAnnotations(inner.shape[key], json.properties[key]);
}
}
} else if (inner instanceof z.ZodArray && json.items) {
applyAnnotations(inner.element, json.items);
} else if (inner instanceof z.ZodUnion && json.anyOf) {
for (let i = 0; i < inner.options.length; i++) {
applyAnnotations(inner.options[i], json.anyOf[i]);
}
} else if (inner instanceof z.ZodIntersection && json.allOf) {
const schemas: z.ZodTypeAny[] = [];
const collect = (s: z.ZodTypeAny) => {
if (s instanceof z.ZodIntersection) {
collect(s._def.left);
collect(s._def.right);
} else {
schemas.push(s);
}
};
collect(inner);
if (schemas.length === json.allOf.length) {
for (let i = 0; i < schemas.length; i++) {
applyAnnotations(schemas[i], json.allOf[i]);
}
}
} else if (inner instanceof z.ZodRecord && json.additionalProperties) {
applyAnnotations(inner.valueSchema, json.additionalProperties);
} else if (inner instanceof z.ZodTuple && Array.isArray(json.items)) {
for (let i = 0; i < inner.items.length; i++) {
applyAnnotations(inner.items[i], json.items[i]);
}
} else if (inner instanceof z.ZodDiscriminatedUnion && json.anyOf) {
for (let i = 0; i < inner.options.length; i++) {
applyAnnotations(inner.options[i], json.anyOf[i]);
}
}
return json;
}
/**
* Schema validation error details.
*/
export interface ValidationErrorDetail {
path: string;
message: string;
}
function ajvErrorToValidationErrorDetail(
error: ErrorObject
): ValidationErrorDetail {
return {
path: error.instancePath.substring(1).replace(/\//g, '.') || '(root)',
message: error.message!,
};
}
function cfWorkerErrorToValidationErrorDetail(error: {
instanceLocation: string;
error: string;
}): ValidationErrorDetail {
const path = error.instanceLocation.startsWith('#/')
? error.instanceLocation.substring(2)
: '';
return {
path: path.replace(/\//g, '.') || '(root)',
message: error.error,
};
}
/**
* Validation response.
*/
export type ValidationResponse =
| {
valid: true;
errors?: undefined;
schema: JSONSchema;
}
| {
valid: false;
errors: ValidationErrorDetail[];
schema: JSONSchema;
};
/**
* Validates the provided data against the provided schema.
*/
export function validateSchema(
data: unknown,
options: ProvidedSchema
): ValidationResponse {
const toValidate = toJsonSchema(options);
if (!toValidate) {
return { valid: true, schema: toValidate };
}
const validationMode = getGenkitRuntimeConfig().jsonSchemaMode;
if (validationMode === 'interpret') {
let validator = cfWorkerValidators.get(toValidate);
if (!validator) {
validator = new Validator(toValidate);
cfWorkerValidators.set(toValidate, validator);
}
const result = validator.validate(sanitizeForJsonSchema(data));
if (!result.valid) {
return {
valid: false,
errors: result.errors.map(cfWorkerErrorToValidationErrorDetail),
schema: toValidate,
};
}
return {
valid: result.valid,
schema: toValidate,
};
}
let validator = ajvValidators.get(toValidate);
if (!validator) {
validator = ajv.compile(toValidate);
ajvValidators.set(toValidate, validator);
}
const valid = validator(data) as boolean;
if (!valid) {
return {
valid: false,
errors: (validator.errors ?? []).map(ajvErrorToValidationErrorDetail),
schema: toValidate,
};
}
return { valid, schema: toValidate };
}
/**
* Parses raw data object against the provided schema.
*/
export function parseSchema<T = unknown>(
data: unknown,
options: ProvidedSchema
): T {
const result = validateSchema(data, options);
if (!result.valid) {
throw new ValidationError({
data,
errors: result.errors,
schema: result.schema,
});
}
return data as T;
}
/**
* Registers provided schema as a named schema object in the Genkit registry.
*
* @hidden
*/
export function defineSchema<T extends z.ZodTypeAny>(
registry: Registry,
name: string,
schema: T
): T {
registry.registerSchema(name, { schema });
return schema;
}
/**
* Registers provided JSON schema as a named schema object in the Genkit registry.
*
* @hidden
*/
export function defineJsonSchema(
registry: Registry,
name: string,
jsonSchema: JSONSchema
) {
registry.registerSchema(name, { jsonSchema });
return jsonSchema;
}
function sanitizeForJsonSchema(data: any): any {
if (Array.isArray(data)) {
return data.map(sanitizeForJsonSchema);
} else if (data !== null && typeof data === 'object') {
const out: any = {};
for (const key in data) {
if (data[key] !== undefined) {
out[key] = sanitizeForJsonSchema(data[key]);
}
}
return out;
}
return data;
}