Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/jev

Call TypeSafe Jev evaluations via Cloudflare AI Gateway (same token/gateway as @kentcdodds/ai).

src/request.ts

152 lines · 3.9 KB · TypeScript
/**
 * Escape-hatch raw Cloudflare `/ai/run` call for `typesafe/jev`.
 * Prefer `./evaluate` for parsed answers; use this when you need the full envelope.
 *
 * @param input.state - Shared state (string or object) to evaluate.
 * @param input.questions - Named noul / choice / score questions.
 * @param input.accountId - Optional Cloudflare account id override.
 * @param input.gatewayId - Optional AI Gateway id (sent as `cf-aig-gateway-id`).
 * @param input.model - Optional model id (default `typesafe/jev`).
 * @returns `{ ok, status, data }` — never logs secrets.
 *
 * @example
 * import jevRequest from 'kody:@kentcdodds/jev/request'
 *
 * const raw = await jevRequest({
 *   state: 'hello',
 *   questions: {
 *     greeting: { type: 'noul', instructions: 'Is this a greeting?' },
 *   },
 * })
 */

import {
	cloudflareApiTokenPlaceholder,
	missingSetup,
	resolveJevConfig,
} from './resolve.ts'
import type { JevRequestInput, JevRequestResult } from './types.ts'

function stringify(value: unknown) {
	try {
		return JSON.stringify(value)
	} catch {
		return String(value)
	}
}

/**
 * Escape-hatch raw Cloudflare `/ai/run` call for `typesafe/jev`.
 * Prefer `./evaluate` for parsed answers; use this when you need the full envelope.
 *
 * @param input.state - Shared state (string or object) to evaluate.
 * @param input.questions - Named noul / choice / score questions.
 * @param input.accountId - Optional Cloudflare account id override.
 * @param input.gatewayId - Optional AI Gateway id (sent as `cf-aig-gateway-id`).
 * @param input.model - Optional model id (default `typesafe/jev`).
 * @returns `{ ok, status, data }` — never logs secrets.
 *
 * @example
 * import jevRequest from 'kody:@kentcdodds/jev/request'
 *
 * const raw = await jevRequest({
 *   state: 'hello',
 *   questions: {
 *     greeting: { type: 'noul', instructions: 'Is this a greeting?' },
 *   },
 * })
 */
export default async function jevRequest(
	input: JevRequestInput,
): Promise<JevRequestResult> {
	if (!input || typeof input !== 'object') {
		return { ok: false, status: 0, data: null, error: 'input is required' }
	}
	if (input.state === undefined || input.state === null) {
		return { ok: false, status: 0, data: null, error: 'state is required' }
	}
	if (!input.questions || typeof input.questions !== 'object') {
		return {
			ok: false,
			status: 0,
			data: null,
			error: 'questions must be a non-empty object',
		}
	}

	const config = await resolveJevConfig({
		accountId: input.accountId,
		gatewayId: input.gatewayId,
		model: input.model,
	})

	if (!config.accountId) {
		return {
			ok: false,
			status: 0,
			data: null,
			error: 'cloudflareAccountId is not set',
			setup: missingSetup(''),
		}
	}

	const url =
		'https://api.cloudflare.com/client/v4/accounts/' +
		config.accountId +
		'/ai/run'

	const headers: Record<string, string> = {
		Authorization: 'Bearer ' + cloudflareApiTokenPlaceholder,
		'Content-Type': 'application/json',
		'cf-aig-gateway-id': config.gatewayId,
		...(input.headers || {}),
	}

	const body = {
		model: config.model,
		input: {
			state: input.state,
			questions: input.questions,
		},
	}

	try {
		const response = await fetch(url, {
			method: 'POST',
			headers,
			body: JSON.stringify(body),
		})
		const text = await response.text()
		let data: unknown = text
		try {
			data = text ? JSON.parse(text) : null
		} catch {
			data = text
		}
		if (!response.ok) {
			return {
				ok: false,
				status: response.status,
				data,
				error:
					'Cloudflare /ai/run failed: HTTP ' +
					response.status +
					' ' +
					(typeof data === 'string' ? data.slice(0, 500) : stringify(data).slice(0, 500)),
				setup:
					response.status === 401 || response.status === 403
						? missingSetup(config.accountId)
						: undefined,
			}
		}
		return { ok: true, status: response.status, data }
	} catch (error) {
		return {
			ok: false,
			status: 0,
			data: null,
			error: error instanceof Error ? error.message : String(error),
			setup: missingSetup(config.accountId),
		}
	}
}