Files
SRCmail/app/api/ai/server/chat/route.ts
T
Bernd Rodler 30e5059b94 feat(admin): build the AI Policy console (§6) — approved, spec now implemented
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.
2026-08-06 08:48:30 +02:00

112 lines
4.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_BODY_BYTES = 200 * 1024;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface OllamaChatResponse {
message?: { content?: string };
prompt_eval_count?: number;
eval_count?: number;
}
/**
* POST /api/ai/server/chat — the one real enforcement chokepoint for the
* `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates
* ... entitlement against live state; rejects on mismatch ... never trusts
* the client"). Every call re-checks the seat; nothing here is cosmetic.
*
* Retrieval already happened client-side (the same /api/offline/search leg
* `local`/`public` use) — this route receives the already-built prompt
* messages and only proxies the model call + records the metering entry
* that IS the billing record (lib/ai/entitlement.ts).
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
// §6): the admin console can disable the whole `server` class even when
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
// while turning it off for users).
await configManager.ensureLoaded();
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
}
const seat = await checkAndAssignSeat(auth.username);
if (!seat.allowed) {
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'request too large' }, { status: 413 });
}
let body: { model?: unknown; messages?: unknown };
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const model = typeof body.model === 'string' ? body.model : '';
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
if (!model || !messages || messages.length === 0) {
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
}
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
if (allowlist && !allowlist.includes(model)) {
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
}
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) {
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
}
const startedAt = Date.now();
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!res.ok) {
return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 });
}
const data = (await res.json()) as OllamaChatResponse;
const content = data.message?.content;
if (!content) {
return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 });
}
await recordUsage({
timestamp: new Date().toISOString(),
username: auth.username,
model,
promptTokens: data.prompt_eval_count ?? 0,
completionTokens: data.eval_count ?? 0,
latencyMs: Date.now() - startedAt,
});
return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true });
} catch (cause) {
logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 });
}
}