feat(ai): Paperclip-style env-var provider presets + zero-config local default

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>
This commit is contained in:
Bernd Rodler
2026-08-07 14:01:00 +02:00
co-authored by Claude Sonnet 5
parent 1aa0a4686b
commit f121678e2a
13 changed files with 580 additions and 17 deletions
+19
View File
@@ -48,6 +48,24 @@ function validate(body: Partial<AiConsoleConfig>): string | null {
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';
}
@@ -83,6 +101,7 @@ export async function PUT(request: NextRequest) {
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) {
+5
View File
@@ -43,6 +43,11 @@ export async function GET() {
retrievalEnabled: consoleConfig.retrievalEnabled,
consent: consoleConfig.consent,
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
// Sanitized: {id,name,model} only. baseUrl/apiKeyEnvVar stay server-side —
// the client only ever refers to a preset by id (app/api/ai/public/chat
// resolves the rest), so there's no reason to hand a browser tab even
// an internal env var *name*, let alone a provider base URL.
publicPresets: consoleConfig.publicPresets.map((p) => ({ id: p.id, name: p.name, model: p.model })),
};
return NextResponse.json(aiPolicy, {
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_BODY_BYTES = 200 * 1024;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface OpenAiChatResponse {
choices?: Array<{ message?: { content?: string } }>;
}
/**
* POST /api/ai/public/chat — the Paperclip-style, admin-managed alternative
* to the personal-key `chatPublic` path (lib/ai/local-client.ts): the client
* sends a `presetId`, never a key. The preset (name/baseUrl/model/
* apiKeyEnvVar) lives in admin config (lib/ai/types.ts's PublicAiPreset);
* the actual secret value is read from THIS PROCESS's real environment at
* request time and never leaves this route — same custody model as
* AI_SERVER_BASE_URL, just admin-nameable per preset instead of one fixed var.
*
* Deliberately NOT entitlement-metered, same reasoning as `local`/`opencode`
* (lib/ai/entitlement.ts's header): this is still the `public` class, just
* with the org supplying the key instead of the user — no centrally-borne
* inference cost this app is billing for.
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
await configManager.ensureLoaded();
const consoleConfig = configManager.getAiConsoleConfig();
if (consoleConfig.classesEnabled.public === false) {
return NextResponse.json({ error: 'the Public AI class is disabled by admin policy' }, { status: 403 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'request too large' }, { status: 413 });
}
let body: { presetId?: unknown; messages?: unknown };
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const presetId = typeof body.presetId === 'string' ? body.presetId : '';
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
if (!presetId || !messages || messages.length === 0) {
return NextResponse.json({ error: 'presetId and messages are required' }, { status: 400 });
}
const preset = consoleConfig.publicPresets.find((p) => p.id === presetId);
if (!preset) {
return NextResponse.json({ error: `No such preset "${presetId}" — it may have been removed by an admin.` }, { status: 404 });
}
const apiKey = process.env[preset.apiKeyEnvVar];
if (!apiKey) {
return NextResponse.json(
{ error: `Env var "${preset.apiKeyEnvVar}" is not set on the server for preset "${preset.name}" — ask an admin to provision it.` },
{ status: 503 },
);
}
try {
const res = await fetch(`${preset.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: preset.model, messages }),
});
if (!res.ok) {
return NextResponse.json({ error: `Provider returned ${res.status}` }, { status: 502 });
}
const data = (await res.json()) as OpenAiChatResponse;
const content = data.choices?.[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: 'Provider returned no message content' }, { status: 502 });
}
return NextResponse.json({ answer: content });
} catch (cause) {
logger.error('public ai preset chat failed', {
presetId, error: cause instanceof Error ? cause.message : String(cause),
});
return NextResponse.json({ error: `Could not reach ${preset.baseUrl}` }, { status: 502 });
}
}