diff --git a/.env.example b/.env.example index 291c7b0..ede2b3c 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,12 @@ VAPID_SUBJECT="mailto:admin@example.com" # Generate with: openssl rand -base64 32 INTERNAL_PUSH_SECRET="" +# PBI-66 — Anthropic API key voor `npm run db:sync-model-prices`. +# Optional. Alleen nodig om wekelijks de model_prices tabel te synchroniseren. +# Genereer op https://console.anthropic.com/ → API Keys. +# /v1/models is een gratis metadata-call (geen tokens, geen credit nodig). +ANTHROPIC_API_KEY="" + # v1-readiness item 2 — Sentry error monitoring. # Optional. Without DSN, the SDK is a no-op (no network, no overhead). # Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN). diff --git a/docs/INDEX.md b/docs/INDEX.md index 55f5f33..38eaa23 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -54,6 +54,7 @@ Auto-generated on 2026-05-08 from front-matter and headings. | [ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | active | 2026-05-03 | | [ST-1111 — Voer uit-knop met Claude Code job queue](./plans/ST-1111-claude-job-trigger.md) | active | 2026-05-03 | | [ST-1114 — Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | active | 2026-05-03 | +| [Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296)](./plans/sync-model-prices.md) | — | — | | [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 | | [Scrum4Me — v1.0 readiness](./plans/v1-readiness.md) | active | 2026-05-04 | diff --git a/docs/plans/sync-model-prices.md b/docs/plans/sync-model-prices.md new file mode 100644 index 0000000..c075886 --- /dev/null +++ b/docs/plans/sync-model-prices.md @@ -0,0 +1,106 @@ +# Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296) + +## Context + +De tabel `model_prices` ([prisma/schema.prisma:465](../../prisma/schema.prisma)) bevat nu 3 hardcoded rijen via [prisma/seed.ts:198](../../prisma/seed.ts) (Opus 4.7, Sonnet 4.6, Haiku 4.5). Die wordt door [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) en [lib/insights/token-history.ts](../../lib/insights/token-history.ts) ge-`LEFT JOIN`-d voor kostberekening. + +Probleem: prijzen + nieuwe modellen worden alleen bijgewerkt bij een full re-seed. Dat vergeet je. We willen een wekelijks handmatig draaibaar script dat: + +1. De actuele Claude 4.x modellijst ophaalt bij Anthropic (`GET /v1/models`), +2. Per model de prijzen bepaalt uit een onderhouden tabel in code, +3. Nieuwe modellen detecteert en logt (zodat we weten dat de tabel update nodig heeft), +4. Idempotent upsert in `model_prices`. + +**Belangrijke realiteit:** Anthropic biedt geen prijs-API. `/v1/models` levert id, display_name, max_tokens, capabilities — maar **geen pricing**. De prijzen onderhouden we daarom als constanten in het script. De API-call dient om de modellijst te valideren en nieuwe modellen op te merken. + +## Aanpak + +Eén nieuw TypeScript-script `scripts/sync-model-prices.ts` in dezelfde stijl als [scripts/insert-milestone.ts](../../scripts/insert-milestone.ts): + +- dotenv → DATABASE_URL + ANTHROPIC_API_KEY +- `pg.Pool` + `PrismaPg` adapter + `PrismaClient` (zelfde patroon als bestaande scripts) +- `--dry-run` flag voor preview zonder schrijven +- Aangeroepen via `npm run db:sync-model-prices` + +### Datastromen + +``` +ANTHROPIC API /v1/models + │ + ▼ (filter: model_id matcht /^claude-(opus|sonnet|haiku)-4/) + API model list ───────────┐ + │ + PRICE_TABLE (in script) ──┤── join op model_id + │ + ▼ + Per model: + - input_price = PRICE_TABLE[id].input + - output_price = PRICE_TABLE[id].output + - cache_read_price = input * 0.1 + - cache_write_price = input * 1.25 + │ + ▼ + prisma.modelPrice.upsert +``` + +### PRICE_TABLE in script + +```ts +const PRICE_TABLE: Record = { + 'claude-opus-4-7': { input: 15.0, output: 75.0 }, + 'claude-sonnet-4-6': { input: 3.0, output: 15.0 }, + 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 }, +} + +const CACHE_READ_RATIO = 0.1 +const CACHE_WRITE_RATIO = 1.25 // 5-minute cache write +``` + +Cache-ratio's komen overeen met de huidige seed: 1.5/15 = 0.1 en 18.75/15 = 1.25 — dus geen waarde-shift voor bestaande rijen. + +### Filter Claude 4.x + +Regex op `id` uit de API: `/^claude-(opus|sonnet|haiku)-4/`. Dit matcht `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001` en toekomstige 4.x varianten. Filtert oudere 3.x modellen weg. + +### Detectie nieuwe modellen + +Per Claude 4.x model uit de API: +- **In PRICE_TABLE** → upsert met de prijs +- **Niet in PRICE_TABLE** → log warning, sla over, exit code blijft 0 + +## Bestanden + +| Bestand | Actie | +|---|---| +| `scripts/sync-model-prices.ts` | **Nieuw** — sync-script | +| `package.json` | **Wijzigen** — entry `"db:sync-model-prices"` toevoegen | +| `.env.example` | **Wijzigen** — `ANTHROPIC_API_KEY=""` toevoegen | +| `lib/env.ts` | **Wijzigen** — `ANTHROPIC_API_KEY` als optional env var | +| `scripts/README.md` | **Wijzigen** — sectie "Sync model prices" toevoegen | +| `prisma/seed.ts` regels 198–229 | **Behouden** — fallback voor verse DB | + +## Edge cases + +| Geval | Gedrag | +|---|---| +| `ANTHROPIC_API_KEY` ontbreekt | Error + exit 1 | +| API geeft 401 | Error met hint "controleer API key" | +| API geeft 5xx | Retry 1× met 2s delay, dan falen | +| API levert 0 Claude 4.x modellen | Warning, exit 1 | +| Model uit DB staat niet meer in API | Niet verwijderen — alleen loggen | +| `--dry-run` | API-call gewoon doen, alleen geen `upsert` | + +## Verificatie + +```bash +npm run db:sync-model-prices -- --dry-run +npm run db:sync-model-prices +psql $DATABASE_URL -c "SELECT model_id, input_price_per_1m, output_price_per_1m, updated_at FROM model_prices ORDER BY model_id" +npm run lint && npm test && npm run build +``` + +## Buiten scope + +- Geen Vercel cron route — bewust gekozen: handmatig draaien geeft moment om PRICE_TABLE bij te werken. +- Geen pricing-page scraping — fragiel. +- Geen 1-uurs cache write tier — schema heeft één veld. diff --git a/lib/env.ts b/lib/env.ts index 482cef5..f3efe8c 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -20,6 +20,9 @@ const envSchema = z.object({ ) .optional(), INTERNAL_PUSH_SECRET: z.string().min(32).optional(), + // PBI-66 — Anthropic API key voor scripts/sync-model-prices.ts. + // Niet nodig in app-runtime, alleen bij het wekelijkse sync-script. + ANTHROPIC_API_KEY: z.string().optional(), }) const parsed = envSchema.safeParse(process.env) diff --git a/package.json b/package.json index 517f5b7..3ac913f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "db:erd": "prisma generate", "db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"", "db:insert-milestone": "tsx scripts/insert-milestone.ts", + "db:sync-model-prices": "tsx scripts/sync-model-prices.ts", "create-admin": "tsx scripts/create-admin.ts", "seed": "prisma db seed", "docs:index": "node scripts/generate-docs-index.mjs", diff --git a/scripts/README.md b/scripts/README.md index 0011845..1227a0b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,4 +1,57 @@ -# API Test Scripts +# Scripts + +## sync-model-prices.ts + +Wekelijks handmatig draaibaar script dat de tabel `model_prices` synchroniseert. Haalt de actuele Claude 4.x modellijst op via `GET /v1/models` (Anthropic API) en upsert de prijzen vanuit een hardcoded `PRICE_TABLE` in het script. Anthropic biedt geen prijs-API; bij elke prijswijziging update je de tabel in [`scripts/sync-model-prices.ts`](./sync-model-prices.ts). + +### Prerequisites + +| What | How | +|---|---| +| `ANTHROPIC_API_KEY` in `.env.local` | Genereer op [console.anthropic.com](https://console.anthropic.com/) → API Keys. Free Evaluation tier is voldoende — `/v1/models` is een gratis metadata-call. | +| `DATABASE_URL` in `.env.local` | Standaard Scrum4Me-setup. | + +### Gebruik + +```bash +# Eerst droog draaien — toont wat er zou gebeuren, schrijft niets +npm run db:sync-model-prices -- --dry-run + +# Echt synchroniseren +npm run db:sync-model-prices +``` + +### Output + +``` +Fetching /v1/models from Anthropic API... + → 12 models received, 4 match Claude 4.x filter + +Syncing prices: + ✓ claude-opus-4-7 (unchanged) + ✓ claude-sonnet-4-6 (unchanged) + ✓ claude-haiku-4-5-20251001 (unchanged) + ⚠ claude-sonnet-4-9 (geen prijs in PRICE_TABLE — ...) + +Result: 0 created, 0 updated, 3 unchanged, 1 skipped +``` + +### Bij een nieuw model (`⚠ skipped`) + +1. Open de Anthropic [pricing-pagina](https://platform.claude.com/docs/en/about-claude/pricing). +2. Voeg het model toe aan `PRICE_TABLE` in [`scripts/sync-model-prices.ts`](./sync-model-prices.ts): + ```ts + 'claude-sonnet-4-9': { input: 3.0, output: 15.0 }, + ``` +3. Draai het script opnieuw. + +### Edge cases + +- **API geeft 401**: controleer `ANTHROPIC_API_KEY`. +- **API geeft 5xx**: script doet 1× retry met 2s delay, daarna falen. +- **Model in DB maar niet meer in API**: wordt niet verwijderd — alleen gelogd, zodat oude `claude_jobs` rijen kostberekening blijven hebben. + +--- ## test-api.sh diff --git a/scripts/sync-model-prices.ts b/scripts/sync-model-prices.ts new file mode 100644 index 0000000..dbe296a --- /dev/null +++ b/scripts/sync-model-prices.ts @@ -0,0 +1,272 @@ +// Wekelijks handmatig sync-script voor de model_prices tabel. +// +// Gebruik: +// npm run db:sync-model-prices # echt synchroniseren +// npm run db:sync-model-prices -- --dry-run # tonen, niets schrijven +// +// Anthropic biedt geen prijs-API. /v1/models levert alleen modellijst + +// metadata. De prijzen onderhouden we daarom in PRICE_TABLE hieronder. +// De API-call dient om de modellijst te valideren en nieuwe modellen op te +// merken (warning) zodat we weten dat PRICE_TABLE een update nodig heeft. +// +// Plan: docs/plans/sync-model-prices.md + +import { PrismaClient } from '@prisma/client' +import * as dotenv from 'dotenv' +import * as path from 'path' +import { Pool } from 'pg' +import { PrismaPg } from '@prisma/adapter-pg' + +const root = path.resolve(__dirname, '..') +dotenv.config({ path: path.join(root, '.env.local'), override: true }) +dotenv.config({ path: path.join(root, '.env') }) + +const ANTHROPIC_API_BASE = 'https://api.anthropic.com' +const ANTHROPIC_API_VERSION = '2023-06-01' + +// Prijzen per 1M tokens in USD. Bij elke prijswijziging hier updaten. +// Bron: https://platform.claude.com/docs/en/about-claude/pricing +const PRICE_TABLE: Record = { + 'claude-opus-4-7': { input: 15.0, output: 75.0 }, + 'claude-sonnet-4-6': { input: 3.0, output: 15.0 }, + 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 }, +} + +// Cache-tier multipliers t.o.v. input-prijs (Anthropic standaarden, mei 2026): +// cache hit (read) = 0.1× input +// cache write 5-minute = 1.25× input (dit veld in onze DB) +// cache write 1-hour = 2.0× input (niet apart opgeslagen) +const CACHE_READ_RATIO = 0.1 +const CACHE_WRITE_RATIO = 1.25 + +// Alleen Claude 4.x synchroniseren — oudere 3.x worden overgeslagen. +const CLAUDE_4X_REGEX = /^claude-(opus|sonnet|haiku)-4/ + +interface AnthropicModel { + id: string + type: string + display_name: string + created_at: string +} + +interface AnthropicModelsResponse { + data: AnthropicModel[] + has_more: boolean + last_id: string | null +} + +interface Args { + dryRun: boolean +} + +function parseArgs(argv: string[]): Args { + let dryRun = false + for (const a of argv) { + if (a === '--dry-run') dryRun = true + else if (a.startsWith('--')) throw new Error(`Unknown flag: ${a}`) + else throw new Error(`Unexpected argument: ${a}`) + } + return { dryRun } +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function fetchModelsPage( + apiKey: string, + afterId: string | null, +): Promise { + const url = new URL(`${ANTHROPIC_API_BASE}/v1/models`) + url.searchParams.set('limit', '1000') + if (afterId) url.searchParams.set('after_id', afterId) + + const headers = { + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + } + + let lastError: unknown = null + for (let attempt = 0; attempt < 2; attempt++) { + try { + const res = await fetch(url, { headers }) + if (res.status === 401) { + throw new Error( + 'Anthropic API gaf 401 Unauthorized. Controleer ANTHROPIC_API_KEY in .env.local.', + ) + } + if (res.status >= 500) { + lastError = new Error(`Anthropic API gaf ${res.status} ${res.statusText}`) + if (attempt === 0) { + console.warn(` ⚠ ${(lastError as Error).message} — retry over 2s...`) + await sleep(2000) + continue + } + throw lastError + } + if (!res.ok) { + const body = await res.text() + throw new Error(`Anthropic API gaf ${res.status} ${res.statusText}: ${body.slice(0, 300)}`) + } + return (await res.json()) as AnthropicModelsResponse + } catch (err) { + if (err instanceof TypeError) { + // Network/DNS error + lastError = err + if (attempt === 0) { + console.warn(` ⚠ Netwerkfout: ${err.message} — retry over 2s...`) + await sleep(2000) + continue + } + } + throw err + } + } + throw lastError ?? new Error('Unbekende fout bij ophalen /v1/models') +} + +async function fetchAllClaude4xModels(apiKey: string): Promise { + const all: AnthropicModel[] = [] + let afterId: string | null = null + let totalReceived = 0 + + while (true) { + const page = await fetchModelsPage(apiKey, afterId) + totalReceived += page.data.length + for (const m of page.data) { + if (CLAUDE_4X_REGEX.test(m.id)) all.push(m) + } + if (!page.has_more || !page.last_id) break + afterId = page.last_id + } + + console.log(` → ${totalReceived} models received, ${all.length} match Claude 4.x filter`) + return all +} + +interface SyncResult { + created: number + updated: number + unchanged: number + skipped: number +} + +async function syncModel( + prisma: PrismaClient, + modelId: string, + price: { input: number; output: number }, + dryRun: boolean, +): Promise<'created' | 'updated' | 'unchanged'> { + const cacheRead = round6(price.input * CACHE_READ_RATIO) + const cacheWrite = round6(price.input * CACHE_WRITE_RATIO) + + const data = { + model_id: modelId, + input_price_per_1m: price.input, + output_price_per_1m: price.output, + cache_read_price_per_1m: cacheRead, + cache_write_price_per_1m: cacheWrite, + } + + const existing = await prisma.modelPrice.findUnique({ + where: { model_id: modelId }, + select: { + input_price_per_1m: true, + output_price_per_1m: true, + cache_read_price_per_1m: true, + cache_write_price_per_1m: true, + }, + }) + + if (!existing) { + if (!dryRun) { + await prisma.modelPrice.create({ data }) + } + return 'created' + } + + const same = + Number(existing.input_price_per_1m) === data.input_price_per_1m && + Number(existing.output_price_per_1m) === data.output_price_per_1m && + Number(existing.cache_read_price_per_1m) === data.cache_read_price_per_1m && + Number(existing.cache_write_price_per_1m) === data.cache_write_price_per_1m + + if (same) return 'unchanged' + + if (!dryRun) { + await prisma.modelPrice.update({ where: { model_id: modelId }, data }) + } + return 'updated' +} + +function round6(n: number): number { + return Math.round(n * 1_000_000) / 1_000_000 +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + throw new Error( + 'ANTHROPIC_API_KEY is not set. Voeg toe aan .env.local — zie console.anthropic.com → API Keys.', + ) + } + + const dbUrl = process.env.DATABASE_URL + if (!dbUrl) throw new Error('DATABASE_URL is not set. Check .env.local') + + const pool = new Pool({ connectionString: dbUrl }) + const adapter = new PrismaPg(pool) + const prisma = new PrismaClient({ adapter }) + + try { + console.log(`Fetching /v1/models from Anthropic API...${args.dryRun ? ' [DRY RUN]' : ''}`) + const models = await fetchAllClaude4xModels(apiKey) + + if (models.length === 0) { + console.error(' ✗ Geen Claude 4.x modellen ontvangen — aborting.') + process.exit(1) + } + + console.log('\nSyncing prices:') + const result: SyncResult = { created: 0, updated: 0, unchanged: 0, skipped: 0 } + + const apiIds = new Set() + for (const m of models) { + apiIds.add(m.id) + const price = PRICE_TABLE[m.id] + if (!price) { + console.warn( + ` ⚠ ${m.id.padEnd(36)} (geen prijs in PRICE_TABLE — voeg toe aan scripts/sync-model-prices.ts)`, + ) + result.skipped++ + continue + } + const action = await syncModel(prisma, m.id, price, args.dryRun) + const tag = action === 'created' ? '✓' : action === 'updated' ? '✓' : '·' + console.log(` ${tag} ${m.id.padEnd(36)} (${action})`) + result[action]++ + } + + // Detect models in DB that no longer appear in the API (don't delete!). + const dbModels = await prisma.modelPrice.findMany({ select: { model_id: true } }) + const orphaned = dbModels.filter((m) => !apiIds.has(m.model_id)) + if (orphaned.length > 0) { + console.log('\nDB-modellen die niet (meer) in /v1/models staan (worden NIET verwijderd):') + for (const o of orphaned) console.log(` · ${o.model_id}`) + } + + console.log( + `\nResult: ${result.created} created, ${result.updated} updated, ${result.unchanged} unchanged, ${result.skipped} skipped${args.dryRun ? ' [DRY RUN — niets geschreven]' : ''}`, + ) + } finally { + await prisma.$disconnect() + await pool.end() + } +} + +main().catch((err) => { + console.error('\nsync-model-prices failed:', err.message) + process.exit(1) +})