diff --git a/js/plugins/fetch/.npmignore b/js/plugins/fetch/.npmignore new file mode 100644 index 0000000000..5ea8bc610d --- /dev/null +++ b/js/plugins/fetch/.npmignore @@ -0,0 +1,3 @@ +node_modules +tsconfig.json +tsup.config.ts \ No newline at end of file diff --git a/js/plugins/fetch/README.md b/js/plugins/fetch/README.md new file mode 100644 index 0000000000..95d95a0681 --- /dev/null +++ b/js/plugins/fetch/README.md @@ -0,0 +1,174 @@ +# Genkit Web Fetch Plugin + +This plugin provides utilities for exposing Genkit flows and actions over the **Web Fetch API** (`Request` / `Response`). Use it with any runtime or framework that supports the standard Fetch API (such as Hono, Bun, Cloudflare Workers, Deno, Node (18+), Vercel Edge, Netlify Edge, Elysia, SvelteKit, etc). + +No framework-specific dependencies; only `genkit` and the standard Web APIs. + +## Installation + +```bash +npm i @genkit-ai/fetch +``` + +## Usage (Hono) + +### Single flow with `handleFlow` + +```ts +import { handleFlow } from '@genkit-ai/fetch'; +import { Hono } from 'hono'; + +const simpleFlow = ai.defineFlow('simpleFlow', async (input, { sendChunk }) => { + const { text } = await ai.generate({ + model: googleAI.model('gemini-2.0-flash'), + prompt: input, + onChunk: (c) => sendChunk(c.text), + }); + return text; +}); + +const app = new Hono(); +app.all('/simpleFlow', async (c) => handleFlow(c.req.raw, simpleFlow)); +``` + +### Multiple flows with `handleFlows` + +Mount several flows under one path; the flow is selected by the request path (e.g. `/api/hello` runs the flow named `hello`): + +```ts +import { handleFlows } from '@genkit-ai/fetch'; + +const flows = [helloFlow, greetingFlow, streamingFlow]; + +app.all('/api/*', async (c) => handleFlows(c.req.raw, flows, '/api')); +``` + +Clients call `POST /api/` with body `{ "data": }`. + +### Auth with context providers + +Use a context provider (e.g. for auth) and attach it to a flow with `withFlowOptions`: + +```ts +import { UserFacingError } from 'genkit'; +import type { ContextProvider, RequestData } from 'genkit/context'; +import { handleFlow, handleFlows, withFlowOptions } from '@genkit-ai/fetch'; + +const authContext: ContextProvider<{ userId: string }> = (req: RequestData) => { + if (req.headers['authorization'] !== 'Bearer open-sesame') { + throw new UserFacingError('PERMISSION_DENIED', 'not authorized'); + } + return { userId: 'authenticated-user' }; +}; + +// Single flow with auth +app.all('/secureFlow', async (c) => + handleFlow(c.req.raw, secureFlow, { contextProvider: authContext }) +); + +// Or wrap the flow for use with handleFlows +const flows = [ + publicFlow, + withFlowOptions(secureFlow, { contextProvider: authContext }), +]; +app.all('/api/*', async (c) => handleFlows(c.req.raw, flows, '/api')); +``` + +### Durable streaming (Beta) + +You can configure flows to use a `StreamManager` so stream state is persisted. Clients can disconnect and reconnect without losing the stream. + +Provide a `streamManager` in the options. For development, use `InMemoryStreamManager`: + +```ts +import { InMemoryStreamManager } from 'genkit/beta'; +import { handleFlow, withFlowOptions } from '@genkit-ai/fetch'; + +app.all('/myDurableFlow', async (c) => + handleFlow(c.req.raw, myFlow, { + streamManager: new InMemoryStreamManager(), + }) +); + +// Or with handleFlows +const flows = [ + withFlowOptions(myFlow, { + streamManager: new InMemoryStreamManager(), + }), +]; +app.all('/api/*', async (c) => handleFlows(c.req.raw, flows, '/api')); +``` + +For production, use a durable implementation such as `FirestoreStreamManager` or `RtdbStreamManager` from `@genkit-ai/firebase`, or a custom `StreamManager`. + +Clients can reconnect using the `streamId`: + +```ts +import { streamFlow } from 'genkit/beta/client'; + +// Start a new stream +const result = streamFlow({ + url: 'http://localhost:3780/api/myDurableFlow', + input: 'tell me a long story', +}); +const streamId = await result.streamId; // save for reconnect + +// Reconnect later +const reconnected = streamFlow({ + url: 'http://localhost:3780/api/myDurableFlow', + streamId, +}); +``` + +### Calling flows from the client + +Use `runFlow` and `streamFlow` from `genkit/beta/client` (same protocol as the Express plugin): + +```ts +import { runFlow, streamFlow } from 'genkit/beta/client'; + +const result = await runFlow({ + url: 'http://localhost:3780/api/hello', + input: 'world', +}); +console.log(result); + +// With auth headers +const result = await runFlow({ + url: 'http://localhost:3780/api/secureGreeting', + headers: { Authorization: 'Bearer open-sesame' }, + input: { name: 'Alex' }, +}); + +// Streaming +const result = streamFlow({ + url: 'http://localhost:3780/api/streaming', + input: { prompt: 'Say hello in chunks' }, +}); +for await (const chunk of result.stream) { + console.log(chunk); +} +console.log(await result.output); +``` + +## API summary + +| Export | Description | +|---------------------|-----------------------------------------------------------------------------| +| `handleFlow(request, action, options?)` | Handles a single flow/action; returns `Promise`. | +| `handleFlows(request, flows, pathPrefix?)` | Dispatches by path to one of the given flows; returns `Promise`. | +| `withFlowOptions(flow, options)` | Wraps a flow with `contextProvider`, `streamManager`, or custom `path`. | +| `FlowWithOptions` | Type for a flow plus options. | +| `HandleFlowOptions` | Options for `handleFlow`: `contextProvider`, `streamManager`. | + +Request body must be JSON with a `data` field: `{ "data": }`. For streaming, use `Accept: text/event-stream` or query `?stream=true`. + +## Contributing + +The sources for this package are in the main [Genkit](https://github.com/firebase/genkit) repo. Please file issues and pull requests there. + +More details are in the [Genkit documentation](https://genkit.dev/docs/get-started/). + +## License + +Licensed under the Apache 2.0 License. diff --git a/js/plugins/fetch/package.json b/js/plugins/fetch/package.json new file mode 100644 index 0000000000..1cdc0cba60 --- /dev/null +++ b/js/plugins/fetch/package.json @@ -0,0 +1,59 @@ +{ + "name": "@genkit-ai/fetch", + "description": "Genkit AI framework plugin for Web Fetch API Request/Response handlers", + "keywords": [ + "genkit", + "genkit-plugin", + "langchain", + "ai", + "genai", + "generative-ai", + "web", + "web-api", + "fetch", + "hono", + "cloudflare", + "bun", + "deno" + ], + "version": "1.29.0", + "type": "commonjs", + "scripts": { + "check": "tsc", + "compile": "tsup-node", + "build:clean": "rimraf ./lib", + "build": "npm-run-all build:clean check compile", + "build:watch": "tsup-node --watch", + "test": "node --import tsx --test tests/*_test.ts", + "test:watch": "node --import tsx --watch --test tests/*_test.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/firebase/genkit.git", + "directory": "js/plugins/web" + }, + "author": "genkit", + "license": "Apache-2.0", + "peerDependencies": { + "genkit": "workspace:^" + }, + "devDependencies": { + "get-port": "^5.1.0", + "@types/node": "^20.11.16", + "genkit": "workspace:*", + "npm-run-all": "^4.1.5", + "rimraf": "^6.0.1", + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^4.9.0" + }, + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.mjs", + "require": "./lib/index.js", + "default": "./lib/index.js" + } + } +} diff --git a/js/plugins/fetch/src/index.ts b/js/plugins/fetch/src/index.ts new file mode 100644 index 0000000000..aa752524fd --- /dev/null +++ b/js/plugins/fetch/src/index.ts @@ -0,0 +1,523 @@ +/** + * Copyright 2025 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 { + Action, + ActionStreamInput, + AsyncTaskQueue, + Flow, + StreamNotFoundError, + type ActionContext, + type StreamManager, + type z, +} from 'genkit/beta'; +import { + getCallableJSON, + getHttpStatus, + type ContextProvider, + type RequestData, +} from 'genkit/context'; +import { logger } from 'genkit/logging'; +import { getErrorMessage, getErrorStack } from './utils'; + +const streamDelimiter = '\n\n'; + +// Let the runtime provide the randomUUID function. +const randomUUID = () => globalThis.crypto.randomUUID(); + +/** + * A wrapper object containing a flow with its associated options. + */ +export type FlowWithOptions< + I extends z.ZodTypeAny = z.ZodTypeAny, + O extends z.ZodTypeAny = z.ZodTypeAny, + S extends z.ZodTypeAny = z.ZodTypeAny, +> = { + flow: Flow; + options: { + contextProvider?: ContextProvider; + streamManager?: StreamManager; + path?: string; + }; +}; + +/** + * Options for handling a flow request. + */ +export interface HandleFlowOptions< + C extends ActionContext = ActionContext, + I extends z.ZodTypeAny = z.ZodTypeAny, +> { + contextProvider?: ContextProvider; + streamManager?: StreamManager; +} + +/** + * Wraps a flow with options (e.g. contextProvider, streamManager, path) for use with {@link handleFlows}. + */ +export function withFlowOptions< + I extends z.ZodTypeAny, + O extends z.ZodTypeAny, + S extends z.ZodTypeAny, +>( + flow: Flow, + options: { + contextProvider?: ContextProvider; + streamManager?: StreamManager; + path?: string; + } +): FlowWithOptions { + return { + flow, + options, + }; +} + +/** + * Converts Headers object to a plain object with lowercase keys. + */ +function headersToObject(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key.toLowerCase()] = value; + }); + return result; +} + +/** + * Gets context from the request using the context provider if available. + */ +async function getContext( + request: Request, + input: z.infer, + provider?: ContextProvider +): Promise { + const context = {} as C; + if (!provider) { + return context; + } + + const requestData: RequestData = { + method: request.method as RequestData['method'], + headers: headersToObject(request.headers), + input, + }; + + return await provider(requestData); +} + +/** + * Subscribes to an existing stream using StreamManager. + */ +async function subscribeToStream( + streamManager: StreamManager, + streamId: string +): Promise { + try { + const encoder = new TextEncoder(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + + await streamManager.subscribe(streamId, { + onChunk: (chunk) => { + writer.write( + encoder.encode( + 'data: ' + JSON.stringify({ message: chunk }) + streamDelimiter + ) + ); + }, + onDone: (output) => { + writer.write( + encoder.encode( + 'data: ' + JSON.stringify({ result: output }) + streamDelimiter + ) + ); + writer.close(); + }, + onError: (err) => { + logger.error( + `Streaming request failed with error: ${getErrorMessage(err)}\n${getErrorStack(err)}` + ); + writer.write( + encoder.encode( + `error: ${JSON.stringify({ + error: getCallableJSON(err), + })}${streamDelimiter}` + ) + ); + writer.close(); + }, + }); + + return new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Transfer-Encoding': 'chunked', + 'x-genkit-stream-id': streamId, + }, + }); + } catch (e: any) { + if (e instanceof StreamNotFoundError) { + return new Response(null, { status: 204 }); + } + if (e.status === 'DEADLINE_EXCEEDED') { + const encoder = new TextEncoder(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + writer.write( + encoder.encode( + `error: ${JSON.stringify({ + error: getCallableJSON(e), + })}${streamDelimiter}` + ) + ); + writer.close(); + return new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Transfer-Encoding': 'chunked', + }, + }); + } + throw e; + } +} + +/** + * Runs an action with durable streaming support. + */ +async function runActionWithDurableStreaming< + I extends z.ZodTypeAny, + O extends z.ZodTypeAny, + S extends z.ZodTypeAny, +>( + action: Action, + streamManager: StreamManager | undefined, + streamId: string, + input: z.infer, + context: ActionContext, + writer: WritableStreamDefaultWriter, + abortSignal: AbortSignal +): Promise { + const encoder = new TextEncoder(); + let taskQueue: AsyncTaskQueue | undefined; + let durableStream: ActionStreamInput | undefined; + + if (streamManager) { + taskQueue = new AsyncTaskQueue(); + durableStream = await streamManager.open(streamId); + } + + try { + let onChunk = (chunk: z.infer) => { + writer.write( + encoder.encode( + 'data: ' + JSON.stringify({ message: chunk }) + streamDelimiter + ) + ); + }; + + if (streamManager && durableStream) { + const originalOnChunk = onChunk; + onChunk = (chunk: z.infer) => { + originalOnChunk(chunk); + taskQueue!.enqueue(() => durableStream!.write(chunk)); + }; + } + + const result = await action.run(input, { + onChunk, + context, + abortSignal, + }); + + if (streamManager && durableStream) { + taskQueue!.enqueue(() => durableStream!.done(result.result)); + await taskQueue!.merge(); + } + + writer.write( + encoder.encode( + 'data: ' + JSON.stringify({ result: result.result }) + streamDelimiter + ) + ); + writer.close(); + } catch (e) { + if (durableStream) { + taskQueue!.enqueue(() => durableStream!.error(e)); + await taskQueue!.merge(); + } + logger.error( + `Streaming request failed with error: ${(e as Error).message}\n${ + (e as Error).stack + }` + ); + writer.write( + encoder.encode( + `error: ${JSON.stringify({ + error: getCallableJSON(e), + })}${streamDelimiter}` + ) + ); + writer.close(); + } +} + +/** + * Handles a single flow request and returns a Response. + * + * @param request - The Web API Request object + * @param action - The Genkit Action/Flow to execute + * @param options - Optional configuration including contextProvider and streamManager + * @returns A Promise that resolves to a Response object + * + * @example + * ```typescript + * import { handleFlow } from '@genkit-ai/fetch'; + * + * app.all('/myFlow', async (c) => { + * return handleFlow(c.req.raw, myFlow); + * }); + * ``` + */ +export async function handleFlow< + C extends ActionContext = ActionContext, + I extends z.ZodTypeAny = z.ZodTypeAny, + O extends z.ZodTypeAny = z.ZodTypeAny, + S extends z.ZodTypeAny = z.ZodTypeAny, +>( + request: Request, + action: Action, + options?: HandleFlowOptions +): Promise { + // Parse query parameters + const url = new URL(request.url); + const streamParam = url.searchParams.get('stream'); + const shouldStream = streamParam === 'true'; + + // Get stream ID from headers + const streamIdHeader = request.headers.get('x-genkit-stream-id'); + const streamId = streamIdHeader || undefined; + + // Parse request body (same as Express: input only from JSON body) + let body: any; + try { + body = await request.json(); + } catch (e) { + const errMsg = + `Error: Failed to parse request body as JSON. ` + + `Make sure the request has 'Content-Type: application/json' header.`; + logger.error(errMsg); + return new Response( + JSON.stringify({ message: errMsg, status: 'INVALID_ARGUMENT' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + if (!body || typeof body !== 'object' || !('data' in body)) { + const errMsg = + `Error: Request body must be a JSON object with a 'data' field. ` + + `Expected format: {"data": ...}`; + logger.error(errMsg); + return new Response( + JSON.stringify({ message: errMsg, status: 'INVALID_ARGUMENT' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const input = body.data as z.infer; + + // Get context from context provider + let context: C; + try { + context = await getContext(request, input, options?.contextProvider); + } catch (e: any) { + logger.error( + `Context provider failed with error: ${(e as Error).message}\n${ + (e as Error).stack + }` + ); + return new Response(JSON.stringify(getCallableJSON(e)), { + status: getHttpStatus(e), + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check if streaming is requested + const acceptHeader = request.headers.get('Accept') || ''; + const isStreaming = acceptHeader === 'text/event-stream' || shouldStream; + + if (isStreaming) { + const streamManager = options?.streamManager; + if (streamManager && streamId) { + const response = await subscribeToStream(streamManager, streamId); + if (response) { + return response; + } + } + + const streamIdToUse = randomUUID(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + + // Start streaming in the background + runActionWithDurableStreaming( + action, + streamManager, + streamIdToUse, + input, + context, + writer, + request.signal + ).catch((err) => { + logger.error(`Error in streaming handler: ${err}`); + }); + + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Transfer-Encoding': 'chunked', + }; + + if (streamManager) { + headers['x-genkit-stream-id'] = streamIdToUse; + } + + return new Response(readable, { + status: 200, + headers, + }); + } + + // Non-streaming request + try { + const result = await action.run(input, { + context, + abortSignal: request.signal, + }); + + const headers: Record = { + 'x-genkit-trace-id': result.telemetry.traceId, + 'x-genkit-span-id': result.telemetry.spanId, + }; + + return new Response(JSON.stringify({ result: result.result }), { + status: 200, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + } catch (e) { + logger.error( + `Non-streaming request failed with error: ${(e as Error).message}\n${ + (e as Error).stack + }` + ); + return new Response(JSON.stringify(getCallableJSON(e)), { + status: getHttpStatus(e), + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +/** + * Handles multiple flows by routing based on the URL path. + * + * @param request - The Web API Request object + * @param flows - Array of flows with their options + * @param pathPrefix - Optional path prefix to strip from the URL (e.g., '/api/genkit') + * @returns A Promise that resolves to a Response (200 for success, 404 if no flow matches) + * + * @example + * ```typescript + * import { handleFlows } from '@genkit-ai/fetch'; + * + * app.all('/api/genkit/*', async (c) => { + * return handleFlows(c.req.raw, [flow1, flow2], '/api/genkit'); + * }); + * ``` + */ +export async function handleFlows( + request: Request, + flows: (Flow | FlowWithOptions)[], + pathPrefix?: string +): Promise { + const url = new URL(request.url); + let pathname = url.pathname; + + // Remove path prefix if provided (exact match or prefix followed by /) + if (pathPrefix) { + const prefix = pathPrefix.endsWith('/') ? pathPrefix : pathPrefix + '/'; + if (pathname === pathPrefix) { + pathname = ''; + } else if (pathname.startsWith(prefix)) { + pathname = pathname.slice(prefix.length); + } else { + return new Response( + JSON.stringify({ + status: 'NOT_FOUND', + message: 'No flow matched the request path.', + }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + } + + // Remove leading slash + pathname = pathname.replace(/^\//, ''); + + // Find matching flow + let matchedFlow: Flow | null = null; + let flowOptions: HandleFlowOptions | undefined = undefined; + + for (const flow of flows) { + if ('flow' in flow) { + const options = flow.options; + const flowPath = options.path || flow.flow.__action.name; + if (pathname === flowPath) { + matchedFlow = flow.flow; + flowOptions = { + contextProvider: options.contextProvider, + streamManager: options.streamManager, + }; + break; + } + } else { + // Plain Flow + if (pathname === flow.__action.name) { + matchedFlow = flow; + break; + } + } + } + + if (!matchedFlow) { + return new Response( + JSON.stringify({ + status: 'NOT_FOUND', + message: 'No flow matched the request path.', + }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + return handleFlow(request, matchedFlow, flowOptions); +} diff --git a/js/plugins/fetch/src/utils.ts b/js/plugins/fetch/src/utils.ts new file mode 100644 index 0000000000..86ac7eaf05 --- /dev/null +++ b/js/plugins/fetch/src/utils.ts @@ -0,0 +1,33 @@ +/** + * Copyright 2025 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. + */ +/** + * Extracts error message from the given error object, or if input is not an error then just turn the error into a string. + */ +export function getErrorMessage(e: any): string { + if (e instanceof Error) { + return e.message; + } + return `${e}`; +} +/** + * Extracts stack trace from the given error object, or if input is not an error then returns undefined. + */ +export function getErrorStack(e: any): string | undefined { + if (e instanceof Error) { + return e.stack; + } + return undefined; +} diff --git a/js/plugins/fetch/tests/web_test.ts b/js/plugins/fetch/tests/web_test.ts new file mode 100644 index 0000000000..887554596f --- /dev/null +++ b/js/plugins/fetch/tests/web_test.ts @@ -0,0 +1,729 @@ +/** + * Copyright 2025 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 * as assert from 'assert'; +import { + UserFacingError, + genkit, + z, + type GenerateResponseData, + type Genkit, +} from 'genkit'; +import { InMemoryStreamManager } from 'genkit/beta'; +import { runFlow, streamFlow } from 'genkit/beta/client'; +import type { ContextProvider, RequestData } from 'genkit/context'; +import type { GenerateResponseChunkData, ModelAction } from 'genkit/model'; +import getPort from 'get-port'; +import * as http from 'http'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { handleFlow, handleFlows, type FlowWithOptions } from '../src/index.js'; + +interface Context { + auth: { + user: string; + }; +} + +const contextProvider: ContextProvider = (req: RequestData) => { + assert.ok(req.method, 'method must be set'); + assert.ok(req.headers, 'headers must be set'); + assert.ok(req.input, 'input must be set'); + + if (req.headers['authorization'] !== 'open sesame') { + throw new UserFacingError('PERMISSION_DENIED', 'not authorized'); + } + return { + auth: { + user: 'Ali Baba', + }, + }; +}; + +/** + * Collects the request body from a Node.js IncomingMessage. + */ +function getRequestBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +/** + * Creates a Web API Request from a Node.js IncomingMessage and body. + */ +function nodeRequestToWebRequest( + req: http.IncomingMessage, + baseUrl: string, + body: Buffer +): Request { + const url = new URL(req.url || '/', baseUrl); + return new Request(url.toString(), { + method: req.method || 'GET', + headers: req.headers as HeadersInit, + body: body.length > 0 ? new Uint8Array(body) : undefined, + }); +} + +/** + * Writes a Web API Response to a Node.js ServerResponse. + */ +async function writeWebResponseToNode( + response: Response, + res: http.ServerResponse +): Promise { + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + res.writeHead(response.status, headers); + if (response.body) { + const reader = response.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + } + res.end(); +} + +describe('handleFlow', () => { + describe('direct Request/Response (no server)', () => { + it('should return 400 when body is not JSON', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('test', async () => 'ok'); + const request = new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json', + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 400); + const json = (await response.json()) as { status: string }; + assert.strictEqual(json.status, 'INVALID_ARGUMENT'); + }); + + it('should return 400 when body has no data field', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('test', async () => 'ok'); + const request = new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ foo: 'bar' }), + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 400); + const json = (await response.json()) as { status: string }; + assert.strictEqual(json.status, 'INVALID_ARGUMENT'); + }); + + it('should run a void input flow', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('voidInput', async () => 'banana'); + const request = new Request('http://localhost/voidInput', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 200); + const json = (await response.json()) as { result: string }; + assert.strictEqual(json.result, 'banana'); + }); + + it('should run a flow with string input', async () => { + const ai = genkit({}); + defineEchoModel(ai); + const flow = ai.defineFlow('stringInput', async (input: string) => { + const { text } = await ai.generate({ + model: 'echoModel', + prompt: input, + }); + return text; + }); + const request = new Request('http://localhost/stringInput', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'hello' }), + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 200); + const json = (await response.json()) as { result: string }; + assert.strictEqual(json.result, 'Echo: hello'); + }); + + it('should run a flow with object input', async () => { + const ai = genkit({}); + defineEchoModel(ai); + const flow = ai.defineFlow( + { + name: 'objectInput', + inputSchema: z.object({ question: z.string() }), + }, + async (input) => { + const { text } = await ai.generate({ + model: 'echoModel', + prompt: input.question, + }); + return text; + } + ); + const request = new Request('http://localhost/objectInput', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: { question: 'olleh' } }), + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 200); + const json = (await response.json()) as { result: string }; + assert.strictEqual(json.result, 'Echo: olleh'); + }); + + it('should return error for invalid input', async () => { + const ai = genkit({}); + defineEchoModel(ai); + const flow = ai.defineFlow( + { + name: 'objectInput', + inputSchema: z.object({ question: z.string() }), + }, + async (input) => input.question + ); + const request = new Request('http://localhost/objectInput', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: { badField: 'hello' } }), + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 400); + const json = (await response.json()) as { status?: string }; + assert.ok( + json.status === 'INVALID_ARGUMENT' || + (json as { message?: string }).message?.includes('INVALID') + ); + }); + + it('should call a flow with auth', async () => { + const ai = genkit({}); + const flow = ai.defineFlow( + { + name: 'flowWithAuth', + inputSchema: z.object({ question: z.string() }), + }, + async (input, { context }) => { + return `${input.question} - ${JSON.stringify(context!.auth)}`; + } + ); + const request = new Request('http://localhost/flowWithAuth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'open sesame', + }, + body: JSON.stringify({ data: { question: 'hello' } }), + }); + const response = await handleFlow(request, flow, { contextProvider }); + assert.strictEqual(response.status, 200); + const json = (await response.json()) as { result: string }; + assert.strictEqual(json.result, 'hello - {"user":"Ali Baba"}'); + }); + + it('should fail a flow with auth when unauthorized', async () => { + const ai = genkit({}); + const flow = ai.defineFlow( + { + name: 'flowWithAuth', + inputSchema: z.object({ question: z.string() }), + }, + async (input, { context }) => String(context?.auth) + ); + const request = new Request('http://localhost/flowWithAuth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'thief #24', + }, + body: JSON.stringify({ data: { question: 'hello' } }), + }); + const response = await handleFlow(request, flow, { contextProvider }); + assert.strictEqual(response.status, 403); + const json = (await response.json()) as { message?: string }; + assert.ok(json.message?.includes('not authorized')); + }); + + it('should set x-genkit-trace-id and x-genkit-span-id headers', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('traceFlow', async () => 'ok'); + const request = new Request('http://localhost/traceFlow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlow(request, flow); + assert.strictEqual(response.status, 200); + assert.ok(response.headers.get('x-genkit-trace-id')); + assert.ok(response.headers.get('x-genkit-span-id')); + }); + }); +}); + +describe('handleFlow with HTTP server (runFlow/streamFlow)', () => { + let server: http.Server; + let port: number; + + beforeEach(async () => { + const ai = genkit({}); + const echoModel = defineEchoModel(ai); + const voidInput = ai.defineFlow('voidInput', async () => 'banana'); + const stringInput = ai.defineFlow('stringInput', async (input: string) => { + const { text } = await ai.generate({ + model: 'echoModel', + prompt: input, + }); + return text; + }); + const objectInput = ai.defineFlow( + { name: 'objectInput', inputSchema: z.object({ question: z.string() }) }, + async (input) => { + const { text } = await ai.generate({ + model: 'echoModel', + prompt: input.question, + }); + return text; + } + ); + const streamingFlow = ai.defineFlow( + { + name: 'streamingFlow', + inputSchema: z.object({ question: z.string() }), + }, + async (input, sendChunk) => { + const { text } = await ai.generate({ + model: 'echoModel', + prompt: input.question, + onChunk: sendChunk, + }); + return text; + } + ); + const flowWithAuth = ai.defineFlow( + { + name: 'flowWithAuth', + inputSchema: z.object({ question: z.string() }), + }, + async (input, { context }) => { + return `${input.question} - ${JSON.stringify(context!.auth)}`; + } + ); + + port = await getPort(); + server = await createServerWithFlows( + port, + voidInput, + stringInput, + objectInput, + streamingFlow, + flowWithAuth, + echoModel + ); + }); + + afterEach(() => { + server.close(); + }); + + describe('runFlow', () => { + it('should call a void input flow', async () => { + const result = await runFlow({ + url: `http://localhost:${port}/voidInput`, + input: null, + }); + assert.strictEqual(result, 'banana'); + }); + + it('should run a flow with string input', async () => { + const result = await runFlow({ + url: `http://localhost:${port}/stringInput`, + input: 'hello', + }); + assert.strictEqual(result, 'Echo: hello'); + }); + + it('should run a flow with object input', async () => { + const result = await runFlow({ + url: `http://localhost:${port}/objectInput`, + input: { question: 'olleh' }, + }); + assert.strictEqual(result, 'Echo: olleh'); + }); + + it('should fail a bad input', async () => { + const result = runFlow({ + url: `http://localhost:${port}/objectInput`, + input: { badField: 'hello' }, + }); + await assert.rejects(result, (err: Error) => + err.message.includes('INVALID_ARGUMENT') + ); + }); + + it('should call a flow with auth', async () => { + const result = await runFlow({ + url: `http://localhost:${port}/flowWithAuth`, + input: { question: 'hello' }, + headers: { Authorization: 'open sesame' }, + }); + assert.strictEqual(result, 'hello - {"user":"Ali Baba"}'); + }); + + it('should fail a flow with auth', async () => { + const result = runFlow({ + url: `http://localhost:${port}/flowWithAuth`, + input: { question: 'hello' }, + headers: { Authorization: 'thief #24' }, + }); + await assert.rejects(result, (err: Error) => + (err as Error).message.includes('not authorized') + ); + }); + + it('should call a model', async () => { + const result = await runFlow({ + url: `http://localhost:${port}/echoModel`, + input: { + messages: [{ role: 'user', content: [{ text: 'hello' }] }], + }, + }); + assert.strictEqual((result as GenerateResponseData).finishReason, 'stop'); + assert.deepStrictEqual((result as GenerateResponseData).message, { + role: 'model', + content: [{ text: 'Echo: hello' }], + }); + }); + + it('should call a model with auth', async () => { + const result = await runFlow({ + url: `http://localhost:${port}/echoModelWithAuth`, + input: { + messages: [{ role: 'user', content: [{ text: 'hello' }] }], + }, + headers: { Authorization: 'open sesame' }, + }); + assert.strictEqual(result.finishReason, 'stop'); + assert.deepStrictEqual(result.message, { + role: 'model', + content: [{ text: 'Echo: hello' }], + }); + }); + + it('should fail a model with auth when unauthorized', async () => { + const result = runFlow({ + url: `http://localhost:${port}/echoModelWithAuth`, + input: { + messages: [{ role: 'user', content: [{ text: 'hello' }] }], + }, + headers: { Authorization: 'thief #24' }, + }); + await assert.rejects(result, (err: Error) => + (err as Error).message.includes('not authorized') + ); + }); + }); + + describe('streamFlow', () => { + it('stream a flow', async () => { + const result = streamFlow({ + url: `http://localhost:${port}/streamingFlow`, + input: { question: 'olleh' }, + }); + + const gotChunks: GenerateResponseChunkData[] = []; + for await (const chunk of result.stream) { + gotChunks.push(chunk); + } + + assert.deepStrictEqual(gotChunks, [ + { index: 0, role: 'model', content: [{ text: '3' }] }, + { index: 0, role: 'model', content: [{ text: '2' }] }, + { index: 0, role: 'model', content: [{ text: '1' }] }, + ]); + + assert.strictEqual(await result.output, 'Echo: olleh'); + }); + + it('should create and subscribe to a durable stream', async () => { + const result = streamFlow({ + url: `http://localhost:${port}/streamingFlowDurable`, + input: { question: 'durable' }, + }); + + const streamId = await result.streamId; + assert.ok(streamId); + + const subscription = streamFlow({ + url: `http://localhost:${port}/streamingFlowDurable`, + input: { question: 'durable' }, + streamId: streamId!, + }); + + const gotChunks: GenerateResponseChunkData[] = []; + for await (const chunk of subscription.stream) { + gotChunks.push(chunk); + } + + const originalChunks: GenerateResponseChunkData[] = []; + for await (const chunk of result.stream) { + originalChunks.push(chunk); + } + + assert.deepStrictEqual(gotChunks, originalChunks); + assert.strictEqual(await subscription.output, 'Echo: durable'); + assert.strictEqual(await result.output, 'Echo: durable'); + }); + + it('should subscribe to a stream in progress', async () => { + const result = streamFlow({ + url: `http://localhost:${port}/streamingFlowDurable`, + input: { question: 'durable' }, + }); + + const streamId = await result.streamId; + assert.ok(streamId); + + const subscription = streamFlow({ + url: `http://localhost:${port}/streamingFlowDurable`, + input: { question: 'durable' }, + streamId: streamId!, + }); + + const gotChunks: GenerateResponseChunkData[] = []; + for await (const chunk of subscription.stream) { + gotChunks.push(chunk); + } + + assert.strictEqual(gotChunks.length, 3); + assert.strictEqual(await subscription.output, 'Echo: durable'); + }); + + it('should return 204 for a non-existent stream', async () => { + try { + const result = streamFlow({ + url: `http://localhost:${port}/streamingFlowDurable`, + input: { question: 'durable' }, + streamId: 'non-existent-stream-id', + }); + for await (const _ of result.stream) { + } + assert.fail('should have thrown'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'NOT_FOUND: Stream not found.' + ); + } + }); + + it('stream a model', async () => { + const result = streamFlow({ + url: `http://localhost:${port}/echoModel`, + input: { + messages: [{ role: 'user', content: [{ text: 'olleh' }] }], + }, + }); + + const gotChunks: unknown[] = []; + for await (const chunk of result.stream) { + gotChunks.push(chunk); + } + + const output = await result.output; + assert.strictEqual((output as GenerateResponseData).finishReason, 'stop'); + assert.deepStrictEqual((output as GenerateResponseData).message, { + role: 'model', + content: [{ text: 'Echo: olleh' }], + }); + + assert.deepStrictEqual(gotChunks, [ + { content: [{ text: '3' }] }, + { content: [{ text: '2' }] }, + { content: [{ text: '1' }] }, + ]); + }); + }); +}); + +describe('handleFlows', () => { + it('should return 404 when no flow matches path', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('myFlow', async () => 'ok'); + const request = new Request('http://localhost/otherPath', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlows(request, [flow]); + assert.strictEqual(response.status, 404); + const json = (await response.json()) as { status: string }; + assert.strictEqual(json.status, 'NOT_FOUND'); + }); + + it('should route to flow by name', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('myFlow', async () => 'ok'); + const request = new Request('http://localhost/myFlow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlows(request, [flow]); + assert.ok(response); + assert.strictEqual(response!.status, 200); + const json = (await response!.json()) as { result: string }; + assert.strictEqual(json.result, 'ok'); + }); + + it('should route to flow with path option', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('internalName', async () => 'ok'); + const request = new Request('http://localhost/customPath', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlows(request, [ + { flow, options: { path: 'customPath' } }, + ]); + assert.ok(response); + assert.strictEqual(response!.status, 200); + const json = (await response!.json()) as { result: string }; + assert.strictEqual(json.result, 'ok'); + }); + + it('should strip pathPrefix and route', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('myFlow', async () => 'ok'); + const request = new Request('http://localhost/api/genkit/myFlow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlows(request, [flow], '/api/genkit'); + assert.ok(response); + assert.strictEqual(response!.status, 200); + const json = (await response!.json()) as { result: string }; + assert.strictEqual(json.result, 'ok'); + }); + + it('should return 404 when path does not match prefix', async () => { + const ai = genkit({}); + const flow = ai.defineFlow('myFlow', async () => 'ok'); + const request = new Request('http://localhost/other/myFlow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: null }), + }); + const response = await handleFlows(request, [flow], '/api/genkit'); + assert.strictEqual(response.status, 404); + const json = (await response.json()) as { status: string }; + assert.strictEqual(json.status, 'NOT_FOUND'); + }); +}); + +function defineEchoModel(ai: Genkit): ModelAction { + return ai.defineModel( + { name: 'echoModel' }, + async (request, streamingCallback) => { + streamingCallback?.({ content: [{ text: '3' }] }); + streamingCallback?.({ content: [{ text: '2' }] }); + streamingCallback?.({ content: [{ text: '1' }] }); + return { + message: { + role: 'model', + content: [ + { + text: + 'Echo: ' + + request.messages + .map( + (m) => + (m.role === 'user' || m.role === 'model' + ? '' + : `${m.role}: `) + m.content.map((c) => c.text).join() + ) + .join(), + }, + ], + }, + finishReason: 'stop', + }; + } + ); +} + +async function createServerWithFlows( + port: number, + voidInput: import('genkit/beta').Flow, + stringInput: import('genkit/beta').Flow, + objectInput: import('genkit/beta').Flow, + streamingFlow: import('genkit/beta').Flow, + flowWithAuth: import('genkit/beta').Flow, + echoModel: ModelAction +): Promise { + const flows: ( + | FlowWithOptions + | import('genkit/beta').Flow + )[] = [ + voidInput, + stringInput, + objectInput, + streamingFlow, + { + flow: streamingFlow, + options: { + streamManager: new InMemoryStreamManager(), + path: 'streamingFlowDurable', + }, + }, + { flow: flowWithAuth, options: { contextProvider } }, + echoModel, + { + flow: echoModel, + options: { contextProvider, path: 'echoModelWithAuth' }, + }, + ]; + return new Promise((resolve) => { + const baseUrl = `http://localhost:${port}`; + const server = http.createServer(async (req, res) => { + try { + const body = await getRequestBody(req); + const request = nodeRequestToWebRequest(req, baseUrl, body); + const response = await handleFlows(request, flows); + await writeWebResponseToNode(response, res); + } catch (err) { + res.writeHead(500); + res.end(String(err)); + } + }); + server.listen(port, () => resolve(server)); + }); +} diff --git a/js/plugins/fetch/tsconfig.json b/js/plugins/fetch/tsconfig.json new file mode 100644 index 0000000000..596e2cf729 --- /dev/null +++ b/js/plugins/fetch/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/js/plugins/fetch/tsup.config.ts b/js/plugins/fetch/tsup.config.ts new file mode 100644 index 0000000000..d55507161f --- /dev/null +++ b/js/plugins/fetch/tsup.config.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2025 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 { defineConfig, type Options } from 'tsup'; +import { defaultOptions } from '../../tsup.common'; + +export default defineConfig({ + ...(defaultOptions as Options), +}); diff --git a/js/plugins/fetch/typedoc.json b/js/plugins/fetch/typedoc.json new file mode 100644 index 0000000000..35fed2c958 --- /dev/null +++ b/js/plugins/fetch/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 0542ba7797..eaa6e29214 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -181,7 +181,7 @@ importers: version: 4.1.1 '@genkit-ai/firebase': specifier: ^1.16.1 - version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) + version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) doc-snippets: dependencies: @@ -559,6 +559,33 @@ importers: specifier: ^4.9.0 version: 4.9.5 + plugins/fetch: + devDependencies: + '@types/node': + specifier: ^20.11.16 + version: 20.19.1 + genkit: + specifier: workspace:* + version: link:../../genkit + get-port: + specifier: ^5.1.0 + version: 5.1.1 + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + tsup: + specifier: ^8.3.5 + version: 8.5.0(postcss@8.4.47)(tsx@4.20.3)(typescript@4.9.5)(yaml@2.8.0) + tsx: + specifier: ^4.19.2 + version: 4.20.3 + typescript: + specifier: ^4.9.0 + version: 4.9.5 + plugins/firebase: dependencies: '@genkit-ai/google-cloud': @@ -789,7 +816,7 @@ importers: version: link:../../genkit langchain: specifier: ^0.1.36 - version: 0.1.37(@google-cloud/storage@7.16.0(encoding@0.1.13))(@pinecone-database/pinecone@2.2.2)(chromadb@1.9.2(encoding@0.1.13)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(fast-xml-parser@4.5.3)(firebase-admin@12.3.1(encoding@0.1.13))(google-auth-library@8.9.0(encoding@0.1.13))(handlebars@4.7.8)(ignore@5.3.1)(jsonwebtoken@9.0.2)(lodash@4.17.21)(pdf-parse@1.1.1)(pg@8.16.2)(ws@8.18.3) + version: 0.1.37(@google-cloud/storage@7.16.0(encoding@0.1.13))(@pinecone-database/pinecone@2.2.2)(axios@1.13.5)(chromadb@1.9.2(encoding@0.1.13)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(fast-xml-parser@4.5.3)(firebase-admin@12.3.1(encoding@0.1.13))(google-auth-library@8.9.0(encoding@0.1.13))(handlebars@4.7.8)(ignore@5.3.1)(jsonwebtoken@9.0.2)(lodash@4.17.21)(pdf-parse@1.1.1)(pg@8.16.2)(ws@8.18.3) devDependencies: '@types/node': specifier: ^20.11.16 @@ -1091,7 +1118,7 @@ importers: version: link:../../plugins/compat-oai '@genkit-ai/express': specifier: ^1.1.0 - version: 1.12.0(@genkit-ai/core@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit) + version: 1.12.0(@genkit-ai/core@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit) genkit: specifier: workspace:* version: link:../../genkit @@ -1664,7 +1691,7 @@ importers: version: link:../../plugins/ollama genkitx-openai: specifier: ^0.10.1 - version: 0.10.1(@genkit-ai/ai@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3) + version: 0.10.1(@genkit-ai/ai@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3) devDependencies: rimraf: specifier: ^6.0.1 @@ -2719,11 +2746,11 @@ packages: '@firebase/webchannel-wrapper@1.0.3': resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==} - '@genkit-ai/ai@1.29.0-rc.0': - resolution: {integrity: sha512-sXeU30DY5sCBJ+X6XUbYRpueA/GkaAI/4BwpopLaNtEN54JZ7TMAqTwY8lu8J3xTo6p+QihXVp3stb4me4/jMg==} + '@genkit-ai/ai@1.29.0': + resolution: {integrity: sha512-a5rc3b6xyOk1e0q8uFjN6PrBAELLfILuLs8y2ozwe4daNjwx3DUfgWdHAr7zDxsAzZfTyCxDwuaOetd8fdJ/eQ==} - '@genkit-ai/core@1.29.0-rc.0': - resolution: {integrity: sha512-hvsOjKyyCmV9u8zutF/2PhZaprP32ClTos8bNf2N88ASs1EUD3RcOS5qRweyVqEPdh2cGAU07xI3Tn5omKtsnA==} + '@genkit-ai/core@1.29.0': + resolution: {integrity: sha512-nu4+rSEol1vtmgCbqTWDoEh5hHqXdAOkws4iPgXcIqZqw7/fyUwzNYrBnlFTRJlGUCd6lSQdAvzruqrOHZpR3Q==} '@genkit-ai/express@1.12.0': resolution: {integrity: sha512-QAxSS07dX5ovSfsUB4s90KaDnv4zg1wnoxCZCa+jBsYUyv9NvCCTsOk25xAQgGxc7xi3+MD+3AsPier5oZILIg==} @@ -4653,6 +4680,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} peerDependencies: @@ -5115,6 +5145,10 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -5685,6 +5719,15 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5793,8 +5836,8 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - genkit@1.29.0-rc.0: - resolution: {integrity: sha512-mjqPq9gYsXrZN6WYdzF7UuCoYYyEigfGfLur1NAVGVXos3pBFBOW4yivslMxslCZhWYMDX4koJYtMEMPJApSQg==} + genkit@1.29.0: + resolution: {integrity: sha512-m0oqw4AU8l6LTELH/0JmWPUA5ZuEVwm4ZhgUaVopy86gqgWyDF+SdNIpIxtXqdTmxxQHK9stQXuq/zJUDCLF4Q==} genkitx-openai@0.10.1: resolution: {integrity: sha512-E9/DzyQcBUSTy81xT2pvEmdnn9Q/cKoojEt6lD/EdOeinhqE9oa59d/kuXTokCMekTrj3Rk7LtNBQIDjnyjNOA==} @@ -9725,9 +9768,9 @@ snapshots: '@firebase/webchannel-wrapper@1.0.3': {} - '@genkit-ai/ai@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/ai@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: - '@genkit-ai/core': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/core': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) '@opentelemetry/api': 1.9.0 '@types/node': 20.19.1 colorette: 2.0.20 @@ -9746,9 +9789,9 @@ snapshots: - supports-color optional: true - '@genkit-ai/ai@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': + '@genkit-ai/ai@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': dependencies: - '@genkit-ai/core': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/core': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) '@opentelemetry/api': 1.9.0 '@types/node': 20.19.1 colorette: 2.0.20 @@ -9766,7 +9809,7 @@ snapshots: - genkit - supports-color - '@genkit-ai/core@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/core@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.0) @@ -9779,7 +9822,7 @@ snapshots: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) async-mutex: 0.5.0 - cors: 2.8.5 + cors: 2.8.6 dotprompt: 1.1.1 express: 4.21.2 get-port: 5.1.1 @@ -9788,7 +9831,7 @@ snapshots: zod-to-json-schema: 3.24.5(zod@3.25.67) optionalDependencies: '@cfworker/json-schema': 4.1.1 - '@genkit-ai/firebase': 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/firebase': 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) transitivePeerDependencies: - '@google-cloud/firestore' - encoding @@ -9798,7 +9841,7 @@ snapshots: - supports-color optional: true - '@genkit-ai/core@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': + '@genkit-ai/core@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.0) @@ -9811,7 +9854,7 @@ snapshots: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) async-mutex: 0.5.0 - cors: 2.8.5 + cors: 2.8.6 dotprompt: 1.1.1 express: 4.21.2 get-port: 5.1.1 @@ -9829,9 +9872,9 @@ snapshots: - genkit - supports-color - '@genkit-ai/express@1.12.0(@genkit-ai/core@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit)': + '@genkit-ai/express@1.12.0(@genkit-ai/core@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit)': dependencies: - '@genkit-ai/core': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/core': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) body-parser: 1.20.3 cors: 2.8.5 express: 5.1.0 @@ -9839,12 +9882,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@genkit-ai/firebase@1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/firebase@1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: - '@genkit-ai/google-cloud': 1.16.1(encoding@0.1.13)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/google-cloud': 1.16.1(encoding@0.1.13)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) '@google-cloud/firestore': 7.11.1(encoding@0.1.13) firebase-admin: 13.6.0(encoding@0.1.13) - genkit: 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1) + genkit: 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1) optionalDependencies: firebase: 11.9.1 transitivePeerDependencies: @@ -9865,7 +9908,7 @@ snapshots: - supports-color optional: true - '@genkit-ai/google-cloud@1.16.1(encoding@0.1.13)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/google-cloud@1.16.1(encoding@0.1.13)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: '@google-cloud/logging-winston': 6.0.1(encoding@0.1.13)(winston@3.17.0) '@google-cloud/opentelemetry-cloud-monitoring-exporter': 0.19.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13) @@ -9881,7 +9924,7 @@ snapshots: '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) - genkit: 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1) + genkit: 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1) google-auth-library: 9.15.1(encoding@0.1.13) node-fetch: 3.3.2 winston: 3.17.0 @@ -10748,7 +10791,7 @@ snapshots: '@npmcli/fs@4.0.0': dependencies: - semver: 7.7.2 + semver: 7.7.3 optional: true '@opentelemetry/api-logs@0.52.1': @@ -11986,6 +12029,15 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + optional: true + b4a@1.7.3: {} babel-jest@29.7.0(@babel/core@7.25.7): @@ -12511,6 +12563,11 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -13373,6 +13430,9 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.11: + optional: true + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -13522,10 +13582,10 @@ snapshots: transitivePeerDependencies: - supports-color - genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1): + genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1): dependencies: - '@genkit-ai/ai': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) - '@genkit-ai/core': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/ai': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/core': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)) uuid: 10.0.0 transitivePeerDependencies: - '@google-cloud/firestore' @@ -13535,10 +13595,10 @@ snapshots: - supports-color optional: true - genkitx-openai@0.10.1(@genkit-ai/ai@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3): + genkitx-openai@0.10.1(@genkit-ai/ai@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3): dependencies: - '@genkit-ai/ai': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) - '@genkit-ai/core': 1.29.0-rc.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/ai': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/core': 1.29.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.6.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) openai: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67) zod: 3.25.67 transitivePeerDependencies: @@ -14219,7 +14279,7 @@ snapshots: '@babel/parser': 7.25.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -14810,7 +14870,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.1.37(@google-cloud/storage@7.16.0(encoding@0.1.13))(@pinecone-database/pinecone@2.2.2)(chromadb@1.9.2(encoding@0.1.13)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(fast-xml-parser@4.5.3)(firebase-admin@12.3.1(encoding@0.1.13))(google-auth-library@8.9.0(encoding@0.1.13))(handlebars@4.7.8)(ignore@5.3.1)(jsonwebtoken@9.0.2)(lodash@4.17.21)(pdf-parse@1.1.1)(pg@8.16.2)(ws@8.18.3): + langchain@0.1.37(@google-cloud/storage@7.16.0(encoding@0.1.13))(@pinecone-database/pinecone@2.2.2)(axios@1.13.5)(chromadb@1.9.2(encoding@0.1.13)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(fast-xml-parser@4.5.3)(firebase-admin@12.3.1(encoding@0.1.13))(google-auth-library@8.9.0(encoding@0.1.13))(handlebars@4.7.8)(ignore@5.3.1)(jsonwebtoken@9.0.2)(lodash@4.17.21)(pdf-parse@1.1.1)(pg@8.16.2)(ws@8.18.3): dependencies: '@anthropic-ai/sdk': 0.9.1(encoding@0.1.13) '@langchain/community': 0.0.53(@pinecone-database/pinecone@2.2.2)(chromadb@1.9.2(encoding@0.1.13)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(firebase-admin@12.3.1(encoding@0.1.13))(google-auth-library@8.9.0(encoding@0.1.13))(jsonwebtoken@9.0.2)(lodash@4.17.21)(pg@8.16.2)(ws@8.18.3) @@ -14833,6 +14893,7 @@ snapshots: optionalDependencies: '@google-cloud/storage': 7.16.0(encoding@0.1.13) '@pinecone-database/pinecone': 2.2.2 + axios: 1.13.5 chromadb: 1.9.2(encoding@0.1.13)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67)) fast-xml-parser: 4.5.3 google-auth-library: 8.9.0(encoding@0.1.13) @@ -15076,7 +15137,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 make-error@1.3.6: {} @@ -15385,7 +15446,7 @@ snapshots: make-fetch-happen: 14.0.3 nopt: 8.1.0 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.7.3 tar: 7.5.2 tinyglobby: 0.2.14 which: 5.0.0 @@ -16201,8 +16262,7 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} send@0.19.0: dependencies: diff --git a/samples/js-hono/.gitignore b/samples/js-hono/.gitignore new file mode 100644 index 0000000000..f57b0702c1 --- /dev/null +++ b/samples/js-hono/.gitignore @@ -0,0 +1,4 @@ +test/ +node_modules/ +.genkit/ +*.js \ No newline at end of file diff --git a/samples/js-hono/README.md b/samples/js-hono/README.md new file mode 100644 index 0000000000..c5132cd8d1 --- /dev/null +++ b/samples/js-hono/README.md @@ -0,0 +1,56 @@ +# Genkit + Hono + @genkit-ai/fetch + +This sample runs a [Hono](https://hono.dev) server and exposes Genkit flows over HTTP using [@genkit-ai/fetch](https://www.npmjs.com/package/@genkit-ai/fetch). It uses the Web API `Request`/`Response` so the same code can run on Node, Cloudflare Workers, Deno, or Bun. + +## Setup + +1. Set `GOOGLE_GENAI_API_KEY` (or `GOOGLE_API_KEY` / `GEMINI_API_KEY`). +2. Install and build: + + ```bash + pnpm install + pnpm run build + ``` + +3. Start the server: + + ```bash + pnpm start + ``` + + Or run with Genkit Dev UI: + + ```bash + pnpm run genkit:dev + ``` + + By default the server listens on http://localhost:3780. + +## Usage + +- **GET /** – Info and list of flow names. +- **POST /api/hello** – Flow with string input. Body: `{ "data": "World" }`. +- **POST /api/greeting** – Flow with object input. Body: `{ "data": { "name": "Alice" } }`. +- **POST /api/streaming** – Streaming flow. Body: `{ "data": { "prompt": "Say hi in 3 words" } }`. Use `Accept: text/event-stream` or `?stream=true` to stream. +- **POST /api/secureGreeting** – Same as greeting but requires auth. Header: `Authorization: Bearer open-sesame`. Body: `{ "data": { "name": "Alice" } }`. Uses `withFlowOptions(flow, { contextProvider })` to attach a context provider. + +Example: + +```bash +curl -X POST http://localhost:3780/api/hello \ + -H "Content-Type: application/json" \ + -d '{"data": "Hono"}' + +# With auth (secureGreeting): +curl -X POST http://localhost:3780/api/secureGreeting \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer open-sesame" \ + -d '{"data": {"name": "Alice"}}' +``` + +## How it works + +- **Hono** handles routing; **@genkit-ai/fetch**’s `handleFlows(request, flows, pathPrefix)` turns a Web `Request` into a Genkit flow call and returns a Web `Response`. +- Flow URLs are `POST /api/` with body `{ "data": }`, matching the [Genkit callable protocol](https://firebase.google.com/docs/genkit/reference/js/client). + +**Developing in the genkit repo:** In `package.json` set `"@genkit-ai/fetch": "file:../../js/plugins/web"`, then build the web plugin (`cd ../../js/plugins/web && pnpm run build`) and run `pnpm install` from this directory. diff --git a/samples/js-hono/package.json b/samples/js-hono/package.json new file mode 100644 index 0000000000..fa040519ac --- /dev/null +++ b/samples/js-hono/package.json @@ -0,0 +1,29 @@ +{ + "name": "js-hono", + "version": "1.0.0", + "description": "Genkit with Hono and @genkit-ai/fetch", + "main": "lib/index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node lib/index.js", + "genkit:dev": "genkit start -- npx tsx --watch src/index.ts", + "build": "tsc", + "build:watch": "tsc --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@genkit-ai/google-genai": "^1.17.0", + "@genkit-ai/fetch": "file:../../js/plugins/fetch", + "@hono/node-server": "^1.13.0", + "genkit": "^1.17.0", + "hono": "^4.6.0" + }, + "devDependencies": { + "genkit-cli": "^1.17.0", + "tsx": "^4.20.3", + "typescript": "^5.5.4" + } +} diff --git a/samples/js-hono/src/index.ts b/samples/js-hono/src/index.ts new file mode 100644 index 0000000000..515bd86eae --- /dev/null +++ b/samples/js-hono/src/index.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2025 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 { handleFlows, withFlowOptions } from '@genkit-ai/fetch'; +import { googleAI } from '@genkit-ai/google-genai'; +import { serve } from '@hono/node-server'; +import { UserFacingError, genkit, z } from 'genkit'; +import type { ContextProvider } from 'genkit/context'; +import { Hono } from 'hono'; + +const ai = genkit({ + plugins: [googleAI()], +}); + +// Example: context provider for auth (require header: Authorization: Bearer open-sesame) +const authContextProvider: ContextProvider<{ userId: string }> = (req) => { + const auth = req.headers['authorization']; + if (auth !== 'Bearer open-sesame') { + throw new UserFacingError( + 'PERMISSION_DENIED', + 'Invalid or missing Authorization header. Use: Bearer open-sesame' + ); + } + return { userId: 'authenticated-user' }; +}; + +// Flows served over HTTP via @genkit-ai/fetch +const helloFlow = ai.defineFlow('hello', async (input: string) => { + const { text } = await ai.generate({ + model: googleAI.model('gemini-2.0-flash'), + prompt: `Say hello to: ${input}`, + }); + return text; +}); + +const greetingFlow = ai.defineFlow( + { + name: 'greeting', + inputSchema: z.object({ name: z.string() }), + }, + async (input) => { + const { text } = await ai.generate({ + model: googleAI.model('gemini-2.0-flash'), + prompt: `Write a short greeting for someone named ${input.name}.`, + }); + return text; + } +); + +const streamingFlow = ai.defineFlow( + { + name: 'streaming', + inputSchema: z.object({ prompt: z.string() }), + }, + async (input, { sendChunk }) => { + const { stream } = ai.generateStream({ + model: googleAI.model('gemini-2.0-flash'), + prompt: input.prompt, + }); + let full = ''; + for await (const chunk of stream) { + full += chunk.text ?? ''; + sendChunk(chunk.text ?? ''); + } + return full; + } +); + +// Example: flow that uses context (auth), secured with withFlowOptions +const secureGreetingFlow = ai.defineFlow( + { + name: 'secureGreeting', + inputSchema: z.object({ name: z.string() }), + }, + async (input, { context }) => { + const { text } = await ai.generate({ + model: googleAI.model('gemini-2.0-flash'), + prompt: `Say a short greeting to ${input.name} (user ${context?.userId}).`, + }); + return text?.trim() ?? ''; + } +); + +const flows = [ + helloFlow, + greetingFlow, + streamingFlow, + withFlowOptions(secureGreetingFlow, { contextProvider: authContextProvider }), +]; + +const app = new Hono(); + +app.get('/', (c) => + c.json({ + message: 'Genkit + Hono + @genkit-ai/fetch', + flows: ['hello', 'greeting', 'streaming', 'secureGreeting'], + usage: + 'POST /api/ with body { "data": }. secureGreeting requires header: Authorization: Bearer open-sesame', + }) +); + +// Mount Genkit flows under /api - handleFlows routes by path +app.all('/api/*', async (c) => handleFlows(c.req.raw, flows, '/api')); + +const port = Number(process.env.PORT) || 3780; +serve({ fetch: app.fetch, port }, (info) => { + console.log(`Listening on http://localhost:${info.port}`); + console.log( + `Genkit flows: http://localhost:${info.port}/api/hello, /api/greeting, /api/streaming, /api/secureGreeting` + ); +}); diff --git a/samples/js-hono/tsconfig.json b/samples/js-hono/tsconfig.json new file mode 100644 index 0000000000..bcd4fdb44c --- /dev/null +++ b/samples/js-hono/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compileOnSave": true, + "include": ["src"], + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitReturns": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "ES2022", + "skipLibCheck": true, + "esModuleInterop": true + } +} diff --git a/samples/package.json b/samples/package.json index a5e8b2f322..7a879d1cfa 100644 --- a/samples/package.json +++ b/samples/package.json @@ -11,6 +11,7 @@ "build:js-prompts": "cd js-prompts && npm install && npm run build", "build:js-schoolAgent": "cd js-schoolAgent && npm install && npm run build", "build:js-gemini": "cd js-gemini && npm install && npm run build", + "build:js-hono": "cd js-hono && npm install && npm run build", "build:all-samples": "concurrently npm:build:js-*" }, "pre-commit": [