Three pieces built together tonight since they're naturally linked (the
server-class proxy is the real entitlement enforcement chokepoint):
1. Multi-key BYOK (public class): several named provider profiles
(name/baseUrl/model), each with its own key in lib/ai/key-store.ts
(keyed by profile id, not a single fixed 'public' slot). The "Try it"
pane lets you pick which saved profile answers each question - not one
fixed default.
2. `server` class, real: app/api/ai/server/{models,chat} proxy through
this app's own backend to AI_SERVER_BASE_URL - same-origin from the
browser, no CORS/OLLAMA_ORIGINS story at all, standing in tonight for
VNC's EU/CH-hosted infra with the real Ollama on this Mac (swapping to
the real instance tomorrow is a config change).
3. Real entitlement enforcement (lib/ai/entitlement.ts), scoped to `server`
only (not local/public, per the 2026-08-05 decisions): checkAndAssignSeat()
re-validates on every /api/ai/server/chat call - first use auto-assigns a
seat if any remain, further calls from an unlicensed user get a 402 with
a specific reason. recordUsage() appends to an append-only metering
ledger (timestamp/user/model/tokens/latency) that IS the billing record.
Admin data endpoints at /api/admin/ai/entitlement (seat total, revoke) -
the visual admin console is a separate, not-yet-built task.
Two real bugs found and fixed during verification, not just claimed fixed:
- /api/ai/policy never actually added 'server' to entitlement.classes even
when AI_SERVER_BASE_URL was set (only the type comment was updated) - the
Server radio option silently never appeared until this was caught live.
- The new routes used readStalwartAuthContext(0) (hardcoded slot, SSO/reauth-
specific) instead of getStalwartCredentials() (the general multi-slot
session resolver every other authenticated route uses) - reachable but
wrong, and would have hidden a real auth gap behind "works on my slot".
Verified end-to-end for real: built + ran the actual server, logged in via
the real (non-demo) auth flow, selected Server, listed the real Ollama
models through the proxy, asked "Reply with exactly the words: SERVER CLASS
WORKS" and got back exactly that - plus confirmed on disk (not just in the
UI) that data/admin-state/ai-entitlement.json recorded the seat assignment
and ai-metering.jsonl recorded real prompt/completion token counts and
latency from the actual model call. Rejection-path logic (seat limit
reached, zero seats configured, revocation) covered by 5 new unit tests
rather than a second live round trip. Full suite: typecheck clean, lint
clean, translations 48/48, production build succeeds.
38 lines
1.5 KiB
TypeScript
38 lines
1.5 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 classes = [...DEFAULT_AI_ENTITLEMENT.classes];
|
|
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
|
|
|
|
const aiPolicy: AiPolicy = {
|
|
enabled: policy.features.aiAssistantEnabled,
|
|
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
|
publicConsentVersion: null,
|
|
};
|
|
|
|
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 });
|
|
}
|
|
}
|