Files
proof-of-work/proof-of-work/app/api/ai/test/route.ts
T
Keysat 8f149d35ab v1.1.0:3 — AI upgrades: history context, test connection, cost estimator, streaming preview
Four incremental upgrades to the AI program generator. No schema change, no /data migration.

1. History as context (the killer feature)
   - lib/ai/historyContext.ts builds a 90-day per-exercise rollup:
     frequency, recent weights, estimated 1RM (Epley), avg RPE,
     days-since-last, plus a STAGNANT flag when the heaviest weight in
     the new half doesn't beat the old half.
   - Generate page surfaces an "Include my workout history as context"
     checkbox (default on at >=10 logged workouts). When checked, the
     ~1-3 KB summary is appended to the system prompt so the model can
     recommend things like "you've stalled bench at 245 — try paused reps."
   - We deliberately don't ship raw set logs (privacy + token cost).

2. Test connection
   - POST /api/ai/test sends a tiny "say hi in 3 words" prompt and
     reports latency + first sample, or the error inline.
   - "Test connection" button next to "Save AI config" in
     Settings -> AI integration. Verifies provider/model/key/baseUrl
     without going through full program generation.

3. Cost estimator
   - lib/ai/pricing.ts ships a price table for major models
     (Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5).
     Ollama always returns 0; openai-compatible returns null.
   - Generation history shows per-row cost + a 30-day rolling total
     at the top of the page.

4. Streaming preview render
   - lib/ai/lenientJson.ts: stack-aware partial-JSON parser that
     auto-closes open strings/brackets/braces in reverse-of-opening
     order, drops dangling key:value pairs and partial keywords.
     Returns a best-effort snapshot of the program-so-far on each chunk.
   - Generate UI now renders a live "Building program..." panel that
     updates as weeks/days/exercises arrive instead of just showing
     raw text and waiting for stream end.

Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts,
ai-pricing.test.ts). 161 total pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:35 -05:00

111 lines
3.1 KiB
TypeScript

import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getProvider } from '@/lib/ai/providers';
/**
* POST /api/ai/test
*
* Sends a tiny "say hi in 3 words" prompt to the user's currently
* configured AI provider and reports success/failure inline. Lets
* the operator validate provider/model/key/baseUrl without going
* through a full program generation.
*
* Returns:
* { ok: true, sample: "Hello there friend", tokensIn?, tokensOut?, ms }
* { ok: false, error: "..." }
*
* Times out after 30s — long enough for cold Ollama starts, short
* enough that a hung connection doesn't hang the UI.
*/
const TEST_TIMEOUT_MS = 30_000;
export async function POST() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
}
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
});
if (!prefs?.aiProvider || !prefs?.aiModel) {
return NextResponse.json(
{
ok: false,
error: 'Pick a provider + model in Settings → AI integration first.',
},
{ status: 400 },
);
}
const provider = getProvider(prefs.aiProvider);
if (!provider) {
return NextResponse.json(
{ ok: false, error: `Unknown provider: ${prefs.aiProvider}` },
{ 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 provider.generate({
apiKey: prefs.aiApiKey,
baseUrl: prefs.aiBaseUrl,
model: prefs.aiModel,
systemPrompt:
'You are a connectivity test. Reply with exactly three words: "Hello there friend." Nothing else.',
userPrompt: 'Say hi.',
signal: controller.signal,
})) {
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:
'Got an empty response. The model returned successfully but with no text — check the model name and try again.',
ms,
},
{ status: 200 },
);
}
return NextResponse.json({
ok: true,
sample: sample.trim().slice(0, 200),
tokensIn,
tokensOut,
ms,
});
}