Scrum4Me/scripts/sync-model-prices.ts
Janpeter Visser eaabec8471
feat(PBI-66): wekelijkse sync van model_prices via Anthropic /v1/models (#167)
Nieuw script `npm run db:sync-model-prices` haalt de actuele Claude 4.x
modellijst op bij de Anthropic API en upsert prijzen in `model_prices`.
Anthropic biedt geen prijs-API, dus prijzen blijven onderhouden in een
PRICE_TABLE constante in het script. Cache-tier-prijzen worden afgeleid
via vaste multipliers (read 0.1x, write 1.25x). Nieuwe Claude 4.x modellen
worden gedetecteerd en gelogd als warning zodat duidelijk is wanneer de
tabel handmatig moet worden bijgewerkt.

- scripts/sync-model-prices.ts: idempotent upsert, --dry-run, retry op 5xx
- ANTHROPIC_API_KEY als optional env-var (.env.example, lib/env.ts)
- scripts/README.md: gebruiksinstructies + edge cases
- docs/plans/sync-model-prices.md: ontwerpdocument

Verificatie: `npm run lint`, `vitest` (563/563), TypeScript clean.
Echt gedraaid tegen DB: 3 created (eerste run) -> 3 unchanged (tweede run).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:38:33 +02:00

272 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, { input: number; output: number }> = {
'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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function fetchModelsPage(
apiKey: string,
afterId: string | null,
): Promise<AnthropicModelsResponse> {
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<AnthropicModel[]> {
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<void> {
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<string>()
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)
})