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.
Opt-in Vercel AI Gateway provider (AI_GATEWAY_API_KEY/VERCEL_OIDC_TOKEN), inert without a key. Cursor lookback now period-aligned with a 6-month floor. Gateway fetch hardened on merge with an 8s timeout and try/catch fallback.
Claude Code routed through a GitHub-Copilot-backed proxy (ANTHROPIC_BASE_URL
-> claude-code-over-github-copilot / claudegate) costs ~$0 marginal but was
priced at full Anthropic API rates, producing a misleading cost figure. The
JSONL records only the model name and no endpoint, so proxying cannot be
auto-detected; the user declares it.
Add a `proxyPaths` config (managed via a new `codeburn proxy-path` subcommand)
listing project directories served through a subscription proxy. Sessions whose
canonical path is under one keep their full API-rate totalCostUSD (the billable
would-be figure is never destroyed) and additionally report totalProxiedCostUSD,
so the JSON report overview exposes cost / proxiedCost / netCost
(netCost = cost - proxiedCost). With no proxyPaths configured the new fields are
0 and every existing consumer is unchanged.
Design: "full cost, flagged" was chosen over zeroing cost so a misconfigured or
since-changed path can never silently erase real Anthropic spend. Attribution is
project-level (one canonical path per project), computed in a single helper
(summarizeProject) that all ProjectSummary builders route through, including the
cross-provider merge in parseAllSessions (re-derived from the merged total so a
repo used with both Claude and Codex stays correct). The in-memory session-cache
key folds in a proxy-config hash so toggling proxyPaths in a long-lived process
cannot serve stale attribution. Path matching is segment-boundary anchored
(/foo does not match /foobar), trailing-slash and backslash tolerant, leading-
slash agnostic (so a non-Claude provider's slash-stripped path matches the same
way Claude's absolute path does), and case-folded only on case-insensitive
filesystems (macOS/Windows, not Linux). The proxy-path CLI sanitizes a
hand-edited config.json (non-array / non-string entries) rather than crashing.
Tested: isProxiedPath matching matrix (boundary, case, Windows, empty, root,
multi-path, leading-slash form); config-hash distinctness/order-independence;
end-to-end attribution through parseAllSessions incl. the critical negative
cases (real spend must not be zeroed); cross-provider Claude+Codex merge;
Codex-only project under a proxy path; date-range-filtered attribution;
cache-staleness after a config change; and the proxy-path CLI add/list/remove,
malformed-config robustness, and the report --format json overview.
Scope note: proxiedCost/netCost currently surface in `report --format json`
only; wiring them into the TUI dashboard and menubar payload is a follow-up.
The #426 fix moved waitUntilExit and its timeout onto the same global(qos:.utility)
queue. Under sustained load every utility worker blocked in waitUntilExit, so the
timeout could never be scheduled to kill them and the menubar wedged on Loading
forever (confirmed via sample after ~a week of soak). Await process.terminationHandler
(fires on a Foundation queue, blocks no worker) so the timeout always has a free
thread. Add an actor-based async semaphore capping concurrent CLI spawns at 6.
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.
Forked Codex sessions copy the parent's entire token_count history
(re-timestamped), so those replays are keyed into the parent's namespace
(forkedFromId) and deduped to avoid double-counting the parent's spend. But the
key used cumulativeTotal alone, which is too coarse: a genuine post-divergence
fork event whose running total coincidentally equals some parent total collided
with it and was dropped, undercounting real spend.
Add the cumulative token breakdown (input, cached, output, reasoning) to the
dedupe key. A fork replays those cumulative figures verbatim, so a true replay
still collides and parent and fork are not double-counted, while genuinely
different work that happens to reach the same cumulative total stays distinct.
The breakdown must come from the CUMULATIVE totals, not the per-event deltas:
the deltas are computed against a running `prev` that the fork advances
differently once the 5s replay cutoff skips some events, so a delta-based key
would spuriously diverge on a replay and double-count it (in the total-only
fallback branch).
This is the correct fix for issue #413. The reported "undercount" is mostly
replayed parent history being correctly credited to the parent, not lost; the
issue's suggested change (key on the fork's own session id) would instead
re-double-count the entire replayed history. Three regression tests pin the
invariants: a replay past the 5s cutoff counts once, a divergent fork event
sharing a parent's cumulative total is kept, and a total-only fork whose replay
straddles the cutoff does not overcount.
Follow-up to #433. Adds tests for the JetBrains format: discovery of a jb
chat.jsonl, parsing into a call with inferred model/tools/user message, the
isJetBrainsFormat routing guard (legacy user.message files must not route to
the JB parser), and the id-less dedup fallback.
Also fixes inferJBProjectFromContent: it split homedir() on the platform
separator but the recorded tool path on a fixed '/', so project inference
always fell back to the raw session id on Windows. Split both on either
separator.
* feat(copilot): add JetBrains (IntelliJ/DataGrip) session discovery and parsing
The Copilot provider previously only discovered sessions from:
- ~/.copilot/session-state/ (legacy VS Code format)
- VS Code workspace storage directories
IntelliJ and other JetBrains IDEs store Copilot chat sessions in
~/.copilot/jb/<session-id>/partition-*.jsonl using a slightly different
event format. This commit adds:
- discoverJetBrainsSessions(): finds all .jsonl files under ~/.copilot/jb/
- isJetBrainsFormat(): detects JB event format (no session.start header)
- parseJetBrainsEvents(): parses JB events (assistant.message with text/
thinking, tool.execution_start for tool names, tool call ID prefixes
for model inference)
- inferJBProjectName(): extracts project name from tool execution paths
Closes #N/A
* fix: address PR review feedback for JetBrains Copilot support
- Add 'sep' to path import (fixes ReferenceError in project inference)
- Use incrementing index as dedup key fallback when messageId is absent
to prevent undercounting tokens/cost
- Move project inference to parse time using already-loaded content
(avoids bypassing readSessionFile's 128MB safety cap)
- Tighten isJetBrainsFormat to only match user.message_rendered and
partition.created (avoids false-positive on legacy files starting
with user.message)
- Add dedicated inferModelFromEvents for JB format that checks
data.model (100x weight) and tool call ID prefixes on
tool.execution_start/complete events
- Remove dead first-pass loop that was never read
- Add jbDirOverride param to createCopilotProvider for test fixtures
Adds assets/menubar-logo.png and generates AppIcon.icns (all standard sizes
via sips + iconutil) in package-app.sh, so the menubar app ships with a real
icon instead of the generic one. Info.plist already referenced AppIcon.
Icon and bundler change from @tvcsantos's #439; the unrelated tsconfig.json
change in that PR was left out.
Without a model-savings mapping the Saved column was an unlabeled column of
dashes between Cost and Calls (menubar) and a dim '-' column (CLI models
report). Show it only when at least one model has savingsUSD > 0, so users
with no mapping keep the plain Cost/Calls layout. The menubar Hero caption was
already conditional.
Resolves conflicts from the post-PR refactor that moved buildPeriodData,
hydrateCache, and the menubar payload builder out of main.ts into
usage-aggregator.ts. The PR's savings additions to those functions are
re-homed there; config.ts keeps both new fields; parser.ts keeps both imports;
redact.ts session details carry savingsUSD.
Follow-up to #450. When a session file throws during parse it was excluded but
left uncached, so every refresh (~4x/min in the menubar) re-read and re-parsed
it, and only the first failing file per provider was ever surfaced.
- Add a negative-result marker: a failed file is cached as { fingerprint,
turns: [], failed: true }. reconcileFile treats it as 'unchanged' at the same
fingerprint, so it's skipped (no re-read) until the file changes. Empty turns
=> contributes no usage.
- Warn per offending file (with its path), capped at 5 per provider per run,
instead of once-per-provider — so a systemic break surfaces more than one file
without flooding. Cached markers keep it quiet across refreshes.
Tests: marker round-trips through save/load; reconcile stays 'unchanged' at the
same fingerprint and re-parses when the file changes.
Some agents (Pi, and others for injected turns) write a message's `content`
as a string instead of an array of blocks. Parsers did `(content ?? []).filter`,
which throws on a string — and because the daily-cache backfill swallowed parse
errors, one bad session silently wiped the entire trend/history.
- Add normalizeContentBlocks(): string -> one text block, array -> as-is (by
reference; drops null/undefined elements so the same crash can't happen one
level down), else -> []. Applied across pi/codex/droid/cursor-agent and the
Claude path in parser.ts.
- Isolate per-file parse failures in parseProviderSources: skip the offending
file (warn once per provider) instead of re-throwing and aborting the whole
backfill. The stale cache entry is already cleared, so the file is excluded.
- Surface backfill failures in hydrateCache via stderr instead of silently
returning an empty cache.
Likely fixes#425 (previous-day always 0) for the throwing-file cause.
Tests: content-utils unit tests + a Pi string-content regression test.
The pricing fetch (loadPricing -> fetchAndCachePricing) runs on every CLI
invocation, and the macOS menubar shells out to the CLI and blocks on its
exit. fetch() had no timeout, so a half-open network after wake-from-sleep
made it hang forever once the 24h pricing cache expired — wedging the
menubar on its loading spinner until relaunch.
Add a shared fetchWithTimeout helper (8s default, AbortSignal.timeout) and
apply it to the two daily-critical-path fetches: pricing (models.ts) and
the currency rate (currency.ts). On timeout the existing catch falls back
to the bundled price snapshot / cached or USD rate.
Reproduced on main (stale cache + black-holed host -> hangs indefinitely);
with the timeout the same scenario aborts in 8s and renders via fallback.
cacheReadInputTokens (Anthropic) and cachedInputTokens (OpenAI) name the
same tokens. ~11 providers populate both with the same value, so summing
them doubled the cache-read token figure in the models report. Take the
max instead. Cost is unaffected (calculateCost never used the sum).
usage.reasoningTokens is the AI SDK v6 normalized field across all
provider families, so the providerMetadata.openai fallback never fired.
Removed it and the now-unused openai extraction.
* feat(providers): add coder/mux as a datasource
Reads coder/mux session data from ~/.mux/sessions/<workspaceId>/chat.jsonl and normalizes per-assistant-message token usage into ParsedProviderCall. Discovery resolves project names from config.json; the token decomposition matches mux's own inclusive input/output accounting, and cost is recomputed via LiteLLM. Includes unit tests and a provider doc.
Change-Id: Ie6ce9d41254d8bf05ff0965b443328dfa7b598de
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Kosiewski <tk@coder.com>
* fix(providers): discover mux sub-agent transcripts
discoverSessions only globbed sessions/<id>/chat.jsonl and skipped each
spawned sub-agent's transcript at
sessions/<id>/subagent-transcripts/<childTaskId>/chat.jsonl. Against a real
~/.mux (629 workspaces) those nested files are 5,889 sessions and ~51% of all
assistant messages, so sub-agent spend was silently dropped: total mux usage
went from $17,113 to $21,564 (+26%) once counted.
Walk the subagent-transcripts dir per workspace and attribute each child
session to the parent's project. Dedup is unaffected: the parser keys off the
<childTaskId> dir name, which is disjoint from every workspace id, so each call
is still counted once. Cross-checked parsed per-model token totals against
mux's sibling session-usage.json.
Also corrects the provider doc, which wrongly claimed sub-agents get their own
top-level sessions/<id>/chat.jsonl, and documents the Google reasoning>output
decomposition edge case.
Change-Id: I1d43f26eec7c9c6c523e5ea541e2ff8d0c3aa07e
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Config Directories" section under the Claude settings tab so users
can aggregate usage across multiple Claude config directories (work /
personal accounts) from the GUI. The menubar is an accessory app that
doesn't inherit the user's shell environment, so CLAUDE_CONFIG_DIRS was
previously unreachable from the app.
Rather than injecting the value as an env var into every spawned subprocess
(which would force arbitrary user paths through the shell/AppleScript
allowlist that deliberately excludes shell metacharacters), the list is
persisted to the shared ~/.config/codeburn/config.json. The CLI reads it
regardless of how it's launched, so both the menubar's data refresh and
terminal-launched `report`/`optimize` honor it consistently.
CLI: getClaudeConfigDirs() now reads a claudeConfigDirs array from config
as a fallback below the CLAUDE_CONFIG_DIRS / CLAUDE_CONFIG_DIR env vars
(env still wins for per-shell overrides), above the ~/.claude default.
Menubar: CLIClaudeConfig mirrors CLICurrencyConfig's flock-guarded write;
the Settings UI offers an add/remove list with a folder picker and forces
a refresh on edit.
The currency picker hardcoded 8 currencies while the SupportedCurrency
enum defines 18. New currencies added to the enum (like CNY in #430)
required a separate SettingsView edit to appear in the picker.
discoverSessions always checks the SQLite path at ~/.local/share/kilo
regardless of overrideDir, so the test found real sessions on the host.
Filter to override-dir sessions and avoid hardcoded /tmp paths.
- Add per-install random salt to pseudonym hash to prevent rainbow table
reversal of common project names
- Redact session details (dates, models) when project names are hashed
to prevent re-identification via cost fingerprinting
- Return structuredContent in error responses to satisfy strict MCP
clients that validate against declared outputSchema
- Remove pre-warm fire-and-forget that raced with the inflight map
- Fix empty markdown table producing malformed cells
- Add tests for session detail redaction and cross-field consistency
The menubar wedged on "Loading Today…" for hours after an idle period.
Root cause: DataClient.runCLI called the blocking process.waitUntilExit()
from an async function on Swift's cooperative thread pool. On a 16-core
machine, 16 concurrent slow `codeburn` subprocesses pinned all 16
cooperative threads inside waitUntilExit; the 45s timeout — itself a Task
on that same pool — could then never be scheduled to kill them, so the
deadlock was permanent. Confirmed via sample: 16/16 cooperative threads
parked in waitUntilExit. PR #412 (AppStore inFlightKeys bookkeeping) was a
layer above the OS-thread deadlock and could not fix it.
Move both blocking points off the cooperative pool: bridge waitUntilExit
through a global (overcommit) queue via a continuation, and drive the
timeout from a DispatchSource on a global queue so it fires even when the
pool is saturated. Extract runProcess for testability; add a concurrency +
timeout smoke test and an output/exit-code test.
Add a new `localModelSavings` config and `codeburn model-savings` CLI
that maps a local-model name (e.g. llama3.1:8b) to a paid baseline
(e.g. gpt-4o). The local call still costs $0; the new `savingsUSD`
field tracks the counterfactual spend avoided by running locally and
is reported separately from `costUSD` everywhere a number is shown.
* Parser normalization (`applyLocalModelSavings`) runs on Claude
parse, direct provider calls, and the cached-call path. It forces
`costUSD` to 0 and attaches `savingsUSD` + `savingsBaselineModel`
+ `isLocalSavings` on the `ParsedApiCall`. Local-savings wins for
actual cost even when the same model is also in `modelAliases`.
* Session, project, day, model, category, activity, skill, and
subagent rollups all carry `savingsUSD` alongside `costUSD`.
* `status --format json` adds `today.savings` and `month.savings`.
* `status --format menubar-json` adds a `current.localModelSavings`
block (totalUSD, calls, byModel, byProvider) plus savings on
topModels, topProjects, topSessions, topActivities, and history
daily entries. Schema fields default-decode for backward compat.
* `report --format json` adds savings across overview/daily/
projects/models/activities/skills/subagents/topSessions, with
the active paid baseline name on each model row.
* `models` command gains a `Saved` column on table/markdown/CSV
and a `savingsUSD`/`savingsBaselineModel` pair in JSON. Default
`--min-cost 0.01` filter now ORs in `savingsUSD >= minCost` so
local models with $0 actual cost but >0 savings still surface.
* CSV/JSON exports add a `Saved (CODE)` column on summary/daily/
models/projects/sessions.
* Dashboard TUI shows a green 'saved $X by local models' footer
line in the overview when any savings are present.
* macOS Swift payload gains a `LocalModelSavings` Codable block
and savings fields on every model/activity/session/daily
struct. Hero shows a green leaf 'Saved $X' caption, models
section gets a green `Saved` column. `swift build` clean.
* GNOME indicator adds 'saved $X' to the hero meta line and a
`codeburn-model-saved` column to the model row.
* Daily cache schema bumped to v8 (`savingsUSD` on day/model/
category/provider). `savingsConfigHash` invalidates the cache
when the user changes their baseline mapping so historical
saved-spend numbers never lie about a stale baseline.
* Defensive `Object.hasOwn` lookup in `getLocalSavingsBaseline`
blocks the prototype-pollution test that previously surfaced via
the savings path with a hostile `__proto__` model name.
* New tests (5 files, 25 tests, 549 lines) cover pricing helpers,
end-to-end parser normalization, day aggregator savings,
menubar payload savings, CLI set/list/remove, and
daily-cache hash invalidation. Existing tests for daily-cache
/ day-aggregator / models-report updated for the new fields.
Full vitest suite: 1028/1028 passing across 73 test files.
`tsc --noEmit` clean. `npm run build` clean.
(Note: `mac/Tests` has a pre-existing `no such module 'Testing'`
environment error on the installed Swift toolchain, confirmed
on `main` before this PR; not caused by these changes.)
Extract the duplicated codeburn-binary lookup (PATH building, npx-shim skipping,
default lookup dirs, resolver) from menubar-installer and the Antigravity
statusline installer into src/persistent-codeburn.ts. Both now import it and
keep their own user-facing "install globally" message.
Match the Antigravity statusLine hook on the trailing agy-statusline-hook token
instead of a bare substring, so a custom command that merely mentions the token
is treated as custom (protected, backed up) rather than silently overwritten.
Fix Antigravity hook stale CLI paths: install the statusLine hook through a
persistent codeburn binary resolved from PATH and repair stale CodeBurn-owned
hooks on re-install.
A bare startsWith let an unlisted future minor collapse into the base entry
(gpt-5.6 -> "GPT-5"), the same mis-bucketing class as #420 but for non-Claude
families. Require an exact match or a key-plus-dash boundary in both
getShortModelName and the Codex provider so new versions fall through to their
raw id instead of a wrong name and pricing tier.
The Claude provider kept its own shortNames map with no claude-opus-4-8
entry, so the models table and menubar bucketed it into "Opus 4" — the
actual #420 path (the earlier models.ts map is unused here). Delegating to
getShortModelName removes the duplicate and picks up new versions for free.
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.
The menubar kept its own copy of each provider's OAuth grant and refreshed
it on a timer, racing the CLI. Both Claude and Codex use single-use refresh
tokens that rotate on every refresh, so the menubar's self-rotation could
invalidate the user's own CLI login and surfaced as "disconnected" after a
long idle period.
Codex: read ~/.codex/auth.json fresh each cycle; only self-refresh when
last_refresh is older than 8 days; on 401 re-read the source before spending
our token; write rotated tokens back to auth.json (atomic, preserving other
keys); recover from reuse/invalid_grant by re-reading instead of going
terminal.
Claude: never HTTP-refresh the CLI-owned token. On expiry/401 re-read the
keychain with a no-UI query (LAContext.interactionNotAllowed) to adopt a
token the CLI already rotated; when none is available yet, report a transient
sourceTokenStale rather than a terminal disconnect.