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:
@@ -1,27 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { RefreshCw, CheckCircle, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
|
||||
import { getAiApiKey, setAiApiKey } from '@/lib/ai/key-store';
|
||||
import { loadAiSettings, saveAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||
import { askMail, listLocalModels, testLocalConnection, type AskResult } from '@/lib/ai/local-client';
|
||||
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
|
||||
import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||
import {
|
||||
askMail,
|
||||
listLocalModels,
|
||||
listServerModels,
|
||||
testLocalConnection,
|
||||
type AskResult,
|
||||
} from '@/lib/ai/local-client';
|
||||
|
||||
const inputClass =
|
||||
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]';
|
||||
|
||||
/**
|
||||
* Prototype scope (docs/AI-ASSISTANT-CONCEPT.md, decisions recorded
|
||||
* 2026-08-05 evening — see lib/ai/types.ts): `local` (loopback
|
||||
* Ollama-compatible runtime) ships free with no entitlement check; `public`
|
||||
* (BYOK, OpenAI-compatible) is available but explicitly unmonitored for now
|
||||
* — no seats, no metering, no server-recorded consent yet. `server`
|
||||
* (VNC-hosted) isn't wired up here; that infra is landing on the dev k8s
|
||||
* cluster separately.
|
||||
* Decisions recorded 2026-08-05 (see lib/ai/types.ts, lib/ai/entitlement.ts):
|
||||
* `local` (loopback Ollama) ships free, no entitlement check. `server`
|
||||
* (centrally-hosted, proxied through this app's own backend) is real and
|
||||
* entitlement-enforced — every call re-checks a licensed seat server-side.
|
||||
* `public` (BYOK) supports several named provider profiles, picked case by
|
||||
* case per question, and is explicitly unmonitored for now.
|
||||
*/
|
||||
export function AiAssistantSettings() {
|
||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||
@@ -52,16 +57,17 @@ export function AiAssistantSettings() {
|
||||
}, []);
|
||||
|
||||
const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local');
|
||||
const canUseServer = policy.entitlement.classes.includes('server');
|
||||
const canUsePublic = policy.entitlement.classes.includes('public');
|
||||
|
||||
// ── Local provider ──
|
||||
const [localModels, setLocalModels] = useState<string[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshingLocal, setRefreshingLocal] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const refreshModels = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
const refreshLocalModels = useCallback(async () => {
|
||||
setRefreshingLocal(true);
|
||||
try {
|
||||
const models = await listLocalModels(settings.localBaseUrl);
|
||||
setLocalModels(models);
|
||||
@@ -69,7 +75,7 @@ export function AiAssistantSettings() {
|
||||
} catch {
|
||||
setLocalModels([]);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setRefreshingLocal(false);
|
||||
}
|
||||
}, [settings.localBaseUrl, settings.localModel, update]);
|
||||
|
||||
@@ -85,20 +91,54 @@ export function AiAssistantSettings() {
|
||||
}
|
||||
}, [settings.localBaseUrl]);
|
||||
|
||||
// ── Public provider ──
|
||||
const [hasSavedKey, setHasSavedKey] = useState(false);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
// ── Server provider ──
|
||||
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||
const [refreshingServer, setRefreshingServer] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [seatNotice, setSeatNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasSavedKey(!!getAiApiKey('public'));
|
||||
}, []);
|
||||
const refreshServerModels = useCallback(async () => {
|
||||
setRefreshingServer(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
const models = await listServerModels();
|
||||
setServerModels(models);
|
||||
if (!settings.serverModel && models[0]) update('serverModel', models[0]);
|
||||
} catch (err) {
|
||||
setServerModels([]);
|
||||
setServerError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRefreshingServer(false);
|
||||
}
|
||||
}, [settings.serverModel, update]);
|
||||
|
||||
const saveKey = useCallback(() => {
|
||||
if (!apiKeyInput) return;
|
||||
setAiApiKey('public', apiKeyInput);
|
||||
setHasSavedKey(true);
|
||||
setApiKeyInput('');
|
||||
}, [apiKeyInput]);
|
||||
// ── Public provider — several named profiles, one picked per question ──
|
||||
const [newProfileName, setNewProfileName] = useState('');
|
||||
const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1');
|
||||
const [newProfileModel, setNewProfileModel] = useState('');
|
||||
const [newProfileKey, setNewProfileKey] = useState('');
|
||||
|
||||
const addProfile = useCallback(() => {
|
||||
if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return;
|
||||
const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel);
|
||||
setAiApiKey(profile.id, newProfileKey);
|
||||
update('publicProfiles', [...settings.publicProfiles, profile]);
|
||||
if (!settings.activeProfileId) update('activeProfileId', profile.id);
|
||||
setNewProfileName('');
|
||||
setNewProfileBaseUrl('https://openrouter.ai/api/v1');
|
||||
setNewProfileModel('');
|
||||
setNewProfileKey('');
|
||||
}, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update]);
|
||||
|
||||
const removeProfile = useCallback(
|
||||
(id: string) => {
|
||||
clearAiApiKey(id);
|
||||
const remaining = settings.publicProfiles.filter((p) => p.id !== id);
|
||||
update('publicProfiles', remaining);
|
||||
if (settings.activeProfileId === id) update('activeProfileId', remaining[0]?.id ?? null);
|
||||
},
|
||||
[settings.publicProfiles, settings.activeProfileId, update],
|
||||
);
|
||||
|
||||
// ── Ask ──
|
||||
const [question, setQuestion] = useState('');
|
||||
@@ -106,41 +146,50 @@ export function AiAssistantSettings() {
|
||||
const [askResult, setAskResult] = useState<AskResult | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
|
||||
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||
|
||||
const canAsk =
|
||||
question.trim().length > 0 &&
|
||||
(settings.provider === 'local'
|
||||
? canUseLocal && !!settings.localModel
|
||||
: settings.provider === 'public'
|
||||
? canUsePublic && !!settings.publicModel && settings.publicConsentAccepted && hasSavedKey
|
||||
: false);
|
||||
: settings.provider === 'server'
|
||||
? canUseServer && !!settings.serverModel
|
||||
: settings.provider === 'public'
|
||||
? canUsePublic && !!activeProfile && settings.publicConsentAccepted
|
||||
: false);
|
||||
|
||||
const runAsk = useCallback(async () => {
|
||||
setAsking(true);
|
||||
setAskError(null);
|
||||
setAskResult(null);
|
||||
setSeatNotice(null);
|
||||
try {
|
||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||
const result = await askMail(question.trim(), {
|
||||
provider: settings.provider as 'local' | 'public',
|
||||
provider: settings.provider as 'local' | 'server' | 'public',
|
||||
localBaseUrl: settings.localBaseUrl,
|
||||
localModel: settings.localModel,
|
||||
publicBaseUrl: settings.publicBaseUrl,
|
||||
publicModel: settings.publicModel,
|
||||
publicApiKey: getAiApiKey('public'),
|
||||
serverModel: settings.serverModel,
|
||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||
});
|
||||
setAskResult(result);
|
||||
if (result.seatJustAssigned) {
|
||||
setSeatNotice('A licensed seat on the server-hosted class was just assigned to your account.');
|
||||
}
|
||||
} catch (err) {
|
||||
setAskError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}, [question, settings]);
|
||||
}, [question, settings, activeProfile]);
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => [
|
||||
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
|
||||
...(canUsePublic ? [{ value: 'public', label: 'Public (your API key)' }] : []),
|
||||
...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
|
||||
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
|
||||
],
|
||||
[canUseLocal, canUsePublic],
|
||||
[canUseLocal, canUseServer, canUsePublic],
|
||||
);
|
||||
|
||||
if (policyLoading) {
|
||||
@@ -155,13 +204,13 @@ export function AiAssistantSettings() {
|
||||
<div className="space-y-6">
|
||||
<SettingsSection
|
||||
title="AI Assistant"
|
||||
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; public sends your question (and any retrieved excerpts) to a provider you choose, using your own API key."
|
||||
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; server is centrally hosted and licensed per seat; public sends your question to a provider you choose, using your own API key."
|
||||
>
|
||||
<SettingItem label="Provider">
|
||||
{providerOptions.length > 0 ? (
|
||||
<RadioGroup
|
||||
value={settings.provider ?? ''}
|
||||
onChange={(v) => update('provider', v as 'local' | 'public')}
|
||||
onChange={(v) => update('provider', v as 'local' | 'server' | 'public')}
|
||||
options={providerOptions}
|
||||
/>
|
||||
) : (
|
||||
@@ -199,8 +248,8 @@ export function AiAssistantSettings() {
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.localModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshModels} disabled={refreshing}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<Button variant="outline" size="sm" onClick={refreshLocalModels} disabled={refreshingLocal}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingLocal ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -226,51 +275,112 @@ export function AiAssistantSettings() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'server' && canUseServer && (
|
||||
<SettingsSection
|
||||
title="Server (VNC-hosted)"
|
||||
description="Centrally hosted — no setup needed on your side. Licensed per seat; using this for the first time consumes one automatically if seats remain."
|
||||
>
|
||||
<SettingItem label="Model" description={serverModels.length === 0 ? 'Refresh to list available models.' : undefined}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{serverModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.serverModel ?? ''}
|
||||
onChange={(v) => update('serverModel', v)}
|
||||
options={serverModels.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.serverModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshServerModels} disabled={refreshingServer}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingServer ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
{serverError && (
|
||||
<SettingItem label="Status">
|
||||
<span className="flex items-center gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {serverError}
|
||||
</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'public' && canUsePublic && (
|
||||
<SettingsSection
|
||||
title="Public provider"
|
||||
description="Any OpenAI-compatible endpoint. Defaults to OpenRouter. Your key is stored only in this browser, never sent anywhere but the provider below — and, for now, use of this class is not monitored or metered by VNC."
|
||||
title="Public providers"
|
||||
description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
|
||||
>
|
||||
<SettingItem label="Base URL">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.publicBaseUrl}
|
||||
onChange={(e) => update('publicBaseUrl', e.target.value)}
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Model">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.publicModel}
|
||||
onChange={(e) => update('publicModel', e.target.value)}
|
||||
placeholder="e.g. anthropic/claude-sonnet-4.5"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="API key"
|
||||
description={hasSavedKey ? 'A key is saved in this browser. Enter a new one to replace it.' : 'Stored in this browser only.'}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder={hasSavedKey ? '•••• saved' : 'sk-...'}
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={saveKey} disabled={!apiKeyInput}>
|
||||
Save
|
||||
</Button>
|
||||
{settings.publicProfiles.length > 0 && (
|
||||
<SettingItem label="Saved profiles">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{settings.publicProfiles.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{p.model} · {p.baseUrl}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeProfile(p.id)} aria-label={`Remove ${p.name}`}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem label="Add a provider">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={newProfileName}
|
||||
onChange={(e) => setNewProfileName(e.target.value)}
|
||||
placeholder="Name, e.g. Claude via OpenRouter"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newProfileModel}
|
||||
onChange={(e) => setNewProfileModel(e.target.value)}
|
||||
placeholder="Model, e.g. anthropic/claude-sonnet-4.5"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={newProfileBaseUrl}
|
||||
onChange={(e) => setNewProfileBaseUrl(e.target.value)}
|
||||
placeholder="Base URL"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newProfileKey}
|
||||
onChange={(e) => setNewProfileKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addProfile}
|
||||
disabled={!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 me-1.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="I understand this leaves the organisation"
|
||||
description="Your question and any retrieved mail excerpts are sent to the provider above, outside this organisation."
|
||||
description="Your question and any retrieved mail excerpts are sent to the provider you pick below, outside this organisation."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={settings.publicConsentAccepted}
|
||||
@@ -283,6 +393,15 @@ export function AiAssistantSettings() {
|
||||
{settings.provider && (
|
||||
<SettingsSection title="Try it" description="Ask a question against your synced mail.">
|
||||
<div className="flex flex-col gap-3">
|
||||
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (
|
||||
<SettingItem label="Answer with">
|
||||
<Select
|
||||
value={settings.activeProfileId ?? ''}
|
||||
onChange={(v) => update('activeProfileId', v)}
|
||||
options={settings.publicProfiles.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
@@ -295,6 +414,13 @@ export function AiAssistantSettings() {
|
||||
Ask
|
||||
</Button>
|
||||
|
||||
{seatNotice && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 p-3">
|
||||
<CheckCircle className="w-4 h-4 mt-0.5 text-green-600 dark:text-green-500 shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">{seatNotice}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
|
||||
|
||||
Reference in New Issue
Block a user