codeburn/scripts/bundle-litellm.mjs
iamtoruk f4fd6a1640 fix(pricing): restore Mythos 5 pricing dropped by gap-fill cleanup
Mythos 5 ($10/$50) is not in LiteLLM or the models.dev/OpenRouter gap-fill
yet (Fable is), so dropping the manual patch left it priced at $0. Add it to
MANUAL_ENTRIES + the snapshot and map the "Mythos 5" display name.
2026-06-10 00:09:31 +02:00

168 lines
7.9 KiB
JavaScript

import { writeFileSync, mkdirSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
// Pricing sources, in priority order:
// 1. LiteLLM - broad, maintained, tracks provider list prices.
// 2. MANUAL_ENTRIES - hand-curated overrides for the primary snapshot.
// 3. models.dev - only FIRST-PARTY maker providers (not the 100+
// gateways/resellers): official direct price for models
// LiteLLM hasn't added yet (e.g. MiniMax-M3).
// 4. OpenRouter - resale rates, one clean price per canonical model;
// a coverage backstop for makers not in models.dev.
//
// Output is TWO files:
// litellm-snapshot.json - primary (LiteLLM + MANUAL_ENTRIES). Used for the
// exact / canonical / prefix lookups.
// pricing-fallback.json - gap-fill (models.dev + OpenRouter). Consulted ONLY
// as a last resort, so a reseller variant name can
// never shadow an existing canonical/alias match.
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
const MODELS_DEV_URL = 'https://models.dev/api.json'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/models'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dataDir = join(__dirname, '..', 'src', 'data')
const snapshotPath = join(dataDir, 'litellm-snapshot.json')
const fallbackPath = join(dataDir, 'pricing-fallback.json')
// models.dev provider ids that are the actual model MAKERS (publish official
// list prices), as opposed to gateways/resellers (openrouter, nano-gpt, vercel,
// poe, novita, etc.) that mark up or discount. An id missing here just means
// that maker's models fall through to OpenRouter; an unknown id is ignored.
const MODELS_DEV_FIRST_PARTY = new Set([
'openai', 'anthropic', 'google', 'google-vertex', 'mistral', 'deepseek',
'xai', 'minimax', 'minimax-cn', 'moonshotai', 'zhipuai', 'alibaba',
'alibaba-cn', 'cohere', 'perplexity', 'inception', 'morph',
])
const MANUAL_ENTRIES = {
'MiniMax-M2.7': [0.3e-6, 1.2e-6, 0.375e-6, 0.06e-6],
'MiniMax-M2.7-highspeed': [0.6e-6, 2.4e-6, 0.375e-6, 0.06e-6],
// LiteLLM PR #27056 is not merged yet. Source: https://api-docs.deepseek.com/quick_start/pricing
'deepseek-v4-flash': [1.4e-7, 2.8e-7, 0, 2.8e-9],
'deepseek-v4-pro': [4.35e-7, 8.7e-7, 0, 3.625e-9],
// Mythos 5 launch pricing; not yet in LiteLLM or the models.dev/OpenRouter gap-fill (Fable is).
'claude-mythos-5': [10e-6, 50e-6, 12.5e-6, 1e-6],
}
const snapshot = {}
// --- Pass 1+2: LiteLLM (primary) ---
const res = await fetch(LITELLM_URL)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const entries = Object.entries(data).filter(([k]) => k !== 'sample_spec')
function toVal(entry) {
const inp = entry.input_cost_per_token
const out = entry.output_cost_per_token
if (inp == null || out == null) return null
return [inp, out, entry.cache_creation_input_token_cost ?? null, entry.cache_read_input_token_cost ?? null, entry.provider_specific_entry?.fast ?? null]
}
// Pass 1: direct entries (no prefix) get priority
for (const [name, entry] of entries) {
if (name.includes('/')) continue
const val = toVal(entry)
if (val) snapshot[name] = val
}
// Pass 2: prefixed entries - store full key + stripped (first-write-wins)
for (const [name, entry] of entries) {
if (!name.includes('/')) continue
const val = toVal(entry)
if (!val) continue
if (!snapshot[name]) snapshot[name] = val
const stripped = name.replace(/^[^/]+\//, '')
if (stripped !== name && !snapshot[stripped]) snapshot[stripped] = val
}
// A MANUAL_ENTRY that LiteLLM now ships is a candidate to delete (the override
// would otherwise shadow upstream forever with a possibly-stale hand value).
for (const k of Object.keys(MANUAL_ENTRIES)) {
if (snapshot[k]) console.log(`note: MANUAL_ENTRIES['${k}'] is now in LiteLLM - candidate to remove`)
}
Object.assign(snapshot, MANUAL_ENTRIES)
// --- Gap fill into a SEPARATE fallback map (last-resort only) ---
const fallback = {}
// Strip the vendor prefix to the last path segment, then the @pin and trailing
// -YYYYMMDD date that the runtime's getCanonicalName also strips, so a fallback
// key lines up with the canonical form actually queried (otherwise e.g.
// `vendor/claude-3-5-sonnet@20241022` becomes a key the lookup can never reach).
const bareKey = (name) => name.replace(/^.*\//, '').replace(/@.*$/, '').replace(/-\d{8}$/, '')
// `seen` holds every primary key AND its bareKey form (both lowercased) so we
// never re-add a model LiteLLM/MANUAL already covers under either shape; fallback
// keys are added too so the first source wins (models.dev before OpenRouter).
const seen = new Set()
for (const k of Object.keys(snapshot)) {
seen.add(k.toLowerCase())
seen.add(bareKey(k).toLowerCase())
}
const finite = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
// A rate pair is usable only if both sides are non-negative and not both zero.
// OpenRouter uses -1 as a "variable / BYOK price" sentinel; without this guard a
// negative per-token cost would ship and subtract from a user's spend totals.
const validRates = (inp, out) => inp != null && out != null && inp >= 0 && out >= 0 && !(inp === 0 && out === 0)
// Drop the same negative sentinel on optional cache fields.
const nonNeg = (v) => (v != null && v >= 0 ? v : null)
function addGap(key, val) {
if (!key || !val) return false
const lk = key.toLowerCase()
if (seen.has(lk)) return false
fallback[key] = val
seen.add(lk)
return true
}
// --- Pass 3: models.dev first-party makers (official list prices) ---
try {
const md = await (await fetch(MODELS_DEV_URL)).json()
// Surface drift in our hand-maintained maker allowlist: if an id we classify
// as first-party is gone from the API, it was renamed/removed and the set is
// stale (its models would silently fall through to OpenRouter resale rates).
for (const id of MODELS_DEV_FIRST_PARTY) {
if (!md[id]) console.warn(`note: models.dev no longer lists first-party id '${id}' - allowlist may be stale`)
}
let added = 0
for (const pid of Object.keys(md).sort()) {
if (!MODELS_DEV_FIRST_PARTY.has(pid)) continue
const models = md[pid].models ?? {}
for (const mid of Object.keys(models).sort()) {
const c = models[mid].cost
if (!c) continue
const inp = finite(c.input), out = finite(c.output)
if (!validRates(inp, out)) continue
// models.dev cost is per MILLION tokens; snapshot is per token.
const cw = nonNeg(c.cache_write != null ? finite(c.cache_write) : null)
const cr = nonNeg(c.cache_read != null ? finite(c.cache_read) : null)
if (addGap(bareKey(mid), [inp / 1e6, out / 1e6, cw != null ? cw / 1e6 : null, cr != null ? cr / 1e6 : null, null])) added++
}
}
console.log(`models.dev (first-party): +${added} models`)
} catch (e) {
console.warn(`models.dev skipped: ${e.message}`)
}
// --- Pass 4: OpenRouter (resale backstop) ---
try {
const or = (await (await fetch(OPENROUTER_URL)).json()).data ?? []
let added = 0
for (const m of or) {
const p = m.pricing ?? {}
const inp = finite(p.prompt), out = finite(p.completion)
if (!validRates(inp, out)) continue
// OpenRouter pricing fields are already per-token.
const cw = nonNeg(p.input_cache_write != null ? finite(p.input_cache_write) : null)
const cr = nonNeg(p.input_cache_read != null ? finite(p.input_cache_read) : null)
if (addGap(bareKey(m.id ?? ''), [inp, out, cw, cr, null])) added++
}
console.log(`openrouter (backstop): +${added} models`)
} catch (e) {
console.warn(`openrouter skipped: ${e.message}`)
}
mkdirSync(dataDir, { recursive: true })
writeFileSync(snapshotPath, JSON.stringify(snapshot))
writeFileSync(fallbackPath, JSON.stringify(fallback))
console.log(`Bundled ${Object.keys(snapshot).length} primary + ${Object.keys(fallback).length} fallback models`)