8f149d35ab
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>
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { findPrice, estimateCost, formatCost } from '@/lib/ai/pricing';
|
|
|
|
describe('findPrice', () => {
|
|
it('matches a known model exactly', () => {
|
|
expect(findPrice('claude-sonnet-4-5')).toBeTruthy();
|
|
});
|
|
|
|
it('matches a known model with a date suffix (longest-prefix)', () => {
|
|
const p = findPrice('claude-sonnet-4-5-20251022');
|
|
expect(p?.inputPerM).toBe(3);
|
|
expect(p?.outputPerM).toBe(15);
|
|
});
|
|
|
|
it('is case-insensitive', () => {
|
|
expect(findPrice('GPT-5-Mini')).toBeTruthy();
|
|
});
|
|
|
|
it('returns null for unknown models', () => {
|
|
expect(findPrice('mistral-medium-9000')).toBeNull();
|
|
});
|
|
|
|
it('prefers longer-prefix when multiple keys match', () => {
|
|
// claude-sonnet-4-5 is more specific than claude-sonnet-4
|
|
const p = findPrice('claude-sonnet-4-5');
|
|
expect(p).toEqual({ inputPerM: 3, outputPerM: 15 });
|
|
});
|
|
});
|
|
|
|
describe('estimateCost', () => {
|
|
it('returns 0 for ollama (self-hosted)', () => {
|
|
expect(
|
|
estimateCost({
|
|
provider: 'ollama',
|
|
model: 'llama3.1:8b',
|
|
tokensIn: 1000,
|
|
tokensOut: 500,
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it('returns null for openai-compatible (unknown gateway pricing)', () => {
|
|
expect(
|
|
estimateCost({
|
|
provider: 'openai-compatible',
|
|
model: 'meta-llama/llama-3.1-8b-instruct',
|
|
tokensIn: 1000,
|
|
tokensOut: 500,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns null when the model isn\'t in the price table', () => {
|
|
expect(
|
|
estimateCost({
|
|
provider: 'claude',
|
|
model: 'claude-vintage-edition',
|
|
tokensIn: 1000,
|
|
tokensOut: 500,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns null when token counts are missing', () => {
|
|
expect(
|
|
estimateCost({
|
|
provider: 'claude',
|
|
model: 'claude-sonnet-4-5',
|
|
tokensIn: null,
|
|
tokensOut: 500,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('computes the right $ for a known model', () => {
|
|
// claude-sonnet-4-5: $3/M in, $15/M out
|
|
// 100K in + 50K out = 0.1*3 + 0.05*15 = 0.3 + 0.75 = 1.05
|
|
const cost = estimateCost({
|
|
provider: 'claude',
|
|
model: 'claude-sonnet-4-5',
|
|
tokensIn: 100_000,
|
|
tokensOut: 50_000,
|
|
});
|
|
expect(cost).toBeCloseTo(1.05, 5);
|
|
});
|
|
|
|
it('computes correctly for gpt-5-nano (very cheap)', () => {
|
|
// gpt-5-nano: $0.05/M in, $0.4/M out
|
|
// 1000 in + 500 out = 0.001*0.05 + 0.0005*0.4 = 0.00005 + 0.0002 = 0.00025
|
|
const cost = estimateCost({
|
|
provider: 'openai',
|
|
model: 'gpt-5-nano',
|
|
tokensIn: 1000,
|
|
tokensOut: 500,
|
|
});
|
|
expect(cost).toBeCloseTo(0.00025, 8);
|
|
});
|
|
});
|
|
|
|
describe('formatCost', () => {
|
|
it('formats null as em dash', () => {
|
|
expect(formatCost(null)).toBe('—');
|
|
});
|
|
it('formats 0 as "free"', () => {
|
|
expect(formatCost(0)).toBe('free');
|
|
});
|
|
it('formats sub-cent costs as "<$0.01"', () => {
|
|
expect(formatCost(0.0023)).toBe('<$0.01');
|
|
});
|
|
it('formats sub-dollar costs with 3 decimal places', () => {
|
|
expect(formatCost(0.123)).toBe('$0.123');
|
|
});
|
|
it('formats dollar+ costs with 2 decimal places', () => {
|
|
expect(formatCost(2.567)).toBe('$2.57');
|
|
});
|
|
});
|