import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getCurrentUser } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { activate } from '@/lib/ai/activateConfig'; import { isCustomUrlProvider } from '@/lib/ai/providers'; /** * v1.1.0:4 โ€” Multi-config CRUD. * * GET /api/ai/configs List the actor's saved AI configs + * their active id. apiKey is REDACTED in * list output (only `keyConfigured: bool`). * POST /api/ai/configs Create a new config. Pass `setActive: true` * to also activate it. * * Per-row endpoints in [id]/route.ts. "Activate" is its own POST in * [id]/activate/route.ts so the action is explicit + auditable. */ const PROVIDERS = [ 'claude', 'openai', 'openai-compatible', 'gemini', 'ollama', 'sparkcontrol', ] as const; export async function GET() { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const [profiles, prefs] = await Promise.all([ prisma.aIConfigProfile.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'asc' }, select: { id: true, name: true, provider: true, model: true, baseUrl: true, apiKey: true, // pulled only to compute keyConfigured; never returned createdAt: true, }, }), prisma.userPreferences.findUnique({ where: { userId: user.id }, select: { activeAIConfigId: true }, }), ]); return NextResponse.json({ activeId: prefs?.activeAIConfigId ?? null, configs: profiles.map((p) => ({ id: p.id, name: p.name, provider: p.provider, model: p.model, baseUrl: p.baseUrl, keyConfigured: !!p.apiKey, createdAt: p.createdAt.toISOString(), })), }); } const createSchema = z.object({ name: z.string().min(1).max(80).optional(), provider: z.enum(PROVIDERS), model: z.string().min(1).max(200), baseUrl: z.string().url().nullable().optional().or(z.literal('')), apiKey: z.string().nullable().optional(), setActive: z.boolean().optional(), }); export async function POST(request: NextRequest) { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json().catch(() => ({})); const parsed = createSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: 'Invalid body', details: parsed.error.errors }, { status: 400 }, ); } const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data; // Custom-URL providers (Ollama, SparkControl, OpenAI-compatible) are // admin-only โ€” a non-admin pointing the server at an arbitrary URL is the // SSRF actor vector. Fixed-URL cloud providers stay per-user. if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) { return NextResponse.json( { error: 'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).', }, { status: 403 }, ); } // Only custom-URL providers (Ollama / OpenAI-compatible / SparkControl) carry // a base URL. Fixed-URL providers (claude/openai/gemini) hit their hardcoded // endpoint and ignore it โ€” so we drop any stale baseUrl here rather than // storing an impossible config (the footgun behind the gemini-with-a-baseURL // mismatch). The UI also clears the field on provider change. const normalizedBaseUrl = isCustomUrlProvider(provider) ? baseUrl || null : null; const profile = await prisma.aIConfigProfile.create({ data: { userId: user.id, name: name ?? defaultName(provider, model), provider, model, baseUrl: normalizedBaseUrl, apiKey: apiKey || null, }, }); if (setActive) { await activate(user.id, profile.id, { provider, model, baseUrl: normalizedBaseUrl, apiKey, }); } return NextResponse.json({ id: profile.id, name: profile.name, provider: profile.provider, model: profile.model, baseUrl: profile.baseUrl, keyConfigured: !!profile.apiKey, activated: !!setActive, }); } function defaultName(provider: string, model: string): string { const PRETTY: Record = { claude: 'Claude', openai: 'OpenAI', 'openai-compatible': 'Custom', gemini: 'Gemini', ollama: 'Ollama', sparkcontrol: 'SparkControl', }; const label = PRETTY[provider] ?? provider; return `${label} ยท ${model}`; }