// 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) })