New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class toggles, server model allow-list, BYOK provider allow-list, seats/usage (front-end for the already-real lib/ai/entitlement.ts), retrieval on/off, consent text + version bump. Real backend, not cosmetic: AiConsoleConfig persisted via config-manager (lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT /api/admin/ai/policy. Enforcement wired at every real chokepoint, not just the picker: /api/ai/server/chat checks classesEnabled.server and the model allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models filters by allow-list. GET /api/ai/policy folds classesEnabled into the classes list clients see. Resolved the spec's 3 open questions as recommended: BYOK allow-list stays client-side/advisory (wired into ai-assistant-settings.tsx's addProfile), tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the existing Policy tab (this tab links to it instead of duplicating it). Defaults preserve today's behavior exactly (classesEnabled/allowlists all start empty/null) — turning this on changes nothing until an admin touches it.
60 lines
2.6 KiB
TypeScript
60 lines
2.6 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|