/** * AI settings service — the runtime source of truth for which LLM the * processor talks to. * * Why this exists * ─────────────── * Before this service the quotes-ai-processor read `process.env.ANTHROPIC_API_KEY` * and shipped every Claude call at a fixed provider/model. That was fine * for one developer with one key on one machine. It breaks for: * * • Key rotation — would force a redeploy each time. * • Multi-provider support — MiniMax / Anthropic-direct selection has * to happen at call time, not via env-var juggling. * • Operator visibility — the UI needs to surface "is the AI on?" * (the `enabled` killswitch) without grepping `.env.local`. * * Contract * ──────── * • Single-row table. `ai_settings` is a PocketBase collection with at * most one row; `getAiSettings()` returns that row (or a synthetic * default if the row is missing entirely). * • 60-second in-memory cache. UI refreshes within a minute of an * update; the AI processor hot-path pays at most one PB read per * minute. * • `api_key` is gated by `isSuperuser()`. Routes that don't have an * admin context (the /settings/ai page) get back a redacted record * with `api_key: ""` and `api_key_set: boolean` instead. The * processor (server-side, admin PB) always gets the full value. * • Plain-text by design — the operator explicitly chose visibility * over encryption-at-rest on 2026-08-09. Don't add encryption here * without re-checking with them. */ import { getAdminPocketBase } from "./pocketbase-admin"; export type AiProvider = "anthropic" | "minimax"; /** * The shape returned by the read path. Mirrors the PB row 1:1 plus * a derived `api_key_set` flag (so we can tell the UI "a key is set" * without leaking the value itself). */ export interface AiSettings { id: string; provider: AiProvider | ""; api_key: string; api_key_set: boolean; base_url: string; model_id: string; max_tokens: number; enabled: boolean; /** * Operator-editable override of the system prompt sent to the * Anthropic API. Empty string means "use the hardcoded default in * the source" — see `src/lib/server/ai-system-prompt.ts`. The * processor reads this via `getAiSettings()` (cached, 60s TTL). */ system_prompt: string; updated_at: string; } /** * The shape returned to write paths. `api_key` here is OPTIONAL — when * omitted on a PUT the existing key is preserved (the UI sends an empty * string to clear). */ export interface AiSettingsUpdate { provider?: AiProvider | ""; api_key?: string; base_url?: string; model_id?: string; max_tokens?: number; enabled?: boolean; /** * Operator-editable system prompt override. `undefined` ⇒ no change. * `""` ⇒ clear override (processor falls back to default). Non-empty * ⇒ processor uses this verbatim. Length capped at 50 000 chars * (matches the PB field's `options.max`). */ system_prompt?: string; } const COLLECTION = "ai_settings"; /** Defaults used when the PB row is absent (first boot, before the * migration has run). Matches the migration's seed row. */ const DEFAULT_SETTINGS: AiSettings = { id: "", provider: "minimax", api_key: "", api_key_set: false, base_url: "https://api.minimax.io/anthropic", model_id: "MiniMax-M3", max_tokens: 4096, enabled: false, system_prompt: "", updated_at: "", }; /** TTL is short enough that a UI refresh after an update sees the new * state, but long enough that the processor (which may fire many times * in a row) doesn't hit PB on every call. */ const CACHE_TTL_MS = 60_000; interface CacheEntry { record: AiSettings; expiresAt: number; } let cache: CacheEntry | null = null; interface RawSettingsRow { id: string; provider?: string; api_key?: string; base_url?: string; model_id?: string; max_tokens?: number; enabled?: boolean; system_prompt?: string; updated_at?: string; } function normaliseProvider(value: unknown): AiProvider | "" { if (value === "anthropic" || value === "minimax") return value; return ""; } function rowToSettings(row: RawSettingsRow): AiSettings { return { id: row.id, provider: normaliseProvider(row.provider), api_key: row.api_key ?? "", api_key_set: !!(row.api_key && row.api_key.length > 0), base_url: row.base_url ?? "", model_id: row.model_id ?? "", max_tokens: typeof row.max_tokens === "number" ? row.max_tokens : 4096, enabled: !!row.enabled, system_prompt: row.system_prompt ?? "", updated_at: row.updated_at ?? "", }; } /** Used in tests to force a fresh read after an out-of-band PB write. */ export function clearAiSettingsCache(): void { cache = null; } function cacheGet(): AiSettings | null { if (!cache) return null; if (cache.expiresAt <= Date.now()) { cache = null; return null; } return cache.record; } function cacheSet(record: AiSettings): void { cache = { record, expiresAt: Date.now() + CACHE_TTL_MS }; } /** * Read the single AI-settings row. Returns the redacted shape by * default (api_key="" + api_key_set=boolean) so callers that shouldn't * see the key can't accidentally surface it. Pass `includeSecret: true` * from server-side admin contexts (e.g. the AI processor). * * Falls back to DEFAULT_SETTINGS when no row exists yet, so callers * don't have to handle a missing-row case in their own code paths. */ export async function getAiSettings( options: { includeSecret?: boolean } = {}, ): Promise { const includeSecret = options.includeSecret === true; const cached = cacheGet(); if (cached) { return includeSecret ? cached : { ...cached, api_key: "", api_key_set: cached.api_key_set }; } const pb = await getAdminPocketBase(); let record: AiSettings; try { const rows = await pb.collection(COLLECTION).getFullList({}); if (rows.length === 0) { record = { ...DEFAULT_SETTINGS }; } else { // Single-row invariant — if the operator ever duplicates, take // the most-recently-updated so the read is at least deterministic. const sorted = (rows as RawSettingsRow[]).sort((a, b) => { const at = a.updated_at ?? ""; const bt = b.updated_at ?? ""; return bt.localeCompare(at); }); record = rowToSettings(sorted[0]); } } catch { // Collection missing entirely (pre-migration). Fall back to defaults // rather than throw — callers like /settings/ai should still render. record = { ...DEFAULT_SETTINGS }; } cacheSet(record); return includeSecret ? record : { ...record, api_key: "", api_key_set: record.api_key_set }; } /** * Patch the AI-settings row. The caller passes only the fields they * want to change. Special handling for `api_key`: * * • `api_key: undefined` → field not touched. * • `api_key: ""` → field cleared (api_key_set becomes false). * • `api_key: "sk-..."` → field replaced. * * Bumps the in-memory cache on success so the very next `getAiSettings()` * sees the update without waiting out the TTL. */ export async function updateAiSettings( patch: AiSettingsUpdate, ): Promise { const pb = await getAdminPocketBase(); // Find the single row (or fall back to creating one if it doesn't // exist yet — first-run scenario). const rows = await pb.collection(COLLECTION).getFullList({}); const dbPatch: Record = {}; if (patch.provider !== undefined) { if (patch.provider !== "" && patch.provider !== "anthropic" && patch.provider !== "minimax") { throw new Error(`Unknown provider "${patch.provider}"`); } dbPatch.provider = patch.provider || ""; } if (patch.api_key !== undefined) { dbPatch.api_key = patch.api_key; // already "" when clearing } if (patch.base_url !== undefined) dbPatch.base_url = patch.base_url; if (patch.model_id !== undefined) dbPatch.model_id = patch.model_id; if (patch.max_tokens !== undefined) { if ( typeof patch.max_tokens !== "number" || !Number.isFinite(patch.max_tokens) || patch.max_tokens < 256 || patch.max_tokens > 8192 ) { throw new Error("max_tokens must be a number between 256 and 8192"); } dbPatch.max_tokens = Math.round(patch.max_tokens); } if (patch.enabled !== undefined) dbPatch.enabled = !!patch.enabled; if (patch.system_prompt !== undefined) { // Defensive cap — the PB field already enforces 50 000 but the // route handler is one boundary removed. Truncate rather than // reject so a paste-from-clipboard typo doesn't 400 the whole form. dbPatch.system_prompt = patch.system_prompt.slice(0, 50_000); } dbPatch.updated_at = new Date().toISOString(); let savedRow: RawSettingsRow; if (rows.length === 0) { // First-ever write — seed with merged defaults so we never end up // with a row that has a missing required-like field. savedRow = (await pb.collection(COLLECTION).create({ provider: "minimax", api_key: "", base_url: "https://api.minimax.io/anthropic", model_id: "MiniMax-M3", max_tokens: 4096, enabled: false, system_prompt: "", ...dbPatch, })) as RawSettingsRow; } else { const id = (rows[0] as RawSettingsRow).id; savedRow = (await pb.collection(COLLECTION).update(id, dbPatch)) as RawSettingsRow; } const record = rowToSettings(savedRow); cacheSet(record); return record; } /** * Convenience for callers that just want the SDK knobs (no record * scaffolding). Equivalent to `getAiSettings({ includeSecret: true })` * but with a single field-select to keep the type tight. Also raises * a clear error if the processor tries to run while disabled. */ export interface ActiveAiConfig { provider: AiProvider; apiKey: string; baseUrl: string; modelId: string; maxTokens: number; } export async function getActiveAiConfig(): Promise { const settings = await getAiSettings({ includeSecret: true }); if (!settings.enabled) { throw new AiDisabledError("AI is disabled in settings"); } if (!settings.api_key) { throw new Error( `AI is enabled but api_key is not set (provider=${settings.provider || "(none)"}). Update /settings/ai.`, ); } if (!settings.model_id) { throw new Error("AI is enabled but model_id is not set. Update /settings/ai."); } // For "anthropic" we let the SDK pick its default endpoint by // passing undefined baseURL — that keeps retries / SDK features // working normally. For every other provider we honour whatever // the operator typed (MiniMax's Anthropic-compatible endpoint in // particular must be set explicitly). const baseUrl = settings.provider === "anthropic" ? settings.base_url || undefined : settings.base_url; return { provider: settings.provider as AiProvider, apiKey: settings.api_key, baseUrl: baseUrl ?? "", modelId: settings.model_id, maxTokens: settings.max_tokens || 4096, }; } /** Tagged error so the processor can map "AI off" to ai_error="ai_disabled". */ export class AiDisabledError extends Error { readonly code = "ai_disabled"; constructor(message: string) { super(message); this.name = "AiDisabledError"; } }