import { NextRequest, NextResponse } from 'next/server'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { configManager } from '@/lib/admin/config-manager'; import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode'; import { logger } from '@/lib/logger'; export const runtime = 'nodejs'; const MAX_BODY_BYTES = 200 * 1024; interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } /** * POST /api/ai/opencode/chat — one-shot chat against a locally-running * `opencode serve`. * * Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs * on the user's own machine against provider credentials opencode itself * holds, so there is no centrally-borne cost for this app to bill — the same * reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header). */ export async function POST(request: NextRequest) { const auth = await getStalwartCredentials(request); if (!auth) { return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); } await configManager.ensureLoaded(); if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { return NextResponse.json({ error: 'the OpenCode 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: { model?: unknown; messages?: unknown }; try { body = JSON.parse(rawBody); } catch { return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); } const model = typeof body.model === 'string' ? body.model : ''; const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null; if (!model || !messages || messages.length === 0) { return NextResponse.json({ error: 'model and messages are required' }, { status: 400 }); } const found = await findOpencodeServer(); if (!found) { return NextResponse.json( { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, { status: 503 }, ); } if (!found.models.some((m) => m.ref === model)) { // The picker is populated from this same list, so a mismatch means the // saved model was removed/renamed in opencode since it was chosen - // clearer to say so than to forward it and surface opencode's own error. return NextResponse.json( { error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` }, { status: 400 }, ); } const parsed = parseModelRef(model); if (!parsed) { return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 }); } // Flatten our chat-messages shape onto opencode's (system field + text // parts). Every non-system message is already just the built prompt. const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined; const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n'); if (!userText.trim()) { return NextResponse.json({ error: 'no user content to send' }, { status: 400 }); } try { const result = await opencodePrompt(found.baseUrl, parsed, system, userText); if (!result.ok) { logger.error('opencode prompt failed', { error: result.error }); return NextResponse.json({ error: result.error }, { status: 502 }); } return NextResponse.json({ answer: result.answer }); } catch (cause) { logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) }); return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 }); } }