UNPKG

@genkit-ai/ai

Version:

Genkit AI framework generative AI APIs.

505 lines (483 loc) 18.2 kB
/** * Copyright 2026 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 type { HasRegistry, Registry } from '@genkit-ai/core/registry'; import { Message } from '../message.js'; import { defineModel, type GenerateRequest, type GenerateResponseChunkData, type GenerateResponseData, type ModelAction, type ModelInfo, type Part, } from '../model.js'; /** * A streamed chunk a mock model may emit. A bare string is shorthand for a * single text part (`{ content: [{ text }] }`). */ export type MockChunk = string | GenerateResponseChunkData; /** Context passed to a {@link MockModelOptions.respond} callback. */ export interface MockContext { /** * Emit a streamed chunk. A no-op unless the caller used `generateStream` / * `{ onChunk }`. A string is shorthand for a text part. */ sendChunk: (chunk: MockChunk) => void; } /** * What a {@link MockModelOptions.respond} callback may return. From lightest to * fullest control: * - a `string` — shorthand for a single text response; * - an object with any of `text` / `toolRequests` / `content` — the common * cases, assembled into a well-formed response for you; * - a full {@link GenerateResponseData} (anything with a `message`) — used as-is. */ export interface MockResponseObject { /** Text content of the model message. */ text?: string; /** Tool/function calls to emit, by tool name. */ toolRequests?: Array<{ name: string; input?: unknown; ref?: string }>; /** Escape hatch: raw message parts, prepended before `text`/`toolRequests`. */ content?: Part[]; finishReason?: GenerateResponseData['finishReason']; usage?: GenerateResponseData['usage']; } export type MockResponse = string | MockResponseObject | GenerateResponseData; /** A `respond` callback: given the request, returns the response to emit. */ export type MockRespondFn = ( request: GenerateRequest, context: MockContext ) => MockResponse | Promise<MockResponse>; /** Options for {@link mockModel}. */ export interface MockModelOptions { /** Registered model name. Defaults to `'mockModel'`. */ name?: string; /** * Model metadata (e.g. `supports`, `versions`). * * `supports.constrained` defaults to `'all'` (native constrained generation), * so a `generate` with an `output.schema` reaches `respond` with that schema * on `request.output.schema`. Pass `supports: { constrained: 'none' }` to * instead exercise the framework's simulated path, where the schema is * injected into the prompt (and thus visible in `lastRequestText`) and * stripped from what `respond` sees. */ info?: ModelInfo; /** * What to respond with on each `generate` call. Defaults to empty text. * * - A **single response** (string / object) is returned on every call. * - A **callback** `(request, { sendChunk }) => response` is invoked once per * call, so you can branch on request history (for tool loops) and stream via * `sendChunk`. * - An **array** is a queue consumed one item per call, with the last item * repeating once exhausted — handy for scripted multi-turn tests. A queued * `Error` is thrown when reached, to inject a failure on a given turn. * (Queued items are static, so streaming needs the callback form.) * * ```ts * mockModel(ai, { respond: 'always this' }); * mockModel(ai, { respond: ['first', 'second'] }); * mockModel(ai, { respond: ['ok', new Error('rate limited')] }); * ``` */ respond?: MockRespond; } /** * Anything accepted as respond behavior: a single {@link MockResponse}, a * per-call callback, or a queue of responses/errors. See * {@link MockModelOptions.respond}. */ export type MockRespond = | MockRespondFn | MockResponse | Array<MockResponse | Error>; /** * A mock model with typed inspection of the calls it received. The extra * members are read-only views over the recorded calls. */ export type MockModel = ModelAction & { /** The request from the most recent call, or `undefined` if never called. */ readonly lastRequest: GenerateRequest | undefined; /** * The final message of the most recent request, wrapped as a {@link Message} * so you can read it the same way you read a response — `.text`, `.media`, * `.toolRequests`, etc. `undefined` if the model was never called. * * ```ts * assert.match(model.lastRequestMessage!.text, /Summarize: long text/); * ``` */ readonly lastRequestMessage: Message | undefined; /** * The full assembled conversation of the most recent request, flattened to a * single string (system + every message, in order). Use it for prompt- * assembly assertions on any mock — including ones returning structured * output, where {@link echoModel} can't be used. `undefined` if never called. * * ```ts * assert.match(model.lastRequestText!, /system: Be terse/); * ``` */ readonly lastRequestText: string | undefined; /** * The tool results fed back to the model in the most recent request, in order. * Use it to assert which tools ran and what they returned, without digging * through message content yourself. Empty if the model was never called or saw * no tool results. * * ```ts * assert.deepStrictEqual(model.toolResponses.map((t) => t.name), ['lookup']); * ``` */ readonly toolResponses: Array<{ name: string; ref?: string; output: unknown; }>; /** Every request this model received, oldest first. */ readonly requests: GenerateRequest[]; /** How many times this model was called. */ readonly requestCount: number; /** * Replaces the respond behavior for subsequent calls. Accepts everything * {@link MockModelOptions.respond} does; an array re-arms as a fresh queue. * Recorded history is untouched — use {@link MockModel.reset} for that. * * Together with `reset()` this supports the register-once idiom: define the * mock once per test file, then give each test its own behavior. * * ```ts * const model = mockModel(ai, { name: 'menuModel' }); * beforeEach(() => model.reset()); * * test('...', async () => { * model.respondWith({ text: 'scripted' }); * // ... * }); * ``` */ respondWith(respond: MockRespond): void; /** * Clears recorded history (`requests`, `requestCount`, …) and restores the * respond behavior given at construction, re-arming a queued `respond` from * its first item. Call it in `beforeEach` so tests sharing a mock stay * order-independent. */ reset(): void; }; function resolveRegistry(registry: Registry | HasRegistry): Registry { return (registry as HasRegistry).registry ?? (registry as Registry); } /** * Snapshots a request so later mutation can't alter recorded history. Prefers a * deep `structuredClone`, but falls back to a message/part-level copy when the * request carries non-serializable values (e.g. a function or class instance in * `config`), which would otherwise throw a `DataCloneError`. */ function cloneRequest(request: GenerateRequest): GenerateRequest { try { return structuredClone(request); } catch { return { ...request, messages: request.messages.map((m) => ({ ...m, content: m.content.map((c) => ({ ...c })), })), }; } } function toChunkData(chunk: MockChunk): GenerateResponseChunkData { return typeof chunk === 'string' ? { content: [{ text: chunk }] } : chunk; } /** * Renders a single request part to text for {@link renderRequestText}. Non-text * parts (media, tool requests/responses, reasoning, resource, data, custom) are * rendered as a labelled placeholder rather than silently dropped. */ function renderPart(part: Part): string { if (part.text !== undefined) return part.text; if (part.media) { const type = part.media.contentType ? ` ${part.media.contentType}` : ''; return `[media${type}: ${part.media.url}]`; } if (part.toolRequest) { const { name, input } = part.toolRequest; return `[toolRequest ${name}(${JSON.stringify(input)})]`; } if (part.toolResponse) { const { name, output } = part.toolResponse; return `[toolResponse ${name}: ${JSON.stringify(output)}]`; } if (part.reasoning !== undefined) return `[reasoning: ${part.reasoning}]`; if (part.resource) return `[resource: ${part.resource.uri}]`; if (part.data !== undefined) return `[data: ${JSON.stringify(part.data)}]`; if (part.custom !== undefined) return `[custom: ${JSON.stringify(part.custom)}]`; return ''; } /** * Flattens a request's full message list to text — system and tool messages are * prefixed with their role; `user`/`model` are not. Messages are newline- * separated so adjacent messages' text can't fuse into a single token (which * would silently break boundary-spanning assertions). This is the assembled * conversation the model would have seen, used by both {@link echoModel} and * {@link MockModel.lastRequestText}. */ function renderRequestText(request: GenerateRequest): string { return request.messages .map( (m) => (m.role === 'user' || m.role === 'model' ? '' : `${m.role}: `) + m.content.map(renderPart).join('') ) .join('\n'); } /** * Normalizes the `respond` option into a single callback. A single response is * returned on every call; an array becomes a queue consumed one item per call, * with the last item repeating once exhausted; a queued `Error` is thrown when * reached. */ function toRespondFn(respond: MockRespond | undefined): MockRespondFn { if (respond === undefined) { return () => ({ text: '' }); } if (typeof respond === 'function') { return respond; } const queue = Array.isArray(respond) ? respond : [respond]; if (queue.length === 0) { return () => ({ text: '' }); } let i = 0; return () => { const item = queue[Math.min(i, queue.length - 1)]; i++; if (item instanceof Error) throw item; return item; }; } function toResponseData(response: MockResponse): GenerateResponseData { // A `respond` that streams but returns nothing (void) yields an empty message // rather than throwing on the `'message' in response` check below. Checked // against null/undefined only, so `respond: ''` still means empty *text*. if (response === undefined || response === null) { return { message: { role: 'model', content: [] }, finishReason: 'stop' }; } if (typeof response === 'string') { return { message: { role: 'model', content: [{ text: response }] }, finishReason: 'stop', }; } if ('message' in response && response.message) { const data = response as GenerateResponseData; // Default finishReason without letting an explicit `undefined` clobber it. return { ...data, finishReason: data.finishReason ?? 'stop' }; } const obj = response as MockResponseObject; const content: Part[] = [...(obj.content ?? [])]; if (obj.text !== undefined) { content.push({ text: obj.text }); } for (const tool of obj.toolRequests ?? []) { content.push({ toolRequest: { name: tool.name, input: tool.input, ref: tool.ref }, }); } return { message: { role: 'model', content }, finishReason: obj.finishReason ?? 'stop', usage: obj.usage, }; } /** * Defines a programmable mock model for tests. Drive each call's response with * `respond`, and inspect what the model was called with via `lastRequest` / * `requests` / `requestCount`. * * ```ts * const model = mockModel(ai, { respond: () => ({ text: 'a summary' }) }); * const out = (await ai.generate({ model, prompt: 'Summarize: ...' })).text; * assert.match(model.lastRequest!.messages.at(-1)!.content[0].text!, /Summarize/); * ``` * * Streaming and tool calls are first-class: * * ```ts * mockModel(ai, { * respond: (req, { sendChunk }) => { * sendChunk('hel'); * sendChunk('lo'); * return { text: 'hello' }; * }, * }); * * mockModel(ai, { * respond: () => ({ toolRequests: [{ name: 'lookup', input: { id: 1 } }] }), * }); * ``` * * For structured output, the mock defaults to native constrained generation * (`supports.constrained: 'all'`), so `respond` sees `request.output.schema` * and no schema blob is injected into the prompt. Override with * `supports: { constrained: 'none' }` to test the simulated path. * * @param registry a `Genkit` instance (or anything holding a `Registry`). * @param options model name, metadata, and the `respond` callback. */ export function mockModel( registry: Registry | HasRegistry, options: MockModelOptions = {} ): MockModel { const requests: GenerateRequest[] = []; let respond = toRespondFn(options.respond); const model = defineModel( resolveRegistry(registry), { apiVersion: 'v2', name: options.name ?? 'mockModel', // Forward only the metadata fields defineModel accepts; ModelInfo's // `configSchema`/`stage` aren't part of DefineModelOptions. versions: options.info?.versions, label: options.info?.label, // Default to native constrained generation (like modern provider models), // so a structured-output request reaches `respond` with `output.schema` // intact instead of the framework injecting a schema blob into the prompt // and stripping it. Spread last so callers can opt out with // `supports: { constrained: 'none' }` to exercise the simulated path. supports: { constrained: 'all', ...options.info?.supports }, }, async (request, { sendChunk }) => { // Snapshot so later mutation of the request can't alter recorded history. requests.push(cloneRequest(request)); const context: MockContext = { sendChunk: (chunk) => sendChunk?.(toChunkData(chunk)), }; return toResponseData(await respond(request, context)); } ) as MockModel; Object.defineProperties(model, { // Return clones so callers can't mutate recorded history through a view. requests: { get: () => requests.map((r) => cloneRequest(r)) }, lastRequest: { get: () => { const last = requests[requests.length - 1]; return last ? cloneRequest(last) : undefined; }, }, lastRequestMessage: { get: () => { const last = requests[requests.length - 1]?.messages.at(-1); return last ? new Message(last) : undefined; }, }, lastRequestText: { get: () => { const last = requests[requests.length - 1]; return last ? renderRequestText(last) : undefined; }, }, toolResponses: { get: () => (requests[requests.length - 1]?.messages ?? []) .flatMap((m) => m.content) .filter((p) => p.toolResponse) .map((p) => ({ name: p.toolResponse!.name, ref: p.toolResponse!.ref, output: p.toolResponse!.output, })), }, requestCount: { get: () => requests.length }, respondWith: { value: (next: MockRespond) => { respond = toRespondFn(next); }, }, reset: { value: () => { requests.length = 0; respond = toRespondFn(options.respond); }, }, }); return model; } /** Options for {@link echoModel}. */ export interface EchoModelOptions { /** Registered model name. Defaults to `'echoModel'`. */ name?: string; /** Model metadata. */ info?: ModelInfo; } /** * A {@link mockModel} preset for *text* paths: a zero-config model that echoes * the rendered request back as text, for asserting prompt and message assembly * (what the model *would have seen*). Supports the same inspection members as * {@link mockModel}. * * ```ts * const model = echoModel(ai); * const res = await ai.generate({ model, system: 'Be terse', prompt: 'hi' }); * assert.match(res.text, /system: Be terse/); * ``` * * Because it returns text, it can't satisfy a structured **output schema** — * Genkit derives `output` by parsing the response text and validating it, which * prose can't pass. If the request carries an output schema, `echoModel` throws * an explanatory error. For structured-output paths, use {@link mockModel} with * a conforming response and assert assembly via {@link MockModel.lastRequestText} * / {@link MockModel.lastRequest} instead. * * @param registry a `Genkit` instance (or anything holding a `Registry`). * @param options model name and metadata. */ export function echoModel( registry: Registry | HasRegistry, options: EchoModelOptions = {} ): MockModel { return mockModel(registry, { name: options.name ?? 'echoModel', // Declare native constrained support so the framework hands the output // schema to the model directly (in `request.output.schema`) rather than // injecting it as prompt text — that lets the guard below detect it // reliably, and keeps the echo free of framework-injected schema blobs. info: { ...options.info, supports: { ...options.info?.supports, constrained: 'all' }, }, respond: (request) => { if (request.output?.schema) { throw new Error( "echoModel returns text and can't satisfy an output schema: this " + 'request asks for structured output. Either move `output: { schema }` ' + 'to the generate()/flow call site so the prompt stays text-only, or ' + 'use mockModel(...) with a conforming response and assert prompt ' + 'assembly via model.lastRequestText / model.lastRequest.' ); } return { content: [ { text: 'Echo: ' + renderRequestText(request) }, { text: '; config: ' + JSON.stringify(request.config) }, ], }; }, }); }