codeburn/scripts/bundle-litellm.mjs

421 lines
23 KiB
JavaScript

import { readFileSync, 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 = {
// Mistral Large 4 ("Le Chonk"), public preview from 2026-10-06. Mistral's
// pricing page lists $1.36 / $4.18 (cached $0.14) struck through for the
// preview rate below, which is what Mistral and OpenRouter bill today. Without
// these rows the ids prefix-match the 2024 bare `mistral-large` ($4 / $12).
// Remove once LiteLLM carries them; reprice if the preview discount ends.
'mistral-large-4-0': [0.68e-6, 2.09e-6, null, 0.07e-6],
'mistral-large-2610': [0.68e-6, 2.09e-6, null, 0.07e-6],
'mistral-large-4': [0.68e-6, 2.09e-6, null, 0.07e-6],
'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],
// deepseek-v4-flash / deepseek-v4-pro were hand-pinned here while LiteLLM PR
// #27056 was open (#1134). LiteLLM now carries official PEAK pricing for both
// spellings (bare + `deepseek/`-prefixed, verified equal against
// https://api-docs.deepseek.com/quick_start/pricing - off-peak is half of
// peak), so the pins are gone and the snapshot speaks for itself.
// 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],
// gpt-5.6-codex / gpt-5.6-codex-max (#1077, #1134): STILL not in LiteLLM as
// of the 2026-09-29 refresh (the 2026-08-24 repricing reached the gpt-5.6
// base row, $5/$30 -> $4/$20, but no codex SKU row exists upstream). Every
// prior Codex-suffixed id LiteLLM DOES carry bills identically to its
// bare-model sibling of the same generation - gpt-5-codex == gpt-5,
// gpt-5.1-codex == gpt-5.1-codex-max == gpt-5.1, gpt-5.2-codex == gpt-5.2,
// gpt-5.3-codex == gpt-5.3 (all four rates identical, verified against the
// live model_prices_and_context_window.json). Mirroring that pattern onto
// gpt-5.6 rather than inventing a number: both ids carry the exact gpt-5.6
// row, verbatim INCLUDING the >272k tier block and the Flex slot, so the
// tests/models.test.ts codex-equals-base assertion can hold. These are full-row mirrors, not
// hand-picked rates: drop both entries entirely once LiteLLM ships the
// codex SKUs, rather than editing them in place.
'gpt-5.6-codex': [4e-6, 2e-5, 5e-6, 4e-7, 2, { threshold: 272000, input: 8e-6, output: 3e-5, cacheWrite: 1e-5, cacheRead: 8e-7 }, [2e-6, 1e-5, 2.5e-6, 2e-7, null, { threshold: 272000, input: 4e-6, output: 1.5e-5, cacheWrite: 5e-6, cacheRead: 4e-7 }]],
'gpt-5.6-codex-max': [4e-6, 2e-5, 5e-6, 4e-7, 2, { threshold: 272000, input: 8e-6, output: 3e-5, cacheWrite: 1e-5, cacheRead: 8e-7 }, [2e-6, 1e-5, 2.5e-6, 2e-7, null, { threshold: 272000, input: 4e-6, output: 1.5e-5, cacheWrite: 5e-6, cacheRead: 4e-7 }]],
// LiteLLM dropped `claude-opus-4` upstream (a refresh moves dropped ids to
// the fallback tier), but the Cursor-style alias `claude-4-opus` resolves
// against PRIMARY rows - without this pin the bare id falls to the
// snowflake gateway row and under-prices by 3x. Anthropic list price.
'claude-opus-4': [15e-6, 75e-6, 18.75e-6, 1.5e-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')
// The plain context-length tiers only: `input_cost_per_token_above_272k_tokens`
// and siblings. Service-tier variants (`_above_272k_priority_tokens`,
// `_above_272k_flex_tokens`) and the 1-hour cache-write combination are NOT
// context thresholds and are deliberately not matched. The threshold comes
// from the key suffix (272k -> 272000) because LiteLLM carries no numeric
// threshold field (#1076). Mirrored in src/models.ts parseLiteLLMEntry.
const TIER_KEY_RE = /^(input_cost_per_token|output_cost_per_token|cache_read_input_token_cost|cache_creation_input_token_cost)_above_(\d+)k_tokens$/
// OpenAI's priority processing tier ships as explicit `<rate>_priority` keys
// beside the standard ones (and `_above_<n>k_tokens_priority` for gpt-5.6's
// long-context tier). Codex's Fast speed setting bills through it (#1616), so
// slot 5 falls back to that ratio when the row carries no
// `provider_specific_entry.fast`. Derived only where every bucket the row
// prices agrees on one ratio — models without priority keys, or rows whose
// ratios disagree (azure/gpt-5.5: 2.5x base, 2x above 272k), stay null (1x).
// A tier without its own priority keys (gpt-5.4, gpt-5.5) gets `fast: 1` so it
// stays at standard tier rates: OpenAI quotes no Fast long-context price there.
// Never a hand-picked number. Mirrored in src/models.ts parseLiteLLMEntry.
const PRIORITY_KEY_SUFFIX = '_priority'
const MAX_DERIVED_FAST_MULTIPLIER = 100
function priorityMultiplierOf(entry) {
const ratios = []
let inputRatio
let outputRatio
for (const [key, value] of Object.entries(entry)) {
if (!key.endsWith(PRIORITY_KEY_SUFFIX)) continue
const base = entry[key.slice(0, -PRIORITY_KEY_SUFFIX.length)]
if (typeof value !== 'number' || typeof base !== 'number') continue
if (!Number.isFinite(value) || !Number.isFinite(base) || value <= 0 || base <= 0) continue
const ratio = value / base
if (key === 'input_cost_per_token_priority') inputRatio = ratio
else if (key === 'output_cost_per_token_priority') outputRatio = ratio
ratios.push(ratio)
}
if (inputRatio === undefined || outputRatio === undefined) return null
const agreed = ratios.every((r) => Math.abs(r - inputRatio) <= 1e-9 * Math.max(r, inputRatio))
// Rounded to 4 decimals so division noise (1.7999999999999998) never ships.
return agreed && inputRatio <= MAX_DERIVED_FAST_MULTIPLIER ? Math.round(inputRatio * 1e4) / 1e4 : null
}
function tierOf(entry) {
// Rates are read ONLY from the largest threshold a model carries, so a
// hypothetical entry with two tiers can never mix a smaller tier's rates
// under the bigger threshold. Values must be finite and non-negative, the
// same validation src/models.ts applies on the live path.
const byThreshold = new Map()
for (const [key, value] of Object.entries(entry)) {
const m = TIER_KEY_RE.exec(key)
if (!m || typeof value !== 'number' || !Number.isFinite(value) || value < 0) continue
const tokens = Number(m[2]) * 1000
const rates = byThreshold.get(tokens) ?? {}
if (m[1] === 'input_cost_per_token') rates.input = value
else if (m[1] === 'output_cost_per_token') rates.output = value
else if (m[1] === 'cache_read_input_token_cost') rates.cacheRead = value
else rates.cacheWrite = value
byThreshold.set(tokens, rates)
}
if (byThreshold.size === 0) return null
const threshold = Math.max(...byThreshold.keys())
const rates = byThreshold.get(threshold)
if (rates.input == null || rates.output == null) return null
return { threshold, input: rates.input, output: rates.output, cacheWrite: rates.cacheWrite ?? null, cacheRead: rates.cacheRead ?? null }
}
// OpenAI's Flex processing tier ships as explicit `<rate>_flex` keys
// (`_above_<n>k_tokens_flex` for the long-context tier), carried as slot 6: a
// full tuple of Flex rates, appended only where the row publishes input and
// output flex rates so every other row stays byte-identical. Rates, not a
// ratio (gpt-5.4's flex cache read is $0.13/M, not half of $0.25/M); a bucket
// without a flex rate keeps its standard rate. Mirrored in src/models.ts flexOf.
const FLEX_KEY_SUFFIX = '_flex'
function flexOf(entry, cacheWrite, cacheRead, tier) {
const rate = (key) => {
const v = entry[key + FLEX_KEY_SUFFIX]
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null
}
const input = rate('input_cost_per_token')
const output = rate('output_cost_per_token')
if (input === null || output === null) return null
const above = (key) => `${key}_above_${tier.threshold / 1000}k_tokens`
return [input, output, rate('cache_creation_input_token_cost') ?? cacheWrite, rate('cache_read_input_token_cost') ?? cacheRead, null, tier ? {
threshold: tier.threshold,
input: rate(above('input_cost_per_token')) ?? tier.input,
output: rate(above('output_cost_per_token')) ?? tier.output,
cacheWrite: rate(above('cache_creation_input_token_cost')) ?? tier.cacheWrite,
cacheRead: rate(above('cache_read_input_token_cost')) ?? tier.cacheRead,
} : null]
}
function toVal(entry) {
const inp = entry.input_cost_per_token
const out = entry.output_cost_per_token
if (inp == null || out == null) return null
const explicitFast = entry.provider_specific_entry?.fast
const priorityFast = explicitFast == null ? priorityMultiplierOf(entry) : null
const tier = tierOf(entry)
if (tier && priorityFast !== null && !Object.keys(entry).some((k) => k.endsWith(`_above_${tier.threshold / 1000}k_tokens${PRIORITY_KEY_SUFFIX}`))) {
tier.fast = 1
}
const val = [inp, out, entry.cache_creation_input_token_cost ?? null, entry.cache_read_input_token_cost ?? null, explicitFast ?? priorityFast, tier]
const flex = flexOf(entry, val[2], val[3], tier)
return flex ? [...val, flex] : val
}
// 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
}
// A tuple's completeness: how many optional rate slots (cache-write,
// cache-read) carry a published value. A richer upstream row may cite it to
// FILL a sparser entry's missing slots - never as a license to re-price it
// (see the fillsOnly guard in Pass 2). The tier slot (5) is deliberately not
// counted: tier presence must never decide which row wins, or a tier-bearing
// row would outrank the base-richer row main would have picked.
const completeness = (val) => (val[2] != null ? 1 : 0) + (val[3] != null ? 1 : 0)
// Pass 2: prefixed entries - store full key + stripped (slot-fill-only)
// A prefixed row may claim the slot its vendor-stripped key would answer
// (e.g. `fireworks_ai/accounts/fireworks/models/x` keeps the
// `accounts/fireworks/models/x` query priced) - but never when the stripped
// key is itself an upstream entry name. LiteLLM sorts reseller namespaces
// (`openrouter/deepseek/deepseek-v4-pro`) before the vendor's own row
// (`deepseek/deepseek-v4-pro`), so an unconditional claim let resale rates
// shadow the official row for the very same id, with the winner decided
// purely by JSON key order (#1134: the openrouter row held the
// `deepseek/deepseek-v4-pro` slot at ~40% under official peak pricing).
const entryNames = new Set(Object.keys(data))
// Which prefixed row claims an absent bare key: the model maker's own row
// (`xai/grok-4.6`) over any reseller's (`azure_ai/grok-4.6`), whatever the JSON
// order, even at $0 (Gemma is free on Google's own API). Among the other rows
// a $0/$0 one yields to any priced one (the `codestral/` free-beta rows priced
// `codestral-latest` at nothing); otherwise the first row claims, as before.
// The key keeps the position its first claimant gave it. Mirrored in
// src/models.ts.
const MAKER_PREFIXES = new Set([
'xai', 'mistral', 'cohere', 'anthropic', 'openai', 'gemini', 'deepseek', 'moonshot',
'zai', 'minimax', 'ai21', 'perplexity', 'dashscope', 'meta_llama', 'xiaomi_mimo',
])
// Two segments only: `perplexity/openai/gpt-5.6-sol` is Perplexity reselling.
const isMaker = (name) => name.split('/').length === 2 && MAKER_PREFIXES.has(name.split('/')[0])
const isFree = (val) => val[0] === 0 && val[1] === 0
const bareClaims = new Map()
const makerClaimed = new Set()
for (const [name, entry] of [...entries.filter(([n]) => isMaker(n)), ...entries.filter(([n]) => !isMaker(n))]) {
if (!name.includes('/')) continue
const stripped = name.replace(/^[^/]+\//, '')
if (entryNames.has(stripped)) continue
const val = toVal(entry)
if (!val) continue
const prev = bareClaims.get(stripped)
if (!prev || (!makerClaimed.has(stripped) && isFree(prev) && !isFree(val))) bareClaims.set(stripped, val)
if (isMaker(name)) makerClaimed.add(stripped)
}
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) continue
const existing = snapshot[stripped]
// The stripped key may already hold Pass 1's direct entry or an earlier
// Pass 2 row. A "more complete" upstream row may only top up missing
// slots - it never re-prices a filled one: input/output must be identical,
// and every non-null optional slot of the existing tuple must survive
// verbatim (val may add slots, never change them). Guarantees no rate ever
// changes across a refresh; only missing slots fill. The completeness-wins
// version re-priced 43 input/output and 34 cache rates by swapping in a
// different upstream row (grok-3 3/15 -> 1.25/2.5). Slot 5 (the tier object) stays out of the guard: it is
// built fresh per row, so a reference compare is always false and would
// veto fills main performs (it silently dropped the azure cache-read fill
// for gpt-5.4-pro-class rows); and since the replacement only fires when
// the candidate fills a missing BASE slot, the winning row's tier travels
// with its own base rates - splicing the old row's tier onto the new row's
// base would mix two different upstream rows.
const fillsOnly = (cand, prev) =>
cand[0] === prev[0]
&& cand[1] === prev[1]
&& (prev[2] == null || cand[2] === prev[2])
&& (prev[3] == null || cand[3] === prev[3])
if (!existing) {
if (bareClaims.has(stripped)) snapshot[stripped] = bareClaims.get(stripped)
continue
}
if (completeness(val) > completeness(existing) && fillsOnly(val, existing)) 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())
}
// A refresh must never leave a model that HAD pricing without any: carry the
// previous files' entries forward verbatim when neither the new primary nor
// the new gap-fill covers them exactly. That means both the previous
// fallback's own last-resort entries AND previous PRIMARY rows the new
// upstream data dropped or renamed (LiteLLM removed e.g.
// `gpt-image-2-2026-04-21` and the Bedrock marengo embeds between regens) —
// an id users priced yesterday stays priced at its last known rate in the
// fallback tier, which is exactly the last-resort tier orphaned ids belong
// in. Primary rows are consulted before fallback rows so a key present in
// both keeps its authoritative primary value.
const previousFallback = (() => {
try {
return JSON.parse(readFileSync(fallbackPath, 'utf8'))
} catch {
return {}
}
})()
const previousSnapshot = (() => {
try {
return JSON.parse(readFileSync(snapshotPath, 'utf8'))
} catch {
return {}
}
})()
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 })
let carried = 0
// Coverage here is exact-key ONLY. The runtime resolver (`getModelCosts`)
// never tries `vendor/<id>` for a bare `<id>` query — it looks the given id
// up verbatim, peels segments off it, and strips variant suffixes, but never
// adds a vendor prefix — so treating a `~x-ai/grok-latest` primary as
// covering a bare `grok-latest` would drop the old entry while the model
// still prices as null (the first version of this refresh did exactly that
// to 96 fallback ids). NOT date-stripped either, for the same reason: a
// dated primary variant like `qwen/qwen3.5-plus-20260420` does not answer
// the undated query.
const coveredByKey = (key) =>
snapshot[key] !== undefined
|| fallback[key] !== undefined
for (const [k, v] of [...Object.entries(previousSnapshot), ...Object.entries(previousFallback)]) {
if (coveredByKey(k)) continue
if (fallback[k] !== undefined) continue
// Same hygiene the gap-fill passes enforce: a carried row must not be
// @pin or date-suffixed (a query can never arrive in those forms - the
// runtime only ever peels them off, never adds them; a vendor-prefixed
// key CAN arrive verbatim, so it stays carriable) and must not be free on
// both ends (an unpriced model falls to expected-free handling, not a $0
// fallback row). Primary files legitimately hold such rows, so the guard
// lives here: when upstream drops one, carrying it verbatim would
// re-import exactly what tests/pricing-fallback-data.test.ts keeps out
// of this file.
if (/@/.test(k) || /-\d{8}$/.test(k)) continue
if (!validRates(v[0], v[1])) continue
fallback[k] = v
carried += 1
}
if (carried > 0) console.log(`carried ${carried} previously-priced entries forward (dropped primary rows + fallback)`)
writeFileSync(snapshotPath, JSON.stringify(snapshot))
writeFileSync(fallbackPath, JSON.stringify(fallback))
console.log(`Bundled ${Object.keys(snapshot).length} primary + ${Object.keys(fallback).length} fallback models`)