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>
This commit is contained in:
parent
8a6b2d2cb3
commit
eaabec8471
7 changed files with 443 additions and 1 deletions
|
|
@ -25,6 +25,12 @@ VAPID_SUBJECT="mailto:admin@example.com"
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
INTERNAL_PUSH_SECRET=""
|
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.
|
# v1-readiness item 2 — Sentry error monitoring.
|
||||||
# Optional. Without DSN, the SDK is a no-op (no network, no overhead).
|
# 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).
|
# Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN).
|
||||||
|
|
|
||||||
|
|
@ -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-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-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 |
|
| [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 |
|
| [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 |
|
| [Scrum4Me — v1.0 readiness](./plans/v1-readiness.md) | active | 2026-05-04 |
|
||||||
|
|
||||||
|
|
|
||||||
106
docs/plans/sync-model-prices.md
Normal file
106
docs/plans/sync-model-prices.md
Normal file
|
|
@ -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<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 },
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -20,6 +20,9 @@ const envSchema = z.object({
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
INTERNAL_PUSH_SECRET: z.string().min(32).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)
|
const parsed = envSchema.safeParse(process.env)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"db:erd": "prisma generate",
|
"db:erd": "prisma generate",
|
||||||
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"",
|
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"",
|
||||||
"db:insert-milestone": "tsx scripts/insert-milestone.ts",
|
"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",
|
"create-admin": "tsx scripts/create-admin.ts",
|
||||||
"seed": "prisma db seed",
|
"seed": "prisma db seed",
|
||||||
"docs:index": "node scripts/generate-docs-index.mjs",
|
"docs:index": "node scripts/generate-docs-index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -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
|
## test-api.sh
|
||||||
|
|
||||||
|
|
|
||||||
272
scripts/sync-model-prices.ts
Normal file
272
scripts/sync-model-prices.ts
Normal file
|
|
@ -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<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)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue