UNPKG

@genkit-ai/core

Version:

Genkit AI framework core libraries.

426 lines (373 loc) 11.9 kB
/** * 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 { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import * as assert from 'assert'; import { beforeEach, describe, it } from 'node:test'; import { z } from 'zod'; import { action, defineAction, defineActionAsync } from '../src/action.js'; import { initNodeFeatures } from '../src/node.js'; import { Registry } from '../src/registry.js'; import { enableTelemetry } from '../src/tracing.js'; import { TestSpanExporter } from './utils.js'; initNodeFeatures(); const spanExporter = new TestSpanExporter(); enableTelemetry({ spanProcessors: [new SimpleSpanProcessor(spanExporter)], }); describe('action', () => { var registry: Registry; beforeEach(() => { registry = new Registry(); spanExporter.exportedSpans = []; }); it('applies middleware', async () => { const act = action( { name: 'foo', inputSchema: z.string(), outputSchema: z.number(), use: [ async (input, next) => (await next(input + 'middle1')) + 1, async (input, opts, next) => (await next(input + 'middle2', opts)) + 2, ], actionType: 'util', }, async (input) => { return input.length; } ); assert.strictEqual( await act('foo'), 20 // "foomiddle1middle2".length + 1 + 2 ); }); it('returns telemetry info', async () => { const act = action( { name: 'foo', inputSchema: z.string(), outputSchema: z.number(), use: [ async (input, next) => (await next(input + 'middle1')) + 1, async (input, opts, next) => (await next(input + 'middle2', opts)) + 2, ], actionType: 'util', }, async (input) => { return input.length; } ); const result = await act.run('foo'); assert.strictEqual( result.result, 20 // "foomiddle1middle2".length + 1 + 2 ); assert.strictEqual(result.telemetry !== null, true); assert.strictEqual( result.telemetry.traceId !== null && result.telemetry.traceId.length > 0, true ); assert.strictEqual( result.telemetry.spanId !== null && result.telemetry.spanId.length > 0, true ); }); it('run the action with options', async () => { let passedContext; const act = action( { name: 'foo', inputSchema: z.string(), outputSchema: z.number(), actionType: 'util', }, async (input, { sendChunk, context }) => { passedContext = context; sendChunk(1); sendChunk(2); sendChunk(3); return input.length; } ); const chunks: any[] = []; await act.run('1234', { context: { foo: 'bar' }, onChunk: (c) => chunks.push(c), }); assert.deepStrictEqual(passedContext, { foo: 'bar', }); assert.deepStrictEqual(chunks, [1, 2, 3]); }); it('runs the action with context plus registry global context', async () => { let passedContext; let calledWithStreamingRequestedValue; const act = action( { name: 'foo', inputSchema: z.string(), outputSchema: z.number(), actionType: 'util', }, async (input, { sendChunk, context, streamingRequested }) => { calledWithStreamingRequestedValue = streamingRequested; passedContext = context; sendChunk(1); sendChunk(2); sendChunk(3); return input.length; } ); registry.context = { bar: 'baz' }; act.__registry = registry; await act.run('1234', { context: { foo: 'bar' }, }); assert.strictEqual(calledWithStreamingRequestedValue, false); assert.deepStrictEqual(passedContext, { foo: 'bar', bar: 'baz', // these come from glboal registry context }); registry.context = { bar2: 'baz2' }; const { output } = act.stream('1234', { context: { foo2: 'bar2' }, }); await output; assert.strictEqual(calledWithStreamingRequestedValue, true); assert.deepStrictEqual(passedContext, { foo2: 'bar2', bar2: 'baz2', // these come from glboal registry context }); }); it('should stream the response', async () => { const action = defineAction( registry, { name: 'hello', actionType: 'custom' }, async (input, { sendChunk, streamingRequested }) => { sendChunk({ count: 1 }); sendChunk({ count: 2 }); sendChunk({ count: 3 }); return `hi ${input}`; } ); const response = action.stream('Pavel'); const gotChunks: any[] = []; for await (const chunk of response.stream) { gotChunks.push(chunk); } assert.equal(await response.output, 'hi Pavel'); assert.deepStrictEqual(gotChunks, [ { count: 1 }, { count: 2 }, { count: 3 }, ]); }); it('cancels the underlying stream when the consumer stops reading early', async () => { // The producer keeps sending chunks until it is told to stop. Before the // fix, abandoning the stream early left the reader holding the lock and // never cancelled the underlying stream, so the producer happily kept // pushing chunks. After the fix, the generator's `finally` cancels the // reader, which closes the chunk stream and surfaces back to the producer // as a failing `sendChunk`. const totalChunks = 20; let producedCount = 0; let sendChunkError: unknown; let releaseProducer: () => void; const producerDone = new Promise<void>((resolve) => { releaseProducer = resolve; }); const act = defineAction( registry, { name: 'streamer', actionType: 'custom' }, async (input, { sendChunk }) => { try { for (let i = 0; i < totalChunks; i++) { // Yield to the event loop so the consumer can interleave / bail out. await new Promise((r) => setTimeout(r, 1)); producedCount = i + 1; sendChunk({ count: i + 1 }); } } catch (e) { sendChunkError = e; throw e; } finally { releaseProducer(); } return `hi ${input}`; } ); const response = act.stream('Pavel'); // The action throws once the stream is cancelled, so its output rejects. // Swallow it to avoid an unhandled rejection failing the test runner. response.output.catch(() => {}); const gotChunks: any[] = []; for await (const chunk of response.stream) { gotChunks.push(chunk); if (gotChunks.length === 2) { break; // consumer abandons the stream early } } await producerDone; // Consumer only ever saw the first two chunks. assert.deepStrictEqual(gotChunks, [{ count: 1 }, { count: 2 }]); // The producer was stopped well before emitting all 20 chunks. assert.ok( producedCount < totalChunks, `expected producer to be cancelled early, but it produced ${producedCount}/${totalChunks} chunks` ); // Cancelling the reader closed the chunk stream, so the next sendChunk threw. assert.ok( sendChunkError, 'expected sendChunk to throw after the consumer abandoned the stream' ); }); it('should inherit context from parent action invocation', async () => { const child = defineAction( registry, { name: 'child', actionType: 'custom' }, async (_, { context }) => { return `hi ${context?.auth?.email}`; } ); const parent = defineAction( registry, { name: 'parent', actionType: 'custom' }, async () => { return child(); } ); const response = await parent(undefined, { context: { auth: { email: 'a@b.c' } }, }); assert.strictEqual(response, 'hi a@b.c'); }); it('should include trace info in the context', async () => { const act = defineAction( registry, { name: 'child', actionType: 'custom' }, async (_, ctx) => { return `traceId=${!!ctx.trace.traceId} spanId=${!!ctx.trace.spanId}`; } ); const response = await act(undefined); assert.strictEqual(response, 'traceId=true spanId=true'); }); it('passes through the abort signal', async () => { var gotAbortSignal; const act = defineAction( registry, { name: 'child', actionType: 'custom' }, async (_, ctx) => { gotAbortSignal = ctx.abortSignal; return `traceId=${!!ctx.trace.traceId} spanId=${!!ctx.trace.spanId}`; } ); const signal = new AbortController().signal; await act(undefined, { abortSignal: signal }); assert.strictEqual(gotAbortSignal, signal); }); it('passes through the abort signal with middleware', async () => { var gotAbortSignal; const act = defineAction( registry, { name: 'child', actionType: 'custom', use: [async (input, next) => (await next(input + 'middle1')) + 1], }, async (_, ctx) => { gotAbortSignal = ctx.abortSignal; return `traceId=${!!ctx.trace.traceId} spanId=${!!ctx.trace.spanId}`; } ); const signal = new AbortController().signal; await act(undefined, { abortSignal: signal }); assert.strictEqual(gotAbortSignal, signal); }); it('includes genkit:key in telemetry', async () => { const act = defineAction( registry, { name: 'keyedAction', actionType: 'custom', }, async () => { return 'success'; } ); await act(); assert.strictEqual(spanExporter.exportedSpans.length, 1); assert.strictEqual( spanExporter.exportedSpans[0].attributes['genkit:key'], '/custom/keyedAction' ); }); it('records init in telemetry', async () => { const act = action( { name: 'initAction', inputSchema: z.string(), outputSchema: z.number(), initSchema: z.object({ setting: z.string() }), actionType: 'custom', }, async (input) => { return input.length; } ); await act.run('foo', { init: { setting: 'value' } }); assert.strictEqual(spanExporter.exportedSpans.length, 1); assert.strictEqual( spanExporter.exportedSpans[0].attributes['genkit:init'], JSON.stringify({ setting: 'value' }) ); }); it('does not record init in telemetry when not provided', async () => { const act = action( { name: 'noInitAction', inputSchema: z.string(), outputSchema: z.number(), actionType: 'custom', }, async (input) => { return input.length; } ); await act.run('foo'); assert.strictEqual(spanExporter.exportedSpans.length, 1); assert.strictEqual( spanExporter.exportedSpans[0].attributes['genkit:init'], undefined ); }); it('sets genkit:key on action metadata when using defineActionAsync', async () => { const actPromise = defineActionAsync( registry, 'custom', 'asyncKeyedAction', Promise.resolve({ name: 'asyncKeyedAction', actionType: 'custom', fn: async () => 'success', }) ); const act = await actPromise; assert.strictEqual(act.__action.key, '/custom/asyncKeyedAction'); }); });