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 }); } }