feat(admin): build the AI Policy console (§6) — approved, spec now implemented
New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class toggles, server model allow-list, BYOK provider allow-list, seats/usage (front-end for the already-real lib/ai/entitlement.ts), retrieval on/off, consent text + version bump. Real backend, not cosmetic: AiConsoleConfig persisted via config-manager (lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT /api/admin/ai/policy. Enforcement wired at every real chokepoint, not just the picker: /api/ai/server/chat checks classesEnabled.server and the model allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models filters by allow-list. GET /api/ai/policy folds classesEnabled into the classes list clients see. Resolved the spec's 3 open questions as recommended: BYOK allow-list stays client-side/advisory (wired into ai-assistant-settings.tsx's addProfile), tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the existing Policy tab (this tab links to it instead of duplicating it). Defaults preserve today's behavior exactly (classesEnabled/allowlists all start empty/null) — turning this on changes nothing until an admin touches it.
This commit is contained in:
@@ -17,14 +17,25 @@ export async function GET() {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const consoleConfig = configManager.getAiConsoleConfig();
|
||||
|
||||
const classes = [...DEFAULT_AI_ENTITLEMENT.classes];
|
||||
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
|
||||
// A class must be BOTH infra-available AND not explicitly disabled by
|
||||
// the admin console (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6) to reach
|
||||
// users. Missing classesEnabled entries default to allowed, so this
|
||||
// changes nothing until an admin actually touches the console.
|
||||
const classAllowed = (cls: (typeof DEFAULT_AI_ENTITLEMENT.classes)[number]) => consoleConfig.classesEnabled[cls] !== false;
|
||||
const classes: typeof DEFAULT_AI_ENTITLEMENT.classes = [];
|
||||
if (classAllowed('local')) classes.push('local');
|
||||
if (classAllowed('public')) classes.push('public');
|
||||
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
||||
|
||||
const aiPolicy: AiPolicy = {
|
||||
enabled: policy.features.aiAssistantEnabled,
|
||||
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
||||
publicConsentVersion: null,
|
||||
publicConsentVersion: consoleConfig.consent?.version ?? null,
|
||||
retrievalEnabled: consoleConfig.retrievalEnabled,
|
||||
consent: consoleConfig.consent,
|
||||
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
|
||||
};
|
||||
|
||||
return NextResponse.json(aiPolicy, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -31,6 +32,14 @@ export async function POST(request: NextRequest) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||
// §6): an admin can disable mail-content-to-embeddings augmentation
|
||||
// independent of disabling the `server` chat class outright.
|
||||
await configManager.ensureLoaded();
|
||||
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
|
||||
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { query?: unknown; limit?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -35,6 +36,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||
// §6): the admin console can disable the whole `server` class even when
|
||||
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
|
||||
// while turning it off for users).
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
|
||||
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const seat = await checkAndAssignSeat(auth.username);
|
||||
if (!seat.allowed) {
|
||||
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
|
||||
@@ -58,6 +68,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||
if (allowlist && !allowlist.includes(model)) {
|
||||
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -37,7 +38,17 @@ export async function GET(request: NextRequest) {
|
||||
// lists them in the same /api/tags response, but calling /api/chat with
|
||||
// one fails outright. `capabilities` absent (older Ollama) fails open
|
||||
// rather than hiding every model on an upgrade.
|
||||
const chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
||||
let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
||||
|
||||
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every
|
||||
// completion-capable model (today's behavior, unchanged).
|
||||
await configManager.ensureLoaded();
|
||||
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||
if (allowlist) {
|
||||
const allowed = new Set(allowlist);
|
||||
chatModels = chatModels.filter((m) => allowed.has(m.name));
|
||||
}
|
||||
|
||||
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
|
||||
} catch (cause) {
|
||||
return NextResponse.json(
|
||||
|
||||
Reference in New Issue
Block a user