Files
proof-of-work/proof-of-work/lib/ai/pricing.ts
T
Keysat dba478aa23 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.
2026-05-10 22:17:35 -05:00

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)}`;
}