/** * Server-side configuration, read from the environment on every request so * that editing `.env.local` only requires a dev-server restart. * * Never import this into a client component: it would leak the API key. */ import { sanitiseApiKey } from "./http-guard"; import { translate, type Locale } from "./i18n-dict"; import path from "node:path"; export type AuthMode = "user-key" | "server" | "local"; export type ServerConfig = { baseUrl: string; apiKey: string; authMode: AuthMode; visionModel: string; textModel: string; /** True when talking straight to https://ollama.com (needs an API key). */ directCloud: boolean; timeoutMs: number; healthTimeoutMs: number; /** `false` sends `think: false`; `null` omits the field entirely. */ think: boolean | null; defaultActivityCount: number; rateLimits: { windowMs: number; generate: number; grade: number; homework: number; health: number; sync: number; }; sync: { /** Where opaque encrypted sync blobs live (SYNC_DIR). */ dir: string; /** Per-blob cap in bytes (SYNC_MAX_BYTES). */ maxBytes: number; /** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */ totalBytes: number; }; }; /** What a single request should use to talk to Ollama. */ export type OllamaAuth = { baseUrl: string; apiKey: string; directCloud: boolean; authMode: AuthMode; }; function positiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(value ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function trimTrailingSlash(url: string): string { return url.replace(/\/+$/, ""); } export function getServerConfig(): ServerConfig { const baseUrl = trimTrailingSlash( process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434", ); const apiKey = process.env.OLLAMA_API_KEY?.trim() || ""; const rawAuthMode = process.env.OLLAMA_AUTH?.trim().toLowerCase(); const authMode: AuthMode = rawAuthMode === "local" || rawAuthMode === "server" || rawAuthMode === "user-key" ? rawAuthMode : rawAuthMode === "none" ? "local" : "user-key"; let directCloud = false; try { const host = new URL(baseUrl).hostname.toLowerCase(); directCloud = host === "ollama.com" || host.endsWith(".ollama.com"); } catch { directCloud = false; } const thinkRaw = process.env.OLLAMA_THINK?.trim().toLowerCase(); const think = thinkRaw === "false" ? false : thinkRaw === "true" ? true : null; const count = Number.parseInt( process.env.FREEZMO_DEFAULT_ACTIVITY_COUNT ?? "", 10, ); return { baseUrl, apiKey, authMode, visionModel: process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud", textModel: process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud", directCloud, timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000, healthTimeoutMs: Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000, think, defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10, rateLimits: { windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000), generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12), grade: positiveInt(process.env.RATE_LIMIT_GRADE, 40), // Homework is one to two model calls, like a deck. homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12), health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90), // Sync is cheap (no model calls) but shouldn't be hammered. sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60), }, sync: { dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"), maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024), totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 1024 * 1024), }, }; } /** * Human-readable explanation for every auth configuration problem, so the UI * never has to show a bare "unauthorised". */ const AUTH_ERROR_KEYS: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = { missing_key: "server.authMissingKey", server_key_missing: "server.authServerKeyMissing", local_with_cloud_url: "server.authLocalCloud", }; export function authErrorMessage(locale: Locale, error: keyof typeof AUTH_ERROR_KEYS): string { return translate(locale, AUTH_ERROR_KEYS[error]); } export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string { if (error === "missing_key") return "needs_key"; return "config"; } /** Header a browser uses to hand over its own key for a single request. */ export const API_KEY_HEADER = "x-ollama-key"; /** * Works out which credentials a request may use. * * - `user-key` (default): the key must come from the browser. Nothing is * stored server-side, ever — it is used for this request and dropped. * - `server`: the key comes from `.env.local` (single-user installs). * - `local`: neither; a local daemon signed in with `ollama signin`. */ export function resolveAuth( config: ServerConfig, request: Request, ): { auth: OllamaAuth } | { error: "missing_key" | "server_key_missing" | "local_with_cloud_url" } { const headerKey = sanitiseApiKey(request.headers.get(API_KEY_HEADER)); if (config.authMode === "local") { // A daemon without sign-in can't talk to the cloud API: catch the // misconfiguration here instead of returning a confusing 401 later. if (config.directCloud) return { error: "local_with_cloud_url" }; return { auth: { baseUrl: config.baseUrl, apiKey: "", directCloud: config.directCloud, authMode: "local" }, }; } if (config.authMode === "server") { if (!config.apiKey) return { error: "server_key_missing" }; return { auth: { baseUrl: config.baseUrl, apiKey: config.apiKey, directCloud: config.directCloud, authMode: "server" }, }; } if (!headerKey) return { error: "missing_key" }; return { auth: { baseUrl: config.baseUrl, apiKey: headerKey, directCloud: config.directCloud, authMode: "user-key" }, }; } /** * Cloud models are addressed as `:cloud` (or `-cloud`) by a * local daemon, but the direct cloud API uses the plain library name * (`gemma4:31b-cloud` -> `gemma4:31b`, `glm-5.3-flash:cloud` -> `glm-5.3-flash`). * Normalise so the same .env works in both modes. */ export function resolveModelName(model: string, directCloud: boolean): string { if (!directCloud) return model; if (model.endsWith(":cloud")) return model.slice(0, -":cloud".length); if (model.endsWith("-cloud")) return model.slice(0, -"-cloud".length); return model; } /** Strip local/daemon routing suffixes so model names can be compared. */ export function canonicalModelName(model: string): string { return model .replace(/:cloud$/, "") .replace(/-cloud$/, "") .toLowerCase(); }