import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config"; import { friendlyErrorMessage, ollamaListModels, ollamaVerifyKey, OllamaError } from "@/lib/ollama"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { requestLocale } from "@/lib/http-guard"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export type ModelHealth = { configured: string; resolved: string; available: boolean | null; note?: string; }; export type ChatModelHealth = { name: string; badge: "text" | "vision" | "both"; available: boolean | null; }; export type HealthResponse = { ok: boolean; reachable: boolean; mode: "local-daemon" | "direct-cloud"; authMode: AuthMode; /** True when the browser has to provide its own key and hasn't. */ needsKey: boolean; /** null = not checked (no key), true/false = Ollama accepted/rejected the key. */ keyAccepted: boolean | null; baseUrl: string; hasApiKey: boolean; vision: ModelHealth; text: ModelHealth; /** The chat model allowlist (CHAT_MODELS) with availability from the list. */ chatModels: ChatModelHealth[]; latencyMs: number | null; modelCount: number | null; error: string | null; kind?: string; checkedAt: string; }; export async function GET(request: Request) { const locale = requestLocale(request); const config = getServerConfig(); const limit = rateLimit(`health:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); const started = Date.now(); const resolvedAuth = resolveAuth(config, request); const badgeFor = (name: string): ChatModelHealth["badge"] => { const canonical = canonicalModelName(name); const isText = canonical === canonicalModelName(config.textModel); const isVision = canonical === canonicalModelName(config.visionModel); if (isText && isVision) return "both"; if (isVision) return "vision"; return "text"; }; const health: HealthResponse = { ok: false, reachable: false, mode: config.directCloud ? "direct-cloud" : "local-daemon", authMode: config.authMode, needsKey: "error" in resolvedAuth, keyAccepted: null, baseUrl: config.baseUrl, hasApiKey: !("error" in resolvedAuth) && Boolean(resolvedAuth.auth.apiKey), vision: { configured: config.visionModel, resolved: resolveModelName(config.visionModel, config.directCloud), available: null, }, text: { configured: config.textModel, resolved: resolveModelName(config.textModel, config.directCloud), available: null, }, chatModels: config.chatModels.map((name) => ({ name, badge: badgeFor(name), available: null })), latencyMs: null, modelCount: null, error: null, checkedAt: new Date().toISOString(), }; if ("error" in resolvedAuth) { health.error = resolvedAuth.error === "missing_key" ? translate(locale, "server.healthNoKey") : authErrorMessage(locale, resolvedAuth.error); health.kind = authErrorKind(resolvedAuth.error); return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } }); } try { const models = await ollamaListModels(resolvedAuth.auth); health.reachable = true; health.latencyMs = Date.now() - started; health.modelCount = models.length; // A key is only "accepted" once Ollama authenticated a real request — // model listings are public, so an actual (tiny, cached) chat proves it. if (resolvedAuth.auth.apiKey) { const probe = await ollamaVerifyKey(resolvedAuth.auth, config.textModel, config.healthTimeoutMs * 3); health.keyAccepted = probe.valid; if (probe.valid === false) { health.error = translate(locale, "server.healthKeyRejected"); health.kind = "auth"; return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } }); } if (probe.reason === "no_credits") { health.error = translate(locale, "server.healthNoCredits", { model: config.textModel }); health.kind = "no_credits"; } if (probe.reason === "usage_limit") { health.error = translate(locale, "server.usageLimit"); health.kind = "usage_limit"; } if (probe.reason === "throttled") { health.error = translate(locale, "server.ollamaThrottled"); health.kind = "throttled"; } } const names = new Set(models.map((model) => canonicalModelName(model.name))); for (const key of ["vision", "text"] as const) { const entry = health[key]; const wanted = canonicalModelName(entry.resolved); const listed = names.has(wanted); entry.available = listed; if (!listed) { entry.note = health.mode === "local-daemon" ? translate(locale, "server.healthModelNoteLocal", { model: entry.resolved }) : translate(locale, "server.healthModelNoteCloud", { url: config.baseUrl }); } } for (const entry of health.chatModels) { entry.available = names.has(canonicalModelName(entry.name)); } // Everything the server lists is selectable in chat (the picker shows // exactly this array), so append the rest of the tags after the // favourites — trying a free Ollama model needs no .env change. const known = new Set(health.chatModels.map((entry) => canonicalModelName(entry.name))); const extras: ChatModelHealth[] = []; for (const model of models) { const canonical = canonicalModelName(model.name); if (known.has(canonical)) continue; if (/embed|bert|whisper|clip|rerank/i.test(model.name)) continue; known.add(canonical); extras.push({ name: model.name, badge: badgeFor(model.name), available: true }); } extras.sort((a, b) => a.name.localeCompare(b.name)); health.chatModels = [...health.chatModels, ...extras.slice(0, 120)]; health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false); if (!health.ok && !health.error) { health.error = translate(locale, "server.healthModelsMissing"); } } catch (error) { health.error = friendlyErrorMessage(error, config.baseUrl, locale); health.kind = error instanceof OllamaError ? error.kind : "unknown"; if (error instanceof OllamaError && (error.kind === "auth" || error.kind === "http")) { health.keyAccepted = false; } health.latencyMs = null; } return Response.json(health satisfies HealthResponse, { status: 200, headers: { "Cache-Control": "no-store" }, }); }