Files
proof-of-work/proof-of-work/app/api/ai/configs/route.ts
T
Keysat 91b5b04d97
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
v1.2.0:7 — add SparkControl AI provider + fix base-URL footgun
SparkControl is a self-hosted local-inference gateway with an OpenAI-compatible API, reached over the internal same-box StartOS address (http://spark-control.startos:9999/v1, plain HTTP). It takes no API key, so generateOpenAIStyle gained a { requireApiKey } option and now omits the Authorization header when no key is set. The Settings form auto-detects the loaded vLLM model via SparkControl's /api/endpoints probe, mirroring the Ollama auto-detect; it's $0 in the cost UI. Custom-URL => admin-only + SSRF-guarded, same as Ollama.

Also fixes a config footgun behind the empty-response report: a custom base URL could ride along to a fixed-URL provider (claude/openai/gemini) whose form field is hidden, get stored, and be silently ignored (the provider always hits its hardcoded endpoint). Both config write paths now null baseUrl for non-custom-URL providers, and the form clears it on provider change.

No schema/data change (AIConfigProfile.provider is free-text). 259 tests pass; built + sideloaded to immense-voyage.local with a clean non-root launch.
2026-06-19 14:47:30 -05:00

155 lines
4.6 KiB
TypeScript

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<string, string> = {
claude: 'Claude',
openai: 'OpenAI',
'openai-compatible': 'Custom',
gemini: 'Gemini',
ollama: 'Ollama',
sparkcontrol: 'SparkControl',
};
const label = PRETTY[provider] ?? provider;
return `${label} · ${model}`;
}