91b5b04d97
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.
155 lines
4.6 KiB
TypeScript
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}`;
|
|
}
|