mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 07:09:41 +00:00
421 lines
23 KiB
JavaScript
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`)
|