@genkit-ai/core
Version:
Genkit AI framework core libraries.
426 lines (373 loc) • 11.9 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 { 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');
});
});