import { NextRequest, NextResponse } from 'next/server'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { configManager } from '@/lib/admin/config-manager'; export const runtime = 'nodejs'; /** * GET /api/ai/server/models — list models on the centrally-hosted `server` * class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted * open-weight model stack as `local`... running on VNC's own infrastructure * instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for * that infra with the Ollama already running on this developer's Mac — see * the module comment in lib/ai/entitlement.ts. Swapping to the real * EU/CH-hosted instance tomorrow is a config change, not a rewrite. * * Listing models is not a billable action (doc §10 point 1 — cosmetic), so * this only requires a valid session, not a seat. */ export async function GET(request: NextRequest) { const auth = await getStalwartCredentials(request); if (!auth) { return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); } const baseUrl = process.env.AI_SERVER_BASE_URL; if (!baseUrl) { return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 }); } try { const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`); if (!res.ok) { return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 }); } const body = (await res.json()) as { models?: Array<{ name: string; capabilities?: string[] }> }; // Excludes embedding-only models (e.g. nomic-embed-text, used by // lib/ai/retrieval/mail-embeddings.ts) from the *chat* picker — Ollama // lists them in the same /api/tags response, but calling /api/chat with // one fails outright. `capabilities` absent (older Ollama) fails open // rather than hiding every model on an upgrade. let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion')); // Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every // completion-capable model (today's behavior, unchanged). await configManager.ensureLoaded(); const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist; if (allowlist) { const allowed = new Set(allowlist); chatModels = chatModels.filter((m) => allowed.has(m.name)); } return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) }); } catch (cause) { return NextResponse.json( { error: cause instanceof Error ? cause.message : 'AI server unreachable' }, { status: 502 }, ); } }