feat(ai): multi-key BYOK, real server class, real entitlement enforcement
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.
This commit is contained in:
+87
-18
@@ -1,11 +1,18 @@
|
||||
// The AI assistant's wire client — mirrors vncmail-native's src/api/ai.ts
|
||||
// (same prototype scope: local Ollama + BYOK public, no VNC-hosted `server`
|
||||
// class, no streaming) so the two clients stay in lockstep. Runs entirely
|
||||
// client-side (`'use client'` callers only) — a direct loopback/provider
|
||||
// fetch, matching docs/AI-ASSISTANT-CONCEPT.md §2's "local"/"public" rows,
|
||||
// not proxied through this app's own Next.js server. That distinction
|
||||
// matters once this app is hosted remotely: a server-side proxy would reach
|
||||
// the *server's* loopback, not the user's own laptop running Ollama.
|
||||
// The AI assistant's wire client. `local`/`public` mirror vncmail-native's
|
||||
// src/api/ai.ts (direct loopback/provider fetch, no streaming) so the two
|
||||
// clients stay in lockstep — matching docs/AI-ASSISTANT-CONCEPT.md §2's
|
||||
// "local"/"public" rows, not proxied through this app's own Next.js server.
|
||||
// That distinction matters once this app is hosted remotely: a server-side
|
||||
// proxy would reach the *server's* loopback, not the user's own laptop
|
||||
// running Ollama.
|
||||
//
|
||||
// `server` (added 2026-08-05 night) is the opposite by design: it DOES
|
||||
// proxy through this app's own backend (app/api/ai/server/*), because it's
|
||||
// centrally-hosted infra (VNC's EU/CH stack — standing in tonight for a real
|
||||
// Ollama on this Mac, see lib/ai/entitlement.ts), not a user's own machine.
|
||||
// That server-side hop is also the one real entitlement enforcement point
|
||||
// (§10 point 2) — `local`/`public` never reach it, by design, and so cannot
|
||||
// be metered or billed the same way.
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -74,6 +81,42 @@ export async function chatLocal(
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── Server: centrally-hosted, proxied through this app's own backend
|
||||
// (app/api/ai/server/*). Unlike `local`, this is same-origin from the
|
||||
// browser's perspective — no CORS/OLLAMA_ORIGINS story at all — and unlike
|
||||
// both `local` and `public`, every call is entitlement-checked server-side. ──
|
||||
|
||||
export async function listServerModels(): Promise<string[]> {
|
||||
const res = await fetch('/api/ai/server/models');
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(body?.error ?? `AI server returned ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { models?: string[] };
|
||||
return body.models ?? [];
|
||||
}
|
||||
|
||||
export interface ServerChatResult {
|
||||
answer: string;
|
||||
/** True the moment this call consumed a previously-unassigned licensed seat
|
||||
* (lib/ai/entitlement.ts) — surfaced so the UI can say so once, not left
|
||||
* to happen silently the first time someone uses this class. */
|
||||
seatJustAssigned: boolean;
|
||||
}
|
||||
|
||||
export async function chatServer(model: string, messages: ChatMessage[]): Promise<ServerChatResult> {
|
||||
const res = await fetch('/api/ai/server/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages }),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as { answer?: string; error?: string; seatJustAssigned?: boolean } | null;
|
||||
if (!res.ok || !body?.answer) {
|
||||
throw new Error(body?.error ?? `AI server returned ${res.status}`);
|
||||
}
|
||||
return { answer: body.answer, seatJustAssigned: body.seatJustAssigned === true };
|
||||
}
|
||||
|
||||
// ── Public: OpenAI-compatible chat-completions. OpenRouter by default, but any
|
||||
// endpoint speaking this shape works unmodified (self-hosted vLLM, LiteLLM, etc). ──
|
||||
|
||||
@@ -118,6 +161,10 @@ export interface AskResult {
|
||||
sources: AskSource[];
|
||||
/** True when the question was answered without any retrieved context. */
|
||||
unaugmented: boolean;
|
||||
/** True the moment this call consumed a previously-unassigned licensed
|
||||
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
|
||||
* `local`/`public`, which aren't entitlement-gated. */
|
||||
seatJustAssigned: boolean;
|
||||
}
|
||||
|
||||
interface OfflineSearchHit {
|
||||
@@ -152,21 +199,34 @@ export function buildPrompt(question: string, contextBlock: string): ChatMessage
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* One saved BYOK profile, resolved to an actual key — the caller picks which
|
||||
* profile answers *this* question (docs decision 2026-08-05: several keys,
|
||||
* selected case by case, not one fixed "the" public provider).
|
||||
*/
|
||||
export interface ResolvedPublicProfile {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface AskConfig {
|
||||
provider: 'local' | 'public';
|
||||
provider: 'local' | 'server' | 'public';
|
||||
localBaseUrl: string;
|
||||
localModel: string | null;
|
||||
publicBaseUrl: string;
|
||||
publicModel: string;
|
||||
publicApiKey: string | null;
|
||||
serverModel: string | null;
|
||||
publicProfile: ResolvedPublicProfile | null;
|
||||
}
|
||||
|
||||
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
|
||||
if (config.provider === 'local' && !config.localModel) {
|
||||
throw new Error('No local model selected');
|
||||
}
|
||||
if (config.provider === 'public' && !config.publicApiKey) {
|
||||
throw new Error('No public API key saved');
|
||||
if (config.provider === 'server' && !config.serverModel) {
|
||||
throw new Error('No server model selected');
|
||||
}
|
||||
if (config.provider === 'public' && !config.publicProfile) {
|
||||
throw new Error('No provider profile selected');
|
||||
}
|
||||
|
||||
const retrieved = await retrieveContext(question);
|
||||
@@ -174,14 +234,23 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
? buildPrompt(question, retrieved.contextBlock)
|
||||
: [{ role: 'user' as const, content: question }];
|
||||
|
||||
const answer =
|
||||
config.provider === 'public'
|
||||
? await chatPublic(config.publicBaseUrl, config.publicApiKey as string, config.publicModel, messages)
|
||||
: await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
||||
let answer: string;
|
||||
let seatJustAssigned = false;
|
||||
if (config.provider === 'public') {
|
||||
const profile = config.publicProfile as ResolvedPublicProfile;
|
||||
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
|
||||
} else if (config.provider === 'server') {
|
||||
const result = await chatServer(config.serverModel as string, messages);
|
||||
answer = result.answer;
|
||||
seatJustAssigned = result.seatJustAssigned;
|
||||
} else {
|
||||
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
||||
}
|
||||
|
||||
return {
|
||||
answer,
|
||||
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
||||
unaugmented: !retrieved,
|
||||
seatJustAssigned,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user