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:
@@ -4,14 +4,31 @@
|
||||
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
|
||||
// shared store belongs with whichever phase makes these settings real
|
||||
// product config rather than a local-AI test harness.
|
||||
export type AiProvider = 'local' | 'public';
|
||||
export type AiProvider = 'local' | 'server' | 'public';
|
||||
|
||||
/**
|
||||
* A named public-provider configuration (BYOK). Decision 2026-08-05: several
|
||||
* of these, not one — different models/providers for different questions,
|
||||
* picked case by case at Ask time (see `activeProfileId`). The API key
|
||||
* itself lives in `lib/ai/key-store.ts`, keyed by `id`, not here — so a
|
||||
* profile's metadata can be listed/edited without ever handling the secret.
|
||||
*/
|
||||
export interface AiProviderProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AiLocalSettings {
|
||||
provider: AiProvider | null;
|
||||
localBaseUrl: string;
|
||||
localModel: string | null;
|
||||
publicBaseUrl: string;
|
||||
publicModel: string;
|
||||
serverModel: string | null;
|
||||
publicProfiles: AiProviderProfile[];
|
||||
/** Which saved profile answers the next question. Not a permanent default —
|
||||
* the "Try it" UI lets this be changed per question. */
|
||||
activeProfileId: string | null;
|
||||
publicConsentAccepted: boolean;
|
||||
}
|
||||
|
||||
@@ -21,17 +38,38 @@ export const DEFAULT_AI_SETTINGS: AiLocalSettings = {
|
||||
provider: null,
|
||||
localBaseUrl: 'http://127.0.0.1:11434',
|
||||
localModel: null,
|
||||
publicBaseUrl: 'https://openrouter.ai/api/v1',
|
||||
publicModel: '',
|
||||
serverModel: null,
|
||||
publicProfiles: [],
|
||||
activeProfileId: null,
|
||||
publicConsentAccepted: false,
|
||||
};
|
||||
|
||||
function newProfileId(): string {
|
||||
return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/** One-time upgrade from the earlier single-profile shape (a bare
|
||||
* publicBaseUrl/publicModel pair) into the profile list, so a browser that
|
||||
* already saved settings before profiles existed doesn't just lose them. */
|
||||
function migrate(raw: Record<string, unknown>): Partial<AiLocalSettings> {
|
||||
if (Array.isArray(raw.publicProfiles)) return raw as Partial<AiLocalSettings>;
|
||||
if (typeof raw.publicBaseUrl === 'string' && typeof raw.publicModel === 'string' && raw.publicModel) {
|
||||
const id = newProfileId();
|
||||
return {
|
||||
...raw,
|
||||
publicProfiles: [{ id, name: 'Default', baseUrl: raw.publicBaseUrl, model: raw.publicModel }],
|
||||
activeProfileId: id,
|
||||
};
|
||||
}
|
||||
return raw as Partial<AiLocalSettings>;
|
||||
}
|
||||
|
||||
export function loadAiSettings(): AiLocalSettings {
|
||||
if (typeof window === 'undefined') return { ...DEFAULT_AI_SETTINGS };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_AI_SETTINGS };
|
||||
return { ...DEFAULT_AI_SETTINGS, ...JSON.parse(raw) };
|
||||
return { ...DEFAULT_AI_SETTINGS, ...migrate(JSON.parse(raw)) };
|
||||
} catch {
|
||||
return { ...DEFAULT_AI_SETTINGS };
|
||||
}
|
||||
@@ -41,3 +79,7 @@ export function saveAiSettings(settings: AiLocalSettings): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
export function createProfile(name: string, baseUrl: string, model: string): AiProviderProfile {
|
||||
return { id: newProfileId(), name, baseUrl, model };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user