Three pieces built together tonight since they're naturally linked (the
server-class proxy is the real entitlement enforcement chokepoint):
1. Multi-key BYOK (public class): several named provider profiles
(name/baseUrl/model), each with its own key in lib/ai/key-store.ts
(keyed by profile id, not a single fixed 'public' slot). The "Try it"
pane lets you pick which saved profile answers each question - not one
fixed default.
2. `server` class, real: app/api/ai/server/{models,chat} proxy through
this app's own backend to AI_SERVER_BASE_URL - same-origin from the
browser, no CORS/OLLAMA_ORIGINS story at all, standing in tonight for
VNC's EU/CH-hosted infra with the real Ollama on this Mac (swapping to
the real instance tomorrow is a config change).
3. Real entitlement enforcement (lib/ai/entitlement.ts), scoped to `server`
only (not local/public, per the 2026-08-05 decisions): checkAndAssignSeat()
re-validates on every /api/ai/server/chat call - first use auto-assigns a
seat if any remain, further calls from an unlicensed user get a 402 with
a specific reason. recordUsage() appends to an append-only metering
ledger (timestamp/user/model/tokens/latency) that IS the billing record.
Admin data endpoints at /api/admin/ai/entitlement (seat total, revoke) -
the visual admin console is a separate, not-yet-built task.
Two real bugs found and fixed during verification, not just claimed fixed:
- /api/ai/policy never actually added 'server' to entitlement.classes even
when AI_SERVER_BASE_URL was set (only the type comment was updated) - the
Server radio option silently never appeared until this was caught live.
- The new routes used readStalwartAuthContext(0) (hardcoded slot, SSO/reauth-
specific) instead of getStalwartCredentials() (the general multi-slot
session resolver every other authenticated route uses) - reachable but
wrong, and would have hidden a real auth gap behind "works on my slot".
Verified end-to-end for real: built + ran the actual server, logged in via
the real (non-demo) auth flow, selected Server, listed the real Ollama
models through the proxy, asked "Reply with exactly the words: SERVER CLASS
WORKS" and got back exactly that - plus confirmed on disk (not just in the
UI) that data/admin-state/ai-entitlement.json recorded the seat assignment
and ai-metering.jsonl recorded real prompt/completion token counts and
latency from the actual model call. Rejection-path logic (seat limit
reached, zero seats configured, revocation) covered by 5 new unit tests
rather than a second live round trip. Full suite: typecheck clean, lint
clean, translations 48/48, production build succeeds.
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
|
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 });
|
|
}
|
|
|
|
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 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 });
|
|
}
|
|
}
|