Two product decisions from tonight: 1. Public AI providers can now be published by an admin as named presets (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar). The admin names an env var, never a secret value - the actual key is whatever ops has set in the server's real environment, same custody model as the existing AI_SERVER_BASE_URL var. A new server route (app/api/ai/public/chat) resolves it and makes the call itself, which also sidesteps the CORS/wrong-base-URL failure class chatPublic hit earlier tonight. Users pick a preset from a dropdown in Settings - Answer with - no key field at all; personal BYOK (paste your own key) stays available as a secondary "Add your own key" option, not removed. Admin UI: new "Public - org-managed presets" card in the AI policy tab. 2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts): on first load, if no provider is chosen yet, probe OpenCode (this app auto-spawns `opencode serve` itself, so it's the one local option with zero external install step) then Ollama via the existing auto-discovery, and adopt whichever answers. Never overrides an explicit choice - only fires while provider is still null. Wired into both AI entry points (the Ask button and the Settings pane) so it resolves before either renders its "not configured" state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
112 lines
4.9 KiB
TypeScript
112 lines
4.9 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|
import { auditLog } from '@/lib/admin/audit';
|
|
import { logger } from '@/lib/logger';
|
|
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
|
|
|
|
/**
|
|
* GET/PUT /api/admin/ai/policy - the admin console's writable config
|
|
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
|
|
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
|
|
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
|
|
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
|
|
* toggle stays there, this console only links to it, per spec §6 open
|
|
* question 3).
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
try {
|
|
await configManager.ensureLoaded();
|
|
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
|
|
} catch (error) {
|
|
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
function validate(body: Partial<AiConsoleConfig>): string | null {
|
|
if (body.classesEnabled !== undefined) {
|
|
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
|
|
for (const key of Object.keys(body.classesEnabled)) {
|
|
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
|
|
}
|
|
}
|
|
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
|
|
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
|
|
return 'serverModelAllowlist must be an array of strings or null';
|
|
}
|
|
}
|
|
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
|
|
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
|
|
return 'publicProviderAllowlist must be an array of strings or null';
|
|
}
|
|
}
|
|
if (body.publicPresets !== undefined) {
|
|
if (!Array.isArray(body.publicPresets)) return 'publicPresets must be an array';
|
|
const ids = new Set<string>();
|
|
for (const preset of body.publicPresets) {
|
|
if (
|
|
typeof preset !== 'object' || preset === null ||
|
|
typeof preset.id !== 'string' || !preset.id ||
|
|
typeof preset.name !== 'string' || !preset.name ||
|
|
typeof preset.baseUrl !== 'string' || !preset.baseUrl ||
|
|
typeof preset.model !== 'string' || !preset.model ||
|
|
typeof preset.apiKeyEnvVar !== 'string' || !preset.apiKeyEnvVar
|
|
) {
|
|
return 'each publicPresets entry needs non-empty id, name, baseUrl, model, apiKeyEnvVar';
|
|
}
|
|
if (ids.has(preset.id)) return `duplicate publicPresets id "${preset.id}"`;
|
|
ids.add(preset.id);
|
|
}
|
|
}
|
|
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
|
|
return 'retrievalEnabled must be a boolean';
|
|
}
|
|
if (body.consent !== undefined && body.consent !== null) {
|
|
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
|
|
return 'consent must be { version: string, text: string } or null';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function PUT(request: NextRequest) {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
const ip = getClientIP(request);
|
|
|
|
let body: Partial<AiConsoleConfig>;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
}
|
|
|
|
const validationError = validate(body);
|
|
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
|
|
|
try {
|
|
await configManager.ensureLoaded();
|
|
const next = await configManager.setAiConsoleConfig(body);
|
|
await auditLog('ai.console_policy.update', {
|
|
classesEnabled: next.classesEnabled,
|
|
retrievalEnabled: next.retrievalEnabled,
|
|
consentVersion: next.consent?.version ?? null,
|
|
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
|
|
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
|
|
publicPresetsCount: next.publicPresets.length,
|
|
}, ip);
|
|
return NextResponse.json(next);
|
|
} catch (error) {
|
|
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|