mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 22:44:31 +00:00
Reports bucket rows by model id and label them afterwards, so the two ids collapsing onto one display name printed what looked like the same row twice with different numbers. Give the variant its own SHORT_NAMES entry, which the longest-first match picks over the grok-4.5 prefix. Display only: ids are untouched, so nothing re-parses and no cost moves. Fixed in the shared table rather than the grok provider so the menubar and model-breakdown, which call getShortModelName directly, get it too. Fixes #1029.
1036 lines
46 KiB
TypeScript
1036 lines
46 KiB
TypeScript
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||
import { join } from 'path'
|
||
|
||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||
import snapshotData from './data/litellm-snapshot.json' with { type: 'json' }
|
||
import fallbackData from './data/pricing-fallback.json' with { type: 'json' }
|
||
import { fetchWithTimeout } from './fetch-utils.js'
|
||
|
||
export type ModelCosts = {
|
||
inputCostPerToken: number
|
||
outputCostPerToken: number
|
||
cacheWriteCostPerToken: number
|
||
cacheReadCostPerToken: number
|
||
webSearchCostPerRequest: number
|
||
fastMultiplier: number
|
||
}
|
||
|
||
type PriceOverrideRates = {
|
||
input: number
|
||
output: number
|
||
cacheRead?: number
|
||
cacheCreation?: number
|
||
}
|
||
|
||
type LiteLLMEntry = {
|
||
input_cost_per_token?: number
|
||
output_cost_per_token?: number
|
||
cache_creation_input_token_cost?: number
|
||
cache_read_input_token_cost?: number
|
||
provider_specific_entry?: { fast?: number }
|
||
}
|
||
|
||
// [input, output, cacheWrite, cacheRead, fastMultiplier]. The trailing fast
|
||
// multiplier is carried straight from LiteLLM's provider_specific_entry.fast so
|
||
// new models pick it up automatically — no hand-maintained per-model table.
|
||
type SnapshotEntry = [number, number, number | null, number | null, (number | null)?]
|
||
|
||
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||
const WEB_SEARCH_COST = 0.01
|
||
const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6
|
||
|
||
// Explicit USD/token prices that must override LiteLLM/cache data. Cursor
|
||
// publishes house-model rates in the models table at cursor.com/docs/models
|
||
// (provider "Cursor", USD per 1M tokens): composer-2/2.5: $0.50 input, $2.50
|
||
// output, $0.20 cache read; composer-1.5: $3.50/$17.50/$0.35; composer-1:
|
||
// $1.25/$10/$0.125. Cursor publishes no separate cache-write rate for these,
|
||
// so cache write uses the input rate.
|
||
const BUILTIN_PRICE_OVERRIDES: Record<string, SnapshotEntry> = {
|
||
'composer-2.5': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6],
|
||
'composer-2': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6],
|
||
'composer-1.5': [3.5e-6, 17.5e-6, 3.5e-6, 0.35e-6],
|
||
'composer-1': [1.25e-6, 10e-6, 1.25e-6, 0.125e-6],
|
||
}
|
||
|
||
// Assemble a ModelCosts, applying the cache-cost heuristics (write = 1.25x
|
||
// input, read = 0.1x input) when a source omits them. Shared by the bundled
|
||
// tuple path (tupleToCosts) and the live LiteLLM path (parseLiteLLMEntry) so the
|
||
// multipliers live in exactly one place.
|
||
function buildCosts(
|
||
input: number,
|
||
output: number,
|
||
cacheWrite: number | null | undefined,
|
||
cacheRead: number | null | undefined,
|
||
fast: number | null | undefined,
|
||
): ModelCosts {
|
||
return {
|
||
inputCostPerToken: input,
|
||
outputCostPerToken: output,
|
||
cacheWriteCostPerToken: cacheWrite ?? input * 1.25,
|
||
cacheReadCostPerToken: cacheRead ?? input * 0.1,
|
||
webSearchCostPerRequest: WEB_SEARCH_COST,
|
||
fastMultiplier: fast ?? 1,
|
||
}
|
||
}
|
||
|
||
function tupleToCosts(raw: SnapshotEntry): ModelCosts {
|
||
const [input, output, cacheWrite, cacheRead, fast] = raw
|
||
return buildCosts(input, output, cacheWrite, cacheRead, fast)
|
||
}
|
||
|
||
function applyBuiltinPriceOverrides(pricing: Map<string, ModelCosts>): Map<string, ModelCosts> {
|
||
for (const [name, raw] of Object.entries(BUILTIN_PRICE_OVERRIDES)) {
|
||
pricing.set(name, tupleToCosts(raw))
|
||
}
|
||
return pricing
|
||
}
|
||
|
||
function loadSnapshot(): Map<string, ModelCosts> {
|
||
const map = new Map<string, ModelCosts>()
|
||
for (const [name, raw] of Object.entries(snapshotData as unknown as Record<string, SnapshotEntry>)) {
|
||
map.set(name, tupleToCosts(raw))
|
||
}
|
||
return map
|
||
}
|
||
|
||
// Gap-fill pricing from models.dev / OpenRouter, keyed lowercase. Consulted ONLY
|
||
// as the last-resort fallback in getModelCosts (never for exact/canonical/prefix
|
||
// matches), so a reseller variant name can't shadow a real canonical entry.
|
||
const fallbackCosts: Map<string, ModelCosts> = (() => {
|
||
const map = new Map<string, ModelCosts>()
|
||
for (const [name, raw] of Object.entries(fallbackData as unknown as Record<string, SnapshotEntry>)) {
|
||
const lk = name.toLowerCase()
|
||
if (!map.has(lk)) map.set(lk, tupleToCosts(raw))
|
||
}
|
||
return map
|
||
})()
|
||
|
||
let pricingCache: Map<string, ModelCosts> = applyBuiltinPriceOverrides(loadSnapshot())
|
||
let sortedPricingKeys: string[] | null = null
|
||
let lowercasePricingIndex: Map<string, ModelCosts> | null = null
|
||
|
||
function getSortedPricingKeys(): string[] {
|
||
if (sortedPricingKeys === null) {
|
||
sortedPricingKeys = Array.from(pricingCache.keys()).sort((a, b) => b.length - a.length)
|
||
}
|
||
return sortedPricingKeys
|
||
}
|
||
|
||
// Case-insensitive index, built lazily. Lets a session model like `MiniMax-M3`
|
||
// resolve to a gap-filled OpenRouter key like `minimax-m3` (lowercase slug).
|
||
// First key wins on a lowercase collision so it stays deterministic.
|
||
//
|
||
// Zero-priced entries are excluded: LiteLLM ships `[0,0]` stubs (e.g.
|
||
// `GigaChat-2-Max`) for models it lists but has no price for. Indexing those
|
||
// would let a case-mismatched query (`gigachat-2-max`) resolve to a silent $0
|
||
// instead of returning null, which suppresses the unknown-model warning and
|
||
// hides real spend. A case-EXACT query still finds the stub via the normal
|
||
// pipeline; only the fuzzy case-insensitive path skips them.
|
||
function getLowercasePricingIndex(): Map<string, ModelCosts> {
|
||
if (lowercasePricingIndex === null) {
|
||
lowercasePricingIndex = new Map()
|
||
const priced = (c: ModelCosts) => c.inputCostPerToken > 0 || c.outputCostPerToken > 0
|
||
// The live pricing data wins on any lowercase collision; the gap-fill only
|
||
// fills names that resolve to nothing through the normal pipeline.
|
||
for (const [key, costs] of pricingCache) {
|
||
const lk = key.toLowerCase()
|
||
if (priced(costs) && !lowercasePricingIndex.has(lk)) lowercasePricingIndex.set(lk, costs)
|
||
}
|
||
for (const [lk, costs] of fallbackCosts) {
|
||
if (priced(costs) && !lowercasePricingIndex.has(lk)) lowercasePricingIndex.set(lk, costs)
|
||
}
|
||
}
|
||
return lowercasePricingIndex
|
||
}
|
||
|
||
function getCachePath(): string {
|
||
return join(getCodeburnCacheDir(), 'litellm-pricing.json')
|
||
}
|
||
|
||
/// Clamp a per-token rate to a sane non-negative value. Defense in depth
|
||
/// against a tampered LiteLLM JSON shipping a negative `input_cost_per_token`,
|
||
/// which would otherwise produce negative costs that subtract from totals.
|
||
/// We use Number.isFinite to also reject NaN/Infinity, and cap at $1/token
|
||
/// (well above the most expensive frontier model) so a stray decimal-place
|
||
/// shift in the upstream JSON can't wildly inflate spend numbers either.
|
||
function safePerTokenRate(n: number | undefined): number | null {
|
||
if (n === undefined || !Number.isFinite(n) || n < 0) return null
|
||
if (n > 1) return 1
|
||
return n
|
||
}
|
||
|
||
export function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
|
||
// The live LiteLLM map is remote JSON; a null (or non-object) value for a
|
||
// model would make the field reads below throw and abort the whole pricing
|
||
// load. Treat it as unparseable, like any other bad entry.
|
||
if (!entry || typeof entry !== 'object') return null
|
||
const inputCost = safePerTokenRate(entry.input_cost_per_token)
|
||
const outputCost = safePerTokenRate(entry.output_cost_per_token)
|
||
if (inputCost === null || outputCost === null) return null
|
||
return buildCosts(
|
||
inputCost,
|
||
outputCost,
|
||
safePerTokenRate(entry.cache_creation_input_token_cost),
|
||
safePerTokenRate(entry.cache_read_input_token_cost),
|
||
entry.provider_specific_entry?.fast,
|
||
)
|
||
}
|
||
|
||
async function fetchAndCachePricing(): Promise<Map<string, ModelCosts>> {
|
||
// Bounded: runs on every CLI invocation (the menubar shells out and blocks on
|
||
// it). Without a timeout a half-open network after wake-from-sleep makes
|
||
// fetch() hang forever, wedging the menubar's loading spinner. On timeout the
|
||
// caller's catch falls back to the bundled price snapshot.
|
||
const response = await fetchWithTimeout(LITELLM_URL)
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||
const data = await response.json() as Record<string, LiteLLMEntry>
|
||
const pricing = new Map<string, ModelCosts>()
|
||
|
||
for (const [name, entry] of Object.entries(data)) {
|
||
const costs = parseLiteLLMEntry(entry)
|
||
if (!costs) continue
|
||
pricing.set(name, costs)
|
||
// Also index by stripped name so lookups work without provider prefix:
|
||
// 'anthropic/claude-opus-4-6' is also queryable as 'claude-opus-4-6'.
|
||
// First write wins so direct-provider entries take precedence over re-hosters.
|
||
const stripped = name.replace(/^[^/]+\//, '')
|
||
if (stripped !== name && !pricing.has(stripped)) pricing.set(stripped, costs)
|
||
}
|
||
|
||
await mkdir(getCodeburnCacheDir(), { recursive: true })
|
||
await writeFile(getCachePath(), JSON.stringify({
|
||
timestamp: Date.now(),
|
||
data: Object.fromEntries(pricing),
|
||
}))
|
||
|
||
return pricing
|
||
}
|
||
|
||
async function loadCachedPricing(): Promise<Map<string, ModelCosts> | null> {
|
||
try {
|
||
const raw = await readFile(getCachePath(), 'utf-8')
|
||
const cached = JSON.parse(raw) as { timestamp: number; data: Record<string, ModelCosts> }
|
||
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
|
||
return new Map(Object.entries(cached.data))
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function mergeSnapshotFallbacks(pricing: Map<string, ModelCosts>): Map<string, ModelCosts> {
|
||
for (const [name, costs] of loadSnapshot()) {
|
||
if (!pricing.has(name)) pricing.set(name, costs)
|
||
}
|
||
return applyBuiltinPriceOverrides(pricing)
|
||
}
|
||
|
||
export async function loadPricing(): Promise<void> {
|
||
const cached = await loadCachedPricing()
|
||
if (cached) {
|
||
pricingCache = mergeSnapshotFallbacks(cached)
|
||
sortedPricingKeys = null
|
||
lowercasePricingIndex = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
pricingCache = mergeSnapshotFallbacks(await fetchAndCachePricing())
|
||
sortedPricingKeys = null
|
||
lowercasePricingIndex = null
|
||
} catch {
|
||
// snapshot already loaded at init; nothing more to do
|
||
}
|
||
}
|
||
|
||
// Known model name variants that providers emit but LiteLLM/fallback don't index under.
|
||
// OMP emits 'anthropic--claude-4.6-opus' (double-dash, dot version, tier-last).
|
||
// getCanonicalName strips any 'provider/' prefix first, so only the post-strip
|
||
// forms need to be listed here.
|
||
const BUILTIN_ALIASES: Record<string, string> = {
|
||
'anthropic--claude-4.6-opus': 'claude-opus-4-6',
|
||
'anthropic--claude-4.6-sonnet': 'claude-sonnet-4-6',
|
||
'anthropic--claude-4.5-opus': 'claude-opus-4-5',
|
||
'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5',
|
||
'anthropic--claude-4.5-haiku': 'claude-haiku-4-5',
|
||
'claude-sonnet-4.6': 'claude-sonnet-4-6',
|
||
'claude-sonnet-4.5': 'claude-sonnet-4-5',
|
||
'claude-opus-4.7': 'claude-opus-4-7',
|
||
'claude-opus-4.6': 'claude-opus-4-6',
|
||
'claude-opus-4.5': 'claude-opus-4-5',
|
||
'cursor-auto': 'claude-sonnet-4-5',
|
||
'cursor-agent-auto': 'claude-sonnet-4-5',
|
||
'copilot-auto': 'claude-sonnet-4-5',
|
||
'copilot-openai-auto': 'gpt-5.3-codex',
|
||
'copilot-anthropic-auto': 'claude-sonnet-4-5',
|
||
'openai-codex:gpt-5.5': 'gpt-5.5',
|
||
'ibm-bob-auto': 'claude-sonnet-4-5',
|
||
'kiro-auto': 'claude-sonnet-4-5',
|
||
'quickdesk-auto': 'claude-sonnet-4-5',
|
||
'cline-auto': 'claude-sonnet-4-5',
|
||
'openclaw-auto': 'claude-sonnet-4-5',
|
||
'warp-auto-efficient': 'gpt-5.3-codex',
|
||
'warp-auto-powerful': 'claude-opus-4-6',
|
||
'grok-build': 'grok-build-0.1',
|
||
'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex',
|
||
'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex',
|
||
'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex',
|
||
'GPT-5.3 Codex (extra high reasoning)': 'gpt-5.3-codex',
|
||
'Claude Sonnet 4.6': 'claude-sonnet-4-6',
|
||
'Claude Sonnet 4.5': 'claude-sonnet-4-5',
|
||
'Claude Haiku 4.5': 'claude-haiku-4-5',
|
||
'Claude Opus 4.6': 'claude-opus-4-6',
|
||
'claude-4-6-sonnet-high': 'claude-sonnet-4-6',
|
||
'claude-4-6-sonnet-low': 'claude-sonnet-4-6',
|
||
'claude-4-6-sonnet-medium': 'claude-sonnet-4-6',
|
||
'claude-4-6-sonnet-high-fast': 'claude-sonnet-4-6',
|
||
'claude-4-7-opus-xhigh': 'claude-opus-4-7',
|
||
'claude-4-7-opus-xhigh-fast': 'claude-opus-4-7',
|
||
'qwen-auto': 'claude-sonnet-4-5',
|
||
'kimi-auto': 'kimi-k2-thinking',
|
||
'kimi-code': 'kimi-k2-thinking',
|
||
'kimi-for-coding': 'kimi-k2-thinking',
|
||
// Kimi Code wires report the bare `k3` id in llm.request.model; without an
|
||
// alias those calls priced at $0 and the provider looked absent in the UI.
|
||
'k3': 'kimi-k3',
|
||
// Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`.
|
||
'k3-agent': 'kimi-k3',
|
||
'k2d6-agent': 'kimi-k2p6',
|
||
'mimo-v2-flash': 'xiaomi/mimo-v2-flash',
|
||
'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro',
|
||
// Cursor emits dot-version tier-last names plus tier/reasoning suffixes
|
||
// that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`,
|
||
// `-high-thinking`, `-fast-mode`). Missing aliases here surface as $0 in
|
||
// the dashboard for users on non-Auto models (issue #159). Sources: the
|
||
// display map at `src/providers/cursor.ts:modelDisplayNames`, Cursor's
|
||
// public model docs at https://cursor.com/docs/models, and forum bug
|
||
// reports that quote literal slugs (e.g. forum.cursor.com/t/154933).
|
||
'claude-4-sonnet': 'claude-sonnet-4',
|
||
'claude-4-sonnet-1m': 'claude-sonnet-4',
|
||
'claude-4-sonnet-thinking': 'claude-sonnet-4',
|
||
'claude-4.5-sonnet': 'claude-sonnet-4-5',
|
||
'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5',
|
||
'claude-4.6-sonnet': 'claude-sonnet-4-6',
|
||
'claude-4.6-sonnet-high': 'claude-sonnet-4-6',
|
||
'claude-4.6-sonnet-low': 'claude-sonnet-4-6',
|
||
'claude-4.6-sonnet-thinking': 'claude-sonnet-4-6',
|
||
'claude-4.6-sonnet-high-thinking':'claude-sonnet-4-6',
|
||
'claude-4-opus': 'claude-opus-4',
|
||
'claude-4.5-opus': 'claude-opus-4-5',
|
||
'claude-4.5-opus-high': 'claude-opus-4-5',
|
||
'claude-4.5-opus-low': 'claude-opus-4-5',
|
||
'claude-4.5-opus-medium': 'claude-opus-4-5',
|
||
'claude-4.5-opus-high-thinking': 'claude-opus-4-5',
|
||
'claude-4.6-opus': 'claude-opus-4-6',
|
||
'claude-4.6-opus-fast-mode': 'claude-opus-4-6',
|
||
'claude-4.6-opus-high': 'claude-opus-4-6',
|
||
'claude-4.6-opus-low': 'claude-opus-4-6',
|
||
'claude-4.6-opus-medium': 'claude-opus-4-6',
|
||
'claude-4.6-opus-high-thinking': 'claude-opus-4-6',
|
||
'claude-4.7-opus': 'claude-opus-4-7',
|
||
// Dash form (NOT dot) seen in forum.cursor.com/t/158597.
|
||
'claude-opus-4-7-thinking-high': 'claude-opus-4-7',
|
||
'claude-4.5-haiku': 'claude-haiku-4-5',
|
||
'claude-4.6-haiku': 'claude-haiku-4-5',
|
||
// Cursor house composer models use Cursor-published rates in
|
||
// BUILTIN_PRICE_OVERRIDES; keep them out of this alias map so they do not
|
||
// inherit Claude Sonnet proxy pricing.
|
||
// Cursor's "fast" routing variant of GPT-5 is the same model behind a
|
||
// lower-latency endpoint; price as base GPT-5 until LiteLLM tracks it.
|
||
'gpt-5-fast': 'gpt-5',
|
||
'gpt-4.1': 'gpt-4.1',
|
||
'gpt-5.2-low': 'gpt-5',
|
||
'gpt-5.1-codex-high': 'gpt-5.3-codex',
|
||
// Antigravity Gemini model IDs resolve to preview-priced entries.
|
||
'gemini-3.1-pro': 'gemini-3.1-pro-preview',
|
||
'gemini-3-flash': 'gemini-3-flash-preview',
|
||
'gemini-3.1-pro-high': 'gemini-3.1-pro-preview',
|
||
'gemini-3.1-pro-low': 'gemini-3.1-pro-preview',
|
||
'gemini-3-flash-agent': 'gemini-3-flash-preview',
|
||
'gemini-3.5-flash-high': 'gemini-3.5-flash',
|
||
'gemini-3.5-flash-medium': 'gemini-3.5-flash',
|
||
'gemini-3.5-flash-low': 'gemini-3.5-flash',
|
||
'Gemini 3.5 Flash (High)': 'gemini-3.5-flash',
|
||
'Gemini 3.5 Flash (Medium)': 'gemini-3.5-flash',
|
||
'Gemini 3.5 Flash (Low)': 'gemini-3.5-flash',
|
||
'gemini-3-pro': 'gemini-3-pro-preview',
|
||
'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview',
|
||
'gemini-3.1-flash-lite': 'gemini-3.1-flash-lite-preview',
|
||
// ZCode runs GLM-5.2 through z.ai's start-plan subscription; it isn't in
|
||
// LiteLLM yet. Price as the nearest released sibling (GLM-5.1) until it is.
|
||
'GLM-5.2': 'glm-5p1',
|
||
// Hermes Agent stores the same model id lowercased (`glm-5.2`) in its
|
||
// sessions table, so it misses the capitalized alias above and goes
|
||
// unpriced. Map the lowercase spelling to the same sibling.
|
||
'glm-5.2': 'glm-5p1',
|
||
}
|
||
|
||
let userAliases: Record<string, string> = {}
|
||
let userPriceOverrides: Map<string, ModelCosts> = new Map()
|
||
let userPriceOverridesConfig: Record<string, PriceOverrideRates> = {}
|
||
let sortedPriceOverrideKeys: string[] | null = null
|
||
let lowercasePriceOverrideIndex: Map<string, ModelCosts> | null = null
|
||
|
||
// Called once during CLI startup after config is loaded.
|
||
// User aliases take precedence over built-ins.
|
||
export function setModelAliases(aliases: Record<string, string>): void {
|
||
userAliases = aliases
|
||
}
|
||
|
||
function priceOverrideRatePerToken(usdPerMillion: number | undefined): number | null {
|
||
if (typeof usdPerMillion !== 'number') return null
|
||
return safePerTokenRate(usdPerMillion / 1_000_000)
|
||
}
|
||
|
||
// Called once during CLI startup after config is loaded.
|
||
// Config/CLI rates are USD per 1,000,000 tokens; ModelCosts stores USD/token.
|
||
export function setPriceOverrides(overrides: Record<string, PriceOverrideRates>): void {
|
||
const next = new Map<string, ModelCosts>()
|
||
const nextConfig: Record<string, PriceOverrideRates> = {}
|
||
for (const [model, rates] of Object.entries(overrides)) {
|
||
if (!model || !rates || typeof rates !== 'object') continue
|
||
nextConfig[model] = { ...rates }
|
||
const input = priceOverrideRatePerToken(rates.input)
|
||
const output = priceOverrideRatePerToken(rates.output)
|
||
if (input === null || output === null) continue
|
||
next.set(model, buildCosts(
|
||
input,
|
||
output,
|
||
priceOverrideRatePerToken(rates.cacheCreation),
|
||
priceOverrideRatePerToken(rates.cacheRead),
|
||
undefined,
|
||
))
|
||
}
|
||
userPriceOverrides = next
|
||
userPriceOverridesConfig = nextConfig
|
||
sortedPriceOverrideKeys = null
|
||
lowercasePriceOverrideIndex = null
|
||
}
|
||
|
||
function getSortedPriceOverrideKeys(): string[] {
|
||
if (sortedPriceOverrideKeys === null) {
|
||
sortedPriceOverrideKeys = Array.from(userPriceOverrides.keys()).sort((a, b) => b.length - a.length)
|
||
}
|
||
return sortedPriceOverrideKeys
|
||
}
|
||
|
||
function getLowercasePriceOverrideIndex(): Map<string, ModelCosts> {
|
||
if (lowercasePriceOverrideIndex === null) {
|
||
lowercasePriceOverrideIndex = new Map()
|
||
for (const [key, costs] of userPriceOverrides) {
|
||
const lk = key.toLowerCase()
|
||
if (!lowercasePriceOverrideIndex.has(lk)) lowercasePriceOverrideIndex.set(lk, costs)
|
||
}
|
||
}
|
||
return lowercasePriceOverrideIndex
|
||
}
|
||
|
||
function getPriceOverrideExact(...keys: string[]): ModelCosts | null {
|
||
for (const key of keys) {
|
||
const costs = userPriceOverrides.get(key)
|
||
if (costs) return costs
|
||
}
|
||
return null
|
||
}
|
||
|
||
function getPriceOverridePrefix(canonical: string): ModelCosts | null {
|
||
for (const key of getSortedPriceOverrideKeys()) {
|
||
if (canonical.startsWith(key + '-') || canonical === key) {
|
||
return userPriceOverrides.get(key)!
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function getPriceOverrideCaseInsensitive(canonical: string, withPrefix: string): ModelCosts | null {
|
||
const lowerIndex = getLowercasePriceOverrideIndex()
|
||
return lowerIndex.get(canonical.toLowerCase()) ?? lowerIndex.get(withPrefix.toLowerCase()) ?? null
|
||
}
|
||
|
||
// Local-model savings config. Kept separate from userAliases: a `modelAliases`
|
||
// entry rewrites a model's identity for actual cost; a `localModelSavings`
|
||
// entry keeps the model cost at $0 and reports the *avoided* spend against a
|
||
// paid baseline. Set during preAction from `config.localModelSavings`.
|
||
let userLocalModelSavings: Record<string, string> = {}
|
||
|
||
export function setLocalModelSavings(mappings: Record<string, string>): void {
|
||
userLocalModelSavings = { ...mappings }
|
||
}
|
||
|
||
export function getLocalSavingsBaseline(rawModel: string): string | undefined {
|
||
if (!rawModel || typeof rawModel !== 'string') return undefined
|
||
// Defensive: bracket-accessing user-controlled keys on a plain object
|
||
// exposes the prototype chain (`__proto__` would resolve to Object.prototype).
|
||
// Use Object.hasOwn so a hostile JSONL model name cannot piggyback into
|
||
// Object.prototype either through the alias map or here.
|
||
if (!Object.hasOwn(userLocalModelSavings, rawModel)) return undefined
|
||
return userLocalModelSavings[rawModel]
|
||
}
|
||
|
||
/// Compute the hypothetical baseline cost for a local call. The baseline
|
||
/// model is priced through the normal `calculateCost` pipeline (so it can
|
||
/// be aliased / canonicalized). Returns `null` when the source model has
|
||
/// no savings mapping, the baseline is unknown to the pricing snapshot, or
|
||
/// any input is unusable — callers should treat null as "no savings
|
||
/// recorded for this call" rather than a hard error.
|
||
export function calculateLocalModelSavings(
|
||
rawModel: string,
|
||
inputTokens: number,
|
||
outputTokens: number,
|
||
cacheCreationTokens: number,
|
||
cacheReadTokens: number,
|
||
webSearchRequests: number,
|
||
speed: 'standard' | 'fast' = 'standard',
|
||
oneHourCacheCreationTokens = 0,
|
||
): { savingsUSD: number; baselineModel: string } | null {
|
||
const baseline = getLocalSavingsBaseline(rawModel)
|
||
if (!baseline) return null
|
||
if (!getModelCosts(baseline)) return null
|
||
const savingsUSD = calculateCost(
|
||
baseline,
|
||
inputTokens,
|
||
outputTokens,
|
||
cacheCreationTokens,
|
||
cacheReadTokens,
|
||
webSearchRequests,
|
||
speed,
|
||
oneHourCacheCreationTokens,
|
||
)
|
||
return { savingsUSD, baselineModel: baseline }
|
||
}
|
||
|
||
/// Stable hash of the current savings config so the daily cache can detect
|
||
/// "user changed their baseline mapping" and rebuild instead of presenting
|
||
/// stale saved-spend numbers. Two configs with the same key→baseline pairs
|
||
/// in any order collapse to the same hash.
|
||
export function getLocalModelSavingsConfigHash(): string {
|
||
const keys = Object.keys(userLocalModelSavings).sort()
|
||
if (keys.length === 0) return ''
|
||
const parts = keys.map(k => `${k}\u0001${userLocalModelSavings[k]}`)
|
||
return parts.join('\u0002')
|
||
}
|
||
|
||
/// Stable hash of the model-alias map, for the same staleness class as the
|
||
/// hashes below: a resident process (codeburn serve) must not serve memoized
|
||
/// parse results priced under aliases the user has since changed.
|
||
export function getModelAliasesConfigHash(): string {
|
||
const keys = Object.keys(userAliases).sort()
|
||
if (keys.length === 0) return ''
|
||
return keys.map(k => `${k}\u0001${userAliases[k]}`).join('\u0002')
|
||
}
|
||
|
||
export function getPriceOverridesConfigHash(): string {
|
||
// The builtin overrides participate so editing BUILTIN_PRICE_OVERRIDES in a
|
||
// release invalidates cached daily costs the same way a user override does.
|
||
const builtin = `builtin:${JSON.stringify(BUILTIN_PRICE_OVERRIDES)}`
|
||
const keys = Object.keys(userPriceOverridesConfig).sort()
|
||
if (keys.length === 0) return builtin
|
||
const parts = keys.map(k => {
|
||
const rates = userPriceOverridesConfig[k]
|
||
return [
|
||
k,
|
||
rates.input,
|
||
rates.output,
|
||
rates.cacheRead ?? '',
|
||
rates.cacheCreation ?? '',
|
||
].join('\u0001')
|
||
})
|
||
return [builtin, ...parts].join('\u0002')
|
||
}
|
||
|
||
// Absolute directory prefixes whose sessions are routed through a
|
||
// subscription-backed proxy (config `proxyPaths`). Stored already-normalized so
|
||
// the per-project match is a cheap compare. Set during preAction. See
|
||
// CodeburnConfig.proxyPaths for the product rationale.
|
||
let userProxyPaths: string[] = []
|
||
|
||
/// Normalize a path for prefix comparison: backslashes -> forward slashes
|
||
/// (Windows configs / cwds), strip leading AND trailing slashes, fold case on
|
||
/// case-insensitive filesystems. Leading slashes are stripped because provider
|
||
/// project paths arrive in two forms — Claude keeps the absolute "/Users/x"
|
||
/// while Codex (sanitizeProject) and the unsanitizePath fallback drop the
|
||
/// leading slash to "Users/x". Folding both to a slashless form (mirroring
|
||
/// crossProviderKey) makes matching agnostic to which provider produced the
|
||
/// path, so the same directory is flagged whether or not a Claude session
|
||
/// happens to co-exist there. Case is folded only on macOS/Windows; on Linux
|
||
/// "/home/Me" and "/home/me" are different dirs, so folding would risk
|
||
/// crediting unrelated spend. A path that normalizes to empty (e.g. "/" or "")
|
||
/// is dropped by callers so it can never match everything. Exported so the CLI
|
||
/// dedupes with the same rule.
|
||
export function normalizeProxyPath(p: string): string {
|
||
const s = p.trim().replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '')
|
||
return (process.platform === 'darwin' || process.platform === 'win32') ? s.toLowerCase() : s
|
||
}
|
||
|
||
export function setProxyPaths(paths: string[]): void {
|
||
userProxyPaths = (Array.isArray(paths) ? paths : [])
|
||
.filter((p): p is string => typeof p === 'string')
|
||
.map(normalizeProxyPath)
|
||
.filter(p => p !== '')
|
||
}
|
||
|
||
/// True when `cwd` is at or under a configured proxy path. Prefix match is
|
||
/// anchored to a path-segment boundary so "/a/proj" matches "/a/proj" and
|
||
/// "/a/proj/sub" but NOT "/a/project-x". Empty/undefined cwd or empty config
|
||
/// never matches (so a misconfig can't silently zero unrelated spend).
|
||
export function isProxiedPath(cwd: string | undefined | null): boolean {
|
||
if (!cwd || typeof cwd !== 'string') return false
|
||
if (userProxyPaths.length === 0) return false
|
||
const c = normalizeProxyPath(cwd)
|
||
if (c === '') return false
|
||
return userProxyPaths.some(p => c === p || c.startsWith(p + '/'))
|
||
}
|
||
|
||
/// Stable hash of the active proxy-path config. Project-level proxy attribution
|
||
/// is computed live from this set and then cached in the in-memory session
|
||
/// cache, so the cache key must vary with it — otherwise a long-lived process
|
||
/// (menubar) that re-reads config could serve attribution from a stale set.
|
||
export function getProxyPathsConfigHash(): string {
|
||
if (userProxyPaths.length === 0) return ''
|
||
return [...userProxyPaths].sort().join('')
|
||
}
|
||
|
||
function resolveAlias(model: string): string {
|
||
if (Object.hasOwn(userAliases, model)) return userAliases[model]!
|
||
if (Object.hasOwn(BUILTIN_ALIASES, model)) return BUILTIN_ALIASES[model]!
|
||
const lowercase = model.toLowerCase()
|
||
if (lowercase !== model && Object.hasOwn(BUILTIN_ALIASES, lowercase)) return BUILTIN_ALIASES[lowercase]!
|
||
return model
|
||
}
|
||
function getCanonicalName(model: string): string {
|
||
return model
|
||
.replace(/@.*$/, '') // strip pin: claude-sonnet-4-6@20250929 -> claude-sonnet-4-6
|
||
.replace(/-\d{8}$/, '') // strip date: claude-sonnet-4-20250514 -> claude-sonnet-4
|
||
.replace(/^[^/]+\//, '') // strip provider prefix: anthropic/foo -> foo
|
||
.replace(/\[[^\]]*\]$/, '') // strip context tag: Codex records Kimi as k3[1m], so kimi/k3[1m] -> k3
|
||
}
|
||
|
||
function stripKnownPricingVariantSuffix(model: string): string | null {
|
||
const withoutColonSuffix = model.replace(/:(thinking|cloud)$/i, '')
|
||
if (withoutColonSuffix !== model) return withoutColonSuffix
|
||
|
||
const withoutTeeSuffix = model.replace(/-TEE$/i, '')
|
||
if (withoutTeeSuffix !== model) return withoutTeeSuffix
|
||
|
||
return null
|
||
}
|
||
|
||
export function getModelCosts(model: string): ModelCosts | null {
|
||
// Try with provider prefix preserved (azure/gpt-5.4, openrouter/anthropic/claude-opus-4.6)
|
||
const withPrefix = model.replace(/@.*$/, '').replace(/-\d{8}$/, '')
|
||
const canonicalName = getCanonicalName(model)
|
||
const canonical = resolveAlias(canonicalName)
|
||
|
||
const override = getPriceOverrideExact(model, withPrefix, canonicalName, canonical)
|
||
if (override) return override
|
||
|
||
// An explicit alias for a bare (un-prefixed) model name is authoritative: it
|
||
// must win over a coincidental stripped reseller key of the same name. LiteLLM
|
||
// ships `snowflake/claude-4-opus` ($5), which the bundler strips to a bare
|
||
// `claude-4-opus` key; without this, that would shadow the curated alias
|
||
// `claude-4-opus -> claude-opus-4` ($15 official Anthropic price).
|
||
if (canonical !== canonicalName && withPrefix === canonicalName && pricingCache.has(canonical)) {
|
||
return pricingCache.get(canonical)!
|
||
}
|
||
|
||
if (pricingCache.has(withPrefix)) return pricingCache.get(withPrefix)!
|
||
|
||
if (pricingCache.has(canonical)) return pricingCache.get(canonical)!
|
||
|
||
const prefixOverride = getPriceOverridePrefix(canonical)
|
||
if (prefixOverride) return prefixOverride
|
||
|
||
// Iterate keys longest-first so a model id like `gpt-5-mini` matches the
|
||
// `gpt-5-mini` entry rather than collapsing to the shorter `gpt-5` entry
|
||
// due to dictionary insertion order.
|
||
for (const key of getSortedPricingKeys()) {
|
||
if (canonical.startsWith(key + '-') || canonical === key) {
|
||
return pricingCache.get(key)!
|
||
}
|
||
}
|
||
|
||
const caseInsensitiveOverride = getPriceOverrideCaseInsensitive(canonical, withPrefix)
|
||
if (caseInsensitiveOverride) return caseInsensitiveOverride
|
||
|
||
// Case-insensitive fallback: gap-filled keys from OpenRouter are lowercase
|
||
// slugs (e.g. `minimax-m3`), but sessions report `MiniMax-M3`. Only consulted
|
||
// after the exact/canonical/prefix attempts, so it never changes a match that
|
||
// already resolved above.
|
||
const lowerIndex = getLowercasePricingIndex()
|
||
const byCanonical = lowerIndex.get(canonical.toLowerCase())
|
||
if (byCanonical) return byCanonical
|
||
const byPrefix = lowerIndex.get(withPrefix.toLowerCase())
|
||
if (byPrefix) return byPrefix
|
||
|
||
const withPrefixVariant = stripKnownPricingVariantSuffix(withPrefix)
|
||
if (withPrefixVariant && withPrefixVariant !== withPrefix) {
|
||
const variantCosts = getModelCosts(withPrefixVariant)
|
||
if (variantCosts) return variantCosts
|
||
}
|
||
|
||
const canonicalVariant = stripKnownPricingVariantSuffix(canonical)
|
||
if (canonicalVariant && canonicalVariant !== canonical && canonicalVariant !== withPrefixVariant) {
|
||
const variantCosts = getModelCosts(canonicalVariant)
|
||
if (variantCosts) return variantCosts
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// Warn at most once per unknown model name per process. Without this, a model
|
||
// missing from the pricing snapshot would silently price at $0 for every
|
||
// session that used it, hiding real spend until the user noticed.
|
||
const warnedUnknownModels = new Set<string>()
|
||
|
||
/// Heuristic for "this looks like a local model that will never be in LiteLLM's
|
||
/// pricing JSON". We suppress the unknown-model warning for these because the
|
||
/// "update codeburn" advice can't help — local Ollama models, llama.cpp tags,
|
||
/// LM Studio loads, etc. are billed locally and don't have public pricing.
|
||
/// Users still get $0 in cost reports for them (correct — local inference is
|
||
/// effectively free); the warning was just noise.
|
||
function looksLikeLocalModel(name: string): boolean {
|
||
// Ollama and LM Studio tags include `:tag` (e.g. qwen3.6:35b-a3b-bf16).
|
||
if (name.includes(':') && !name.startsWith('http')) return true
|
||
// GGUF / quantized fingerprints commonly seen in local inference.
|
||
if (/[-_](q[2-8](_[a-z0-9]+)?|bf16|fp16|gguf|f16|f32)$/i.test(name)) return true
|
||
return false
|
||
}
|
||
|
||
export interface UnpricedModelUsage {
|
||
model: string
|
||
calls: number
|
||
tokens: number
|
||
}
|
||
|
||
function hasBillableRate(costs: ModelCosts): boolean {
|
||
return costs.inputCostPerToken > 0
|
||
|| costs.outputCostPerToken > 0
|
||
|| costs.cacheWriteCostPerToken > 0
|
||
|| costs.cacheReadCostPerToken > 0
|
||
}
|
||
|
||
// Exact-override lookup with the same key derivation getModelCosts uses. Lets
|
||
// the unpriced detector distinguish "explicitly declared free by the user" (a
|
||
// zero-rate override) from a zero-rate LiteLLM stub, which means "listed but
|
||
// unknown price" and must still be flagged. Only the EXACT override form is
|
||
// consulted: getModelCosts checks it before any table hit, so when one exists
|
||
// it is provably what priced the model. Prefix and case-insensitive overrides
|
||
// resolve AFTER table hits and so cannot prove the $0 was intentional; a
|
||
// zero-rate stub shadowed by one still gets flagged (the honest direction).
|
||
function exactPriceOverrideFor(model: string): ModelCosts | null {
|
||
const withPrefix = model.replace(/@.*$/, '').replace(/-\d{8}$/, '')
|
||
const canonicalName = getCanonicalName(model)
|
||
const canonical = resolveAlias(canonicalName)
|
||
return getPriceOverrideExact(model, withPrefix, canonicalName, canonical)
|
||
}
|
||
|
||
// Render-time unpriced detection (#638): flag aggregated model rows that carry
|
||
// usage but $0 cost AND whose pricing lookup yields no billable rate right
|
||
// now. Cost is computed at parse time and cached, so a parse-time registry
|
||
// would miss cached sessions; a render-time check covers both and heals the
|
||
// moment pricing data, an alias, or a price override arrives.
|
||
//
|
||
// Rows with cost > 0 are never flagged: aggregation keys rows by DISPLAY name
|
||
// (parser.ts keys modelBreakdown via getShortModelName), which the pricing
|
||
// lookup misses, so a priced model like "Opus 4.8" would otherwise false-flag.
|
||
// $0 display-name rows ARE flagged even when the raw id would price today:
|
||
// those tokens really did enter the report at $0 (a provider priced a
|
||
// transformed name, or the session was cached before its model's pricing
|
||
// landed). Conservative by design: a display key merging priced and unpriced
|
||
// raw ids carries cost > 0 and is not flagged. Local-looking models and
|
||
// models with a local-savings mapping are excluded because $0 is their
|
||
// correct cost, as are zero-rate USER overrides (explicitly declared free).
|
||
/// Models whose $0 cost is CORRECT rather than a pricing gap, mirroring the
|
||
/// exclusions findUnpricedModels applies: local-looking models, models mapped
|
||
/// to a local-savings baseline, and models an exact zero-rate user override
|
||
/// declares free. Used to keep their calls out of the pricing-coverage
|
||
/// denominator — otherwise a 95%-ollama user reads high coverage while every
|
||
/// genuinely cost-bearing call is unpriced.
|
||
export function isExpectedFreeModel(model: string): boolean {
|
||
if (looksLikeLocalModel(model)) return true
|
||
if (getLocalSavingsBaseline(model)) return true
|
||
const costs = getModelCosts(model)
|
||
if (costs && !hasBillableRate(costs) && exactPriceOverrideFor(model)) return true
|
||
return false
|
||
}
|
||
|
||
export function findUnpricedModels(
|
||
rows: Iterable<{ model: string; calls: number; cost: number; tokens?: number }>,
|
||
): UnpricedModelUsage[] {
|
||
const out: UnpricedModelUsage[] = []
|
||
for (const row of rows) {
|
||
const { model } = row
|
||
const tokens = row.tokens ?? 0
|
||
if (!model || model === '<synthetic>') continue
|
||
if (row.calls <= 0 && tokens <= 0) continue
|
||
if (row.cost > 0) continue
|
||
if (looksLikeLocalModel(model)) continue
|
||
if (getLocalSavingsBaseline(model)) continue
|
||
const costs = getModelCosts(model)
|
||
if (costs && hasBillableRate(costs)) continue
|
||
if (costs && exactPriceOverrideFor(model)) continue
|
||
out.push({ model, calls: row.calls, tokens })
|
||
}
|
||
return out.sort((a, b) => (b.tokens - a.tokens) || (b.calls - a.calls)
|
||
|| (a.model < b.model ? -1 : a.model > b.model ? 1 : 0))
|
||
}
|
||
|
||
function shouldWarnAboutUnknownModel(name: string): boolean {
|
||
if (!name || name === '<synthetic>') return false
|
||
if (warnedUnknownModels.has(name)) return false
|
||
// Suppress for local/quantized models — the "update codeburn" hint is
|
||
// actively misleading there. Users who need cost visibility for local
|
||
// inference can still set an alias via `codeburn model-alias`.
|
||
if (looksLikeLocalModel(name)) return false
|
||
// The warning fired on every CLI invocation (including the default
|
||
// dashboard) which made first launches look broken — three "no pricing
|
||
// data" lines greet a user before the dashboard even draws. Now opt-in
|
||
// via --verbose. The unknown model still costs $0 in reports; users who
|
||
// suspect missing models run `codeburn --verbose` to see the list.
|
||
if (process.env['CODEBURN_VERBOSE'] !== '1') return false
|
||
return true
|
||
}
|
||
|
||
/** Render provider-supplied model IDs without terminal control characters. */
|
||
export function sanitizeModelForDisplay(model: string): string {
|
||
return model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
|
||
}
|
||
|
||
export function calculateCost(
|
||
model: string,
|
||
inputTokens: number,
|
||
outputTokens: number,
|
||
cacheCreationTokens: number,
|
||
cacheReadTokens: number,
|
||
webSearchRequests: number,
|
||
speed: 'standard' | 'fast' = 'standard',
|
||
oneHourCacheCreationTokens = 0,
|
||
): number {
|
||
const costs = getModelCosts(model)
|
||
if (!costs) {
|
||
if (shouldWarnAboutUnknownModel(model)) {
|
||
warnedUnknownModels.add(model)
|
||
// Strip control characters and cap length: model names come from JSONL
|
||
// payloads written by external tools, so a hostile or corrupt file
|
||
// could embed terminal escape sequences here.
|
||
const safeName = sanitizeModelForDisplay(model)
|
||
const aliasHint = `Map it with: codeburn model-alias "${safeName}" <known-model>, or track local-model savings with: codeburn model-savings "${safeName}" <baseline-model>`
|
||
process.stderr.write(
|
||
`codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` +
|
||
`${aliasHint}, or update with: npx codeburn@latest.\n`
|
||
)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
const multiplier = speed === 'fast' ? costs.fastMultiplier : 1
|
||
|
||
// Clamp negative inputs to 0. A corrupt JSONL that emits a negative token
|
||
// count would otherwise produce a negative cost that silently subtracts
|
||
// from real spend in aggregate totals. NaN is also handled here; the
|
||
// arithmetic below short-circuits to 0 when any operand is non-finite.
|
||
const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0)
|
||
const safeOneHourCacheCreation = safe(oneHourCacheCreationTokens)
|
||
const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation)
|
||
const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation)
|
||
|
||
return multiplier * (
|
||
safe(inputTokens) * costs.inputCostPerToken +
|
||
safe(outputTokens) * costs.outputCostPerToken +
|
||
safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken +
|
||
safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE +
|
||
safe(cacheReadTokens) * costs.cacheReadCostPerToken +
|
||
safe(webSearchRequests) * costs.webSearchCostPerRequest
|
||
)
|
||
}
|
||
|
||
const autoModelNames: Record<string, string> = {
|
||
'cursor-auto': 'Cursor (auto)',
|
||
'cursor-agent-auto': 'Cursor (auto)',
|
||
'copilot-auto': 'Copilot (auto)',
|
||
'copilot-openai-auto': 'Copilot (OpenAI)',
|
||
'copilot-anthropic-auto': 'Copilot (Anthropic)',
|
||
'ibm-bob-auto': 'IBM Bob (auto)',
|
||
'kiro-auto': 'Kiro (auto)',
|
||
'quickdesk-auto': 'Quick Desktop (auto)',
|
||
'cline-auto': 'Cline (auto)',
|
||
'openclaw-auto': 'OpenClaw (auto)',
|
||
'qwen-auto': 'Qwen (auto)',
|
||
'kimi-auto': 'Kimi (auto)',
|
||
}
|
||
|
||
const SHORT_NAMES: Record<string, string> = {
|
||
// claude-fable-5 and claude-mythos-5 are outside the opus/sonnet/haiku families deriveClaudeShortName covers.
|
||
'claude-fable-5': 'Fable 5',
|
||
'claude-mythos-5': 'Mythos 5',
|
||
// Modern claude-<family>-<major>-<minor> ids are derived in deriveClaudeShortName.
|
||
// Only the legacy 3.x ids (family-last) need explicit mapping.
|
||
'claude-3-7-sonnet': 'Sonnet 3.7',
|
||
'claude-3-5-sonnet': 'Sonnet 3.5',
|
||
'claude-3-5-haiku': 'Haiku 3.5',
|
||
'gpt-4o-mini': 'GPT-4o Mini',
|
||
'gpt-4o': 'GPT-4o',
|
||
'gpt-4.1-nano': 'GPT-4.1 Nano',
|
||
'gpt-4.1-mini': 'GPT-4.1 Mini',
|
||
'gpt-4.1': 'GPT-4.1',
|
||
'codex-auto-review': 'Codex Auto Review',
|
||
'gpt-5.5-pro': 'GPT-5.5 Pro',
|
||
'gpt-5.5': 'GPT-5.5',
|
||
'gpt-5.4-pro': 'GPT-5.4 Pro',
|
||
'gpt-5.4-nano': 'GPT-5.4 Nano',
|
||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||
'gpt-5.4': 'GPT-5.4',
|
||
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
|
||
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
||
'gpt-5.3': 'GPT-5.3',
|
||
'gpt-5.2-pro': 'GPT-5.2 Pro',
|
||
'gpt-5.2-low': 'GPT-5.2 Low',
|
||
'gpt-5.2': 'GPT-5.2',
|
||
'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini',
|
||
'gpt-5.1-codex': 'GPT-5.1 Codex',
|
||
'gpt-5.1': 'GPT-5.1',
|
||
'gpt-5-pro': 'GPT-5 Pro',
|
||
'gpt-5-nano': 'GPT-5 Nano',
|
||
'gpt-5-mini': 'GPT-5 Mini',
|
||
'gpt-5': 'GPT-5',
|
||
'gemini-3.5-flash': 'Gemini 3.5 Flash',
|
||
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro',
|
||
'gemini-3-flash-preview': 'Gemini 3 Flash',
|
||
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||
'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo',
|
||
'kimi-k2-thinking': 'Kimi K2 Thinking',
|
||
'kimi-k3': 'Kimi K3',
|
||
'kimi-k2p6': 'Kimi K2.6',
|
||
'kimi-thinking-preview': 'Kimi Thinking',
|
||
'kimi-k2.6': 'Kimi K2.6',
|
||
'kimi-k2.5': 'Kimi K2.5',
|
||
'kimi-k2p5': 'Kimi K2.5',
|
||
'kimi-k2-instruct': 'Kimi K2 Instruct',
|
||
'kimi-k2-0905': 'Kimi K2',
|
||
'kimi-k2': 'Kimi K2',
|
||
'kimi-latest': 'Kimi Latest',
|
||
'moonshot-v1': 'Moonshot v1',
|
||
'deepseek-v4-pro': 'DeepSeek v4 Pro',
|
||
'deepseek-v4-flash': 'DeepSeek v4 Flash',
|
||
'deepseek-coder-max': 'DeepSeek Coder Max',
|
||
'deepseek-coder': 'DeepSeek Coder',
|
||
'deepseek-r1': 'DeepSeek R1',
|
||
'o4-mini': 'o4-mini',
|
||
'o3': 'o3',
|
||
'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed',
|
||
'MiniMax-M2.7': 'MiniMax M2.7',
|
||
// Grok (xAI) and GLM ids that otherwise surface raw or as a pricing key in
|
||
// reports. grok-build and GLM-5.2 price via sibling aliases, so
|
||
// getShortModelName resolves to the pricing key before this lookup; map each
|
||
// back to the real model name. grok-composer has no alias, it just lacked an
|
||
// entry.
|
||
'glm-5p1': 'GLM-5.2', // ZCode/Hermes run GLM-5.2 (priced as the GLM-5.1 sibling)
|
||
'grok-build-0.1': 'Grok Build', // Grok Build prices through the 0.1 sibling
|
||
'grok-composer-2.5-fast': 'Grok Composer 2.5 Fast',
|
||
// Fireworks-hosted fleet models arrive as `accounts/fireworks/models/<slug>`;
|
||
// getShortModelName's path fallback strips to the bare slug and re-resolves it
|
||
// through this table. Display-only — getModelCosts prices off the full path,
|
||
// so these entries do not move any dollar amounts. (deepseek-v4-pro/-flash
|
||
// already have entries above and resolve the same way.)
|
||
'glm-5p2': 'GLM-5.2',
|
||
'qwen3p7-plus': 'Qwen 3.7 Plus',
|
||
'kimi-k2p7-code': 'Kimi K2.7 Code',
|
||
// Ids that price correctly but had no display entry, so reports showed the
|
||
// raw slug. All display-only. The GPT-5.6 variants are listed individually
|
||
// rather than as a bare `gpt-5.6`: a base entry would swallow every future
|
||
// `gpt-5.6-*` via the prefix match and hide the variant, which is exactly
|
||
// what getShortModelName's version-boundary rule is there to prevent.
|
||
'gpt-5.6-sol': 'GPT-5.6 Sol',
|
||
'gpt-5.6-terra': 'GPT-5.6 Terra',
|
||
'gpt-5.6-luna': 'GPT-5.6 Luna',
|
||
// The Grok Build harness reports the model it runs (`grok-4.5`), so this is
|
||
// the model's own name; `grok-build*` ids still resolve to "Grok Build".
|
||
'grok-4.5': 'Grok 4.5',
|
||
// The harness also reports a `-build` variant of that model. It is a distinct
|
||
// id and reports bucket by id, so without its own entry the prefix match gave
|
||
// it the same name as `grok-4.5` and the report showed two identical rows.
|
||
'grok-4.5-build': 'Grok 4.5 (build)',
|
||
// ClinePass routes models as `cline-pass/<slug>`; getShortModelName's path
|
||
// fallback strips the prefix and re-resolves the bare slug through this
|
||
// table, the same way it handles `accounts/fireworks/models/<slug>`.
|
||
'qwen3.7-max': 'Qwen 3.7 Max',
|
||
'mimo-v2.5-pro': 'MiMo v2.5 Pro',
|
||
// Both spellings occur in the wild: OpenRouter gap-filled keys are lowercase
|
||
// slugs while sessions report the capitalized name (see the case-insensitive
|
||
// pricing index above). SHORT_NAMES matching is case-sensitive, so map both.
|
||
'minimax-m3': 'MiniMax M3',
|
||
'MiniMax-M3': 'MiniMax M3',
|
||
}
|
||
|
||
// Sorted longest-first so more-specific prefixes match before shorter ones.
|
||
// Without this, `gpt-5-mini` could resolve to "GPT-5" (the entry for `gpt-5`)
|
||
// if it happened to be iterated before `gpt-5-mini`, hiding a distinct model
|
||
// behind the wrong display name and pricing tier.
|
||
const SORTED_SHORT_NAMES: [string, string][] = Object.entries(SHORT_NAMES)
|
||
.sort((a, b) => b[0].length - a[0].length)
|
||
|
||
// Anthropic's id scheme is `claude-<family>-<major>[-<minor>]`, so every new
|
||
// version is derivable — no hand-maintained entry per release. (Legacy 3.x ids
|
||
// put the family last, e.g. `claude-3-5-sonnet`, and stay in SHORT_NAMES.)
|
||
const CLAUDE_FAMILY: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }
|
||
function deriveClaudeShortName(canonical: string): string | undefined {
|
||
const m = canonical.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?/)
|
||
if (!m) return undefined
|
||
const [, family, major, minor] = m
|
||
return `${CLAUDE_FAMILY[family]} ${major}${minor ? `.${minor}` : ''}`
|
||
}
|
||
|
||
export function getShortModelName(model: string): string {
|
||
if (autoModelNames[model]) return autoModelNames[model]
|
||
const canonical = resolveAlias(getCanonicalName(model))
|
||
const claude = deriveClaudeShortName(canonical)
|
||
if (claude) return claude
|
||
for (const [key, name] of SORTED_SHORT_NAMES) {
|
||
// Match on a version boundary, not a bare prefix: an unlisted future minor
|
||
// (e.g. gpt-5.6) must NOT collapse into the base "gpt-5" entry — it should
|
||
// fall through to its raw id rather than show a wrong name/tier.
|
||
if (canonical === key || canonical.startsWith(key + '-')) return name
|
||
}
|
||
// getCanonicalName only strips the leading provider prefix, so a raw
|
||
// path-style id (e.g. accounts/fireworks/models/glm-5p2) still has slashes
|
||
// here. Take the last path segment and re-resolve it: the segment may itself
|
||
// be a known model slug (Fireworks fleet ids), earning a friendly name; a
|
||
// genuinely unmapped slug resolves to itself, preserving the raw-segment
|
||
// fallback for everything else.
|
||
if (canonical.includes('/')) {
|
||
const segment = canonical.slice(canonical.lastIndexOf('/') + 1)
|
||
return segment ? getShortModelName(segment) : canonical
|
||
}
|
||
return canonical
|
||
}
|
||
|
||
// Pricing is process-global state assembled at CLI startup from the cached
|
||
// LiteLLM snapshot plus user config. A parse worker thread starts with none of
|
||
// it, and re-running loadPricing() there would mean N more disk reads (or, on a
|
||
// cold pricing cache, N network fetches). Ship the resolved state across
|
||
// instead, so every thread prices a call exactly as the main thread would.
|
||
export type PricingSnapshot = {
|
||
pricing: Map<string, ModelCosts>
|
||
aliases: Record<string, string>
|
||
priceOverrides: Record<string, PriceOverrideRates>
|
||
localModelSavings: Record<string, string>
|
||
}
|
||
|
||
export function snapshotPricingState(): PricingSnapshot {
|
||
return {
|
||
pricing: pricingCache,
|
||
aliases: userAliases,
|
||
priceOverrides: userPriceOverridesConfig,
|
||
localModelSavings: userLocalModelSavings,
|
||
}
|
||
}
|
||
|
||
export function restorePricingState(snapshot: PricingSnapshot): void {
|
||
pricingCache = snapshot.pricing
|
||
sortedPricingKeys = null
|
||
lowercasePricingIndex = null
|
||
setModelAliases(snapshot.aliases)
|
||
setPriceOverrides(snapshot.priceOverrides)
|
||
setLocalModelSavings(snapshot.localModelSavings)
|
||
}
|