import { NextRequest, NextResponse } from 'next/server'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { configManager } from '@/lib/admin/config-manager'; import { findOpencodeServer, listOpencodeProviders, setOpencodeProviderKey, removeOpencodeProvider, } from '@/lib/ai/opencode'; import { logger } from '@/lib/logger'; export const runtime = 'nodejs'; const SETUP_ERROR = 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed — install it from opencode.ai, then restart VNCmail+.'; async function requireOpencode(request: NextRequest) { const auth = await getStalwartCredentials(request); if (!auth) return { error: NextResponse.json({ error: 'not authenticated' }, { status: 401 }) } as const; await configManager.ensureLoaded(); if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { return { error: NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }) } as const; } const found = await findOpencodeServer(); if (!found) return { error: NextResponse.json({ error: SETUP_ERROR }, { status: 503 }) } as const; return { baseUrl: found.baseUrl } as const; } /** * GET/PUT/DELETE /api/ai/opencode/providers — lets a user add "any LLM * OpenCode supports" from inside this app, rather than only whatever was * already authenticated via its own CLI. See lib/ai/opencode.ts's module * note on why this only covers API-key providers for now, not OAuth ones. */ export async function GET(request: NextRequest) { const result = await requireOpencode(request); if ('error' in result) return result.error; try { const providers = await listOpencodeProviders(result.baseUrl); return NextResponse.json({ providers }, { headers: { 'Cache-Control': 'no-store' } }); } catch (cause) { logger.error('opencode providers list failed', { error: cause instanceof Error ? cause.message : String(cause) }); return NextResponse.json({ error: 'Could not list OpenCode providers' }, { status: 502 }); } } export async function PUT(request: NextRequest) { const result = await requireOpencode(request); if ('error' in result) return result.error; let body: { providerID?: unknown; key?: unknown }; try { body = await request.json(); } catch { return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); } const providerID = typeof body.providerID === 'string' ? body.providerID.trim() : ''; const key = typeof body.key === 'string' ? body.key.trim() : ''; if (!providerID || !key) { return NextResponse.json({ error: 'providerID and key are required' }, { status: 400 }); } try { await setOpencodeProviderKey(result.baseUrl, providerID, key); // VERIFY rather than trust the 200: OpenCode accepts a bare API key for // every provider (confirmed live), but does not consider every provider // "connected" from that alone - Snowflake Cortex, for one real example, // needs SNOWFLAKE_ACCOUNT alongside its token, and a single key field // silently leaves it unconnected with no error from the PUT itself. The // provider's own `env` array length does NOT predict this reliably either // (Azure needs two env vars and DOES connect from one key) - the only // honest source of truth is asking OpenCode again. const after = await listOpencodeProviders(result.baseUrl); const nowConnected = after.find((p) => p.id === providerID)?.connected === true; if (!nowConnected) { return NextResponse.json({ ok: false, error: `OpenCode stored the key but does not show ${providerID} as connected — it likely needs more than one credential field (check its requirements with the opencode CLI: opencode auth login ${providerID}).`, }, { status: 200 }); } return NextResponse.json({ ok: true }); } catch (cause) { logger.error('opencode provider auth failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) }); return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not add the provider' }, { status: 502 }); } } export async function DELETE(request: NextRequest) { const result = await requireOpencode(request); if ('error' in result) return result.error; const providerID = request.nextUrl.searchParams.get('providerID')?.trim(); if (!providerID) { return NextResponse.json({ error: 'providerID is required' }, { status: 400 }); } try { await removeOpencodeProvider(result.baseUrl, providerID); return NextResponse.json({ ok: true }); } catch (cause) { logger.error('opencode provider removal failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) }); return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not remove the provider' }, { status: 502 }); } }