Files
Bernd RodlerandClaude Sonnet 5 f121678e2a 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>
2026-08-07 14:01:00 +02:00

61 lines
3.2 KiB
TypeScript

import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
/**
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
*
* `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 consoleConfig = configManager.getAiConsoleConfig();
// 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');
// `opencode` is offered whenever the admin hasn't disabled it — unlike
// `server` there is no env var to gate on, because availability is "is a
// local `opencode serve` listening right now", which changes minute to
// minute and is answered by /api/ai/opencode/models (503 when absent).
// Advertising the class and letting that probe report the truth beats
// hiding it based on a stale check at policy-fetch time.
if (classAllowed('opencode')) classes.push('opencode');
const aiPolicy: AiPolicy = {
enabled: policy.features.aiAssistantEnabled,
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
publicConsentVersion: consoleConfig.consent?.version ?? null,
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, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('AI policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}