feat(cli): track local-model cost savings against a paid baseline (#421)#423
Open
justingheorghe wants to merge 1 commit into
Open
Conversation
…tagentseal#421) 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.)
|
@justingheorghe thanks for taking this on! Can you include some screenshots of CLI/Mac dashboards to show how this is being displayed? What is the current UX for mapping the local model to various paid models? Can it be done easily via the application UI rather than config tweaks? That's a major convenience feature for evaluating cost savings between models. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the full local-model cost-savings accounting surface for the dashboard, JSON/CSV exports, menubar, GNOME, and macOS clients. Local-model calls now report both the actual spend (still $0) and the counterfactual avoided spend against a user-chosen paid baseline, kept as separate fields so the two never get summed into a misleading "real cost".
Forecasting is intentionally out of scope for this PR per the issue conversation; everything else in the plan is in.
Closes #421
Configuration
A new top-level
localModelSavingsmapping joinsmodelAliasesin~/.config/codeburn/config.json. Distinct frommodelAliases(which rewrites a model's identity for actual cost): alocalModelSavingsentry keeps the local call at $0 and reports what the same tokens would have cost on the baseline.{
"localModelSavings": {
"llama3.1:8b": "gpt-4o",
"qwen2.5-coder:32b": "claude-3-5-sonnet-20241022"
}
}
CLI management mirrors
model-alias:codeburn model-savings # add
codeburn model-savings --list # list
codeburn model-savings --remove # remove
When the same model is also present in
modelAliases, the new command prints a one-time warning that local-savings wins for actual cost (and the parser enforces this — cost stays 0 even if a stalemodelAliasesentry exists).Semantics
costUSDcontinues to mean actual spend. For a local call mapped viamodel-savings, it is forced to0.savingsUSDis the counterfactual baseline cost the same tokens would have incurred against the configured paid model. The baseline is priced through the normalcalculateCostpipeline (aliases, canonicalization, fast multiplier, 1h cache multiplier, web search).localModelSavingsblock in the menubar payload.getLocalModelSavingsConfigHash()produces a stable hash for the daily cache to detect when the user changes their baseline mapping and rebuild history.Code changes (19 files, +747 / -113)
Core pricing & config
src/config.ts— newlocalModelSavingsfield onCodeburnConfigsrc/models.ts—setLocalModelSavings,getLocalSavingsBaseline,calculateLocalModelSavings,getLocalModelSavingsConfigHash; updated unknown-model hint to mentionmodel-savings; defensiveObject.hasOwnlookup so a hostile__proto__model name cannot reachObject.prototype(regression test included)src/types.ts—ParsedApiCallgetssavingsUSD?,savingsBaselineModel?,isLocalSavings?;SessionSummaryandProjectSummarygettotalSavingsUSD; every per-call breakdown (modelBreakdown,categoryBreakdown,skillBreakdown,subagentBreakdown) getssavingsUSDsrc/parser.ts— newapplyLocalModelSavingshelper applied to Claude parse,providerCallToTurn, andcachedCallToApiCall;buildSessionSummaryand the threeProjectSummaryconstruction sites track savings totalssrc/daily-cache.ts—DAILY_CACHE_VERSION7 → 8,MIN_SUPPORTED_VERSION8 (old v7 backups remain),savingsUSDon day/model/category/provider,savingsConfigHashon the cache;ensureCacheHydratedaccepts a hash and discards cached days when the hash mismatchessrc/day-aggregator.ts—DailyEntryandPeriodDatacarrysavingsUSD;buildPeriodDataFromDaysrolls up savings per model/categorysrc/menubar-json.ts— newLocalModelSavingstype, distinct fromoptimize.savingsUSDandroutingWaste.totalSavingsUSD;PeriodData.savingsUSD; savings ontopModels,topActivities,topProjects,topSessions, daily history, and per-daily top-modelsCLI
src/main.ts—preActioncallssetLocalModelSavings(config.localModelSavings ?? {})and threads the hash toensureCacheHydrated; newmodel-savingscommand (set/list/remove + alias-conflict warning);buildJsonReportadds savings to overview/daily/projects/models/activities/skills/subagents/topSessions;buildPeriodDatacarries savings;status --format menubar-jsoncomputes alocalModelSavingsrollup (byModel+byProvider, top-5 each) and threads savings into topModels/topProjects/topSessions/daily history;status --format jsonaddstoday.savings,month.savings, and an optionallocalModelSavingsblocksrc/models-report.ts—ModelReportRowgetssavingsUSD+savingsBaselineModel; sort key is nowcost + savings; default--min-cost 0.01filter ORs insavingsUSD >= minCostso local models with $0 actual cost but > 0 savings still surface; newSavedcolumn in table/markdown/CSV; JSON includessavingsUSD+savingsBaselineModel; drop priority keepsSavedeven when other columns are pruned for narrow terminalssrc/dashboard.tsx— green "saved $X by local models" footer line in the overview when any savings are presentsrc/export.ts—Saved (CODE)column onsummary.csv,daily.csv,models.csv,projects.csv,sessions.csv; sort orders include savingsGUI consumers
mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift— newLocalModelSavings+LocalModelSavingsByModel+LocalModelSavingsByProviderCodable structs; savings fields onCurrentBlock(new requiredlocalModelSavings),DailyModelBreakdown,DailyHistoryEntry,ModelEntry,SessionModelEntry,SessionDetailEntry,ProjectEntry,TopSessionEntry,ActivityEntry; every struct uses custominit(from:)withdecodeIfPresentdefaults for backward compatibility;MenubarPayload.emptyupdatedmac/Sources/CodeBurnMenubar/Views/HeroSection.swift— green leaf "Saved $X with local models" caption below the hero amount (shown only when savings > 0)mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift— new greenSavedcolumn on the model row, distinct fromCostgnome/indicator.js— hero meta line adds "saved $X" whencurrent.localModelSavings.totalUSD > 0;_buildModelRowgets acodeburn-model-savedcolumn for the same dataTests
tests/local-model-savings.test.ts(NEW, 9 tests, 94 lines) — config helpers, hash stability, baseline pricing throughcalculateCost, defensive__proto__/constructor/toStringlookupstests/parser-local-savings.test.ts(NEW, 4 tests, 138 lines) — end-to-end on parsed JSONL: unconfigured local stays at $0; configured local flips to $0 + savings; savings precedence overmodelAliasestests/day-aggregator-savings.test.ts(NEW, 2 tests, 150 lines) — day/model/category/provider savings rollup;buildPeriodDataFromDayssavingstests/menubar-savings.test.ts(NEW, 6 tests, 91 lines) —localModelSavingsblock, savings on topModels/topProjects/topSessions/topActivities, history daily savingstests/cli-model-savings.test.ts(NEW, 3 tests, 76 lines) — set/list/remove flow, alias-conflict warning, remove-unknown errortests/daily-cache.test.ts(UPDATED, +48 lines) — newsavingsConfigHashfield on test fixtures, newensureCacheHydrated: savings config invalidationdescribe block (hash mismatch drops cached days, hash match keeps them)tests/day-aggregator.test.ts(UPDATED) — fixture updates forsavingsUSD: 0on model/category/providertests/models-report.test.ts(UPDATED) — fixture updates forsavingsUSD+savingsBaselineModel; markdown/CSV header expectations includeSavedVerification
All commands run on
darwin, Node>=22.13.0, against commit69b1736 refactor(cli): share persistent-codeburn resolver; tighten Antigravity hook ownershipas the base.TypeScript
$ ./node_modules/.bin/tsc --noEmit
$ echo $?
0
Build
$ npm run build
Bundled 3673 models → src/data/litellm-snapshot.json
CLI Building entry: src/main.ts
CLI Using tsconfig: tsconfig.json
CLI tsup v8.5.1
CLI Using tsup config: /Users/justingheorghe/Documents/Software Projects/codeburn/tsup.config.ts
CLI Target: node20
CLI Cleaning output folder
ESM Build start
ESM dist/main.js 914.65 KB
ESM dist/main.js.map 1.64 MB
ESM ⚡️ Build success in 47ms
```
Vitest — full suite
$ ./node_modules/.bin/vitest run --reporter=default
Test Files 73 passed (73)
Tests 1028 passed (1028)
Duration 12.87s
Breakdown of the new savings test files:
$ ./node_modules/.bin/vitest run tests/local-model-savings.test.ts \
tests/parser-local-savings.test.ts \
tests/day-aggregator-savings.test.ts \
tests/menubar-savings.test.ts \
tests/cli-model-savings.test.ts
Test Files 5 passed (5)
Tests 25 passed (25)
Swift — macOS build
$ cd mac && swift build
[16/19] Write Objects.LinkFileList
[17/19] Linking CodeBurnMenubar
[18/19] Applying CodeBurnMenubar
Build complete!
$ echo $?
0
Swift — tests
The `mac/Tests/CodeBurnMenubarTests` target fails to compile in this environment with `error: no such module 'Testing'` (the Swift Testing framework is not available in the installed Swift toolchain). This is pre-existing on `main` — I verified it by stashing my changes and running `swift test` against `69b1736` and observed the identical `no such module 'Testing'` failure. No files under `mac/Tests/` were modified by this PR; the failure is independent of these changes. `swift build` (the compile step) passes cleanly.
CLI smoke test
```bash
$ TMP=$(mktemp -d)
$ HOME="$TMP" ./dist/cli.js model-savings "foo" "gpt-4o"
Savings mapping saved: foo -> gpt-4o
Config: /var/folders/.../tmp.D9UKCxSp2t/.config/codeburn/config.json
$ cat "$TMP/.config/codeburn/config.json"
{
"localModelSavings": {
"foo": "gpt-4o"
}
}
```
Design notes
Out of scope (per the issue discussion)
Related issues