dba478aa23
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.
97 lines
3.7 KiB
TypeScript
97 lines
3.7 KiB
TypeScript
/**
|
|
* Per-model pricing in USD per million tokens. Used to estimate the
|
|
* cost of an AIGeneration row from its tokensIn/tokensOut.
|
|
*
|
|
* Prices change. This table is a best-effort starting point for
|
|
* common models as of mid-2026; users on other models will see
|
|
* `null` cost (we still surface token counts). Updating: edit this
|
|
* file and ship — no schema change needed.
|
|
*
|
|
* Matching strategy: case-insensitive prefix lookup against the
|
|
* user's configured model string. Model names like
|
|
* "claude-sonnet-4-5-20251022" match the "claude-sonnet-4-5" prefix.
|
|
*
|
|
* Keys are organized by provider for readability but the lookup is
|
|
* provider-agnostic — the model string is the key.
|
|
*/
|
|
|
|
interface PriceEntry {
|
|
inputPerM: number; // USD per 1M input tokens
|
|
outputPerM: number; // USD per 1M output tokens
|
|
}
|
|
|
|
const PRICES: Record<string, PriceEntry> = {
|
|
// Anthropic Claude (Messages API)
|
|
'claude-opus-4': { inputPerM: 15, outputPerM: 75 },
|
|
'claude-opus-4-5': { inputPerM: 15, outputPerM: 75 },
|
|
'claude-sonnet-4': { inputPerM: 3, outputPerM: 15 },
|
|
'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15 },
|
|
'claude-haiku-4': { inputPerM: 0.8, outputPerM: 4 },
|
|
'claude-haiku-4-5': { inputPerM: 0.8, outputPerM: 4 },
|
|
'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15 },
|
|
'claude-3-5-sonnet': { inputPerM: 3, outputPerM: 15 },
|
|
'claude-3-5-haiku': { inputPerM: 0.8, outputPerM: 4 },
|
|
|
|
// OpenAI
|
|
'gpt-5': { inputPerM: 1.25, outputPerM: 10 },
|
|
'gpt-5-mini': { inputPerM: 0.25, outputPerM: 2 },
|
|
'gpt-5-nano': { inputPerM: 0.05, outputPerM: 0.4 },
|
|
'gpt-4o': { inputPerM: 2.5, outputPerM: 10 },
|
|
'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.6 },
|
|
'o1': { inputPerM: 15, outputPerM: 60 },
|
|
'o3': { inputPerM: 2, outputPerM: 8 },
|
|
'o3-mini': { inputPerM: 1.1, outputPerM: 4.4 },
|
|
'o4-mini': { inputPerM: 1.1, outputPerM: 4.4 },
|
|
|
|
// Google Gemini
|
|
'gemini-2.5-pro': { inputPerM: 1.25, outputPerM: 10 },
|
|
'gemini-2.5-flash': { inputPerM: 0.3, outputPerM: 2.5 },
|
|
'gemini-2.0-flash': { inputPerM: 0.1, outputPerM: 0.4 },
|
|
'gemini-2.0-pro': { inputPerM: 1.25, outputPerM: 5 },
|
|
'gemini-1.5-pro': { inputPerM: 1.25, outputPerM: 5 },
|
|
'gemini-1.5-flash': { inputPerM: 0.075, outputPerM: 0.3 },
|
|
};
|
|
|
|
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
|
|
export function findPrice(model: string): PriceEntry | null {
|
|
const m = model.toLowerCase();
|
|
// Longest-prefix-first so e.g. "claude-sonnet-4-5" beats "claude-sonnet-4".
|
|
const sortedKeys = Object.keys(PRICES).sort((a, b) => b.length - a.length);
|
|
for (const key of sortedKeys) {
|
|
if (m.startsWith(key.toLowerCase())) return PRICES[key];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Estimate the USD cost of a generation. Returns null if the model
|
|
* isn't in the price table or if either token count is missing.
|
|
* Ollama and openai-compatible custom gateways always return null
|
|
* (they're either free or self-priced).
|
|
*/
|
|
export function estimateCost(opts: {
|
|
provider: string;
|
|
model: string;
|
|
tokensIn: number | null;
|
|
tokensOut: number | null;
|
|
}): number | null {
|
|
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
|
|
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
|
|
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
|
const price = findPrice(opts.model);
|
|
if (!price) return null;
|
|
return (
|
|
(opts.tokensIn / 1_000_000) * price.inputPerM +
|
|
(opts.tokensOut / 1_000_000) * price.outputPerM
|
|
);
|
|
}
|
|
|
|
/** Format USD to a string suitable for a UI label. Below $0.01 -> "<$0.01". */
|
|
export function formatCost(usd: number | null): string {
|
|
if (usd == null) return '—';
|
|
if (usd === 0) return 'free';
|
|
if (usd < 0.01) return '<$0.01';
|
|
if (usd < 1) return `$${usd.toFixed(3)}`;
|
|
return `$${usd.toFixed(2)}`;
|
|
}
|