* fix(pricing): bare model ids take the maker's price, not a reseller's
A bare id with no direct LiteLLM row went to whichever prefixed row came
first in the JSON, so azure_ai priced grok-4.6 at $1.25/M instead of xAI's
$2/M, and the free codestral/ row priced codestral-latest at $0. The
bundler and the live fetch now let the maker's own row claim the bare id,
and a $0 row yields to any priced one. Snapshot patched for the 9 keys
this changes; cache schema 8 so live caches rebuild. The grok-4.6 builtin
override is now identical to the snapshot and live rows, so it goes.
* fix(pricing): the maker's row wins a bare id even at $0
Gemma is free on Google's own API, so gemini/'s $0 row must not lose
gemma-4-26b-a4b-it and gemma-4-31b-it to a reseller's price. Priced beats
$0 only among non-maker rows; codestral-latest still goes to mistral/.
Snapshot now differs from main in 7 keys. Drops the stale
mistral-large-latest example from the fills-only comment.
* fix(pricing): pin deepseek-v3.2 output at DeepSeek's published $0.42
* fix(codex): price Flex tier turns and the gpt-reserve / gpt-5.3-spark ids
Flex turns (service_tier "flex") billed at standard rates. They now bill at
the model's published `<rate>_flex` rates, carried as an optional seventh
snapshot slot (only the flex fields were added to the bundled snapshot). A
model without flex rates still bills Flex at standard.
gpt-reserve (Luna Reserve) prices as GPT-5.6 Luna and gpt-5.3-spark as
GPT-5.3 Codex Spark; both showed $0 with an unknown-model warning.
* fix(codex): mark gpt-reserve cost as estimated
* fix(codex): price Fast (priority tier) turns at priority rates
Codex bills turns run under the Fast speed setting through the priority
service tier, which the rollout files record per thread in
thread_settings_applied events. Track that tier through the parse (and
across append resumes and fork replays), mark the affected turns as
fast, and derive a fast multiplier from the LiteLLM priority rate keys
so those turns are priced at the priority rates instead of the standard
ones. Cache versions are bumped so already-cached Codex results are
re-priced.
* snapshot: priority slots only, no upstream drift
---------
Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
* fix(pricing): restore the DeepSeek v4 and gpt-5.6-codex repricing invariants (#1134)
The 0.9.21 snapshot caught the 2026-08-24 repricings mid-transition and
relaxed two tests/models.test.ts invariants. Upstream has since synced:
- DeepSeek v4: LiteLLM now carries the official peak rates for both the
bare and the deepseek/-namespaced rows (pro 1.32/3.96 per million,
flash 0.30/1.20), so the stale hand-pinned MANUAL_ENTRIES (from before
PR #27056 merged) are removed and prefixed-equals-bare is asserted
again.
- gpt-5.6-codex / -max: still absent from LiteLLM, so their
MANUAL_ENTRIES remain but now mirror the repriced gpt-5.6 base row
verbatim (4/20 per million, tier block included). codex-equals-base
is asserted again, and the long-context tier test now expects the
codex SKUs to carry the base tier.
The refresh also exposed a pass-2 ordering defect in
scripts/bundle-litellm.mjs: a reseller row like
openrouter/deepseek/deepseek-v4-pro could claim the empty
deepseek/deepseek-v4-pro slot before the official row was reached (the
winner was decided purely by upstream JSON key order), pricing the
namespaced id at the resale rate. A prefixed row may now claim only a
stripped slot that is not itself an upstream entry name; multi-segment
ids that exist only as reseller rows keep resolving as before.
Bundled snapshot regenerated: 24 rows moved by upstream drift
(claude-sonnet-5-5 family added, deepseek-v4.1-flash repriced,
zai/glm-4.6 cache-write filled), plus the deepseek/codex corrections.
Token-priced calls re-price on read; no re-parse is needed.
* fix(pricing): repin cline-cli parse version and cover #1134 shadow bug
- session-cache.ts: bump the cline-cli parse version so cached estimated
costs (e.g. offline DeepSeek v4.1 flash) re-price on read, since
cline-cli is in REPORTED_COST_PROVIDERS.
- CHANGELOG.md: note the bundler fix also reprices zai/glm-4.6 from the
vercel_ai_gateway resale row to the official row, and moves the two
mistral embed models to the fallback table.
- bundle-litellm.test.ts: add a fixture covering a reseller-prefixed row
that sorts before the official row it would otherwise shadow.
---------
Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
* fix(pricing): carry long-context tier rates through bundling and price them where billing evidence exists
LiteLLM publishes *_above_<n>k_tokens rates for 418 models - OpenAI's
gpt-5.4/5.5/5.6 family above 272k, Anthropic above 200k, and 128k/256k/
512k tiers elsewhere - but the bundler flattened every entry to the
fixed five-slot tuple and calculateCost had no threshold dimension, so
a request past its threshold priced at the base rate forever (#1076).
The tuple gains an optional sixth slot {threshold, input, output,
cacheWrite, cacheRead}, the threshold parsed from the key suffix
because LiteLLM ships no numeric field (272k means 272,000; #1075
verified assuming 128k fabricates +64% spend). Old bundles parse
unchanged; CACHE_SCHEMA_VERSION moves to 5 (4 is taken on main by the
implicit cache-write billing) so a stale on-disk live-pricing cache
cannot reintroduce the old behavior; the live-fetch parser picks the
same tier up; service-tier _priority/_flex variants and the 1-hour
cache-write combination are not context thresholds and stay out.
A tier applies when the request's prompt tokens (input + cached input,
the existing grok-4.6 definition) reach the threshold, each published
rate replacing its base and omitted slots keeping the base.
Application is gated on billing evidence via TIERED_PRICING_PROVIDERS:
codex today, where the maintainer's corpus measurement shows the
surcharge passing through; a real Copilot session on gpt-5.6-terra
with ~6M-token prompts billed at the BASE rate, so Copilot keeps base
pricing until such evidence exists - applying it there would fabricate
spend, the exact class #1075 warned about. The audit report recomputes
through the same tier swap so recomputedTotalUSD keeps tracking
attributedCostUSD.
* fix(pricing): read tier rates only from the chosen threshold; e2e codex tier test
Repair pass from review: both tierOf implementations now collect rates
per threshold and read only from the largest one, so a hypothetical
two-tier entry can never mix the smaller tier's rates under the bigger
threshold; the bundler validates tier values exactly like the live
parser (finite, non-negative). A codex end-to-end test drives a real
rollout past gpt-5.5's 272k prompt threshold and asserts the tiered
arithmetic explicitly (a typo'd provider string in codex.ts would
leave the call at base rates and fail it), alongside the no-provider
base-rate control that keeps the real Copilot billing pin intact.
Comment fixes: the priceOverride wording, the grok pin's actual
resolution branch, and the threading note on TIERED_PRICING_PROVIDERS
for whoever adds the next provider.
* fix(pricing): recompute the audit tier per call and re-derive warm daily caches
Both review blockers:
- audit-report no longer feeds a bucket's SUMMED tokens into the
per-call 272k threshold (a \,979 phantom gap on the reviewer's
corpus, any tiered codex model clearing the threshold on the sum
alone). The recompute accumulates per call, through the same tier
swap calculateCost applies, with each call's own prompt total; a new
fixture mixes one 300k call with 200 small ones whose sum blows past
the threshold and pins that only the big call tiers. Web search
stays on the base rates - it never participates in a tier.
- DAILY_CACHE_VERSION 33 -> 34 with MIN_SUPPORTED_VERSION raised to
match, the cfdaa685 convention: a 33 file written under tierless
pricing is below the floor, is not adopted as the finalized base,
and its days re-derive off the warm session cache (\ of codex
spend on the reviewer's corpus lives only in the daily cache). The
migration tests pin the new floor contract: no version-diff repairs
merge into a below-floor file, and the v30 adoption test tracks
CURRENT.
Nits from review: the kimi precision tightening is reverted to (8, 5),
and the tieredCostsFor comment is rewrapped.
Measured on this machine's corpus, tier-only (branch vs the refresh
branch, same data): +\.43 lifetime codex - this corpus runs far
more sessions past 272k than the reviewer's 8-of-51,801 (+\.012964);
both numbers are real, the corpora differ.
* fix(pricing): preserve base alias selection when bundling context tiers
* fix(pricing): address the 28 Sep review - floor stays at 28, tier bills the implicit cache-write rate
Restore main's two carry-forward tests: with MIN_SUPPORTED_VERSION kept
at 28 (per the review's correction, warm caches re-derive identically
under 41/28, so raising the floor is unnecessary) a v33 file sits in
the adopt-with-repairs window again and main's expectations hold; the
tier's warm-cache coverage stays in daily-cache-version-rederivation.
The tier-omits-slot test bills an implicit cache-write at the input
rate of the costs object in effect (#1544), so the tiered expectation
is 1_000 * cacheWriteCostPerToken('gpt-5.5', tieredCosts).
Note on the Codex estimate path: an estimated prompt can cross a
long-context threshold and tier itself; measured corpora hold no
estimated calls that cross.
* fix(pricing): pin claude-opus-4 primary and keep carried fallback rows hygienic in the refreshed snapshot
The requested regeneration surfaced two upstream moves beside the tier
work. LiteLLM dropped `claude-opus-4`; a refresh carries dropped ids
into the fallback tier, but the Cursor-style alias `claude-4-opus`
resolves against primary rows, so the bare id fell to the snowflake
gateway row and under-priced by 3x. Pinned at Anthropic's list price
in MANUAL_ENTRIES so it stays primary. LiteLLM also repriced
grok-4.6's base input 2e-6 -> 1.25e-6 (tier rates unchanged); the
below-threshold literal follows. Both changes reproduce identically
with main's unchanged bundler on the same live responses.
Today's drops also exposed a gap in the #1483 carry-forward: primary
files legitimately hold @pin, date-suffixed and free-on-both rows, and
carrying those verbatim imports exactly what
tests/pricing-fallback-data.test.ts keeps out of the fallback file
(main's own next refresh fails those tests today). The carry now skips
@pin/date-suffixed keys (no query can arrive in those forms) and
free-on-both rows (an unpriced model falls to expected-free handling);
vendor-prefixed keys stay carriable since queries match them verbatim.
---------
Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
* Store the session cache in day pieces with a per-provider index
Layout v10: a provider's files live in pieces by the UTC day of their
newest call, each with a key file, and an index row per file (piece,
fingerprint, call span, v9 month) read on every load. A ranged load reads
the index, the key files and only the members it reports on; a save
rewrites only the pieces it touched. Rows keep v9's full-load order and
month scope, so orphans, the first-paint snapshot and dedup see what they
saw on v9. The v9 month shards re-lay out once, streaming, and are removed
after v10 publishes.
* Skip cached transcripts outside a narrow query's months when unchanged
v9 could not see them, so every narrow query re-parsed each older Claude
transcript it discovered and republished the cache. The index knows their
fingerprint; an unchanged one needs nothing, a changed one still re-parses
as if uncached.
* Read piece members in batches through one buffer, and full loads concurrently
* Write piece key files without a sync; rebuild lost keys from the piece
* Changelog: day pieces
* Seed dedup with the keys of unchanged transcripts outside the query's months before a whole-file parse
* Upgrade-path check: verify the v10 envelope, indexes, pieces and key files
* fix: four cost accuracy bugs in export and pricing
- export daily rows bucket each call on its own day (same rule as the day
aggregator) and no longer skip a turn without a timestamp
- cache-write tokens on a non-Anthropic model with no published write rate
bill at the input rate instead of a fabricated 1.25x (calculateCost and
audit); daily cache v35 and pricing schema 4 so stored figures re-derive
- OpenCode/Kilo messages with output but no usage, and Copilot transcript
calls with no token count, are marked estimated
- export records keep unrounded cost and gain estimated/unpriced columns
* fix: plain-decimal CSV costs, re-parse cached copilot transcripts for the estimated flag
* ci(upgrade-path): compare copilot records at cent precision across the export rounding change
* 0.9.25: correctness + provider fixes, energy guard, logo, default DMG
CLI/engine fixes: local-model savings count reasoning; ZCode reasoning billed;
codex-credits version-boundary tiering; GLM alias repointed to z.ai discounted rate;
openclaw/pi/qwen timestamp mtime fallback; hermes delta timestamp;
sqlite/warp/hermes busy+permission reclassified as failed (no sealed empty);
codex-cache dev+ino; cursor-cache WAL; antigravity server TTL; quota timeout+
grokbot footer+claude near-expiry; serve RSS guard env+hysteresis; CSV export
subfolder; providers pane fixed window; Warp credit-based cost.
Desktop/menubar: unified flame logo; default DMG; docs signed+notarized.
* menubar: one running instance, self-relaunch on language change
The single-instance guard only retired strictly older copies, so two login
items starting in the same instant both survived, and a copy whose bundle
identity did not survive install was never seen at all. The decision is now
total (newest launch wins, ties break on pid) and peers are matched on bundle
id or executable name. --keep-both opts one launch out.
Language changes no longer have the desktop app quit and reopen the menu bar,
which made macOS attribute the new process to the desktop app and re-ask for
the data-access grant. The menu bar relaunches itself via the existing remote
command key; older menu bars that do not watch the key fall back to the old
path after 5s.
* desktop: export toast shows the saved path; resource buckets on app_close
The export toast showed the folder that was picked, not the dated subfolder
the files land in. The handler already parsed the CLI's saved line; it now
returns that path and the toast uses it.
app_close carries cpu/mem (and serveCpu/serveMem when serve reported) as bucket
labels, sampled on the existing flush interval. serve adds cpuSec and peak rssMb
to its answer frames. Unmeasured figures are omitted, never sent as zero.
* menubar guard never leaves zero instances; installer replaces in place; JSON export into folder
Guard: an unreadable launch date on either side could make two copies retire
each other. Unreadable peers now count only when their pid is lower, and a copy
that cannot read its own date retires lower pids only, so a disagreement keeps
at least one instance.
Installer: an existing bundle in /Applications or ~/Applications is replaced
where it lives instead of a second copy being added. The old bundle is moved
aside and restored on any failure; a cross-volume move falls back to a copy that
is re-verified with codesign. An unwritable location is never escalated: the
install goes to ~/Applications and the leftover is named.
Export: -o pointing at an existing folder writes the JSON inside it. Short
sessions omit the cpu buckets. setLanguage reopens the menu bar if its own
relaunch never comes back. AppRelaunch passes the bundle path as an argument.
* menubar guard orders by kernel start time; installer opens the copy that exists and recovers a killed install
Guard: launch dates could be unreadable and pid order can disagree with start
order after pid reuse, so two copies could still retire each other. Ordering now
uses the kernel-reported process start time, which both sides read identically.
A copy whose start time cannot be read is left out, so the worst case is two
menu bars, never none.
Installer: with the only copy in an unwritable /Applications, a plain run tried
to open a path that did not exist. It now opens the installed copy. A placement
killed mid-way is recovered on the next run, a concurrent install is refused,
and a failed restore names where the previous bundle is. Process matching uses
the exact executable name.
Export: a trailing separator names a folder. Telemetry cpu is measured from
telemetry start.
* installer: a recycled pid can no longer block installs; language change waits for the replacement process
An aside left by an earlier install counted as an active install whenever its
pid was live, so once that pid was reused every install was refused for good.
It now also has to be recent (ctime within 10 minutes), a pid we may not signal
is not ours, the refusal names the file, and an orphan that cannot be cleared is
reported instead of aborting the install.
setLanguage confirmed the relaunch with a matcher the dying process still
satisfied, so the reopen fallback never fired. It now waits for a pid that was
not running before.
* desktop: only the headline refreshes live; act, yield and the optimize scan move to slower tiers
An open window ran act report (a fresh CLI process each time), yield and an
optimize scan on every refresh. The headline poll now passes --no-optimize and
takes the snapshot path; act report refreshes on mount, on manual refresh and
every 10 minutes, yield every 5. The optimize scan runs at most once a day per
query scope, is stored in userData with its time, and is forced fresh by the
Optimize page or a manual refresh. Figures that are not live show their age,
and no stored scan means no savings clause rather than a zero.
On battery the live interval doubles; the chosen cadence stays the base.
* desktop: a stored optimize scan is only valid on the day it was computed; cadence changes no longer refetch
The scan cache is keyed on the argv, which says 'today' or '30days', not a date,
so yesterday's scan was served as today's. It is now reused only on the same
local day. Stored findings in Signals carry their age, a failed scan shows the
error instead of scanning forever, and the cold-boot label uses the time the app
received the payload.
usePolled fetched immediately whenever its interval changed, so every power
transition (and launching on battery) refetched every live hook and could spawn
an extra act report. Fetching and timer arming are now separate effects.
Optimize's yield is on the same 5 minute tier as Overview.
* desktop: re-ask for the optimize scan on the first render of a new local day
An app left open across midnight kept yesterday's stored savings under Today,
because the scan was only requested on mount, scope change or manual refresh.
The local day is now one of the hook's triggers; the previous day's figure is
dropped while the new scan runs.
* serve: a menubar poll is never answered from the status snapshot
Inside its settle window the status snapshot returns the pre-change payload on
purpose. The resident serve process then memoized that answer as clean, so after
a single write followed by quiet the headline stayed on the old figure until the
next write or the five minute memo cap. The snapshot is for one-shot runs; the
resident process already holds the parsed state, so it now skips it.
* tests stop writing the real serve pid file; superseded caches are swept; installer notes reach the desktop card
ServeConnection takes its pid file as a parameter, so the Swift tests no longer
share ~/.cache/codeburn/menubar-serve.pid with a running menu bar, and the
reaper only signals a record that is a serve --stdio command.
Superseded session-cache, codex-results and cursor-results files (100MB+ each)
are removed once untouched for 14 days, at most once a day. daily-cache and the
hermes ledger are never swept: they carry sealed days a version bump adopts.
The menu bar installer's leftover-copy advice is emitted as a marked line under
CODEBURN_PROGRESS and shown on the desktop card. The skipped-refresh NSLog is
debug only. The app's vitest workers turn off Node's built-in web storage.
* menubar: anonymous usage statistics, under the same consent as the desktop and the Windows tray
The Swift menu bar sent nothing, so every codeburn-menubar row was a Windows
tray. It now follows the desktop's telemetry.v1.json when that exists (read
only: same install id, same on/off, onboarded required) and otherwise keeps its
own state with the same region default (off in EU/EEA/UK/CH and when the region
is unknown). Settings gains one Privacy toggle, disabled when the desktop owns
the choice. Events use the tray's names; the daily usage snapshot is the CLI's
block passed through a sanitizer and is left to the desktop when it is the
consent source. Debug builds never send.
A resident CLI that gets disabled for the run now says so in the log.
* menubar statistics: private network session, abandoned batch on opt-out, a desktop No survives the desktop going away
The sender used URLSession.shared, so a server or CDN cookie and the default
User-Agent rode along and would have outlived an opt-out. It now owns an
ephemeral session with no cookies, no cache, a fixed agent string and no
redirects. A batch that is out when the toggle goes off is dropped rather than
re-queued under the fresh id. The desktop's answer is recorded locally, so a
missing or damaged state file never falls back to the region default, and the
standalone id is persisted when minted. The toggle stays usable as a local off
switch under desktop consent. Copy states what this app actually sends.
* vercel gateway spend is shown on its own row and kept out of totals by default
The gateway reports daily aggregates per model, with nothing to match against
the local tools that were pointed at it, so every headline counted that traffic
twice. Totals now leave the gateway slice out when all providers are shown; the
row keeps its full amount and is marked not in total, --provider vercel-gateway
is unchanged, and codeburn gateway-totals include folds it back in. The daily
cache still stores the slice, so the choice applies retroactively. Reports with
no gateway credential are byte-identical.
The gateway's request_count now feeds the call count instead of each daily row
counting as one call. Hermes: a mid-session price change lands on the day it is
observed and sealed days are never rewritten; stated in the code and changelog.
* gateway exclusion applies to every report through the parse, and the detail panels follow the headline
The first pass narrowed only the day totals, so By tool, top models, top
projects, activities, status json projects and any project-filtered table still
carried gateway spend next to a headline that did not, and By tool shares could
pass 100%. The rule now lives in parseAllSessions: with all providers in scope
and the opt-in off, aggregate-only sessions are dropped after the memo, and the
callers that derive cached days opt out so stored slices are unchanged.
day.projects is reduced like models. The gateway stays visible once, as a
labelled not-in-total row with no share, and standalone reports note the
excluded amount on stderr so machine output is unchanged.
* dashboard: the background history index follows the gateway exclusion too
The index keeps the whole corpus so it can fill the daily cache, but its
per-period projection handed that unfiltered list to the project, model and
activity panels, so they regained gateway spend once the index landed.
* ci: a hung lock suite fails in 90s and retries once with a warning; slow Windows tests get measured limits
The cache-lock step failed 6 of the last 60 runs with no output for five minutes
after the vitest banner; passing runs take 12-18s on every Node version. Each
attempt is now capped at 90s and retried once, with a warning whenever that
happens. On windows-latest a single CLI spawn takes 1.4-12s, so the four-spawn
status snapshot test and the c6 parser suite get limits taken from those runs,
each with its reason. Both test jobs print their ten slowest cases.
* desktop: Privacy & data uses the same rows as the rest of Settings
The pane mixed two static claims with two controls in its own layout. It now
uses the about-row pattern every other pane uses: title and hint on the left,
one control on the right. The claims became the subtitle. Rows: anonymous usage
statistics (toggle, link to what is sent), device sharing state with Manage,
local report snapshots with Clear, export with Open export.
* desktop: Privacy & data keeps one bounded column and rows that never squeeze
The card stretched with the window, stranding each control hundreds of pixels
from its text. This pane's heading, subtitle and card now share a 760px column
and one text measure. Rows stack on their own width, because the window can
never be narrow enough for the old viewport rule to fire; row buttons do not
wrap. The what-is-sent link sits on its own line, and the snapshot and export
descriptions fit their measure and say this machine.
* menubar tests clean up their scratch defaults and leave the real Keychain alone
Four test files never removed their UserDefaults suites and the rest removed the
domain but left the plist, so thousands of files piled up in
~/Library/Preferences. A shared helper forgets a suite and deletes its file, and
every test that makes a suite now uses it. cfprefsd still rewrites an empty plist
when the test process exits, so a run leaves about a hundred 42-byte files with
no test data in them. The real-Keychain round trip only runs with
CODEBURN_TEST_REAL_KEYCHAIN=1.
* desktop: the resident parser retires after 15 idle minutes and restarts on demand
The renderer stops polling while the window is hidden, but the serve child stayed
resident with its parsed cache, one to two GB on a large corpus. After 15 minutes
without a request it is stopped the way a settings restart stops it, without
spending the unexpected-death budget, and the next request starts a new resident
through the existing lazy path, never a one-shot. CODEBURN_SERVE_IDLE_MS tunes or
disables it.
* desktop: every settings pane keeps a bounded column, and dark buttons have an edge
Cards stretched with the window, so on a wide display controls sat up to 1300px
from their labels and intro lines ran past 140 characters. All panes now share
one left-aligned column (760px, 980px for projects, aliases and pricing, which
hold wide rows) and one text measure. Rows stack on their own width instead of a
viewport rule the 900px minimum window could never reach, and a long path in
Projects no longer overflows the pane.
In dark mode the secondary button matched or sat below its surface, and was
invisible on the plugin install dialog. It gets a face and a border from two new
tokens; light is unchanged. The onboarding consent label matches Settings.
* desktop: this device shows its fingerprint on its own line
The local name and the fingerprint are two adjacent inline spans, so they read as
one run of text.
* menubar: the resident parser retires after 15 idle minutes and restarts on demand
The menu bar kept its serve child, and the parsed cache with it, for the whole
run, even with the popover closed and nothing changing. After 15 minutes without
a request the child is retired without counting as a death, and the next request
starts a new resident through ensureStarted, never the one-shot fallback, whose
snapshot settle window would make the figure lag a whole tick. The window sits
above the longest gap between real requests, so a child stays warm while work is
happening. CodeBurnServeIdleSeconds tunes or disables it.
* serve: a poll re-checks only what the watcher saw change, and shard writes are coalesced
With a session being written, every poll re-listed about 2,400 directories and
re-fingerprinted about 6,000 transcripts, then rewrote the whole current-month
shard (49MB on a large corpus) and read it back on the next poll. The resident
process now reuses the previous listing and fingerprints for paths its root
watcher proves untouched, and falls back to the full sweep whenever the watcher
is missing, unhealthy, overflowed, armed after the sweep, does not cover the
path, the memo is older than 4.5 minutes, or the process was suspended. Shard
publication is held for up to 90s and flushed when stdin closes; a held write is
dropped rather than written over something another process published. One-shot
runs are unchanged. Active poll cost 0.71 to 0.33 CPU-s at a 30s cadence;
time-to-reflect unchanged at about 1.2s across append, new dir, new file, delete,
rename-over, in-flight, watcher overflow and unhealthy watcher.
Changelog: state the telemetry default as it is in this version.
* a retired parser gets to publish its held shards, and durable providers are never held
Both apps retired the serve child with SIGTERM before its stdin-close flush could
run, so every retire discarded the coalesced window. They now close stdin, give
the child a bounded grace to publish and exit, and signal it only if it is still
running; a settings restart does the same. Measured: flushed 0/5 before, 5/5
after, for both clients.
copilot's cache entries are the only record once Copilot prunes its own files, so
a held window that was dropped (retire, crash, kill -9) before the source was
pruned lost that spend for good. A window that a durable provider dirtied is
published on the poll that parsed it, as before coalescing existed. The RSS guard
publishes a held window before it clears the caches instead of discarding it.
* serve: release the lifetime parse after the fill, and make the collection real
The RSS guard dropped its memos and then called globalThis.gc, which exists only
under --expose-gc; no launcher passes it, so the collection had never run and the
freed pages never went back. On a cold start the resident idled at 2.2GB with
364MB of live heap: clearing every memo moved RSS by 0MB, a collection alone by
700MB.
After the background fill has sealed its summaries the process now publishes any
held shard window, drops the memos it can re-read from disk (the cold start had
loaded the cache with scope 'all', which then served every later poll), and
collects; it collects again a minute later for as long as a pass returns 64MB,
at most four times. Cold idle 2198MB to 700MB, warm 900MB to 781MB. Reports are
leaf-identical cold and warm, time-to-reflect unchanged, a released month scope
reloads in 0.3-0.5s.
* warp: the billed cost reaches reports and survives the cache
The provider derived a dollar cost from Warp's own billing record (provider cents,
charged usage, or credits), but warp was missing from the reported-cost
pass-through, so the cached call kept no cost and every report, cold or warm,
priced the conversation from its tokens. A fixture billed at $1.22 reported
$0.15. A call whose cost came from a billing record now keeps it; a call that
only has the token floor stores none and is still re-priced when prices change.
Warp entries re-parse once (provider parse version), and the daily cache moves to
v34 so days whose Warp source still exists re-derive; call counts do not move, so
nothing is double counted or dropped.
* openclaw and pi: a reported cost survives the cache like every other reported cost
Both providers read the cost their tool recorded for a message, and both were
missing from the reported-cost pass-through, so reports priced every call from
tokens instead. Fixtures reported $0.011 against $0.056 (openclaw) and $0.013
against $0.410 (pi). A call with a reported cost now keeps it; a call with none,
or an explicit zero, stores none and is still re-priced when prices change. omp
had been added to the list in #1150 and pi, the same parser, was never revisited.
The provider list is one named set, the unreachable providerCallToTurn is gone,
and both providers re-parse once; the v34 daily cache re-derives their days with
call counts unchanged.
* test: pin parse count per report and per resident poll
* chore(pricing): refresh the bundled snapshot with long-context tiers
Regenerated from live LiteLLM/models.dev/OpenRouter: 5,938 primary
entries (1,210 added, 11 removed, 298 repriced vs the 0.9.24 bundle)
and 51 fallback entries; 394 primary entries carry a tier slot. The
Grok Build family upstream is what makes grok-4.6-build priceable,
shifting the Grok parser's authoritative-model pick to the real
modelUsage id (pin updated with the reasoning in the test).
* chore(pricing): refresh the bundled snapshot; a refresh can no longer drop a model's pricing
Regenerated from live LiteLLM/models.dev/OpenRouter: 5,938 primary
entries (1,210 added, 298 repriced vs the 0.9.24 bundle), fallback 51
from the gap-fill plus 9 carried forward. Upstream actually removed
four entries (friendliai llama-3.1 bare and prefixed); seven more are
excluded by the per-token-output filter, which is a rule change and is
called out as one.
Two bundler rules from review:
- vendor-prefixed aliases no longer displace a richer entry for the
same model: completeness (published cache slots) wins over
first-write, so the new nebius/MiniMaxAI/MiniMax-M3 without
cache-read rates cannot replace the publisher's entry that carries
them;
- previously-priced fallback entries carry forward when neither the
new primary nor the gap-fill covers their key exactly or
vendor-prefixed - the form the resolution pipeline queries. Nine
models would otherwise have lost all pricing; a dated variant
sharing the bare name does not count as coverage because it does
not answer the undated query.
The Grok Build family upstream makes grok-4.6-build priceable, so the
Grok parser's authoritative-model pick prefers the real modelUsage id
(pin updated with the reasoning in place).
* fix(pricing): exact-key coverage carry, slot-fill-only completeness guard, regenerated bundle (#1483)
- coveredByKey is exact-key only: the resolver never reaches vendor/<id> for
a bare query, so the old endsWith clause dropped 96 priced fallback ids.
- Pass 2 stripped writes are strictly slot-filling: a richer upstream row may
only fill missing cache slots when input/output rates are identical, never
re-price a filled slot (was re-pricing 43 input/output + 34 cache rates).
- Carry now rescues dropped primary rows as well as fallback entries (twelve
ids this cycle), so a refresh cannot leave a previously-priced model null.
- Regenerated: 5,942 primary + 212 fallback; 159 carried; zero coverage
regression and zero guard-caused rate changes vs upstream/main, verified by
resolving every id from either bundle through the real getModelCosts.
* test(models): glm-5.3 prices at its own row after the refresh (#1483)
Upstream LiteLLM now publishes a distinct z-ai/glm-5.3 rate, so the
prefixed spelling no longer equals the glm-5p2 sibling. Assert against
the snapshot's own row (the gpt-5.6-codex pattern) so the test survives
later upstream reprices and the z-ai/ -> zai/ namespace rename.
Add src/period-diff.ts: pure period-vs-period difference engine. Totals come
from the shared buildPeriodData rules; contributions per project and per model
carry status new/gone/up/down/flat with pct=null at A=0 (labeled New, never an
infinite percentage); normalized views per day and per 100 calls print their
denominators; day arithmetic walks local midnights so DST and month boundaries
count calendar days. historyBasis cross-checks the durable daily cache and
reports aggregate-only carried days separately instead of force-reconciling
them into the totals. diffSessions joins the drill-down on the canonical
session identity (provider + project path + sessionId), attributing the
model's own slice for the model lens.
New command: compare-periods --format json|sessions with explicit
--from-a/--to-a/--from-b/--to-b (local YYYY-MM-DD, validated loudly) defaulting
to the last seven complete days vs the seven before, plus --provider and the
existing --project/--exclude filtering. Range slicing reuses parseAllSessions'
call-granularity behavior, so a session straddling the boundary contributes to
each side by its own calls.
Tests: engine fixtures including the X/Y/Z acceptance reconciliation, A=0/B=0,
unequal lengths, DST and month walks, carried history, a real-parse boundary
session split by call and joined back into one drill-down row, and CLI runs on
a temp HOME fixture.
The block is a wall-clock snapshot (idleSeconds, lastActivityAt), not a parse product, so serial and parallel runs legitimately differ there; same treatment the parse-workers unit test already applies.
Drop pin-baselines.mjs: pinning is a once-per-phase paste and the
script hard-coded this phase's iteration-log row, so every later phase
would edit by hand anyway. Replace three hand-rolled argv parsers with
node:util parseArgs. Read machine facts from node:os instead of eight
subprocess probes (hw.model keeps its sysctl call). Track fixture bytes
with a running counter instead of re-walking the tree per file. Delete
the hover proxy that measured an argv the dock never spawns, plus
unused exports (ensureDir, assertAbsolute, GENERATED_FIXTURE_DIR).
Fixture output is byte-identical for the same seed (verified by full
tree diff against the pre-refactor generator) and all harness metrics
reproduce within run-to-run noise.
Scripted isolated-HOME measurements for session parse, incremental
append, period switch, CLI cold start, and RSS. Extends the #1164
timings.csv shape without touching parser, cache, or UI hot paths.
localeCompare is locale/ICU dependent and fed the signed digest, so a plugin signed on one machine could false-reject on another (live even in ASCII: README.md vs codeburn-plugin.json order flips between locale-aware and codepoint comparison). All four sort sites now use plain codepoint order, commented as a canonicalization requirement. The sign script now throws on unreadable files instead of signing an incomplete list; the loader keeps skipping (its omission causes a digest mismatch and rejection, the fail-closed direction). New test drives the real sign script over a tree with mixed-case and non-ASCII names, so test and production canonicalization cannot diverge again.
Closes security hole: plugin commands (executable .mjs files under commands/) are now part of the signed digest, preventing tampering. getPluginFilesList and sign-plugin.mjs getFilesList now walk directories recursively with relative POSIX paths, hashing all regular files while excluding codeburn-plugin.sig (the signature itself) and sections/ (runtime-mutable plugin output). plugin add copies the full tree recursively, preserving directory structure including commands/ and commands/subdir/*.mjs. Installed plugins verify correctly without CODEBURN_PLUGIN_DEV. checkForSymlinks now recurses to catch symlinks anywhere in the tree. Sections may change at runtime without breaking verification because they are excluded from the canonical digest.
Fixes critical bug where keygen() used random keyId but sign() derived it from sha256 of public key PEM. This caused embedded release key (f40248d0) to not match actual derived id (499923ae), breaking plugin verification.
Now both keygen and sign derive keyId consistently from sha256(publicKeyPem).slice(0,4).hex.
Added tests:
- keygen and sign derive identical keyIds from same ephemeral key
- RELEASE_PUBLIC_KEYS map keys match their derived keyIds from embedded PEM values
End-to-end verified: signing with private key and verifying with embedded public key now works without CODEBURN_PLUGIN_DEV.
Ships ed25519 signature verification for plugins with two new components:
- src/plugins/keys.ts: RELEASE_PUBLIC_KEYS map with one release keypair
- scripts/sign-plugin.mjs: keygen and sign commands for plugin developers
- verifyPlugin() in loader.ts: validates ed25519 signatures, rejects unsigned plugins unless CODEBURN_PLUGIN_DEV=1
- plugin add <path>: installs signed plugins to ~/.config/codeburn/plugins/
- plugin remove <name> --confirm: removes installed plugins
All three gates pass: tsc clean, vitest zero new failures, smoke test complete.
Design decision: public keys stored as base64-encoded PEM format rather than raw 32-byte keys. Node.js crypto.verify requires PEM/DER format or KeyObject for ed25519; raw bytes alone fail. PEM is standard and portable.
Private key written to: /tmp/codeburn-signing/codeburn-signing-key.pem
Teams issue #3 introduces the codeburn plugin socket: an opt-in escape
hatch for third parties to add sync attributes, payload sections, and CLI
commands without touching the codeburn CLI source tree. This commit ships
the first half (CB-3) — the manifest layer, the wire guard, and the user-
facing inspector. Release-key signing (the 9b seam) lands separately.
Why split now:
- The wire guard (filterPluginAttributes in sync/otlp.ts) is the load-
bearing piece. With it, no plugin code can run until the user opts
in by installing one — the default empty socket keeps every byte of
the existing telemetry wire byte-identical. That is the contract the
existing four sync test files were already pinning; this commit
re-pins it explicitly with a new test (tests/plugin-socket.test.ts).
- The inspector () makes rejections
visible. Without it, a misconfigured plugin would just vanish
silently — exactly the foot-gun the socket exists to prevent.
What ships:
- src/plugins/loader.ts: enumerates ~/.config/codeburn/plugins/<name>/,
reads + parses each codeburn-plugin.json, applies name/dir match and
cliCompat check, hands off to verifyPlugin (the 9b seam). Rejected
plugins contribute nothing to the wire.
- src/plugins/manifest.ts: parses + validates the manifest shape
(name, version, cliCompat, capabilities.{commands, syncAttributes,
payloadSections, spanKinds}). Returns {ok, reason} on any failure.
- src/plugins/cli.ts: . Read-only;
never mutates the plugins directory.
- src/sync/otlp.ts: filterPluginAttributes strips any key not in the
declared set, called from buildOtlpPayload when pluginAttributes are
supplied. With no plugin installed, the call path is a no-op.
- src/sync/push.ts, src/sync/cli.ts: thread pluginAttributeKeys
through sendBatches -> buildOtlpPayload. The wire is unchanged when
no plugin declares anything.
- src/usage-aggregator.ts, src/menubar-json.ts: add the
MenubarPayload.plugins field and populate it from pluginPayloadSections
(empty when no plugin installed).
- src/main.ts: register the plugin subcommand.
- tests/plugin-socket.test.ts: 12 tests covering the wire guard, the
byte-identical default, the loader rejection paths, and the CLI.
- scripts/smoke-plugin-socket.mjs: end-to-end smoke test against the
built CLI (13 assertions).
Byte-identical guarantee:
- With no plugin installed: loadPlugins() returns [], pluginPayloadSections
returns {}, filterPluginAttributes(empty) returns []. The OTLP payload
and the menubar payload are bit-for-bit the same as before this commit.
- The dry-run output gains one new line listing the active plugin names
(empty string when no plugin). User-facing only; not on the wire.
What's intentionally NOT here:
- Plugin command invocation: lands when at
least one community plugin publishes a command (deferred).
- : shipping this with no signed-plugin
ecosystem in place would invite supply-chain risk. Deferred.
- Release-key signing (the verifyPlugin return path). Deferred to 9b.
Refs: teams issue #3, CB-3 sync boundary spec section 9a.
Compare, export, report JSON, overview Top-model/By-tool/day, and
menubar localModelSavings still summed raw output. Session totals
stay defined on aggregate-only and stub calls. Upgrade verifier
expects daily-cache.v28.json.
The Copilot session-store.db writes the model as claude-haiku-4.5
(tier-first, dot), but no pricing alias existed for that raw id, so
calculateCost returned $0 while GitHub billed the real Anthropic rate
(kelchm's #946 validation: $0.0063536 for a sampled request). Aliases
it to the existing, correctly-priced claude-haiku-4-5 snapshot row --
no new rate invented. Unlike #1090's gpt-5.6-codex case, this id has
no prefix-fallback self-heal, so the daily cache bumps v26 -> v27 to
force re-derivation of already-finalized days.
Drafted with cline-pass/deepseek-v4-pro via local gateway.
Resolves the conflicts #946 accumulated while it was in validation. Eight
files conflicted; the session-store accounting is unchanged.
src/daily-cache.ts — version collision. This PR minted 25 when main was at
24; #1056 (`codex-auto-review` pricing) then spent 25 on main. The bump moves
to 26/MIN 26 and daily-cache.v26.json, with main's full comment ladder kept as
the foundation and this PR's paragraph rewritten to name 26 and record the
collision. PENDING_REDERIVE_PROVIDERS and the B1 migration semantics from
b6481c19 carry over intact, retargeted at 26.
src/models.ts, src/parser.ts, src/audit-report.ts, src/models-report.ts —
#1075/#1078 replaced the per-site "reasoning is already inside output" tests
with billableOutputTokens() and REASONING_INCLUDED_IN_OUTPUT. This PR had
added copilot to that case at three sites independently. Union: all three
sites take main's helper call verbatim, and copilot joins claude and codex in
the set — same accounting this PR shipped, now through main's single source of
truth. It also reaches parser.ts activeGeneratedTokens (a fourth site, from
#1079), which is the same correction: a copilot supplementary call carries
reasoning with output 0, so counting it as generated repeats the per-turn
output. The audit legend already said so on this side.
src/providers/copilot.ts — comment-only. #1054's lastEventTimestamp-first
shutdown fallback was derived from this branch, so the code was already
identical on both sides: the `shutdownTimestamp` expression and the
`copilot:<sid>:shutdown:<model>:<n>` key are byte-for-byte main's. Both
rationales are kept (leg-collapse on date, and residual anchoring).
src/session-cache.ts — PROVIDER_PARSE_VERSIONS.copilot takes this PR's
`-session-store-v3` suffix; main's #1051 note about why a fingerprint change
is expensive is kept above it. Codex keeps main's #1092 suffix chain untouched.
src/main.ts — #1067 deleted the unreachable live dailyMap fallback that this
PR had taught behavioral weight. Main's deletion wins; the now-unused
isBehavioralTurn import goes with it.
tests/parser.test.ts — import union.
Also: scripts/upgrade-path/run.mjs NEW_DAILY_CACHE -> daily-cache.v26.json,
CODEBURN_COPILOT_SESSION_STORE_DB added to the #1064 env-isolation CLEARED
list, and the CHANGELOG entry's stale "v21" corrected to v26.
Verified: tsc clean; 3132 tests pass across 223 files; test:locks 26/26;
verify:upgrade PASSED, re-deriving daily-cache.v26.json and holding durable
copilot history across the bump. #1054's regression ("keeps three stampless
shutdown legs as :n keys with lastEventTimestamp") passes on the merged tree.
Real-corpus A/B against origin/main over 2026-07-01..2026-08-22: codex, grok,
kimicode and opencode byte-identical in export, audit and models; claude drifts
only monotonically with run order (a live session writing transcripts, confirmed
by interleaving four runs). This machine has no copilot data, so the copilot
recovery semantics rest on the suites and the upgrade-path corpus.
Two defects from @kelchm's two-machine validation of #946.
1. The daily-cache migration carried the copilot slice of every existing day
entry verbatim instead of re-deriving it, so `overview` on a migrated cache
kept serving pre-store numbers (2,980,804 tokens for a day whose fresh
derivation is 74,811,412; 21,608 calls vs 16,326) while export/models/audit
served the corrected ones off the same cache.
Root cause is the partial-survival guard (e9d922ca), not the migration: it
keeps a settled baseline slice whenever the fresh derivation reports FEWER
calls, reading that as aged-out sources. This change's whole point is that
copilot's supplementary accounting calls stop counting as api calls, so
every store-era day shrinks in calls and got pinned to its pre-store value.
The guard's own TRADE-OFF note predicted exactly this case.
A migration from an older cache version now grants copilot ONE
guarded-shrink-exempt re-derivation (`pendingRederive`), spent by the first
COMPLETE parse. It fires only where that parse actually produced a slice for
the (day, provider), so a day whose sources are gone still carries forward
whole - never-lose is unchanged in both directions - and no other provider
is exempt.
The daily cache moves 21 -> 25: 21-24 landed on main during validation, and
only a number the validators' own daily-cache.v21.json cannot claim gets
their stale carried slices re-derived.
2. The `initiator='compaction'` store row was ingested with outputTokens 0. Its
output has no assistant.message anywhere in events.jsonl, so that row is the
only place those tokens exist - dropping them was the sole token discrepancy
(-3,085) across a 30-session store-matched comparison. It is now read (on the
same optional-select rung as `initiator`, which is what identifies the row),
counted and priced; every other row keeps output 0 because a per-turn call
owns it. The copilot parse version moves to session-store-v3 so cached v2
rows re-parse; the dedup key is deliberately unchanged.
Settle window stays at 24 h - the three-machine evidence for tightening it is
recorded next to the constant, but shortening it is a product call.
Main already spent Codex results v13 (#1079) and daily v24 (#1090).
Keep all three parse-version tokens (codex-pricing-v1 +
codex-tps-v1 + activity-price-v1) so a take-ours merge cannot
drop the pricing or throughput invalidation. Do not Extra High again.
Neither id is in LiteLLM yet, so litellm-snapshot.json had no row for
them (285 sessions / 5,446 calls priced at $0 on one real corpus,
flagged during #1075 verification). Add explicit snapshot rows sourced
from the pattern every prior Codex-suffixed id LiteLLM does carry
follows: it 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, verified
against the live model_prices_and_context_window.json) - both new ids
get the exact gpt-5.6 tuple rather than an invented rate.
getModelCosts already resolved both ids to the correct rate through
the `gpt-5.6` prefix fallback before this, so a fresh parse or a warm
session-cache read (codeburn models included) was already pricing
these calls correctly. The daily cache is the one place that does not
self-heal: it has no per-provider invalidation, so a day finalized
before a `gpt-5.6` snapshot row existed at all keeps that $0 forever.
Raising MIN_SUPPORTED_VERSION (v23 -> v24) forces the one-time
re-derivation, a lossless no-op for days already correct - the
mechanism itself is generic and already covered by
daily-cache-version-rederivation.test.ts. #1056 also claims 24 on its
own branch; whichever lands second takes the next number.
Closes#1077
Main already spent Codex results v11 and daily v23 on #1075, and
result caches are now version-suffixed. Keep both parse-version
tokens (codex-pricing-v1 + activity-price-v1) so a take-ours merge
cannot drop the pricing invalidation. Do not Extra High again.
The COST_CHANGED_BY_DESIGN carve-out in compare.mjs left codex cost entirely
unasserted after #1075/#1078. It now requires the upgraded cost to be strictly
lower than baseline and within 25% of it, and the row verdict says "repriced"
instead of the misleading "identical (cost N% drift)".
grok.ts's comment on the reasoning/output split still claimed provider-side
splitting was the repo's only mechanism; it now also names
billableOutputTokens/REASONING_INCLUDED_IN_OUTPUT (models.ts), which is the
other half since #1078. usage-aggregator.ts's "folds reasoning into output"
comment was true pre-#1078 but is backwards for codex now (reasoning is
already inside output, not added to it).
Test exemplars for the "reasoning is additive" case used hermes, whose
upstream is OpenAI-shaped and may not stay a safe example; swapped to gemini,
which documents "thoughts" as genuinely separate output.
CHANGELOG's #1075 entry gets one line noting days whose codex transcripts
have aged out keep their pre-fix totals via the daily-cache never-lose guard,
matching the disclosure already given for #1040.
Reasoning tokens are a subset of output_tokens for OpenAI models, not an
extra bucket: on a 1,396-rollout corpus all 134,316 token_count events
carrying a total satisfy input + output == total. CodeBurn added
reasoning_output_tokens on top when pricing a codex call, in the
cache-rehydration re-price, and in the models/audit display sums. That
overstated codex cost by $166.03 (3.5%) and displayed output tokens by
34.6% on that corpus. Both cost sites and the display sums now go through
one shared billableOutputTokens() so a cold parse and a warm read cannot
drift apart.
cache_write_input_tokens (codex PR #33454) was never read and
cacheCreationInputTokens was hardcoded to 0. It is now carved out of the
uncached-input bucket and clamped to it, but routed to the cache-write
bucket ONLY when the pricing source publishes an explicit cache-write rate.
buildCosts fabricates 1.25x input when a source omits one, which is correct
for Anthropic and would have invented a surcharge OpenAI never charged on
gpt-5.5 / 5.4 / 5.3-codex / gpt-5. ModelCosts now carries
cacheWriteCostIsExplicit so that distinction survives getModelCosts.
A cost change invalidates persisted output: codex-results.json v10 -> v11
(stores costUSD verbatim), the codex parse version moves (the token-bucket
change does not self-heal on read), and the daily cache goes 20 -> 23 (21 is
claimed by the #946 landing branch and 22 by PR #1056). The upgrade-path
corpus asserts codex tokens and calls exactly and reports the repricing.
Closes#1075
Maintainer review on #1056: pin the activity id to the recommended
review model (existing bundled row, not an invented rate), resolve
the same alias for credits, and take daily-cache 22 so this PR does
not collide with #946 claiming v21.
Merge resolution
- daily-cache: main shipped v20 with #1040, so 21 is now the first FREE number
rather than one dodging an unmerged head. Both version notes kept, ours on
top. The re-derivation seed stays one below current (20), now the shipped
predecessor rather than a draft.
- upgrade-path: expected daily cache filename stays v21.
The rollup is cumulative across resume legs — already handled, now pinned
Measured on a real 3-leg session (CLI 1.0.80): every counter in a leg includes
the legs before it, and the last leg of a complete session equals its store-row
total to the nano. The parser has always emitted `cumulative - previous
cumulative` per model, so the interval arithmetic downstream already consumes
per-leg claims; it was the PR body and the provider doc that described the raw
journal as per-leg and misled the reader. Doc corrected, and (c3) pins it end
to end: a complete 3-leg session serves 900 (its last leg / store total), not
1,400 (the sum of its legs); an uncovered one serves 900 exactly once; and a
leg reporting LESS than its predecessor is taken as a fresh epoch rather than
clamped to a negative delta, so an older per-leg CLI never loses a leg.
initiator = 'compaction' replaces the timestamp heuristic where it exists
The summarization request does write its own assistant_usage_events row, and
newer stores label it. The label is now read — schema-adaptively, and the
enrichment select is a graduated chain so a store carrying the billing columns
but not `initiator` keeps its billing metadata instead of falling all the way
back to the base select. Two uses: the row is subtracted from the leg it
belongs to even though it commits before the compaction stamp, and it is kept
out of per-turn pairing since it has no assistant.message to pair with.
Optional twice over — absent on older stores, NULL on 1,504 of 2,509 rows on a
real one — so (c5) pins the labelled path at 350, the identical UNLABELLED
fixture at 400 (the documented one-request-per-compaction over-serve, which no
timestamp rule can close: the request that triggered the compaction completes
immediately before it too), and a compaction row never stealing a pairing
partner.
Attribution invariant, standing guard for the 1.8x report
(c4) runs `codeburn audit`'s own two numbers — attributed vs recomputed —
over all four combinations of the three representations a session can be
written in, at the magnitudes of a real reported day (gpt-5.6-terra, 146 rows,
input 17,792 / cache write 501,395 / cache read 12,097,364 / output 63,344 /
reasoning 24,831, billed $4.47). Every shape reconciles at 1.000, and the
covered case lands on $4.4687 = what the tokens price at = what GitHub billed,
with output 63,344 rather than the pre-fix 88,175 that re-priced reasoning on
top of itself.
upgrade-path: a day KEY is not the unit of never-lose
A parse change that re-dates a call to its true day legitimately empties one
day and fills its neighbour, token for token — observed on a real cache where
2026-08-08's single call moved to 08-07 exactly. The aging step now compares a
+/-1 day WINDOW rather than the day alone, so only a window that shrinks is a
loss, and an emptied day key is reported rather than failed.
RECONCILE_SETTLE_MS keeps 24h, with the measurement that argues against it
recorded at the constant and in the provider doc: across 91 real sessions zero
rows landed after shutdown (median -0.1s, max -0.0s). One machine, one CLI
version — the number to beat is seconds, not hours, once a second machine
agrees.
F1 — the upgrade-path guard was measuring the wrong layer. providerDetails
come from the menubar payload, and on a version bump adoptOlderDailyCaches
carries the superseded daily file forward as the baseline for exactly the days
this scenario creates, so a session-cache loss was mostly masked: reverting the
carry-forward showed a 10% dip where the real loss is 50%. It now sums
export.json's per-call copilot records (straight out of the parse), deletes the
baseline daily cache before the upgrade run so nothing else can answer, and
asserts EQUALITY rather than "did not shrink" — the failure mode on the other
side of the fix is a double. Proven: reverting the carry-forward now reports
80 calls -> 40, LOST history, and fails. (The double direction is asserted but
not demonstrated; a faithful re-keying simulation is collapsed by serve-time
dedup before it can be counted, which is noted at the step.)
F2 — sync was forward-only in the wrong direction. A receiver that took
`copilot:<sid>:shutdown:<model>:<n>` rollup spans from 0.9.20 keeps them
forever (append-once, no retraction for a usage span) and would now also
receive `copilot-store:` rows for the same sessions: permanent doubling on
every synced receiver, on the first push after upgrade. Sessions whose rollup
is already in the ledger are now FROZEN — their rows and residuals never go
out, and the receiver keeps the older, lossier number. A bounded under-count
in place of an unbounded over-count. Per-turn output spans are untouched
either way (the rollup never held output, reconciliation never touches them),
and sessions new to sync take the per-row path in full. `frozen` is reported
by --dry-run and the nothing-to-push line; docs/sync/README.md gains an FAQ
pointing at `codeburn sync reset --confirm` with the caveat that the receiver
must be cleared too.
F3 — docs/providers/copilot.md gains a Sharp edges section saying plainly that
a day sealed on a short store snapshot is a permanent UNDER-REPORT the
watermark advances past, not a stall, and that both realizable causes are now
closed (read ordering, compaction anchor).
F4 — the copilot dedup-key shapes are pinned by reading them back out of the
source, minted keys and discriminator prefixes alike, with the reason: the
durable union recognises history by key, so a changed prefix is a
CACHE_VERSION bump, not a parse-version bump.
F5 — the carry-forward comment now states its price: a bump no longer
re-derives a call it carried forward, so cost-shaped fixes land (cost is
recomputed from cached usage) but metadata and day-attribution fixes reach
only newly parsed calls.
F6 — the durable union filters at CALL level. A turn holding one cached call
beside a new one used to be dropped whole; nothing enforced the one-call-
per-turn assumption that made that safe.
F7 — the retain-source fence no longer skips a store it cannot fingerprint.
Present-but-unreadable defers, matching the classification path; only a
genuinely absent one stays a skip.
The corpus had no durable SQLite provider, so the one never-lose direction it
could not reach was the one this change breaks: a source that keeps its file
while the provider prunes rows out of it. gen-corpus now writes copilot's OTel
span store at its real default path (80 spans over 40 days), and the run caches
it with the published CLI, deletes half the spans from a DB that stays present,
valid and populated, then upgrades through the parse-version bump.
Copilot cost must not shrink across that, and must still not shrink on the
warm run that reads the rewritten cache — a carry-forward that only survived
in memory fails the second check. Verified to bite: with the carry-forward
reverted the step reports LOST $0.361746 (10.0%) and fails.
Also: the expected daily-cache filename moves to v21.
Resolved against main's newer invariants:
- daily-cache: DAILY_CACHE_VERSION/MIN_SUPPORTED -> 21. Main shipped 19
(#1015 grok); 18 was burned by this branch's earlier public head and 20 is
claimed by the unmerged #1040, and isMigratableCache would adopt either as
finalized without re-deriving. Header keeps main's v19 note and adds v21's.
- session-cache: PROVIDER_PARSE_VERSIONS keeps main's grok/dsh entries and
appends this branch's `-session-store-v2` to main's copilot value.
- parser: durable age-out takes main's orphan-only rule (#992).
retainWhilePresent is now redundant under it rather than load-bearing.
- audit-report: both imports (isBehavioralCall + sanitizeModelForDisplay).
- tests/parser (e), (l), (sc): re-pinned to the orphan-only age-out. A
still-discovered >90d copilot events.jsonl now keeps serving its per-turn
output alongside the store rows; the (sc) age-out subject is now an orphan.
- daily-cache-version-rederivation: seed 20 so the adjacent-draft-version
case is what the test pins.
- scripts/upgrade-path: expected daily cache filename -> v21.
The codex parse version and CODEX_CACHE_VERSION bumps in #1040 make codex
sessions re-parse, but the daily cache has no per-provider invalidation, so
every day already finalized keeps its old per-model rows - and usage-aggregator
serves every day before today from that cache, with ten-year retention. Raise
DAILY_CACHE_VERSION and MIN_SUPPORTED_VERSION to 20 so history re-derives once
off the warm session cache.
The re-derivation test now seeds v19, the last shipped version, so it models the
real 19 -> 20 path, and the upgrade-path check expects daily-cache.v20.json.
Measured on a real 110-day cache: no day lost value, none disappeared, 100 came
back identical, and 9 grok days rose by $19.80 in total from rollups an earlier
parse change had left stale. Every codex model row was unchanged - that corpus
predates the provenance field the fix corrects.
Claude Code deletes its transcripts after ~30 days, so between one run and
the next a day can go from fully sourced to PARTIALLY sourced. On such a
day the daily cache re-derives a smaller slice from the surviving files
and that slice REPLACES the baseline one rather than being unioned with
it, so the aged-out portion is lost. A day that aged out completely is
carried forward correctly, which is what makes this a hole in never-lose
rather than a missing feature.
The check ages the corpus the way retention does: two days keep a single
anchoring transcript each, a third loses every one. It then compares the
(date, provider) slices in daily-cache.v19.json against the ones the
0.9.20 baseline recorded in daily-cache.v17.json, requiring the partial
days never to shrink and the fully sourceless day to come back exactly.
This is a hard failure, not a note. It is expected to be red until the
fix lands: on the generated corpus the two partial days currently lose
76.6% and 56.8% of their cost, while the fully sourceless control day
returns to the cent.
It runs last and on its own cache dir, so mutating the corpus cannot
disturb the payload parity comparison. The shard-republication defect
found earlier stays a note, now naming its pending follow-up issue.
Everyone upgrading from the last published CLI crosses the session-cache
v7 -> v9 re-layout (#1005/#1007) and the daily-cache v17 -> v19
re-derivation (#1015) on their first run. That path was covered by unit
tests on one platform against caches the tests wrote themselves.
`npm run verify:upgrade` (and the matching matrix job over
{ubuntu, windows, macos} x node {22.13.0, 22}) instead installs the real
codeburn@0.9.20 into an isolated global prefix, points it at a generated
seven-provider corpus in a HOME whose path contains a space, and lets it
write a genuine session-cache.v7.json + daily-cache.v17.json. This build
then runs against that same cache dir, installed the same way, so
dist/parse-worker.js has to resolve from an entry point outside the
checkout.
It asserts the v7 file is retired, the v9 envelope and shards publish,
the daily history does not shrink, and per-provider calls/tokens/cost
match the baseline exactly for claude, codex, gemini, kiro and cursor.
grok is reported rather than asserted (its accounting changed in #1015)
and dsh is required to be new. It also smoke-tests serve --stdio against
the one-shot payloads, pins CODEBURN_PARSE_WORKERS to 0 and 3 and
requires identical shards and payloads either way, and checks that a
second run re-parses nothing.
Mythos 5 ($10/$50) is not in LiteLLM or the models.dev/OpenRouter gap-fill
yet (Fable is), so dropping the manual patch left it priced at $0. Add it to
MANUAL_ENTRIES + the snapshot and map the "Mythos 5" display name.
Keep model pricing automatic instead of hand-coding new models. The bundler
now layers three sources in priority order: LiteLLM (broad list prices),
hand-curated MANUAL_ENTRIES overrides, then a separate last-resort fallback
file gap-filled from models.dev first-party makers (official direct prices)
and OpenRouter (resale backstop). New models such as MiniMax-M3 ($0.6/$2.4)
now price correctly with no per-model code.
The fallback is written to its own pricing-fallback.json and consulted only
case-insensitively as the final step in getModelCosts, so a reseller variant
name can never shadow a canonical or aliased match.
Fixes surfaced while building and verifying this:
- Alias precedence: LiteLLM ships snowflake/claude-4-opus ($5), which the
bundler strips to a bare claude-4-opus key that shadowed the curated alias
to claude-opus-4 ($15 official). An explicit alias for a bare name now wins
over a coincidental stripped reseller key; the prefixed gateway price is
still returned for the fully-qualified id.
- Zero-stub guard: LiteLLM [0,0] price stubs (e.g. GigaChat-2-Max) are
excluded from the case-insensitive index so a case-mismatched query stays
null and keeps firing the unknown-model warning instead of silently
reporting $0.
- Negative-sentinel guard: OpenRouter returns -1 for variable/BYOK-priced
models. The bundler now rejects any non-positive rate pair (and strips the
sentinel from cache fields) so a negative per-token cost can never ship and
subtract from spend totals.
Bundler hardening: bareKey strips @pin and date suffixes to match the runtime
canonical form, seen-set dedupes on both full and bare key shapes, and it logs
MANUAL_ENTRIES now covered upstream plus models.dev allowlist drift. Extracted
buildCosts so the cache-cost heuristics live in one place. Added a data-hygiene
test that fails CI if a rebundle reintroduces negative, free, or unreachable
fallback entries.
New Claude releases no longer need a hand-maintained SHORT_NAMES entry or
FAST_MULTIPLIERS row. Display names are derived from the claude-<family>-<major>-<minor>
id, and the fast-mode multiplier rides along as a 5th element in the LiteLLM
snapshot tuple (provider_specific_entry.fast). Fixes#420: claude-opus-4-8 gets
its own line and correct pricing instead of falling into the Opus 4 bucket.