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>
272 lines
8.4 KiB
TypeScript
272 lines
8.4 KiB
TypeScript
// 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)
|
||
})
|