+ Only the env var name is stored here — provision the actual key as a real environment variable on
+ the server (k8s secret, .env, Electron packaging). This app never sees or stores the value.
+
+ Paperclip-style: publish a provider by name instead of making every user paste their own key. Users pick
+ one of these in Settings with no key field at all — the server resolves the named env var at request time.
+
+
+ update({ publicPresets: v })} />
+
+
Entitlement & seats
diff --git a/app/api/admin/ai/policy/route.ts b/app/api/admin/ai/policy/route.ts
index 062822e0..1e14ed1f 100644
--- a/app/api/admin/ai/policy/route.ts
+++ b/app/api/admin/ai/policy/route.ts
@@ -48,6 +48,24 @@ function validate(body: Partial): 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();
+ 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) {
diff --git a/app/api/ai/policy/route.ts b/app/api/ai/policy/route.ts
index bb17ab3c..d326a430 100644
--- a/app/api/ai/policy/route.ts
+++ b/app/api/ai/policy/route.ts
@@ -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, {
diff --git a/app/api/ai/public/chat/route.ts b/app/api/ai/public/chat/route.ts
new file mode 100644
index 00000000..70838f55
--- /dev/null
+++ b/app/api/ai/public/chat/route.ts
@@ -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 });
+ }
+}
diff --git a/components/ai/ai-ask-button.tsx b/components/ai/ai-ask-button.tsx
index a40b65df..5cbf2cdd 100644
--- a/components/ai/ai-ask-button.tsx
+++ b/components/ai/ai-ask-button.tsx
@@ -22,8 +22,9 @@ import { useAccountStore } from '@/stores/account-store';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm } from '@/lib/platform-capabilities';
import { getAiApiKey } from '@/lib/ai/key-store';
-import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
+import { loadAiSettings, isPresetActiveId, presetIdFromActiveId, type AiLocalSettings } from '@/lib/ai/local-settings';
import { askMail, type AskResult } from '@/lib/ai/local-client';
+import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
function useAiPolicy(): { policy: AiPolicy; loaded: boolean } {
const [policy, setPolicy] = useState(DEFAULT_AI_POLICY);
@@ -58,6 +59,9 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea
case 'opencode':
return classes.includes('opencode') && !!settings.opencodeModel;
case 'public': {
+ if (isPresetActiveId(settings.activeProfileId)) {
+ return classes.includes('public') && !!presetIdFromActiveId(settings.activeProfileId) && settings.publicConsentAccepted;
+ }
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
return classes.includes('public') && !!active && settings.publicConsentAccepted;
}
@@ -90,6 +94,18 @@ export function AiAskButton() {
setOpen(true);
}, []);
+ // Zero-config default (see lib/ai/auto-provision.ts): resolves as soon as
+ // policy loads, so a user who never visits Settings still finds AI
+ // already on the first time they open this dialog, if OpenCode or Ollama
+ // is available.
+ useEffect(() => {
+ if (!loaded) return;
+ (async () => {
+ const next = await ensureDefaultProvider(policy);
+ setSettings(next);
+ })();
+ }, [loaded, policy]);
+
useEffect(() => {
if (!open) return;
textareaRef.current?.focus();
@@ -109,7 +125,8 @@ export function AiAskButton() {
setAskError(null);
setAskResult(null);
try {
- const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
+ const managedPresetId = presetIdFromActiveId(settings.activeProfileId);
+ const activeProfile = managedPresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
const result = await askMail(question.trim(), {
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
@@ -119,6 +136,7 @@ export function AiAskButton() {
opencodeModel: settings.opencodeModel,
slot: activeSlot,
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
+ publicPresetId: managedPresetId,
});
setAskResult(result);
} catch (err) {
diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx
index d5fc3c8d..dff2d171 100644
--- a/components/settings/ai-assistant-settings.tsx
+++ b/components/settings/ai-assistant-settings.tsx
@@ -9,7 +9,11 @@ import { useAccountStore } from '@/stores/account-store';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
-import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings';
+import {
+ loadAiSettings, saveAiSettings, createProfile, presetActiveId, presetIdFromActiveId,
+ type AiLocalSettings,
+} from '@/lib/ai/local-settings';
+import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
import {
discoverLocalOllama,
recommendDefaultModel,
@@ -56,7 +60,23 @@ export function AiAssistantSettings() {
(async () => {
try {
const res = await apiFetch('/api/ai/policy');
- if (res.ok && !cancelled) setPolicy(await res.json());
+ if (res.ok && !cancelled) {
+ const loadedPolicy = (await res.json()) as AiPolicy;
+ setPolicy(loadedPolicy);
+ // Zero-config default (lib/ai/auto-provision.ts) — a no-op once a
+ // provider is already chosen, so this is safe to run on every
+ // visit to this pane, not just first-run.
+ const next = await ensureDefaultProvider(loadedPolicy);
+ // Separately, once an admin has published at least one org-managed
+ // preset, make IT the default "Answer with" pick too — pasting a
+ // personal key should be the fallback a user reaches for, not the
+ // thing they have to do to get any answer at all.
+ if (!next.activeProfileId && loadedPolicy.publicPresets[0]) {
+ next.activeProfileId = presetActiveId(loadedPolicy.publicPresets[0].id);
+ saveAiSettings(next);
+ }
+ if (!cancelled) setSettings(next);
+ }
} finally {
if (!cancelled) setPolicyLoading(false);
}
@@ -289,7 +309,9 @@ 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 activePresetId = presetIdFromActiveId(settings.activeProfileId);
+ const activeProfile = activePresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
+ const activePublicSelection = !!activeProfile || (!!activePresetId && policy.publicPresets.some((p) => p.id === activePresetId));
const canAsk =
question.trim().length > 0 &&
@@ -300,7 +322,7 @@ export function AiAssistantSettings() {
: settings.provider === 'opencode'
? canUseOpencode && !!settings.opencodeModel
: settings.provider === 'public'
- ? canUsePublic && !!activeProfile && settings.publicConsentAccepted
+ ? canUsePublic && activePublicSelection && settings.publicConsentAccepted
: false);
const runAsk = useCallback(async () => {
@@ -318,6 +340,7 @@ export function AiAssistantSettings() {
opencodeModel: settings.opencodeModel,
slot: activeSlot,
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
+ publicPresetId: activePresetId,
});
setAskResult(result);
if (result.seatJustAssigned) {
@@ -328,7 +351,7 @@ export function AiAssistantSettings() {
} finally {
setAsking(false);
}
- }, [question, settings, activeProfile, activeSlot]);
+ }, [question, settings, activeProfile, activePresetId, activeSlot]);
const providerOptions = useMemo(
() => [
@@ -622,8 +645,25 @@ export function AiAssistantSettings() {
title="Public providers"
description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
>
+ {policy.publicPresets.length > 0 && (
+
+