diff --git a/app/api/admin/ai/entitlement/route.ts b/app/api/admin/ai/entitlement/route.ts new file mode 100644 index 00000000..8f87aedc --- /dev/null +++ b/app/api/admin/ai/entitlement/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement'; + +export const runtime = 'nodejs'; + +/** + * Admin-only data endpoints for the `server` AI class's real entitlement + * enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the + * visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate, + * not-yet-built UI on top of these same endpoints. + */ +export async function GET(request: NextRequest) { + const result = await requireAdminAuth(request); + if ('error' in result) return result.error; + + try { + const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]); + return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const result = await requireAdminAuth(request); + if ('error' in result) return result.error; + const ip = getClientIP(request); + + let body: { seatsTotal?: unknown; revokeUsername?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + + try { + if (typeof body.seatsTotal === 'number') { + const state = await setSeatTotal(body.seatsTotal); + await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip); + return NextResponse.json(state); + } + if (typeof body.revokeUsername === 'string' && body.revokeUsername) { + const state = await revokeSeat(body.revokeUsername); + await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip); + return NextResponse.json(state); + } + return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 }); + } catch (error) { + logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/ai/policy/route.ts b/app/api/ai/policy/route.ts index 04b29f26..77162b7b 100644 --- a/app/api/ai/policy/route.ts +++ b/app/api/ai/policy/route.ts @@ -6,20 +6,24 @@ import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types'; /** * GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this) * - * P0 stub (docs/AI-ASSISTANT-CONCEPT.md §12): proves the client<->server - * policy-fetch plumbing end-to-end with no provider ever called. `enabled` - * mirrors the admin FeatureGates toggle; entitlement is hardcoded unlicensed - * until P2 wires a real seats/billing backend (§9) - there is no provider - * class to grant yet regardless of what an entitlement record might say. + * `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes` + * reflects real configuration, not a hardcoded guess: `server` only appears + * when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503 + * otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the + * client hiding what it can't use; the real gate is checkAndAssignSeat() on + * every /api/ai/server/chat call, not this list. */ export async function GET() { try { await configManager.ensureLoaded(); const policy = configManager.getPolicy(); + const classes = [...DEFAULT_AI_ENTITLEMENT.classes]; + if (process.env.AI_SERVER_BASE_URL) classes.push('server'); + const aiPolicy: AiPolicy = { enabled: policy.features.aiAssistantEnabled, - entitlement: { ...DEFAULT_AI_ENTITLEMENT }, + entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes }, publicConsentVersion: null, }; diff --git a/app/api/ai/server/chat/route.ts b/app/api/ai/server/chat/route.ts new file mode 100644 index 00000000..614bd5a6 --- /dev/null +++ b/app/api/ai/server/chat/route.ts @@ -0,0 +1,96 @@ +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 }); + } +} diff --git a/app/api/ai/server/models/route.ts b/app/api/ai/server/models/route.ts new file mode 100644 index 00000000..328b905f --- /dev/null +++ b/app/api/ai/server/models/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +export const runtime = 'nodejs'; + +/** + * GET /api/ai/server/models — list models on the centrally-hosted `server` + * class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted + * open-weight model stack as `local`... running on VNC's own infrastructure + * instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for + * that infra with the Ollama already running on this developer's Mac — see + * the module comment in lib/ai/entitlement.ts. Swapping to the real + * EU/CH-hosted instance tomorrow is a config change, not a rewrite. + * + * Listing models is not a billable action (doc §10 point 1 — cosmetic), so + * this only requires a valid session, not a seat. + */ +export async function GET(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + const baseUrl = process.env.AI_SERVER_BASE_URL; + if (!baseUrl) { + return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 }); + } + + try { + const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`); + if (!res.ok) { + return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 }); + } + const body = (await res.json()) as { models?: Array<{ name: string }> }; + return NextResponse.json({ models: (body.models ?? []).map((m) => m.name).filter(Boolean) }); + } catch (cause) { + return NextResponse.json( + { error: cause instanceof Error ? cause.message : 'AI server unreachable' }, + { status: 502 }, + ); + } +} diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index 7bfccf18..fcff1322 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -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(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([]); - const [refreshing, setRefreshing] = useState(false); + const [refreshingLocal, setRefreshingLocal] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); const [testError, setTestError] = useState(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([]); + const [refreshingServer, setRefreshingServer] = useState(false); + const [serverError, setServerError] = useState(null); + const [seatNotice, setSeatNotice] = useState(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(null); const [askError, setAskError] = useState(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() {
{providerOptions.length > 0 ? ( update('provider', v as 'local' | 'public')} + onChange={(v) => update('provider', v as 'local' | 'server' | 'public')} options={providerOptions} /> ) : ( @@ -199,8 +248,8 @@ export function AiAssistantSettings() { ) : ( {settings.localModel || 'None selected'} )} -
@@ -226,51 +275,112 @@ export function AiAssistantSettings() { )} + {settings.provider === 'server' && canUseServer && ( + + +
+ {serverModels.length > 0 ? ( + update('publicBaseUrl', e.target.value)} - spellCheck={false} - className={inputClass} - /> - - - update('publicModel', e.target.value)} - placeholder="e.g. anthropic/claude-sonnet-4.5" - spellCheck={false} - className={inputClass} - /> - - -
- setApiKeyInput(e.target.value)} - placeholder={hasSavedKey ? '•••• saved' : 'sk-...'} - spellCheck={false} - className={inputClass} - /> - + {settings.publicProfiles.length > 0 && ( + +
+ {settings.publicProfiles.map((p) => ( +
+
+

{p.name}

+

{p.model} · {p.baseUrl}

+
+ +
+ ))} +
+
+ )} + +
+
+ setNewProfileName(e.target.value)} + placeholder="Name, e.g. Claude via OpenRouter" + spellCheck={false} + className={inputClass} + /> + setNewProfileModel(e.target.value)} + placeholder="Model, e.g. anthropic/claude-sonnet-4.5" + spellCheck={false} + className={inputClass} + /> +
+
+ setNewProfileBaseUrl(e.target.value)} + placeholder="Base URL" + spellCheck={false} + className={inputClass} + /> + setNewProfileKey(e.target.value)} + placeholder="sk-..." + spellCheck={false} + className={inputClass} + /> + +
+ {settings.provider === 'public' && settings.publicProfiles.length > 0 && ( + +