Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Fixed
- **Bundled pricing is refreshed, and the refresh can no longer drop a model's pricing or re-price a model it still covers.** 5,942 primary entries against the 0.9.24 bundle (1,215 added, 182 repriced by upstream's own data; among the additions, the Grok Build family makes `grok-4.6-build` priceable so the Grok parser's authoritative-model rule now prefers the real modelUsage id), with the fallback moving from 203 to 212 entries. Three bundler rules make that true. Coverage carry is exact-key only: the runtime resolver (`getModelCosts`) looks the queried id up verbatim, peels segments and strips variant suffixes, but never adds a vendor prefix — so a `~x-ai/grok-latest` primary does not answer a bare `grok-latest` query, and the first version of this carry, which also accepted a vendor-prefixed form, dropped exactly that way: 96 previously-priced fallback ids resolved to null while their model kept only a prefixed key. The completeness guard in the prefixed pass is strictly slot-filling: a richer upstream row may fill cache-write/cache-read slots an entry lacks, but only when its input and output rates are identical to the entry already present (every filled slot must also survive verbatim) — completeness alone was swapping in a different row's rates (grok-3 $3/$15 became $1.25/$2.50, mistral-large-latest $8/$24 became $0.50/$1.50; 43 input/output and 34 cache rates moved that way), which a refresh has no authority to do, and a sparser alias still cannot displace the publisher's entry (a `nebius/MiniMaxAI/MiniMax-M3` row without cache-read rates can never replace the MiniMax entry that carries them). And what a refresh would otherwise drop stays priced: the previous fallback's entries and the previous primary rows the new upstream data dropped or renamed (this cycle, twelve ids — the gpt-image-2 family, the Bedrock marengo embeds, the friendliai llama-3.1 rows) carry forward verbatim into the fallback tier. Verified by resolving every id from either bundle through the real resolver: nothing that priced on 0.9.24 prices as null now, and no rate changed except where upstream repriced the row itself.

### Fixed (desktop)
- **The Models table shows every model that ran, including the ones under a cent.** The CLI's `models` command defaults `minCost` to $0.01, and the desktop bridge passed neither `--min-cost` nor `--unpriced`, so the table silently dropped every row below a cent — which by construction excluded every unpriced row too (a `0 >= 0.01` filter), leaving #1443's dimming and add-alias affordances unreachable in the shipped app. `codeburn:getModels` now passes `--min-cost 0` (and the demo bridge mirrors it), so sub-cent and unpriced rows arrive and render with their existing dim treatment; on a real lifetime corpus that recovers 10 rows and 2,160 calls the default filter hid (43 → 53 rows, verified in both themes). A row priced between $0.00 and $0.01 renders as "$0.00" without dimming — it is genuinely priced, just below the display floor. Fixes #1465.
- **Packaging no longer aborts on a checkout whose path contains a UUID.** `app/scripts/stage-cli.mjs` resolved the CLI's production dependency closure by matching each `npm ls --parseable` line against the checkout's absolute `<root>/node_modules/` prefix, and npm 11 and later redact UUID-shaped path segments to `***` in that output — so on CI runners and scratch worktrees under a UUID directory the match found nothing and packaging died with the misleading "is the root npm installed?". The top-level package name is now read by position (after the N-th `/node_modules` occurrence, N counted off the real root path, which the redaction cannot move) instead of by absolute-prefix match, so redacted, Windows-separated and nested-checkout shapes all resolve; a missing `npm_execpath` also fails with its own message instead of degrading into the empty-closure one. Fixes #1466.
Expand Down
73 changes: 70 additions & 3 deletions scripts/bundle-litellm.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { writeFileSync, mkdirSync } from 'fs'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'

Expand Down Expand Up @@ -77,14 +77,37 @@ for (const [name, entry] of entries) {
const val = toVal(entry)
if (val) snapshot[name] = val
}
// Pass 2: prefixed entries - store full key + stripped (first-write-wins)
// 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).
const completeness = (val) => (val[2] != null ? 1 : 0) + (val[3] != null ? 1 : 0) + (val[5] != null ? 1 : 0)

// Pass 2: prefixed entries - store full key + stripped (slot-fill-only)
for (const [name, entry] of entries) {
if (!name.includes('/')) continue
const val = toVal(entry)
if (!val) continue
if (!snapshot[name]) snapshot[name] = val
const stripped = name.replace(/^[^/]+\//, '')
if (stripped !== name && !snapshot[stripped]) snapshot[stripped] = val
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, mistral-large-latest
// 8/24 -> 0.5/1.5).
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])
&& (prev[5] == null || cand[5] === prev[5])
if (!existing || (completeness(val) > completeness(existing) && fillsOnly(val, existing))) snapshot[stripped] = val
}

// A MANUAL_ENTRY that LiteLLM now ships is a candidate to delete (the override
Expand All @@ -109,6 +132,30 @@ 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
Expand Down Expand Up @@ -173,6 +220,26 @@ try {
}

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
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`)
2 changes: 1 addition & 1 deletion src/data/litellm-snapshot.json

Large diffs are not rendered by default.

Loading
Loading