@genkit-ai/fetch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -0
- package/lib/index.d.mts +65 -0
- package/lib/index.d.ts +65 -0
- package/lib/index.js +353 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +333 -0
- package/lib/index.mjs.map +1 -0
- package/lib/utils.d.mts +25 -0
- package/lib/utils.d.ts +25 -0
- package/lib/utils.js +42 -0
- package/lib/utils.js.map +1 -0
- package/lib/utils.mjs +17 -0
- package/lib/utils.mjs.map +1 -0
- package/package.json +59 -0
- package/src/index.ts +526 -0
- package/src/utils.ts +33 -0
- package/tests/web_test.ts +802 -0
- package/typedoc.json +3 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2025 Google LLC
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as assert from 'assert';
|
|
18
|
+
import {
|
|
19
|
+
UserFacingError,
|
|
20
|
+
genkit,
|
|
21
|
+
z,
|
|
22
|
+
type GenerateResponseData,
|
|
23
|
+
type Genkit,
|
|
24
|
+
} from 'genkit';
|
|
25
|
+
import { InMemoryStreamManager } from 'genkit/beta';
|
|
26
|
+
import { runFlow, streamFlow } from 'genkit/beta/client';
|
|
27
|
+
import type { ContextProvider, RequestData } from 'genkit/context';
|
|
28
|
+
import type { GenerateResponseChunkData, ModelAction } from 'genkit/model';
|
|
29
|
+
import getPort from 'get-port';
|
|
30
|
+
import * as http from 'http';
|
|
31
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
32
|
+
import {
|
|
33
|
+
fetchHandler,
|
|
34
|
+
fetchHandlers,
|
|
35
|
+
type ActionWithOptions,
|
|
36
|
+
} from '../src/index.js';
|
|
37
|
+
|
|
38
|
+
interface Context {
|
|
39
|
+
auth: {
|
|
40
|
+
user: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const contextProvider: ContextProvider<Context> = (req: RequestData) => {
|
|
45
|
+
assert.ok(req.method, 'method must be set');
|
|
46
|
+
assert.ok(req.headers, 'headers must be set');
|
|
47
|
+
assert.ok(req.input, 'input must be set');
|
|
48
|
+
|
|
49
|
+
if (req.headers['authorization'] !== 'open sesame') {
|
|
50
|
+
throw new UserFacingError('PERMISSION_DENIED', 'not authorized');
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
auth: {
|
|
54
|
+
user: 'Ali Baba',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Collects the request body from a Node.js IncomingMessage.
|
|
61
|
+
*/
|
|
62
|
+
function getRequestBody(req: http.IncomingMessage): Promise<Buffer> {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const chunks: Buffer[] = [];
|
|
65
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
66
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a Web API Request from a Node.js IncomingMessage and body.
|
|
73
|
+
*/
|
|
74
|
+
function nodeRequestToWebRequest(
|
|
75
|
+
req: http.IncomingMessage,
|
|
76
|
+
baseUrl: string,
|
|
77
|
+
body: Buffer
|
|
78
|
+
): Request {
|
|
79
|
+
const url = new URL(req.url || '/', baseUrl);
|
|
80
|
+
return new Request(url.toString(), {
|
|
81
|
+
method: req.method || 'GET',
|
|
82
|
+
headers: req.headers as HeadersInit,
|
|
83
|
+
body: body.length > 0 ? new Uint8Array(body) : undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Writes a Web API Response to a Node.js ServerResponse.
|
|
89
|
+
*/
|
|
90
|
+
async function writeWebResponseToNode(
|
|
91
|
+
response: Response,
|
|
92
|
+
res: http.ServerResponse
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const headers: Record<string, string> = {};
|
|
95
|
+
response.headers.forEach((value, key) => {
|
|
96
|
+
headers[key] = value;
|
|
97
|
+
});
|
|
98
|
+
res.writeHead(response.status, headers);
|
|
99
|
+
if (response.body) {
|
|
100
|
+
const reader = response.body.getReader();
|
|
101
|
+
try {
|
|
102
|
+
for (;;) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
res.write(Buffer.from(value));
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
reader.releaseLock();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
res.end();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('fetchHandler', () => {
|
|
115
|
+
it('returns a handler that runs the action', async () => {
|
|
116
|
+
const ai = genkit({});
|
|
117
|
+
const flow = ai.defineFlow('testFlow', async () => 'ok');
|
|
118
|
+
const handler = fetchHandler(flow);
|
|
119
|
+
const request = new Request('http://localhost/testFlow', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({ data: null }),
|
|
123
|
+
});
|
|
124
|
+
const response = await handler(request);
|
|
125
|
+
assert.strictEqual(response.status, 200);
|
|
126
|
+
const json = (await response.json()) as { result: string };
|
|
127
|
+
assert.strictEqual(json.result, 'ok');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('accepts options (contextProvider)', async () => {
|
|
131
|
+
const ai = genkit({});
|
|
132
|
+
const flow = ai.defineFlow(
|
|
133
|
+
{
|
|
134
|
+
name: 'authFlow',
|
|
135
|
+
inputSchema: z.object({ question: z.string() }),
|
|
136
|
+
},
|
|
137
|
+
async () => 'ok'
|
|
138
|
+
);
|
|
139
|
+
const handler = fetchHandler(flow, { contextProvider });
|
|
140
|
+
const request = new Request('http://localhost/authFlow', {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: {
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
Authorization: 'open sesame',
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({ data: { question: 'hello' } }),
|
|
147
|
+
});
|
|
148
|
+
const response = await handler(request);
|
|
149
|
+
assert.strictEqual(response.status, 200);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('fetchHandler (single action)', () => {
|
|
154
|
+
describe('direct Request/Response (no server)', () => {
|
|
155
|
+
it('should return 400 when body is not JSON', async () => {
|
|
156
|
+
const ai = genkit({});
|
|
157
|
+
const flow = ai.defineFlow('test', async () => 'ok');
|
|
158
|
+
const request = new Request('http://localhost/test', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: 'not json',
|
|
162
|
+
});
|
|
163
|
+
const response = await fetchHandler(flow)(request);
|
|
164
|
+
assert.strictEqual(response.status, 400);
|
|
165
|
+
const json = (await response.json()) as { status: string };
|
|
166
|
+
assert.strictEqual(json.status, 'INVALID_ARGUMENT');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should return 400 when body has no data field', async () => {
|
|
170
|
+
const ai = genkit({});
|
|
171
|
+
const flow = ai.defineFlow('test', async () => 'ok');
|
|
172
|
+
const request = new Request('http://localhost/test', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
175
|
+
body: JSON.stringify({ foo: 'bar' }),
|
|
176
|
+
});
|
|
177
|
+
const response = await fetchHandler(flow)(request);
|
|
178
|
+
assert.strictEqual(response.status, 400);
|
|
179
|
+
const json = (await response.json()) as { status: string };
|
|
180
|
+
assert.strictEqual(json.status, 'INVALID_ARGUMENT');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should run a void input flow', async () => {
|
|
184
|
+
const ai = genkit({});
|
|
185
|
+
const flow = ai.defineFlow('voidInput', async () => 'banana');
|
|
186
|
+
const request = new Request('http://localhost/voidInput', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
189
|
+
body: JSON.stringify({ data: null }),
|
|
190
|
+
});
|
|
191
|
+
const response = await fetchHandler(flow)(request);
|
|
192
|
+
assert.strictEqual(response.status, 200);
|
|
193
|
+
const json = (await response.json()) as { result: string };
|
|
194
|
+
assert.strictEqual(json.result, 'banana');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should run a flow with string input', async () => {
|
|
198
|
+
const ai = genkit({});
|
|
199
|
+
defineEchoModel(ai);
|
|
200
|
+
const flow = ai.defineFlow('stringInput', async (input: string) => {
|
|
201
|
+
const { text } = await ai.generate({
|
|
202
|
+
model: 'echoModel',
|
|
203
|
+
prompt: input,
|
|
204
|
+
});
|
|
205
|
+
return text;
|
|
206
|
+
});
|
|
207
|
+
const request = new Request('http://localhost/stringInput', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify({ data: 'hello' }),
|
|
211
|
+
});
|
|
212
|
+
const response = await fetchHandler(flow)(request);
|
|
213
|
+
assert.strictEqual(response.status, 200);
|
|
214
|
+
const json = (await response.json()) as { result: string };
|
|
215
|
+
assert.strictEqual(json.result, 'Echo: hello');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should run a flow with object input', async () => {
|
|
219
|
+
const ai = genkit({});
|
|
220
|
+
defineEchoModel(ai);
|
|
221
|
+
const flow = ai.defineFlow(
|
|
222
|
+
{
|
|
223
|
+
name: 'objectInput',
|
|
224
|
+
inputSchema: z.object({ question: z.string() }),
|
|
225
|
+
},
|
|
226
|
+
async (input) => {
|
|
227
|
+
const { text } = await ai.generate({
|
|
228
|
+
model: 'echoModel',
|
|
229
|
+
prompt: input.question,
|
|
230
|
+
});
|
|
231
|
+
return text;
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
const request = new Request('http://localhost/objectInput', {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ data: { question: 'olleh' } }),
|
|
238
|
+
});
|
|
239
|
+
const response = await fetchHandler(flow)(request);
|
|
240
|
+
assert.strictEqual(response.status, 200);
|
|
241
|
+
const json = (await response.json()) as { result: string };
|
|
242
|
+
assert.strictEqual(json.result, 'Echo: olleh');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should return error for invalid input', async () => {
|
|
246
|
+
const ai = genkit({});
|
|
247
|
+
defineEchoModel(ai);
|
|
248
|
+
const flow = ai.defineFlow(
|
|
249
|
+
{
|
|
250
|
+
name: 'objectInput',
|
|
251
|
+
inputSchema: z.object({ question: z.string() }),
|
|
252
|
+
},
|
|
253
|
+
async (input) => input.question
|
|
254
|
+
);
|
|
255
|
+
const request = new Request('http://localhost/objectInput', {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
258
|
+
body: JSON.stringify({ data: { badField: 'hello' } }),
|
|
259
|
+
});
|
|
260
|
+
const response = await fetchHandler(flow)(request);
|
|
261
|
+
assert.strictEqual(response.status, 400);
|
|
262
|
+
const json = (await response.json()) as { status?: string };
|
|
263
|
+
assert.ok(
|
|
264
|
+
json.status === 'INVALID_ARGUMENT' ||
|
|
265
|
+
(json as { message?: string }).message?.includes('INVALID')
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should call a flow with auth', async () => {
|
|
270
|
+
const ai = genkit({});
|
|
271
|
+
const flow = ai.defineFlow(
|
|
272
|
+
{
|
|
273
|
+
name: 'flowWithAuth',
|
|
274
|
+
inputSchema: z.object({ question: z.string() }),
|
|
275
|
+
},
|
|
276
|
+
async (input, { context }) => {
|
|
277
|
+
return `${input.question} - ${JSON.stringify(context!.auth)}`;
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
const request = new Request('http://localhost/flowWithAuth', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: {
|
|
283
|
+
'Content-Type': 'application/json',
|
|
284
|
+
Authorization: 'open sesame',
|
|
285
|
+
},
|
|
286
|
+
body: JSON.stringify({ data: { question: 'hello' } }),
|
|
287
|
+
});
|
|
288
|
+
const response = await fetchHandler(flow, { contextProvider })(request);
|
|
289
|
+
assert.strictEqual(response.status, 200);
|
|
290
|
+
const json = (await response.json()) as { result: string };
|
|
291
|
+
assert.strictEqual(json.result, 'hello - {"user":"Ali Baba"}');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should fail a flow with auth when unauthorized', async () => {
|
|
295
|
+
const ai = genkit({});
|
|
296
|
+
const flow = ai.defineFlow(
|
|
297
|
+
{
|
|
298
|
+
name: 'flowWithAuth',
|
|
299
|
+
inputSchema: z.object({ question: z.string() }),
|
|
300
|
+
},
|
|
301
|
+
async (input, { context }) => String(context?.auth)
|
|
302
|
+
);
|
|
303
|
+
const request = new Request('http://localhost/flowWithAuth', {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: {
|
|
306
|
+
'Content-Type': 'application/json',
|
|
307
|
+
Authorization: 'thief #24',
|
|
308
|
+
},
|
|
309
|
+
body: JSON.stringify({ data: { question: 'hello' } }),
|
|
310
|
+
});
|
|
311
|
+
const response = await fetchHandler(flow, { contextProvider })(request);
|
|
312
|
+
assert.strictEqual(response.status, 403);
|
|
313
|
+
const json = (await response.json()) as { message?: string };
|
|
314
|
+
assert.ok(json.message?.includes('not authorized'));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should set x-genkit-trace-id and x-genkit-span-id headers', async () => {
|
|
318
|
+
const ai = genkit({});
|
|
319
|
+
const flow = ai.defineFlow('traceFlow', async () => 'ok');
|
|
320
|
+
const request = new Request('http://localhost/traceFlow', {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: { 'Content-Type': 'application/json' },
|
|
323
|
+
body: JSON.stringify({ data: null }),
|
|
324
|
+
});
|
|
325
|
+
const response = await fetchHandler(flow)(request);
|
|
326
|
+
assert.strictEqual(response.status, 200);
|
|
327
|
+
assert.ok(response.headers.get('x-genkit-trace-id'));
|
|
328
|
+
assert.ok(response.headers.get('x-genkit-span-id'));
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('fetchHandlers with HTTP server (runFlow/streamFlow)', () => {
|
|
334
|
+
let server: http.Server;
|
|
335
|
+
let port: number;
|
|
336
|
+
|
|
337
|
+
beforeEach(async () => {
|
|
338
|
+
const ai = genkit({});
|
|
339
|
+
const echoModel = defineEchoModel(ai);
|
|
340
|
+
const voidInput = ai.defineFlow('voidInput', async () => 'banana');
|
|
341
|
+
const stringInput = ai.defineFlow('stringInput', async (input: string) => {
|
|
342
|
+
const { text } = await ai.generate({
|
|
343
|
+
model: 'echoModel',
|
|
344
|
+
prompt: input,
|
|
345
|
+
});
|
|
346
|
+
return text;
|
|
347
|
+
});
|
|
348
|
+
const objectInput = ai.defineFlow(
|
|
349
|
+
{ name: 'objectInput', inputSchema: z.object({ question: z.string() }) },
|
|
350
|
+
async (input) => {
|
|
351
|
+
const { text } = await ai.generate({
|
|
352
|
+
model: 'echoModel',
|
|
353
|
+
prompt: input.question,
|
|
354
|
+
});
|
|
355
|
+
return text;
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
const streamingFlow = ai.defineFlow(
|
|
359
|
+
{
|
|
360
|
+
name: 'streamingFlow',
|
|
361
|
+
inputSchema: z.object({ question: z.string() }),
|
|
362
|
+
},
|
|
363
|
+
async (input, sendChunk) => {
|
|
364
|
+
const { text } = await ai.generate({
|
|
365
|
+
model: 'echoModel',
|
|
366
|
+
prompt: input.question,
|
|
367
|
+
onChunk: sendChunk,
|
|
368
|
+
});
|
|
369
|
+
return text;
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
const flowWithAuth = ai.defineFlow(
|
|
373
|
+
{
|
|
374
|
+
name: 'flowWithAuth',
|
|
375
|
+
inputSchema: z.object({ question: z.string() }),
|
|
376
|
+
},
|
|
377
|
+
async (input, { context }) => {
|
|
378
|
+
return `${input.question} - ${JSON.stringify(context!.auth)}`;
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
port = await getPort();
|
|
383
|
+
server = await createServerWithFlows(
|
|
384
|
+
port,
|
|
385
|
+
voidInput,
|
|
386
|
+
stringInput,
|
|
387
|
+
objectInput,
|
|
388
|
+
streamingFlow,
|
|
389
|
+
flowWithAuth,
|
|
390
|
+
echoModel
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
afterEach(() => {
|
|
395
|
+
server.close();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('runFlow', () => {
|
|
399
|
+
it('should call a void input flow', async () => {
|
|
400
|
+
const result = await runFlow({
|
|
401
|
+
url: `http://localhost:${port}/voidInput`,
|
|
402
|
+
input: null,
|
|
403
|
+
});
|
|
404
|
+
assert.strictEqual(result, 'banana');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should run a flow with string input', async () => {
|
|
408
|
+
const result = await runFlow({
|
|
409
|
+
url: `http://localhost:${port}/stringInput`,
|
|
410
|
+
input: 'hello',
|
|
411
|
+
});
|
|
412
|
+
assert.strictEqual(result, 'Echo: hello');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should run a flow with object input', async () => {
|
|
416
|
+
const result = await runFlow({
|
|
417
|
+
url: `http://localhost:${port}/objectInput`,
|
|
418
|
+
input: { question: 'olleh' },
|
|
419
|
+
});
|
|
420
|
+
assert.strictEqual(result, 'Echo: olleh');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should fail a bad input', async () => {
|
|
424
|
+
const result = runFlow({
|
|
425
|
+
url: `http://localhost:${port}/objectInput`,
|
|
426
|
+
input: { badField: 'hello' },
|
|
427
|
+
});
|
|
428
|
+
await assert.rejects(result, (err: Error) =>
|
|
429
|
+
err.message.includes('INVALID_ARGUMENT')
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should call a flow with auth', async () => {
|
|
434
|
+
const result = await runFlow<string>({
|
|
435
|
+
url: `http://localhost:${port}/flowWithAuth`,
|
|
436
|
+
input: { question: 'hello' },
|
|
437
|
+
headers: { Authorization: 'open sesame' },
|
|
438
|
+
});
|
|
439
|
+
assert.strictEqual(result, 'hello - {"user":"Ali Baba"}');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should fail a flow with auth', async () => {
|
|
443
|
+
const result = runFlow({
|
|
444
|
+
url: `http://localhost:${port}/flowWithAuth`,
|
|
445
|
+
input: { question: 'hello' },
|
|
446
|
+
headers: { Authorization: 'thief #24' },
|
|
447
|
+
});
|
|
448
|
+
await assert.rejects(result, (err: Error) =>
|
|
449
|
+
(err as Error).message.includes('not authorized')
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should call a model', async () => {
|
|
454
|
+
const result = await runFlow({
|
|
455
|
+
url: `http://localhost:${port}/echoModel`,
|
|
456
|
+
input: {
|
|
457
|
+
messages: [{ role: 'user', content: [{ text: 'hello' }] }],
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
assert.strictEqual((result as GenerateResponseData).finishReason, 'stop');
|
|
461
|
+
assert.deepStrictEqual((result as GenerateResponseData).message, {
|
|
462
|
+
role: 'model',
|
|
463
|
+
content: [{ text: 'Echo: hello' }],
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should call a model with auth', async () => {
|
|
468
|
+
const result = await runFlow<GenerateResponseData>({
|
|
469
|
+
url: `http://localhost:${port}/echoModelWithAuth`,
|
|
470
|
+
input: {
|
|
471
|
+
messages: [{ role: 'user', content: [{ text: 'hello' }] }],
|
|
472
|
+
},
|
|
473
|
+
headers: { Authorization: 'open sesame' },
|
|
474
|
+
});
|
|
475
|
+
assert.strictEqual(result.finishReason, 'stop');
|
|
476
|
+
assert.deepStrictEqual(result.message, {
|
|
477
|
+
role: 'model',
|
|
478
|
+
content: [{ text: 'Echo: hello' }],
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should fail a model with auth when unauthorized', async () => {
|
|
483
|
+
const result = runFlow({
|
|
484
|
+
url: `http://localhost:${port}/echoModelWithAuth`,
|
|
485
|
+
input: {
|
|
486
|
+
messages: [{ role: 'user', content: [{ text: 'hello' }] }],
|
|
487
|
+
},
|
|
488
|
+
headers: { Authorization: 'thief #24' },
|
|
489
|
+
});
|
|
490
|
+
await assert.rejects(result, (err: Error) =>
|
|
491
|
+
(err as Error).message.includes('not authorized')
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('streamFlow', () => {
|
|
497
|
+
it('stream a flow', async () => {
|
|
498
|
+
const result = streamFlow<string, GenerateResponseChunkData>({
|
|
499
|
+
url: `http://localhost:${port}/streamingFlow`,
|
|
500
|
+
input: { question: 'olleh' },
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const gotChunks: GenerateResponseChunkData[] = [];
|
|
504
|
+
for await (const chunk of result.stream) {
|
|
505
|
+
gotChunks.push(chunk);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
assert.deepStrictEqual(gotChunks, [
|
|
509
|
+
{ index: 0, role: 'model', content: [{ text: '3' }] },
|
|
510
|
+
{ index: 0, role: 'model', content: [{ text: '2' }] },
|
|
511
|
+
{ index: 0, role: 'model', content: [{ text: '1' }] },
|
|
512
|
+
]);
|
|
513
|
+
|
|
514
|
+
assert.strictEqual(await result.output, 'Echo: olleh');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should create and subscribe to a durable stream', async () => {
|
|
518
|
+
const result = streamFlow({
|
|
519
|
+
url: `http://localhost:${port}/streamingFlowDurable`,
|
|
520
|
+
input: { question: 'durable' },
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const streamId = await result.streamId;
|
|
524
|
+
assert.ok(streamId);
|
|
525
|
+
|
|
526
|
+
const subscription = streamFlow({
|
|
527
|
+
url: `http://localhost:${port}/streamingFlowDurable`,
|
|
528
|
+
input: { question: 'durable' },
|
|
529
|
+
streamId: streamId!,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const gotChunks: GenerateResponseChunkData[] = [];
|
|
533
|
+
for await (const chunk of subscription.stream) {
|
|
534
|
+
gotChunks.push(chunk);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const originalChunks: GenerateResponseChunkData[] = [];
|
|
538
|
+
for await (const chunk of result.stream) {
|
|
539
|
+
originalChunks.push(chunk);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
assert.deepStrictEqual(gotChunks, originalChunks);
|
|
543
|
+
assert.strictEqual(await subscription.output, 'Echo: durable');
|
|
544
|
+
assert.strictEqual(await result.output, 'Echo: durable');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('should subscribe to a stream in progress', async () => {
|
|
548
|
+
const result = streamFlow({
|
|
549
|
+
url: `http://localhost:${port}/streamingFlowDurable`,
|
|
550
|
+
input: { question: 'durable' },
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const streamId = await result.streamId;
|
|
554
|
+
assert.ok(streamId);
|
|
555
|
+
|
|
556
|
+
const subscription = streamFlow({
|
|
557
|
+
url: `http://localhost:${port}/streamingFlowDurable`,
|
|
558
|
+
input: { question: 'durable' },
|
|
559
|
+
streamId: streamId!,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const gotChunks: GenerateResponseChunkData[] = [];
|
|
563
|
+
for await (const chunk of subscription.stream) {
|
|
564
|
+
gotChunks.push(chunk);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
assert.strictEqual(gotChunks.length, 3);
|
|
568
|
+
assert.strictEqual(await subscription.output, 'Echo: durable');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should return 204 for a non-existent stream', async () => {
|
|
572
|
+
try {
|
|
573
|
+
const result = streamFlow({
|
|
574
|
+
url: `http://localhost:${port}/streamingFlowDurable`,
|
|
575
|
+
input: { question: 'durable' },
|
|
576
|
+
streamId: 'non-existent-stream-id',
|
|
577
|
+
});
|
|
578
|
+
for await (const _ of result.stream) {
|
|
579
|
+
}
|
|
580
|
+
assert.fail('should have thrown');
|
|
581
|
+
} catch (err: unknown) {
|
|
582
|
+
assert.strictEqual(
|
|
583
|
+
(err as Error).message,
|
|
584
|
+
'NOT_FOUND: Stream not found.'
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('stream a model', async () => {
|
|
590
|
+
const result = streamFlow({
|
|
591
|
+
url: `http://localhost:${port}/echoModel`,
|
|
592
|
+
input: {
|
|
593
|
+
messages: [{ role: 'user', content: [{ text: 'olleh' }] }],
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const gotChunks: unknown[] = [];
|
|
598
|
+
for await (const chunk of result.stream) {
|
|
599
|
+
gotChunks.push(chunk);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const output = await result.output;
|
|
603
|
+
assert.strictEqual((output as GenerateResponseData).finishReason, 'stop');
|
|
604
|
+
assert.deepStrictEqual((output as GenerateResponseData).message, {
|
|
605
|
+
role: 'model',
|
|
606
|
+
content: [{ text: 'Echo: olleh' }],
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
assert.deepStrictEqual(gotChunks, [
|
|
610
|
+
{ content: [{ text: '3' }] },
|
|
611
|
+
{ content: [{ text: '2' }] },
|
|
612
|
+
{ content: [{ text: '1' }] },
|
|
613
|
+
]);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe('fetchHandlers (path routing)', () => {
|
|
619
|
+
it('returns a handler that routes by path', async () => {
|
|
620
|
+
const ai = genkit({});
|
|
621
|
+
const flow = ai.defineFlow('myFlow', async () => 'ok');
|
|
622
|
+
const handler = fetchHandlers([flow]);
|
|
623
|
+
const request = new Request('http://localhost/myFlow', {
|
|
624
|
+
method: 'POST',
|
|
625
|
+
headers: { 'Content-Type': 'application/json' },
|
|
626
|
+
body: JSON.stringify({ data: null }),
|
|
627
|
+
});
|
|
628
|
+
const response = await handler(request);
|
|
629
|
+
assert.strictEqual(response.status, 200);
|
|
630
|
+
const json = (await response.json()) as { result: string };
|
|
631
|
+
assert.strictEqual(json.result, 'ok');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('strips pathPrefix when provided', async () => {
|
|
635
|
+
const ai = genkit({});
|
|
636
|
+
const flow = ai.defineFlow('myFlow', async () => 'ok');
|
|
637
|
+
const handler = fetchHandlers([flow], '/api');
|
|
638
|
+
const request = new Request('http://localhost/api/myFlow', {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
headers: { 'Content-Type': 'application/json' },
|
|
641
|
+
body: JSON.stringify({ data: null }),
|
|
642
|
+
});
|
|
643
|
+
const response = await handler(request);
|
|
644
|
+
assert.strictEqual(response.status, 200);
|
|
645
|
+
const json = (await response.json()) as { result: string };
|
|
646
|
+
assert.strictEqual(json.result, 'ok');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('should return 404 when no flow matches path', async () => {
|
|
650
|
+
const ai = genkit({});
|
|
651
|
+
const flow = ai.defineFlow('myFlow', async () => 'ok');
|
|
652
|
+
const request = new Request('http://localhost/otherPath', {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: { 'Content-Type': 'application/json' },
|
|
655
|
+
body: JSON.stringify({ data: null }),
|
|
656
|
+
});
|
|
657
|
+
const response = await fetchHandlers([flow])(request);
|
|
658
|
+
assert.strictEqual(response.status, 404);
|
|
659
|
+
const json = (await response.json()) as { status: string };
|
|
660
|
+
assert.strictEqual(json.status, 'NOT_FOUND');
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('should route to flow by name', async () => {
|
|
664
|
+
const ai = genkit({});
|
|
665
|
+
const flow = ai.defineFlow('myFlow', async () => 'ok');
|
|
666
|
+
const request = new Request('http://localhost/myFlow', {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: { 'Content-Type': 'application/json' },
|
|
669
|
+
body: JSON.stringify({ data: null }),
|
|
670
|
+
});
|
|
671
|
+
const response = await fetchHandlers([flow])(request);
|
|
672
|
+
assert.ok(response);
|
|
673
|
+
assert.strictEqual(response!.status, 200);
|
|
674
|
+
const json = (await response!.json()) as { result: string };
|
|
675
|
+
assert.strictEqual(json.result, 'ok');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should route to flow with path option', async () => {
|
|
679
|
+
const ai = genkit({});
|
|
680
|
+
const flow = ai.defineFlow('internalName', async () => 'ok');
|
|
681
|
+
const request = new Request('http://localhost/customPath', {
|
|
682
|
+
method: 'POST',
|
|
683
|
+
headers: { 'Content-Type': 'application/json' },
|
|
684
|
+
body: JSON.stringify({ data: null }),
|
|
685
|
+
});
|
|
686
|
+
const response = await fetchHandlers([
|
|
687
|
+
{ action: flow, options: { path: 'customPath' } },
|
|
688
|
+
])(request);
|
|
689
|
+
assert.ok(response);
|
|
690
|
+
assert.strictEqual(response!.status, 200);
|
|
691
|
+
const json = (await response!.json()) as { result: string };
|
|
692
|
+
assert.strictEqual(json.result, 'ok');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('should strip pathPrefix and route', async () => {
|
|
696
|
+
const ai = genkit({});
|
|
697
|
+
const flow = ai.defineFlow('myFlow', async () => 'ok');
|
|
698
|
+
const request = new Request('http://localhost/api/genkit/myFlow', {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: { 'Content-Type': 'application/json' },
|
|
701
|
+
body: JSON.stringify({ data: null }),
|
|
702
|
+
});
|
|
703
|
+
const response = await fetchHandlers([flow], '/api/genkit')(request);
|
|
704
|
+
assert.ok(response);
|
|
705
|
+
assert.strictEqual(response!.status, 200);
|
|
706
|
+
const json = (await response!.json()) as { result: string };
|
|
707
|
+
assert.strictEqual(json.result, 'ok');
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('should return 404 when path does not match prefix', async () => {
|
|
711
|
+
const ai = genkit({});
|
|
712
|
+
const flow = ai.defineFlow('myFlow', async () => 'ok');
|
|
713
|
+
const request = new Request('http://localhost/other/myFlow', {
|
|
714
|
+
method: 'POST',
|
|
715
|
+
headers: { 'Content-Type': 'application/json' },
|
|
716
|
+
body: JSON.stringify({ data: null }),
|
|
717
|
+
});
|
|
718
|
+
const response = await fetchHandlers([flow], '/api/genkit')(request);
|
|
719
|
+
assert.strictEqual(response.status, 404);
|
|
720
|
+
const json = (await response.json()) as { status: string };
|
|
721
|
+
assert.strictEqual(json.status, 'NOT_FOUND');
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
function defineEchoModel(ai: Genkit): ModelAction {
|
|
726
|
+
return ai.defineModel(
|
|
727
|
+
{ name: 'echoModel' },
|
|
728
|
+
async (request, streamingCallback) => {
|
|
729
|
+
streamingCallback?.({ content: [{ text: '3' }] });
|
|
730
|
+
streamingCallback?.({ content: [{ text: '2' }] });
|
|
731
|
+
streamingCallback?.({ content: [{ text: '1' }] });
|
|
732
|
+
return {
|
|
733
|
+
message: {
|
|
734
|
+
role: 'model',
|
|
735
|
+
content: [
|
|
736
|
+
{
|
|
737
|
+
text:
|
|
738
|
+
'Echo: ' +
|
|
739
|
+
request.messages
|
|
740
|
+
.map(
|
|
741
|
+
(m) =>
|
|
742
|
+
(m.role === 'user' || m.role === 'model'
|
|
743
|
+
? ''
|
|
744
|
+
: `${m.role}: `) + m.content.map((c) => c.text).join()
|
|
745
|
+
)
|
|
746
|
+
.join(),
|
|
747
|
+
},
|
|
748
|
+
],
|
|
749
|
+
},
|
|
750
|
+
finishReason: 'stop',
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function createServerWithFlows(
|
|
757
|
+
port: number,
|
|
758
|
+
voidInput: import('genkit/beta').Flow<any, any, any>,
|
|
759
|
+
stringInput: import('genkit/beta').Flow<any, any, any>,
|
|
760
|
+
objectInput: import('genkit/beta').Flow<any, any, any>,
|
|
761
|
+
streamingFlow: import('genkit/beta').Flow<any, any, any>,
|
|
762
|
+
flowWithAuth: import('genkit/beta').Flow<any, any, any>,
|
|
763
|
+
echoModel: ModelAction
|
|
764
|
+
): Promise<http.Server> {
|
|
765
|
+
const actions: (
|
|
766
|
+
| ActionWithOptions<any, any, any>
|
|
767
|
+
| import('genkit/beta').Flow<any, any, any>
|
|
768
|
+
)[] = [
|
|
769
|
+
voidInput,
|
|
770
|
+
stringInput,
|
|
771
|
+
objectInput,
|
|
772
|
+
streamingFlow,
|
|
773
|
+
{
|
|
774
|
+
action: streamingFlow,
|
|
775
|
+
options: {
|
|
776
|
+
streamManager: new InMemoryStreamManager(),
|
|
777
|
+
path: 'streamingFlowDurable',
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
{ action: flowWithAuth, options: { contextProvider } },
|
|
781
|
+
echoModel,
|
|
782
|
+
{
|
|
783
|
+
action: echoModel,
|
|
784
|
+
options: { contextProvider, path: 'echoModelWithAuth' },
|
|
785
|
+
},
|
|
786
|
+
];
|
|
787
|
+
return new Promise((resolve) => {
|
|
788
|
+
const baseUrl = `http://localhost:${port}`;
|
|
789
|
+
const server = http.createServer(async (req, res) => {
|
|
790
|
+
try {
|
|
791
|
+
const body = await getRequestBody(req);
|
|
792
|
+
const request = nodeRequestToWebRequest(req, baseUrl, body);
|
|
793
|
+
const response = await fetchHandlers(actions)(request);
|
|
794
|
+
await writeWebResponseToNode(response, res);
|
|
795
|
+
} catch (err) {
|
|
796
|
+
res.writeHead(500);
|
|
797
|
+
res.end(String(err));
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
server.listen(port, () => resolve(server));
|
|
801
|
+
});
|
|
802
|
+
}
|