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 }); } }