Files
proof-of-work/proof-of-work/app/api/ai/test/route.ts
T
Keysat 988a3cca9a
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
v1.1.0:8 — admin-gate whole-DB routes + AI custom-URL providers; SSRF guard
Multi-user authorization hardening from a full security evaluation (EVALUATION.md):

- P0: /api/settings/{export,import}-db are now admin-only. Previously any signed-in user could download the whole instance DB (all bcrypt hashes + plaintext AI keys) or replace it wholesale. Per-user CSV export/import stays open.

- AI custom-URL providers (Ollama, OpenAI-compatible) are now admin-only, and every server fetch to a user-supplied URL passes through assertSafeProviderUrl (blocks link-local/cloud-metadata; private LAN allowed by design). Fixed-URL cloud providers stay per-user. Removed the dead legacy /api/ai/config route.

- Dev: fixed broken quick-start (added npm run create-admin; rewrote README; dropped dead CLAUDE_API_KEY) and the export-db 0-byte path resolution (resolveDatabasePath now matches Prisma).

ExVer bumped to 1.1.0:8 (no schema/data migration). Tests 197 pass, build green, tsc clean.
2026-06-12 23:15:09 -05:00

204 lines
6.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getProvider, isCustomUrlProvider } from '@/lib/ai/providers';
/**
* POST /api/ai/test
*
* Body (optional):
* {
* // If supplied: test this draft config without saving it.
* // Otherwise: test the actor's currently active config.
* provider?: string,
* model?: string,
* baseUrl?: string,
* apiKey?: string,
* // If supplied + apiKey is null: pull the saved key for that
* // profile (so the UI can test a saved profile by id without
* // forcing the user to re-type the key).
* useSavedKeyForId?: string,
* }
*
* Sends a tiny "say hi in 3 words" prompt. Reports latency, sample
* reply (or finishReason if Gemini blocks it).
*
* Times out after 30s — long enough for cold Ollama starts.
*/
const TEST_TIMEOUT_MS = 30_000;
const bodySchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
baseUrl: z.string().nullable().optional(),
apiKey: z.string().nullable().optional(),
useSavedKeyForId: z.string().optional(),
});
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
}
const raw = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: 'Invalid body' },
{ status: 400 },
);
}
const draft = parsed.data;
// Resolve the config to test:
// 1. If draft.provider is set → use the draft fields (testing
// a not-yet-saved config in the UI).
// 2. Else if draft.useSavedKeyForId is set → load that profile.
// 3. Else → use the active config (legacy single-config columns).
let provider: string | null;
let model: string | null;
let baseUrl: string | null;
let apiKey: string | null;
if (draft.provider) {
provider = draft.provider;
model = draft.model ?? null;
baseUrl = draft.baseUrl ?? null;
apiKey = draft.apiKey ?? null;
// Allow the UI to fill in just provider+model+baseUrl and have
// us pull the saved key by profile id (so the user doesn't have
// to retype it just to retest).
if (draft.useSavedKeyForId && (apiKey == null || apiKey === '')) {
const saved = await prisma.aIConfigProfile.findFirst({
where: { id: draft.useSavedKeyForId, userId: user.id },
select: { apiKey: true },
});
if (saved?.apiKey) apiKey = saved.apiKey;
}
} else if (draft.useSavedKeyForId) {
const saved = await prisma.aIConfigProfile.findFirst({
where: { id: draft.useSavedKeyForId, userId: user.id },
});
if (!saved) {
return NextResponse.json(
{ ok: false, error: 'Config not found.' },
{ status: 404 },
);
}
provider = saved.provider;
model = saved.model;
baseUrl = saved.baseUrl;
apiKey = saved.apiKey;
} else {
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
});
provider = prefs?.aiProvider ?? null;
model = prefs?.aiModel ?? null;
baseUrl = prefs?.aiBaseUrl ?? null;
apiKey = prefs?.aiApiKey ?? null;
}
if (!provider || !model) {
return NextResponse.json(
{
ok: false,
error: 'Pick a provider + model first.',
},
{ status: 400 },
);
}
// Testing an arbitrary base URL is the same SSRF surface as configuring
// one — admin-only. Non-admins may only test fixed-URL cloud providers.
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
return NextResponse.json(
{
ok: false,
error:
'Only an admin can test providers with a custom base URL (Ollama / OpenAI-compatible).',
},
{ status: 403 },
);
}
const providerImpl = getProvider(provider);
if (!providerImpl) {
return NextResponse.json(
{ ok: false, error: `Unknown provider: ${provider}` },
{ status: 400 },
);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
const t0 = Date.now();
let sample = '';
let tokensIn: number | undefined;
let tokensOut: number | undefined;
let providerError: string | null = null;
try {
for await (const chunk of providerImpl.generate({
apiKey,
baseUrl,
model,
systemPrompt:
'You are a connectivity test. Reply with EXACTLY three words: "Hello there friend." Nothing else.',
userPrompt: 'Say hi.',
signal: controller.signal,
// Generous output budget so thinking models (Gemini 2.5/3.x,
// OpenAI o-series) actually have room to emit visible text after
// their internal reasoning. Cheap because the prompt is tiny.
maxOutputTokens: 4096,
})) {
if (chunk.type === 'text') sample += chunk.delta;
else if (chunk.type === 'usage') {
tokensIn = chunk.tokensIn;
tokensOut = chunk.tokensOut;
} else if (chunk.type === 'error') {
providerError = chunk.message;
}
}
} catch (e) {
providerError =
controller.signal.aborted
? `Timed out after ${Math.round(TEST_TIMEOUT_MS / 1000)}s`
: (e as Error).message;
} finally {
clearTimeout(timer);
}
const ms = Date.now() - t0;
if (providerError) {
return NextResponse.json({ ok: false, error: providerError, ms }, { status: 200 });
}
if (!sample.trim()) {
return NextResponse.json(
{
ok: false,
error:
'Empty reply. The provider returned a response with no text. ' +
'For Gemini this often means a safety filter blocked the output ' +
'(check the model name + try a flagship model). For thinking ' +
'models the answer may have been spent on internal reasoning — ' +
'try a non-thinking model.',
ms,
},
{ status: 200 },
);
}
return NextResponse.json({
ok: true,
sample: sample.trim().slice(0, 200),
tokensIn,
tokensOut,
ms,
});
}