import { env } from "cloudflare:workers"; import { decryptSecret, encryptSecret } from "../crypto"; import { first, run } from "../db"; import { badRequest } from "../errors"; import { assertOutboundUrl } from "../integrations/outbound"; /** * Bring-your-own AI. An endpoint is saved per account or per repository; the repository's wins. Every AI * caller resolves its endpoint through resolveAiEndpoint, so precedence and validation live in one place. * Keys are sealed with SECRETS_KEY and never returned once saved (summaries carry no key material). */ export const AI_ENDPOINT_KINDS = ["provider", "openai", "cf-gateway"] as const; export type AiEndpointKind = (typeof AI_ENDPOINT_KINDS)[number]; export type AiScope = "account" | "repo"; export const BYOK_PROVIDERS = [ "openai", "anthropic", "google-ai-studio", "groq", "mistral", "deepseek", "cerebras", "grok", "openrouter", "workers-ai", ] as const; export type AiEndpointInput = { kind: AiEndpointKind; /** Provider slug for 'provider' and 'cf-gateway' (routed through Gateway's OpenAI-compatible endpoint). */ provider?: string | null; model: string; /** OpenAI-compatible base URL for 'openai', e.g. https://api.example.com/v1. */ baseUrl?: string | null; cfAccountId?: string | null; cfGatewayId?: string | null; apiKey: string; }; export type AiEndpoint = Required> & { apiKey: string; scope: AiScope; }; export type AiEndpointSummary = Omit & { updatedAt: number }; const MODEL = /^[\w.:@/-]{1,120}$/; const CF_ACCOUNT = /^[a-f0-9]{32}$/; const CF_GATEWAY = /^[a-z0-9][a-z0-9-]{0,63}$/; const KEY_MIN = 8; const KEY_MAX = 512; /** Normalizes and validates an endpoint; custom base URLs must be public https (loopback only in dev). */ export function validateEndpoint(input: AiEndpointInput): AiEndpointInput { const model = input.model.trim(); const apiKey = input.apiKey.trim(); if (!MODEL.test(model)) throw badRequest("Invalid model name"); if (apiKey.length < KEY_MIN || apiKey.length > KEY_MAX) throw badRequest("Invalid API key"); const provider = input.provider?.trim() || null; if (input.kind !== "openai" && !BYOK_PROVIDERS.includes(provider as (typeof BYOK_PROVIDERS)[number])) { throw badRequest("Unknown provider"); } if (input.kind === "openai") { const url = assertOutboundUrl((input.baseUrl ?? "").trim()); if (url.search || url.hash) throw badRequest("The base URL may not have a query or fragment"); return { kind: "openai", provider: null, model, apiKey, baseUrl: url.toString().replace(/\/+$/, "") }; } if (input.kind === "cf-gateway") { const cfAccountId = (input.cfAccountId ?? "").trim().toLowerCase(); const cfGatewayId = (input.cfGatewayId ?? "").trim().toLowerCase(); if (!CF_ACCOUNT.test(cfAccountId)) throw badRequest("Cloudflare account id must be 32 hex characters"); if (!CF_GATEWAY.test(cfGatewayId)) throw badRequest("Invalid AI Gateway id"); return { kind: "cf-gateway", provider, model, apiKey, cfAccountId, cfGatewayId }; } if (input.kind !== "provider") throw badRequest("Unknown endpoint kind"); return { kind: "provider", provider, model, apiKey }; } type EndpointRow = { scope: AiScope; kind: AiEndpointKind; provider: string | null; model: string; base_url: string | null; cf_account_id: string | null; cf_gateway_id: string | null; key_ciphertext: string; key_iv: string; scoped_seal: number; updated_at: number; }; function secretsKey(): string { if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY is not configured on this instance"); return env.SECRETS_KEY; } function sealContext(scope: AiScope, ownerId: string) { return { purpose: "ai-endpoint", aad: `${scope}:${ownerId}` }; } function summarize(row: EndpointRow): AiEndpointSummary { return { scope: row.scope, kind: row.kind, provider: row.provider, model: row.model, baseUrl: row.base_url, cfAccountId: row.cf_account_id, cfGatewayId: row.cf_gateway_id, updatedAt: row.updated_at, }; } async function loadRow(scope: AiScope, ownerId: string): Promise { return first("SELECT * FROM ai_endpoints WHERE scope = ? AND owner_id = ?", scope, ownerId); } /** Rows copied from account_ai_keys used the generic seal; the first read re-seals them to their scope and owner. */ async function upgradeLegacySeal(row: EndpointRow, ownerId: string): Promise { const apiKey = await decryptSecret(secretsKey(), row.key_ciphertext, row.key_iv); const sealed = await encryptSecret(secretsKey(), apiKey, sealContext(row.scope, ownerId)); await run( `UPDATE ai_endpoints SET key_ciphertext = ?, key_iv = ?, scoped_seal = 1 WHERE scope = ? AND owner_id = ? AND key_ciphertext = ? AND scoped_seal = 0`, sealed.ciphertext, sealed.iv, row.scope, ownerId, row.key_ciphertext, ); return apiKey; } async function openRow(row: EndpointRow, ownerId: string): Promise { const apiKey = row.scoped_seal === 1 ? await decryptSecret(secretsKey(), row.key_ciphertext, row.key_iv, sealContext(row.scope, ownerId)) : await upgradeLegacySeal(row, ownerId); const { updatedAt: _updatedAt, ...summary } = summarize(row); return { ...summary, apiKey }; } export async function endpointSummary(scope: AiScope, ownerId: string): Promise { const row = await loadRow(scope, ownerId); return row ? summarize(row) : null; } export async function saveEndpoint( scope: AiScope, ownerId: string, input: AiEndpointInput, actorId: string, ): Promise { const valid = validateEndpoint(input); const sealed = await encryptSecret(secretsKey(), valid.apiKey, sealContext(scope, ownerId)); const now = Date.now(); await run( `INSERT INTO ai_endpoints (scope, owner_id, kind, provider, model, base_url, cf_account_id, cf_gateway_id, key_ciphertext, key_iv, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (scope, owner_id) DO UPDATE SET kind = excluded.kind, provider = excluded.provider, model = excluded.model, base_url = excluded.base_url, cf_account_id = excluded.cf_account_id, cf_gateway_id = excluded.cf_gateway_id, key_ciphertext = excluded.key_ciphertext, key_iv = excluded.key_iv, scoped_seal = 1, updated_by = excluded.updated_by, updated_at = excluded.updated_at`, scope, ownerId, valid.kind, valid.provider ?? null, valid.model, valid.baseUrl ?? null, valid.cfAccountId ?? null, valid.cfGatewayId ?? null, sealed.ciphertext, sealed.iv, actorId, now, ); return { scope, kind: valid.kind, provider: valid.provider ?? null, model: valid.model, baseUrl: valid.baseUrl ?? null, cfAccountId: valid.cfAccountId ?? null, cfGatewayId: valid.cfGatewayId ?? null, updatedAt: now, }; } export async function deleteEndpoint(scope: AiScope, ownerId: string): Promise { await run("DELETE FROM ai_endpoints WHERE scope = ? AND owner_id = ?", scope, ownerId); } /** * A repository's endpoint, but only when the billed account owns that repository: callers that bill the viewer * (pull descriptions) must never spend a repository owner's key. */ async function ownedRepoRow(repoId: string, accountId: string): Promise { return first( `SELECT e.* FROM ai_endpoints e JOIN repos r ON r.id = e.owner_id WHERE e.scope = 'repo' AND e.owner_id = ? AND r.owner_id = ?`, repoId, accountId, ); } /** * The endpoint an AI call billed to `accountId` must use (the repository's, else the account's), or null for * the platform model. The single resolver for every AI caller. */ export async function resolveAiEndpoint(context: { accountId: string; repoId?: string | null; }): Promise { if (context.repoId) { const repo = await ownedRepoRow(context.repoId, context.accountId); if (repo) return openRow(repo, context.repoId); } const account = await loadRow("account", context.accountId); return account ? openRow(account, context.accountId) : null; } /** Same precedence as resolveAiEndpoint, without decrypting: for access checks and settings pages. */ export async function resolveAiEndpointSummary(context: { accountId: string; repoId?: string | null; }): Promise { if (context.repoId) { const repo = await ownedRepoRow(context.repoId, context.accountId); if (repo) return summarize(repo); } return endpointSummary("account", context.accountId); }