Merge pull request #454 from getagentseal/integrate/local-model-savings

feat(cli): local-model cost savings, re-homed onto current main (#421)
This commit is contained in:
Resham Joshi 2026-06-06 22:38:23 +02:00 committed by GitHub
commit fda117face
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1326 additions and 122 deletions

View file

@ -2,6 +2,18 @@
## Unreleased
### Added (CLI)
- **Local-model cost savings reports.** New `codeburn model-savings` command
maps a local-model name (e.g. `llama3.1:8b`) to a paid baseline (e.g.
`gpt-4o`) so the dashboard can report the counterfactual spend the same
tokens would have incurred on the baseline. The local call still costs
$0; the new `savingsUSD` field tracks the avoided spend separately from
`costUSD` everywhere a number is shown (dashboard, JSON/CSV exports,
menubar payload, macOS menubar, GNOME extension, daily cache rollups).
Historical savings are recomputed automatically when the baseline
mapping changes (config-hash invalidation on the daily cache). Daily
cache schema bumped to v8. (#421)
### Fixed (CLI)
- **Antigravity hook stale path repair.** `codeburn antigravity-hook install`
now installs the statusLine command through a persistent `codeburn` binary

View file

@ -576,6 +576,7 @@ class CodeBurnIndicator extends PanelMenu.Button {
_render(payload) {
const current = payload?.current ?? {};
const cost = Number(current.cost ?? 0);
const savings = Number(current?.localModelSavings?.totalUSD ?? 0);
this._panelLabel.set_text(this._fmt(cost));
this._heroLabel.set_text(current.label || '');
@ -583,7 +584,9 @@ class CodeBurnIndicator extends PanelMenu.Button {
const calls = Number(current.calls ?? 0);
const sessions = Number(current.sessions ?? 0);
this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`);
const metaParts = [`${calls.toLocaleString()} calls`, `${sessions} sessions`];
if (savings > 0) metaParts.push(`saved ${this._fmt(savings)}`);
this._heroMeta.set_text(metaParts.join(' '));
this._renderChart(payload?.history?.daily ?? []);
this._renderContent();
@ -946,6 +949,16 @@ class CodeBurnIndicator extends PanelMenu.Button {
const mc = new St.Label({ text: this._fmt(model.cost), style_class: 'codeburn-model-cost' });
mc.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(mc);
// Show saved counterfactual when this local model has a savings
// mapping. Kept as a separate column so it never gets summed with
// the actual cost on the left.
const savings = Number(model.savingsUSD || 0);
const savedLabel = new St.Label({
text: savings > 0 ? this._fmt(savings) : '—',
style_class: 'codeburn-model-saved',
});
savedLabel.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(savedLabel);
const mcalls = new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' });
mcalls.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(mcalls);

View file

@ -16,16 +16,31 @@ struct HistoryBlock: Codable, Sendable {
struct DailyModelBreakdown: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
var totalTokens: Int { inputTokens + outputTokens }
enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, calls, inputTokens, outputTokens
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
}
}
struct DailyHistoryEntry: Codable, Sendable {
let date: String
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
@ -43,12 +58,13 @@ struct DailyHistoryEntry: Codable, Sendable {
extension DailyHistoryEntry {
/// Required for legacy payloads (no topModels emitted yet).
enum CodingKeys: String, CodingKey {
case date, cost, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
case date, cost, savingsUSD, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
@ -99,6 +115,7 @@ struct CurrentBlock: Codable, Sendable {
let cacheHitPercent: Double
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let localModelSavings: LocalModelSavings
let providers: [String: Double]
let topProjects: [ProjectEntry]
let modelEfficiency: [ModelEfficiencyEntry]
@ -114,7 +131,7 @@ struct CurrentBlock: Codable, Sendable {
extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, topActivities, topModels, providers, topProjects,
cacheHitPercent, topActivities, topModels, localModelSavings, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste,
tools, skills, subagents, mcpServers
}
@ -130,6 +147,7 @@ extension CurrentBlock {
cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0
topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? []
topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? []
localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: [])
providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:]
topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? []
modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? []
@ -143,36 +161,117 @@ extension CurrentBlock {
}
}
struct LocalModelSavingsByModel: Codable, Sendable {
let name: String
let calls: Int
let actualUSD: Double
let savingsUSD: Double
let baselineModel: String
let inputTokens: Int
let outputTokens: Int
}
struct LocalModelSavingsByProvider: Codable, Sendable {
let name: String
let calls: Int
let savingsUSD: Double
}
struct LocalModelSavings: Codable, Sendable {
let totalUSD: Double
let calls: Int
let byModel: [LocalModelSavingsByModel]
let byProvider: [LocalModelSavingsByProvider]
}
struct ActivityEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let turns: Int
let oneShotRate: Double?
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
turns = try c.decode(Int.self, forKey: .turns)
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, turns, oneShotRate
}
}
struct ModelEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let savingsBaselineModel: String
let calls: Int
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? ""
calls = try c.decode(Int.self, forKey: .calls)
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, savingsBaselineModel, calls
}
}
struct SessionModelEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD
}
}
struct SessionDetailEntry: Codable, Sendable {
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let date: String
let models: [SessionModelEntry]
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
date = try c.decode(String.self, forKey: .date)
models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? []
}
private enum CodingKeys: String, CodingKey {
case cost, savingsUSD, calls, inputTokens, outputTokens, date, models
}
}
struct ProjectEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let sessions: Int
let avgCostPerSession: Double
let sessionDetails: [SessionDetailEntry]
@ -181,13 +280,14 @@ struct ProjectEntry: Codable, Sendable {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
sessions = try c.decode(Int.self, forKey: .sessions)
avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession)
sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? []
}
private enum CodingKeys: String, CodingKey {
case name, cost, sessions, avgCostPerSession, sessionDetails
case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails
}
}
@ -200,8 +300,22 @@ struct ModelEfficiencyEntry: Codable, Sendable {
struct TopSessionEntry: Codable, Sendable {
let project: String
let cost: Double
let savingsUSD: Double
let calls: Int
let date: String
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
project = try c.decode(String.self, forKey: .project)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
date = try c.decode(String.self, forKey: .date)
}
private enum CodingKeys: String, CodingKey {
case project, cost, savingsUSD, calls, date
}
}
struct ToolEntry: Codable, Sendable {
@ -256,6 +370,7 @@ extension MenubarPayload {
cacheHitPercent: 0,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: [:],
topProjects: [],
modelEfficiency: [],

View file

@ -67,6 +67,16 @@ struct HeroSection: View {
.foregroundStyle(.orange)
.padding(.top, 2)
}
if let savingsCaption {
HStack(spacing: 4) {
Image(systemName: "leaf.fill")
.font(.system(size: 10))
Text(savingsCaption)
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.green)
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
@ -99,6 +109,17 @@ struct HeroSection: View {
return label
}
/// Local-model savings caption shown beneath the hero amount when the
/// user has mapped any local model to a paid baseline via
/// `codeburn model-savings`. Kept as a separate line so actual spend
/// (above) and hypothetical avoided spend (below) never get summed
/// into a misleading "real cost" by the reader.
private var savingsCaption: String? {
let savings = store.payload.current.localModelSavings.totalUSD
guard savings > 0 else { return nil }
return "Saved \(savings.asCurrency()) with local models"
}
private var todayDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE MMM d"

View file

@ -4,6 +4,13 @@ struct ModelsSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
// Only surface the Saved column when something was actually saved by a
// local-model mapping. With no mapping it would be an unlabeled column of
// dashes, so we drop it entirely and keep the plain Cost / Calls layout.
private var showSavings: Bool {
store.payload.current.topModels.contains { $0.savingsUSD > 0 }
}
var body: some View {
CollapsibleSection(
caption: "Models",
@ -11,6 +18,9 @@ struct ModelsSection: View {
trailing: {
HStack(spacing: 8) {
Text("Cost").frame(minWidth: 54, alignment: .trailing)
if showSavings {
Text("Saved").frame(minWidth: 54, alignment: .trailing)
}
Text("Calls").frame(minWidth: 52, alignment: .trailing)
}
.font(.system(size: 10, weight: .medium))
@ -21,7 +31,7 @@ struct ModelsSection: View {
VStack(alignment: .leading, spacing: 7) {
let maxCost = max(store.payload.current.topModels.map(\.cost).max() ?? 1, 0.01)
ForEach(store.payload.current.topModels, id: \.name) { model in
ModelRow(model: model, maxCost: maxCost)
ModelRow(model: model, maxCost: maxCost, showSavings: showSavings)
}
TokensLine()
@ -34,9 +44,13 @@ struct ModelsSection: View {
private struct ModelRow: View {
let model: ModelEntry
let maxCost: Double
let showSavings: Bool
var body: some View {
HStack(spacing: 8) {
// Bar tracks actual cost; for local models the cost is $0 and the
// bar will be empty. Saved counterfactual (if any) renders as
// green text in the saved column, never summed into the bar.
FixedBar(fraction: model.cost / maxCost)
.frame(width: 56, height: 6)
@ -49,6 +63,14 @@ private struct ModelRow: View {
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)
if showSavings {
Text(model.savingsUSD > 0 ? model.savingsUSD.asCompactCurrency() : "")
.font(.codeMono(size: 12))
.tracking(-0.2)
.foregroundStyle(model.savingsUSD > 0 ? Color.green : Color.secondary)
.frame(minWidth: 54, alignment: .trailing)
}
Text("\(model.calls)")
.font(.system(size: 11))
.monospacedDigit()

View file

@ -29,10 +29,16 @@ export type CodeburnConfig = {
modelAliases?: Record<string, string>
// Extra Claude config directories to aggregate usage across (e.g. work /
// personal accounts). Honored by getClaudeConfigDirs() below the
// CLAUDE_CONFIG_DIRS/CLAUDE_CONFIG_DIR env vars. Lets the macOS menubar a
// GUI app that doesn't inherit the user's shell env configure multi-account
// CLAUDE_CONFIG_DIRS/CLAUDE_CONFIG_DIR env vars. Lets the macOS menubar (a
// GUI app that doesn't inherit the user's shell env) configure multi-account
// aggregation without injecting env into every spawned subprocess.
claudeConfigDirs?: string[]
// Map raw local-model names (e.g. "llama3.1:8b") to the paid model we would
// price the call against (e.g. "gpt-4o"). The local call still costs $0; we
// track what the same tokens would have cost on the baseline so the dashboard
// can show "saved $X by running locally". Distinct from modelAliases which
// rewrites actual spend.
localModelSavings?: Record<string, string>
}
function getConfigDir(): string {

View file

@ -5,16 +5,20 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 7: new providers (Codebuff, Mistral Vibe, Kimi, Cline) and
// the per-provider menubar path now reads historical cost from the cache.
// Stale entries computed by older binaries may carry incorrect totals.
export const DAILY_CACHE_VERSION = 7
const MIN_SUPPORTED_VERSION = 7
// Bumped to 8: local-model savings accounting is now part of the daily rollup
// (savingsUSD per day / per model / per category / per provider). Stale entries
// computed by older binaries lack those fields, so MIN_SUPPORTED_VERSION is
// also raised to 8 to force a full re-hydration. The `savingsConfigHash` field
// is invalidated separately when the user changes their `localModelSavings`
// mapping so historical "saved" totals stay in sync with the active baseline.
export const DAILY_CACHE_VERSION = 8
const MIN_SUPPORTED_VERSION = 8
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {
date: string
cost: number
savingsUSD: number
calls: number
sessions: number
inputTokens: number
@ -26,17 +30,23 @@ export type DailyEntry = {
models: Record<string, {
calls: number
cost: number
savingsUSD: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}>
categories: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
providers: Record<string, { calls: number; cost: number }>
categories: Record<string, { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }>
providers: Record<string, { calls: number; cost: number; savingsUSD: number }>
}
export type DailyCache = {
version: number
/// Hash of the active `localModelSavings` config at the time the cache
/// was last written. When the user changes their baseline mapping the
/// hash mismatches and `ensureCacheHydrated` discards the cached days
/// so historical savings are recomputed against the current mapping.
savingsConfigHash: string
lastComputedDate: string | null
days: DailyEntry[]
}
@ -49,11 +59,11 @@ function getCachePath(): string {
return join(getCacheDir(), DAILY_CACHE_FILENAME)
}
export function emptyCache(): DailyCache {
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
export function emptyCache(savingsConfigHash = ''): DailyCache {
return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [] }
}
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; days: Record<string, unknown>[] } {
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[] } {
if (!parsed || typeof parsed !== 'object') return false
const c = parsed as Partial<DailyCache>
if (typeof c.version !== 'number') return false
@ -65,6 +75,7 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
return days.map(d => ({
date: d.date as string,
cost: (d.cost as number) ?? 0,
savingsUSD: (d.savingsUSD as number) ?? 0,
calls: (d.calls as number) ?? 0,
sessions: (d.sessions as number) ?? 0,
inputTokens: (d.inputTokens as number) ?? 0,
@ -93,6 +104,7 @@ export async function loadDailyCache(): Promise<DailyCache> {
if (isMigratableCache(parsed)) {
const migrated: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: parsed.savingsConfigHash ?? '',
lastComputedDate: parsed.lastComputedDate,
days: migrateDays(parsed.days),
}
@ -153,7 +165,12 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
? cache.lastComputedDate
: newestDate
return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: pruned }
return {
version: DAILY_CACHE_VERSION,
savingsConfigHash: cache.savingsConfigHash,
lastComputedDate: nextLast,
days: pruned,
}
}
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
@ -182,6 +199,11 @@ export function toDateString(date: Date): string {
export async function ensureCacheHydrated(
parseSessions: (range: DateRange) => Promise<ProjectSummary[]>,
aggregateDays: (projects: ProjectSummary[]) => DailyEntry[],
/// Hash of the active `localModelSavings` config. When this changes
/// (user re-mapped a baseline) the cached `savingsUSD` totals are no
/// longer accurate, so we treat the cache as stale and force a full
/// re-hydration. Pass `''` for "no savings config" to disable.
savingsConfigHash: string = '',
): Promise<DailyCache> {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
@ -191,6 +213,19 @@ export async function ensureCacheHydrated(
return withDailyCacheLock(async () => {
let c = await loadDailyCache()
// Savings config changed: roll the cache forward into the active
// mapping. We can't cheaply recompute savings for already-cached
// historical days without re-parsing every session, so we drop the
// cached days and re-hydrate from the daily cache retention window.
if (c.savingsConfigHash !== savingsConfigHash) {
c = {
version: DAILY_CACHE_VERSION,
savingsConfigHash,
lastComputedDate: null,
days: [],
}
}
const hadYesterday = c.days.some(d => d.date >= yesterdayStr)
if (hadYesterday) {
const freshDays = c.days.filter(d => d.date < yesterdayStr)

View file

@ -192,6 +192,7 @@ function planStatusText(planUsage: PlanUsage): string {
function Overview({ projects, label, width, planUsages }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[] }) {
const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const allSessions = projects.flatMap(p => p.sessions)
@ -223,6 +224,12 @@ function Overview({ projects, label, width, planUsages }: { projects: ProjectSum
<Text dimColor wrap="truncate-end">
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
</Text>
{totalSavings > 0 && (
<Text wrap="truncate-end">
<Text color="green">{formatCost(totalSavings)}</Text>
<Text dimColor> saved by local models</Text>
</Text>
)}
{activePlanUsages.length > 0 && (
<>
{activePlanUsages.map(planUsage => {

View file

@ -6,6 +6,7 @@ function emptyEntry(date: string): DailyEntry {
return {
date,
cost: 0,
savingsUSD: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
@ -46,13 +47,15 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
const editTurns = turn.hasEdits ? 1 : 0
const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
const turnSavings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0)
turnDay.editTurns += editTurns
turnDay.oneShotTurns += oneShotTurns
const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
cat.turns += 1
cat.cost += turnCost
cat.savingsUSD += turnSavings
cat.editTurns += editTurns
cat.oneShotTurns += oneShotTurns
turnDay.categories[turn.category] = cat
@ -60,8 +63,10 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const call of turn.assistantCalls) {
const callDate = dateKey(call.timestamp)
const callDay = ensure(callDate)
const callSavings = call.savingsUSD ?? 0
callDay.cost += call.costUSD
callDay.savingsUSD += callSavings
callDay.calls += 1
callDay.inputTokens += call.usage.inputTokens
callDay.outputTokens += call.usage.outputTokens
@ -69,21 +74,23 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
const model = callDay.models[call.model] ?? {
calls: 0, cost: 0,
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
}
model.calls += 1
model.cost += call.costUSD
model.savingsUSD += callSavings
model.inputTokens += call.usage.inputTokens
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
callDay.models[call.model] = model
const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0 }
const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 }
provider.calls += 1
provider.cost += call.costUSD
provider.savingsUSD += callSavings
callDay.providers[call.provider] = provider
}
}
@ -94,13 +101,14 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
}
export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData {
let cost = 0, calls = 0, sessions = 0
let cost = 0, savingsUSD = 0, calls = 0, sessions = 0
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
const catTotals: Record<string, { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number; savingsUSD: number }> = {}
for (const d of days) {
cost += d.cost
savingsUSD += d.savingsUSD
calls += d.calls
sessions += d.sessions
inputTokens += d.inputTokens
@ -109,15 +117,17 @@ export function buildPeriodDataFromDays(days: DailyEntry[], label: string): Peri
cacheWriteTokens += d.cacheWriteTokens
for (const [name, m] of Object.entries(d.models)) {
const acc = modelTotals[name] ?? { calls: 0, cost: 0 }
const acc = modelTotals[name] ?? { calls: 0, cost: 0, savingsUSD: 0 }
acc.calls += m.calls
acc.cost += m.cost
acc.savingsUSD += (m.savingsUSD ?? 0)
modelTotals[name] = acc
}
for (const [cat, c] of Object.entries(d.categories)) {
const acc = catTotals[cat] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
const acc = catTotals[cat] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
acc.turns += c.turns
acc.cost += c.cost
acc.savingsUSD += (c.savingsUSD ?? 0)
acc.editTurns += c.editTurns
acc.oneShotTurns += c.oneShotTurns
catTotals[cat] = acc
@ -127,6 +137,7 @@ export function buildPeriodDataFromDays(days: DailyEntry[], label: string): Peri
return {
label,
cost,
savingsUSD,
calls,
sessions,
inputTokens,

View file

@ -36,6 +36,7 @@ function pct(n: number, total: number): number {
type DailyAgg = {
cost: number
savings: number
calls: number
input: number
output: number
@ -52,11 +53,12 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
if (!turn.timestamp) continue
const day = dateKey(turn.timestamp)
if (!daily[day]) {
daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
daily[day] = { cost: 0, savings: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
}
daily[day].sessions.add(session.sessionId)
for (const call of turn.assistantCalls) {
daily[day].cost += call.costUSD
daily[day].savings += call.savingsUSD ?? 0
daily[day].calls++
daily[day].input += call.usage.inputTokens
daily[day].output += call.usage.outputTokens
@ -71,6 +73,7 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
Period: period,
Date: date,
[`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)),
[`Saved (${code})`]: roundForActiveCurrency(convertCost(d.savings)),
'API Calls': d.calls,
Sessions: d.sessions.size,
'Input Tokens': d.input,
@ -105,14 +108,15 @@ function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
}
function buildModelRows(projects: ProjectSummary[], period: string): Row[] {
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number; savings: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, d] of Object.entries(session.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savings: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].savings += d.savingsUSD
modelTotals[model].input += d.tokens.inputTokens
modelTotals[model].output += d.tokens.outputTokens
modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0
@ -124,13 +128,14 @@ function buildModelRows(projects: ProjectSummary[], period: string): Row[] {
const { code } = getCurrency()
return Object.entries(modelTotals)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([model, d]) => {
const efficiency = modelEfficiency.get(model)
return {
Period: period,
Model: model,
[`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)),
[`Saved (${code})`]: roundForActiveCurrency(convertCost(d.savings)),
'Share (%)': pct(d.cost, totalCost),
'API Calls': d.calls,
'Edit Turns': efficiency?.editTurns ?? 0,
@ -190,10 +195,11 @@ function buildProjectRows(projects: ProjectSummary[]): Row[] {
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
return projects
.slice()
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.sort((a, b) => (b.totalCostUSD + b.totalSavingsUSD) - (a.totalCostUSD + a.totalSavingsUSD))
.map(p => ({
Project: p.projectPath,
[`Cost (${code})`]: roundForActiveCurrency(convertCost(p.totalCostUSD)),
[`Saved (${code})`]: roundForActiveCurrency(convertCost(p.totalSavingsUSD)),
[`Avg/Session (${code})`]: p.sessions.length > 0 ? roundForActiveCurrency(convertCost(p.totalCostUSD / p.sessions.length)) : '',
'Share (%)': pct(p.totalCostUSD, total),
'API Calls': p.totalApiCalls,
@ -211,12 +217,13 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] {
'Session ID': s.sessionId,
'Started At': s.firstTimestamp ?? '',
[`Cost (${code})`]: roundForActiveCurrency(convertCost(s.totalCostUSD)),
[`Saved (${code})`]: roundForActiveCurrency(convertCost(s.totalSavingsUSD)),
'API Calls': s.apiCalls,
Turns: s.turns.length,
})
}
}
return rows.sort((a, b) => (b[`Cost (${code})`] as number) - (a[`Cost (${code})`] as number))
return rows.sort((a, b) => ((b[`Cost (${code})`] as number) + (b[`Saved (${code})`] as number)) - ((a[`Cost (${code})`] as number) + (a[`Saved (${code})`] as number)))
}
export type PeriodExport = {
@ -228,12 +235,14 @@ function buildSummaryRows(periods: PeriodExport[]): Row[] {
const { code } = getCurrency()
return periods.map(p => {
const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
const savings = p.projects.reduce((s, proj) => s + proj.totalSavingsUSD, 0)
const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0).length
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0 || proj.totalSavingsUSD > 0).length
return {
Period: p.label,
[`Cost (${code})`]: roundForActiveCurrency(convertCost(cost)),
[`Saved (${code})`]: roundForActiveCurrency(convertCost(savings)),
'API Calls': calls,
Sessions: sessions,
Projects: projectCount,

View file

@ -1,7 +1,7 @@
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
import { loadPricing, setModelAliases, setLocalModelSavings } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
@ -149,6 +149,7 @@ program.hook('preAction', async (thisCommand) => {
}
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
setLocalModelSavings(config.localModelSavings ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
@ -160,6 +161,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const { code } = getCurrency()
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
@ -177,7 +179,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
// `turns`, edit turns count for `editTurns`, edit turns with zero retries
// count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
// dashboards need this without re-deriving from activity-level rollups.
const dailyMap: Record<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
const dailyMap: Record<string, { cost: number; savings: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
// Prefer the user-message timestamp on the turn; fall back to the first
@ -188,7 +190,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
if (!ts) { continue }
const day = dateKey(ts)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[day].turns += 1
if (turn.hasEdits) {
dailyMap[day].editTurns += 1
@ -196,6 +198,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].savings += call.savingsUSD ?? 0
dailyMap[day].calls += 1
}
}
@ -203,6 +206,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
savings: convertCost(d.savings),
calls: d.calls,
turns: d.turns,
editTurns: d.editTurns,
@ -219,6 +223,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
name: p.project,
path: p.projectPath,
cost: convertCost(p.totalCostUSD),
savings: convertCost(p.totalSavingsUSD),
avgCostPerSession: p.sessions.length > 0
? convertCost(p.totalCostUSD / p.sessions.length)
: null,
@ -226,27 +231,51 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
const modelMap: Record<string, { calls: number; cost: number; savings: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; baselineModel: string }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].savings += d.savingsUSD
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
}
}
// Pull the active baseline model name out of the savings config so the
// report can show what the local calls were mapped against without
// forcing the consumer to cross-reference a separate file. Empty when
// no savings are configured for this period.
for (const [model, acc] of Object.entries(modelMap)) {
if (acc.savings <= 0) continue
for (const sess of sessions) {
const bucket = sess.modelBreakdown[model]
if (!bucket || bucket.savingsUSD <= 0) continue
for (const turn of sess.turns) {
for (const call of turn.assistantCalls) {
if (call.model === model && call.savingsBaselineModel) {
acc.baselineModel = call.savingsBaselineModel
break
}
}
if (acc.baselineModel) break
}
if (acc.baselineModel) break
}
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, { cost, ...rest }]) => {
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([name, { cost, savings, baselineModel, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
savings: convertCost(savings),
savingsBaselineModel: baselineModel,
editTurns: efficiency?.editTurns ?? 0,
oneShotTurns: efficiency?.oneShotTurns ?? 0,
oneShotRate: efficiency?.oneShotRate ?? null,
@ -257,21 +286,23 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
}
})
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const catMap: Record<string, { turns: number; cost: number; savings: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, savings: 0, editTurns: 0, oneShotTurns: 0 } }
catMap[cat].turns += d.turns
catMap[cat].cost += d.costUSD
catMap[cat].savings += d.savingsUSD
catMap[cat].editTurns += d.editTurns
catMap[cat].oneShotTurns += d.oneShotTurns
}
}
const activities = Object.entries(catMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([cat, d]) => ({
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
cost: convertCost(d.cost),
savings: convertCost(d.savings),
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
@ -281,8 +312,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
const skillMap: Record<string, { turns: number; cost: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number }> = {}
const skillMap: Record<string, { turns: number; cost: number; savings: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number; savings: number }> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
@ -294,14 +325,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
}
for (const [skill, d] of Object.entries(sess.skillBreakdown)) {
if (!skillMap[skill]) skillMap[skill] = { turns: 0, cost: 0 }
if (!skillMap[skill]) skillMap[skill] = { turns: 0, cost: 0, savings: 0 }
skillMap[skill].turns += d.turns
skillMap[skill].cost += d.costUSD
skillMap[skill].savings += d.savingsUSD
}
for (const [sat, d] of Object.entries(sess.subagentBreakdown)) {
if (!subagentMap[sat]) subagentMap[sat] = { calls: 0, cost: 0 }
if (!subagentMap[sat]) subagentMap[sat] = { calls: 0, cost: 0, savings: 0 }
subagentMap[sat].calls += d.calls
subagentMap[sat].cost += d.costUSD
subagentMap[sat].savings += d.savingsUSD
}
}
@ -309,8 +342,15 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.sort((a, b) => b.cost - a.cost)
.flatMap(p => p.sessions.map(s => ({
project: p.project,
sessionId: s.sessionId,
date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null,
cost: convertCost(s.totalCostUSD),
savings: convertCost(s.totalSavingsUSD),
calls: s.apiCalls,
})))
.sort((a, b) => (b.cost + b.savings) - (a.cost + a.savings))
.slice(0, 5)
return {
@ -320,6 +360,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
periodKey,
overview: {
cost: convertCost(totalCostUSD),
savings: convertCost(totalSavingsUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,
@ -337,8 +378,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost) })),
subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost) })),
skills: Object.entries(skillMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost), savings: convertCost(d.savings) })),
subagents: Object.entries(subagentMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })),
topSessions,
}
}
@ -450,14 +491,25 @@ program
const { code, rate } = getCurrency()
const payload: {
currency: string
today: { cost: number; calls: number }
month: { cost: number; calls: number }
today: { cost: number; savings: number; calls: number }
month: { cost: number; savings: number; calls: number }
localModelSavings?: { today: number; month: number; callsToday: number; callsMonth: number }
plan?: JsonPlanSummary
plans?: JsonPlanSummaryMap
} = {
currency: code,
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
today: { cost: Math.round(todayData.cost * rate * 100) / 100, savings: Math.round(todayData.savingsUSD * rate * 100) / 100, calls: todayData.calls },
month: { cost: Math.round(monthData.cost * rate * 100) / 100, savings: Math.round(monthData.savingsUSD * rate * 100) / 100, calls: monthData.calls },
}
const savingsCallsToday = todayProjects.reduce((s, p) => s + p.sessions.reduce((s2, sess) => s2 + sess.turns.reduce((s3, turn) => s3 + turn.assistantCalls.reduce((s4, c) => s4 + (c.savingsUSD && c.savingsUSD > 0 ? 1 : 0), 0), 0), 0), 0)
const savingsCallsMonth = monthProjects.reduce((s, p) => s + p.sessions.reduce((s2, sess) => s2 + sess.turns.reduce((s3, turn) => s3 + turn.assistantCalls.reduce((s4, c) => s4 + (c.savingsUSD && c.savingsUSD > 0 ? 1 : 0), 0), 0), 0), 0)
if (todayData.savingsUSD > 0 || monthData.savingsUSD > 0) {
payload.localModelSavings = {
today: payload.today.savings,
month: payload.month.savings,
callsToday: savingsCallsToday,
callsMonth: savingsCallsMonth,
}
}
console.log(JSON.stringify(await attachPlanSummaries(payload)))
return
@ -684,6 +736,65 @@ program
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('model-savings [local] [baseline]')
.description('Track a local model as "savings" rather than cost. Maps a local-model name to a paid baseline so the dashboard can show what the same tokens would have cost on the baseline (e.g. codeburn model-savings "llama3.1:8b" gpt-4o). The local call itself still costs $0 — actual cost is left untouched.')
.option('--remove <local>', 'Remove a savings mapping for the given local model')
.option('--list', 'List configured savings mappings')
.action(async (local?: string, baseline?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
const mappings = { ...(config.localModelSavings ?? {}) }
if (opts?.list || (!local && !opts?.remove)) {
const entries = Object.entries(mappings)
if (entries.length === 0) {
console.log('\n No local-model savings mappings configured.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn model-savings <local-model> <baseline-model>\n')
} else {
console.log('\n Local-model savings mappings:')
for (const [src, dst] of entries) {
console.log(` ${src} -> ${dst}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
if (!(opts.remove in mappings)) {
console.error(`\n No savings mapping found for: ${opts.remove}\n`)
process.exitCode = 1
return
}
delete mappings[opts.remove]
config.localModelSavings = Object.keys(mappings).length > 0 ? mappings : undefined
await saveConfig(config)
console.log(`\n Removed savings mapping: ${opts.remove}\n`)
return
}
if (!local || !baseline) {
console.error('\n Usage: codeburn model-savings <local-model> <baseline-model>\n')
process.exitCode = 1
return
}
mappings[local] = baseline
config.localModelSavings = mappings
await saveConfig(config)
// Warn when the same model is also in modelAliases so the user is
// not surprised that `savings` wins for actual cost.
if (config.modelAliases && Object.hasOwn(config.modelAliases, local)) {
console.log(`\n Note: ${local} is also in modelAliases (-> ${config.modelAliases[local]}).`)
console.log(' Local-model savings take precedence: the call is treated as $0 actual cost and the baseline is used for counterfactual savings.')
}
console.log(`\n Savings mapping saved: ${local} -> ${baseline}`)
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')

View file

@ -26,7 +26,7 @@ export function pseudonym(name: string): string {
return `project-${createHash('sha256').update(getSalt() + name).digest('hex').slice(0, 6)}`
}
function redactSessionDetails(details: Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }>): Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }> {
function redactSessionDetails(details: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }>): Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> {
return details.map(d => ({ ...d, date: '', models: [] }))
}

View file

@ -4,17 +4,23 @@
export type PeriodData = {
label: string
cost: number
/// Counterfactual USD the same tokens would have cost on the paid
/// baseline configured for each local model. Stays `0` when no
/// `codeburn model-savings` mappings are active. Always shown
/// separately from `cost` so the two never get summed into a "real
/// spend" number by accident.
savingsUSD: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
projects?: Array<{ name: string; cost: number; sessions: number; sessionDetails?: Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }> }>
categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }>
projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }>
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
topSessions?: Array<{ project: string; cost: number; calls: number; date: string }>
topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }>
}
export type ProviderCost = {
@ -35,6 +41,7 @@ const MODEL_EFFICIENCY_LIMIT = 5
export type DailyModelBreakdown = {
name: string
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
@ -43,6 +50,7 @@ export type DailyModelBreakdown = {
export type DailyHistoryEntry = {
date: string
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
@ -51,6 +59,21 @@ export type DailyHistoryEntry = {
topModels: DailyModelBreakdown[]
}
export type LocalModelSavings = {
totalUSD: number
calls: number
byModel: Array<{
name: string
calls: number
actualUSD: number
savingsUSD: number
baselineModel: string
inputTokens: number
outputTokens: number
}>
byProvider: Array<{ name: string; calls: number; savingsUSD: number }>
}
export type MenubarPayload = {
generated: string
current: {
@ -65,27 +88,38 @@ export type MenubarPayload = {
topActivities: Array<{
name: string
cost: number
savingsUSD: number
turns: number
oneShotRate: number | null
}>
topModels: Array<{
name: string
cost: number
savingsUSD: number
savingsBaselineModel: string
calls: number
}>
/// Local-model savings rollup, distinct from the routing-waste /
/// optimize savings concepts which describe hypothetical optimization
/// opportunities. This block tracks counterfactual spend that was
/// already avoided because the user ran a local model mapped via
/// `codeburn model-savings`.
localModelSavings: LocalModelSavings
providers: Record<string, number>
topProjects: Array<{
name: string
cost: number
savingsUSD: number
sessions: number
avgCostPerSession: number
sessionDetails: Array<{
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
date: string
models: Array<{ name: string; cost: number }>
models: Array<{ name: string; cost: number; savingsUSD: number }>
}>
}>
modelEfficiency: Array<{
@ -96,6 +130,7 @@ export type MenubarPayload = {
topSessions: Array<{
project: string
cost: number
savingsUSD: number
calls: number
date: string
}>
@ -168,6 +203,7 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
name: cat.name,
cost: cat.cost,
savingsUSD: cat.savingsUSD,
turns: cat.turns,
oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
}))
@ -177,7 +213,7 @@ function buildTopModels(models: PeriodData['models']): MenubarPayload['current']
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '' }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
@ -216,16 +252,18 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h
function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] {
return (projects ?? [])
.filter(p => p.cost > 0)
.sort((a, b) => b.cost - a.cost)
.filter(p => p.cost > 0 || p.savingsUSD > 0)
.sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD))
.slice(0, TOP_PROJECTS_LIMIT)
.map(p => ({
name: p.name,
cost: p.cost,
savingsUSD: p.savingsUSD,
sessions: p.sessions,
avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0,
sessionDetails: (p.sessionDetails ?? []).map(s => ({
cost: s.cost,
savingsUSD: s.savingsUSD,
calls: s.calls,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
@ -245,9 +283,9 @@ function buildModelEfficiency(models: PeriodData['modelEfficiency']): MenubarPay
function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['current']['topSessions'] {
return (sessions ?? [])
.sort((a, b) => b.cost - a.cost)
.sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD))
.slice(0, TOP_SESSIONS_LIMIT)
.map(s => ({ project: s.project, cost: s.cost, calls: s.calls, date: s.date }))
.map(s => ({ project: s.project, cost: s.cost, savingsUSD: s.savingsUSD, calls: s.calls, date: s.date }))
}
export type BreakdownArrays = {
@ -255,6 +293,12 @@ export type BreakdownArrays = {
skills?: MenubarPayload['current']['skills']
subagents?: MenubarPayload['current']['subagents']
mcpServers?: MenubarPayload['current']['mcpServers']
/// Optional rollup of per-model and per-provider local-model savings.
/// Computed by the CLI from the parsed projects (we have raw token
/// + baseline info there, not in `PeriodData`). When omitted, the
/// menubar payload defaults to an empty savings block — keeping the
/// schema stable for consumers that don't care about local savings.
localModelSavings?: LocalModelSavings
}
export function buildMenubarPayload(
@ -279,6 +323,7 @@ export function buildMenubarPayload(
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
localModelSavings: breakdowns?.localModelSavings ?? { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: buildProviders(providers),
topProjects: buildTopProjects(current.projects ?? []),
modelEfficiency: buildModelEfficiency(current.modelEfficiency ?? []),

View file

@ -17,6 +17,8 @@ export type ModelReportRow = {
cacheReadTokens: number
totalTokens: number
costUSD: number
savingsUSD: number
savingsBaselineModel: string
calls: number
topCategory?: TaskCategory
topCategoryCost?: number
@ -27,6 +29,10 @@ export type AggregateOptions = {
byTask?: boolean
taskFilter?: TaskCategory
topN?: number
/// Threshold for the `cost`-based filter. The default `0.01` would
/// hide local-only models whose `costUSD` is 0 but `savingsUSD` is
/// meaningful. The implementation ORs in `savingsUSD >= minCost`
/// so saved-only rows still surface by default.
minCost?: number
}
@ -39,6 +45,8 @@ type Bucket = {
cacheWriteTokens: number
cacheReadTokens: number
costUSD: number
savingsUSD: number
savingsBaselineModel: string
calls: number
}
@ -82,6 +90,8 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
cacheWriteTokens: 0,
cacheReadTokens: 0,
costUSD: 0,
savingsUSD: 0,
savingsBaselineModel: '',
calls: 0,
}
buckets.set(key, bucket)
@ -95,6 +105,10 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
// provider that fills both fields.
bucket.cacheReadTokens += Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens)
bucket.costUSD += call.costUSD
bucket.savingsUSD += call.savingsUSD ?? 0
if (!bucket.savingsBaselineModel && call.savingsBaselineModel) {
bucket.savingsBaselineModel = call.savingsBaselineModel
}
bucket.calls += 1
const modelKey = `${provider} ${model}`
@ -139,6 +153,8 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
cacheReadTokens: bucket.cacheReadTokens,
totalTokens: total,
costUSD: bucket.costUSD,
savingsUSD: bucket.savingsUSD,
savingsBaselineModel: bucket.savingsBaselineModel,
calls: bucket.calls,
}
@ -171,15 +187,17 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
if (aTotal !== bTotal) return bTotal - aTotal
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider)
if (a.model !== b.model) return a.model.localeCompare(b.model)
return b.costUSD - a.costUSD
return (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD)
})
} else {
rows.sort((a, b) => b.costUSD - a.costUSD)
rows.sort((a, b) => (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD))
}
let filtered = rows
if (opts.minCost !== undefined) {
filtered = filtered.filter(r => r.costUSD >= opts.minCost!)
// OR with savings so local models with $0 actual cost but > 0 saved
// counterfactual still surface in the default `models` view.
filtered = filtered.filter(r => r.costUSD >= opts.minCost! || r.savingsUSD >= opts.minCost!)
}
if (opts.topN !== undefined) {
filtered = filtered.slice(0, opts.topN)
@ -226,7 +244,7 @@ type Column = {
/// Drop priority. 0 = always shown; higher numbers get dropped first when
/// the terminal is narrow.
priority: number
key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost'
key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost' | 'saved'
}
type TableRenderOptions = {
@ -240,12 +258,16 @@ const DROP_COLUMN_GROUPS: Array<Array<Column['key']>> = [
['cacheWrite', 'cacheRead'],
['input', 'output'],
['task'],
['saved'],
]
function defaultColumns(byTask: boolean): Column[] {
function defaultColumns(byTask: boolean, showSaved: boolean): Column[] {
// Higher priority numbers drop FIRST when the terminal is narrow.
// Cache columns are the cheapest to lose, then input/output, then top-task.
// Provider/Model/Total/Cost stay regardless.
// Provider/Model/Total/Cost stay regardless. The Saved column only appears
// when local-model savings actually exist (a `codeburn model-savings`
// mapping produced nonzero avoided spend); otherwise it would be a column of
// dashes for the majority of users, so it is omitted entirely.
// Widths are MINIMUMS; sizeColumnsToContent() expands them to fit cell text.
return [
{ key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 },
@ -257,6 +279,7 @@ function defaultColumns(byTask: boolean): Column[] {
{ key: 'cacheRead', header: 'Cache Read', align: 'right', width: 10, priority: 3 },
{ key: 'total', header: 'Total', align: 'right', width: 6, priority: 0 },
{ key: 'cost', header: 'Cost', align: 'right', width: 6, priority: 0 },
...(showSaved ? [{ key: 'saved' as const, header: 'Saved', align: 'right' as const, width: 6, priority: 0 }] : []),
]
}
@ -281,8 +304,8 @@ function frameWidth(columns: Column[]): number {
return 2 + columns.reduce((acc, c) => acc + c.width + 2, 0) + (columns.length - 1)
}
function chooseColumns(byTask: boolean, available: number): Column[] {
const all = defaultColumns(byTask)
function chooseColumns(byTask: boolean, available: number, showSaved: boolean): Column[] {
const all = defaultColumns(byTask, showSaved)
if (frameWidth(all) <= available) return all
// Drop in this order so the table degrades sensibly. Cache columns drop as
@ -383,6 +406,8 @@ export function renderTable(
const showTotals = opts.showTotals ?? true
const available = opts.terminalWidth ?? defaultTerminalWidth()
const fullWidth = opts.fullWidth ?? true
// Only render the Saved column when something was actually saved.
const showSaved = rows.some(r => r.savingsUSD > 0)
const valueOf = (row: ModelReportRow, key: Column['key'], isNewGroup: boolean): string => {
switch (key) {
@ -399,6 +424,7 @@ export function renderTable(
case 'cacheRead': return formatTokens(row.cacheReadTokens)
case 'total': return formatTokens(row.totalTokens)
case 'cost': return formatCost(row.costUSD)
case 'saved': return row.savingsUSD > 0 ? formatCost(row.savingsUSD) : chalk.dim('-')
}
}
@ -410,7 +436,7 @@ export function renderTable(
const groupKey = `${row.provider} ${row.model}`
const isNewGroup = !byTask || groupKey !== prevProviderModel
prevProviderModel = groupKey
const allCells = defaultColumns(byTask).map(col => {
const allCells = defaultColumns(byTask, showSaved).map(col => {
const raw = valueOf(row, col.key, isNewGroup)
if (col.key === 'provider' && raw) return chalk.dim(raw)
return raw
@ -428,11 +454,12 @@ export function renderTable(
acc.cacheRead += r.cacheReadTokens
acc.total += r.totalTokens
acc.cost += r.costUSD
acc.savings += r.savingsUSD
return acc
},
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0 },
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
)
const cells = defaultColumns(byTask).map(col => {
const cells = defaultColumns(byTask, showSaved).map(col => {
switch (col.key) {
case 'provider': return ''
case 'model': return chalk.yellow.bold('Total')
@ -443,6 +470,7 @@ export function renderTable(
case 'cacheRead': return chalk.yellow(formatTokens(totals.cacheRead))
case 'total': return chalk.yellow.bold(formatTokens(totals.total))
case 'cost': return chalk.yellow.bold(formatCost(totals.cost))
case 'saved': return totals.savings > 0 ? chalk.yellow.bold(formatCost(totals.savings)) : chalk.dim('-')
}
})
totalsEntry = { kind: 'totals', cells, isNewGroup: true }
@ -451,9 +479,9 @@ export function renderTable(
// Pick which columns to include based on terminal width, then size them.
// We index into `cells` by the column key to avoid object-identity pitfalls
// across defaultColumns() invocations.
const allKeys = defaultColumns(byTask).map(c => c.key)
const allKeys = defaultColumns(byTask, showSaved).map(c => c.key)
const indexByKey = new Map(allKeys.map((k, i) => [k, i]))
const columns = chooseColumns(byTask, available)
const columns = chooseColumns(byTask, available, showSaved)
const projectColumns = (cols: Column[], entry: RowCells) =>
cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '')
const cellMatrix = [
@ -523,13 +551,16 @@ export function renderJson(rows: ModelReportRow[]): string {
totalTokens: r.totalTokens,
calls: r.calls,
costUSD: r.costUSD,
savingsUSD: r.savingsUSD,
savingsBaselineModel: r.savingsBaselineModel,
})),
null,
2,
)
}
function csvEscape(value: string): string {
function csvEscape(value: string | undefined | null): string {
if (value === undefined || value === null) return ''
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`
}
@ -550,9 +581,9 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
const showTotals = opts.showTotals ?? true
const header = byTask
? ['Provider', 'Model', 'Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost']
: ['Provider', 'Model', 'Top Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost']
const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:']
? ['Provider', 'Model', 'Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved']
: ['Provider', 'Model', 'Top Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved']
const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:']
const lines: string[] = []
lines.push(`| ${header.join(' | ')} |`)
@ -574,6 +605,7 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
formatTokens(row.cacheReadTokens),
formatTokens(row.totalTokens),
formatCost(row.costUSD),
row.savingsUSD > 0 ? formatCost(row.savingsUSD) : '-',
]
lines.push(`| ${cells.join(' | ')} |`)
}
@ -587,9 +619,10 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
acc.cacheRead += r.cacheReadTokens
acc.total += r.totalTokens
acc.cost += r.costUSD
acc.savings += r.savingsUSD
return acc
},
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0 },
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
)
const totalCells = [
'',
@ -601,6 +634,7 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
`**${formatTokens(totals.cacheRead)}**`,
`**${formatTokens(totals.total)}**`,
`**${formatCost(totals.cost)}**`,
totals.savings > 0 ? `**${formatCost(totals.savings)}**` : '-',
]
lines.push(`| ${totalCells.join(' | ')} |`)
}
@ -613,8 +647,8 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {
// CSV intentionally repeats provider/model on every row so downstream
// consumers can sort/filter without first reconstructing the grouping.
const header = byTask
? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd']
: ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd']
? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
: ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
const lines: string[] = [header.join(',')]
for (const r of rows) {
const cells = byTask
@ -629,6 +663,8 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {
String(r.totalTokens),
String(r.calls),
r.costUSD.toFixed(6),
(r.savingsUSD ?? 0).toFixed(6),
csvEscape(r.savingsBaselineModel),
]
: [
csvEscape(r.providerDisplayName),
@ -642,6 +678,8 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {
String(r.totalTokens),
String(r.calls),
r.costUSD.toFixed(6),
(r.savingsUSD ?? 0).toFixed(6),
csvEscape(r.savingsBaselineModel),
]
lines.push(cells.join(','))
}

View file

@ -273,6 +273,69 @@ export function setModelAliases(aliases: Record<string, string>): void {
userAliases = aliases
}
// Local-model savings config. Kept separate from userAliases: a `modelAliases`
// entry rewrites a model's identity for actual cost; a `localModelSavings`
// entry keeps the model cost at $0 and reports the *avoided* spend against a
// paid baseline. Set during preAction from `config.localModelSavings`.
let userLocalModelSavings: Record<string, string> = {}
export function setLocalModelSavings(mappings: Record<string, string>): void {
userLocalModelSavings = { ...mappings }
}
export function getLocalSavingsBaseline(rawModel: string): string | undefined {
if (!rawModel || typeof rawModel !== 'string') return undefined
// Defensive: bracket-accessing user-controlled keys on a plain object
// exposes the prototype chain (`__proto__` would resolve to Object.prototype).
// Use Object.hasOwn so a hostile JSONL model name cannot piggyback into
// Object.prototype either through the alias map or here.
if (!Object.hasOwn(userLocalModelSavings, rawModel)) return undefined
return userLocalModelSavings[rawModel]
}
/// Compute the hypothetical baseline cost for a local call. The baseline
/// model is priced through the normal `calculateCost` pipeline (so it can
/// be aliased / canonicalized). Returns `null` when the source model has
/// no savings mapping, the baseline is unknown to the pricing snapshot, or
/// any input is unusable — callers should treat null as "no savings
/// recorded for this call" rather than a hard error.
export function calculateLocalModelSavings(
rawModel: string,
inputTokens: number,
outputTokens: number,
cacheCreationTokens: number,
cacheReadTokens: number,
webSearchRequests: number,
speed: 'standard' | 'fast' = 'standard',
oneHourCacheCreationTokens = 0,
): { savingsUSD: number; baselineModel: string } | null {
const baseline = getLocalSavingsBaseline(rawModel)
if (!baseline) return null
if (!getModelCosts(baseline)) return null
const savingsUSD = calculateCost(
baseline,
inputTokens,
outputTokens,
cacheCreationTokens,
cacheReadTokens,
webSearchRequests,
speed,
oneHourCacheCreationTokens,
)
return { savingsUSD, baselineModel: baseline }
}
/// Stable hash of the current savings config so the daily cache can detect
/// "user changed their baseline mapping" and rebuild instead of presenting
/// stale saved-spend numbers. Two configs with the same key→baseline pairs
/// in any order collapse to the same hash.
export function getLocalModelSavingsConfigHash(): string {
const keys = Object.keys(userLocalModelSavings).sort()
if (keys.length === 0) return ''
const parts = keys.map(k => `${k}\u0001${userLocalModelSavings[k]}`)
return parts.join('\u0002')
}
function resolveAlias(model: string): string {
if (Object.hasOwn(userAliases, model)) return userAliases[model]!
if (Object.hasOwn(BUILTIN_ALIASES, model)) return BUILTIN_ALIASES[model]!
@ -358,7 +421,7 @@ export function calculateCost(
// payloads written by external tools, so a hostile or corrupt file
// could embed terminal escape sequences here.
const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
const aliasHint = `Map it with: codeburn model-alias "${safeName}" <known-model>`
const aliasHint = `Map it with: codeburn model-alias "${safeName}" <known-model>, or track local-model savings with: codeburn model-savings "${safeName}" <baseline-model>`
process.stderr.write(
`codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` +
`${aliasHint}, or update with: npx codeburn@latest.\n`

View file

@ -1,7 +1,7 @@
import { lstat, readFile, readdir, stat } from 'fs/promises'
import { basename, dirname, join, resolve, sep } from 'path'
import { readSessionLines } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
import { calculateCost, calculateLocalModelSavings, getShortModelName } from './models.js'
import { normalizeContentBlocks } from './content-utils.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
@ -1041,6 +1041,33 @@ function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): {
}
}
/// Apply local-model savings accounting to a call. If the raw model name is
/// mapped via `codeburn model-savings`, the call's actual cost is forced
/// to $0 and the hypothetical baseline cost is recorded as `savingsUSD`.
/// Returns the input unchanged when no mapping is configured for the
/// model — keeps the hot path branch-free for the common paid-only case.
function applyLocalModelSavings(call: ParsedApiCall): ParsedApiCall {
const u = call.usage
const savings = calculateLocalModelSavings(
call.model,
u.inputTokens,
u.outputTokens,
u.cacheCreationInputTokens,
u.cacheReadInputTokens,
u.webSearchRequests,
call.speed,
call.cacheCreationOneHourTokens ?? 0,
)
if (!savings) return call
return {
...call,
costUSD: 0,
savingsUSD: savings.savingsUSD,
savingsBaselineModel: savings.baselineModel,
isLocalSavings: true,
}
}
function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
if (entry.type !== 'assistant') return null
const msg = entry.message as AssistantMessageContent | undefined
@ -1088,7 +1115,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
return [call]
})
return {
return applyLocalModelSavings({
provider: 'claude',
model: msg.model,
usage: tokens,
@ -1105,7 +1132,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
deduplicationKey: msg.id ?? `claude:${entry.timestamp}`,
cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined,
toolSequence: toolSeq.length > 0 ? toolSeq : undefined,
}
})
}
function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] {
@ -1244,6 +1271,7 @@ function buildSessionSummary(
const subagentBreakdown: SessionSummary['subagentBreakdown'] = Object.create(null)
let totalCost = 0
let totalSavings = 0
let totalInput = 0
let totalOutput = 0
let totalCacheRead = 0
@ -1254,12 +1282,14 @@ function buildSessionSummary(
for (const turn of turns) {
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
const turnSavings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0)
if (!categoryBreakdown[turn.category]) {
categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }
categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }
}
categoryBreakdown[turn.category].turns++
categoryBreakdown[turn.category].costUSD += turnCost
categoryBreakdown[turn.category].savingsUSD += turnSavings
if (turn.hasEdits) {
categoryBreakdown[turn.category].editTurns++
categoryBreakdown[turn.category].retries += turn.retries
@ -1269,10 +1299,11 @@ function buildSessionSummary(
if (turn.subCategory) {
const skillKey = turn.subCategory
if (!skillBreakdown[skillKey]) {
skillBreakdown[skillKey] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 }
skillBreakdown[skillKey] = { turns: 0, costUSD: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
}
skillBreakdown[skillKey].turns++
skillBreakdown[skillKey].costUSD += turnCost
skillBreakdown[skillKey].savingsUSD += turnSavings
if (turn.hasEdits) {
skillBreakdown[skillKey].editTurns++
if (turn.retries === 0) skillBreakdown[skillKey].oneShotTurns++
@ -1280,7 +1311,9 @@ function buildSessionSummary(
}
for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0
totalCost += call.costUSD
totalSavings += callSavings
totalInput += call.usage.inputTokens
totalOutput += call.usage.outputTokens
totalCacheRead += call.usage.cacheReadInputTokens
@ -1292,11 +1325,13 @@ function buildSessionSummary(
modelBreakdown[modelKey] = {
calls: 0,
costUSD: 0,
savingsUSD: 0,
tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 },
}
}
modelBreakdown[modelKey].calls++
modelBreakdown[modelKey].costUSD += call.costUSD
modelBreakdown[modelKey].savingsUSD += callSavings
modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens
modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
@ -1316,9 +1351,10 @@ function buildSessionSummary(
bashBreakdown[cmd].calls++
}
for (const sat of call.subagentTypes) {
subagentBreakdown[sat] = subagentBreakdown[sat] ?? { calls: 0, costUSD: 0 }
subagentBreakdown[sat] = subagentBreakdown[sat] ?? { calls: 0, costUSD: 0, savingsUSD: 0 }
subagentBreakdown[sat].calls++
subagentBreakdown[sat].costUSD += call.costUSD
subagentBreakdown[sat].savingsUSD += callSavings
}
if (!firstTs || call.timestamp < firstTs) firstTs = call.timestamp
@ -1332,6 +1368,7 @@ function buildSessionSummary(
firstTimestamp: firstTs || turns[0]?.timestamp || '',
lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '',
totalCostUSD: totalCost,
totalSavingsUSD: totalSavings,
totalInputTokens: totalInput,
totalOutputTokens: totalOutput,
totalCacheReadTokens: totalCacheRead,
@ -1581,6 +1618,7 @@ async function scanProjectDirs(
projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
}
@ -1600,7 +1638,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
webSearchRequests: call.webSearchRequests,
}
const apiCall: ParsedApiCall = {
const apiCall: ParsedApiCall = applyLocalModelSavings({
provider: call.provider,
model: call.model,
usage,
@ -1615,7 +1653,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
timestamp: call.timestamp,
bashCommands: call.bashCommands,
deduplicationKey: call.deduplicationKey,
}
})
return {
userMessage: call.userMessage,
@ -1740,7 +1778,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
u.cacheCreationInputTokens, u.cacheReadInputTokens,
u.webSearchRequests, call.speed, u.cacheCreationOneHourTokens,
)
return {
return applyLocalModelSavings({
provider: call.provider,
model: call.model,
usage: {
@ -1765,7 +1803,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
deduplicationKey: call.deduplicationKey,
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
toolSequence: call.toolSequence,
}
})
}
function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn {
@ -2016,6 +2054,7 @@ async function parseProviderSources(
projectPath: projectPath ?? unsanitizePath(dirName),
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
}
@ -2114,6 +2153,7 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
projectPath: project.projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
}
@ -2135,6 +2175,7 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
projectPath: project.projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
}

View file

@ -86,6 +86,13 @@ export type ParsedApiCall = {
deduplicationKey: string
cacheCreationOneHourTokens?: number
toolSequence?: ToolCall[][]
/// When set, `costUSD` is the actual local call (forced to 0) and
/// `savingsUSD` is the counterfactual cost the same tokens would have
/// incurred against `savingsBaselineModel`. Set by the savings
/// normalization step in `src/parser.ts`.
savingsUSD?: number
savingsBaselineModel?: string
isLocalSavings?: boolean
}
export type ToolCall = {
@ -122,19 +129,20 @@ export type SessionSummary = {
firstTimestamp: string
lastTimestamp: string
totalCostUSD: number
totalSavingsUSD: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadTokens: number
totalCacheWriteTokens: number
apiCalls: number
turns: ClassifiedTurn[]
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage }>
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number }>
toolBreakdown: Record<string, { calls: number }>
mcpBreakdown: Record<string, { calls: number }>
bashBreakdown: Record<string, { calls: number }>
categoryBreakdown: Record<TaskCategory, { turns: number; costUSD: number; retries: number; editTurns: number; oneShotTurns: number }>
skillBreakdown: Record<string, { turns: number; costUSD: number; editTurns: number; oneShotTurns: number }>
subagentBreakdown: Record<string, { calls: number; costUSD: number }>
categoryBreakdown: Record<TaskCategory, { turns: number; costUSD: number; savingsUSD: number; retries: number; editTurns: number; oneShotTurns: number }>
skillBreakdown: Record<string, { turns: number; costUSD: number; savingsUSD: number; editTurns: number; oneShotTurns: number }>
subagentBreakdown: Record<string, { calls: number; costUSD: number; savingsUSD: number }>
// Observed MCP tools available in this session, captured from
// `attachment.deferred_tools_delta.addedNames` entries. Union across all
// turns. Each name is a fully-qualified `mcp__<server>__<tool>` identifier.
@ -148,6 +156,7 @@ export type ProjectSummary = {
projectPath: string
sessions: SessionSummary[]
totalCostUSD: number
totalSavingsUSD: number
totalApiCalls: number
}

View file

@ -2,6 +2,7 @@ import { homedir } from 'node:os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js'
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDays } from './parser.js'
import { getLocalModelSavingsConfigHash, getShortModelName } from './models.js'
import { getAllProviders } from './providers/index.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
@ -10,8 +11,8 @@ import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFI
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
const catTotals: Record<string, { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number; savingsUSD: number }> = {}
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
for (const sess of sessions) {
@ -20,22 +21,25 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
cacheReadTokens += sess.totalCacheReadTokens
cacheWriteTokens += sess.totalCacheWriteTokens
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
catTotals[cat].turns += d.turns
catTotals[cat].cost += d.costUSD
catTotals[cat].savingsUSD += d.savingsUSD
catTotals[cat].editTurns += d.editTurns
catTotals[cat].oneShotTurns += d.oneShotTurns
}
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].savingsUSD += d.savingsUSD
}
}
return {
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
savingsUSD: projects.reduce((s, p) => s + p.totalSavingsUSD, 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
@ -53,6 +57,7 @@ async function hydrateCache(): Promise<DailyCache> {
return await ensureCacheHydrated(
(range) => parseAllSessions(range, 'all'),
aggregateProjectsIntoDays,
getLocalModelSavingsConfigHash(),
)
} catch (err) {
// Previously swallowed silently, which turned any backfill failure into an
@ -202,6 +207,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
.map(([name, m]) => ({
name,
cost: m.cost,
savingsUSD: m.savingsUSD,
calls: m.calls,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
@ -209,6 +215,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
return {
date: d.date,
cost: d.cost,
savingsUSD: d.savingsUSD,
calls: d.calls,
inputTokens: d.inputTokens,
outputTokens: d.outputTokens,
@ -218,12 +225,13 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
}
})
} else {
const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[]
const emptyModels = [] as { name: string; cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number }[]
const historyFromCache = allCacheDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 }
return {
date: d.date,
cost: prov.cost,
savingsUSD: prov.savingsUSD,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
@ -235,10 +243,11 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const todayFromParse = aggregateProjectsIntoDays(scanProjects)
.filter(d => d.date === todayStr)
.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 }
return {
date: d.date,
cost: prov.cost,
savingsUSD: prov.savingsUSD,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
@ -260,18 +269,20 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
currentData.projects = scanProjects.map(p => ({
name: friendlyProject(p),
cost: p.totalCostUSD,
savingsUSD: p.totalSavingsUSD,
sessions: p.sessions.length,
sessionDetails: [...p.sessions]
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.slice(0, 10)
.map(s => ({
cost: s.totalCostUSD,
savingsUSD: s.totalSavingsUSD,
calls: s.apiCalls,
inputTokens: s.totalInputTokens,
outputTokens: s.totalOutputTokens,
date: s.firstTimestamp?.split('T')[0] ?? '',
models: Object.entries(s.modelBreakdown)
.map(([name, m]) => ({ name, cost: m.costUSD }))
.map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD }))
.sort((a, b) => b.cost - a.cost)
.slice(0, 3),
})),
@ -304,10 +315,11 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
p.sessions.map(s => ({
project: friendlyProject(p),
cost: s.totalCostUSD,
savingsUSD: s.totalSavingsUSD,
calls: s.apiCalls,
date: s.firstTimestamp?.split('T')[0] ?? '',
}))
).sort((a, b) => b.cost - a.cost).slice(0, 5)
).sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)).slice(0, 5)
// Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits),
// then compute how much each pricier model overpaid.
@ -345,17 +357,49 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const skillMap: Record<string, { turns: number; cost: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number }> = {}
const mcpMap: Record<string, number> = {}
// Local-model savings rollup: avoided spend (cost forced to $0, baseline
// recorded) grouped by model and provider. Mirrors the per-call savingsUSD
// that applyLocalModelSavings stamps in the parser.
const savingsByModel = new Map<string, { calls: number; actualUSD: number; savingsUSD: number; baselineModel: string; inputTokens: number; outputTokens: number }>()
const savingsByProvider = new Map<string, { calls: number; savingsUSD: number }>()
let totalSavings = 0
let totalSavingsCalls = 0
for (const p of scanProjects) for (const s of p.sessions) {
for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls }
for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e }
for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e }
for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls }
for (const turn of s.turns) for (const call of turn.assistantCalls) {
if (!call.savingsUSD || call.savingsUSD <= 0) continue
totalSavings += call.savingsUSD
totalSavingsCalls += 1
const modelKey = getShortModelName(call.model)
const acc = savingsByModel.get(modelKey) ?? { calls: 0, actualUSD: 0, savingsUSD: 0, baselineModel: call.savingsBaselineModel ?? '', inputTokens: 0, outputTokens: 0 }
acc.calls += 1
acc.actualUSD += call.costUSD
acc.savingsUSD += call.savingsUSD
acc.baselineModel = acc.baselineModel || (call.savingsBaselineModel ?? '')
acc.inputTokens += call.usage.inputTokens
acc.outputTokens += call.usage.outputTokens
savingsByModel.set(modelKey, acc)
const provAcc = savingsByProvider.get(call.provider) ?? { calls: 0, savingsUSD: 0 }
provAcc.calls += 1
provAcc.savingsUSD += call.savingsUSD
savingsByProvider.set(call.provider, provAcc)
}
}
const localModelSavings = {
totalUSD: totalSavings,
calls: totalSavingsCalls,
byModel: Array.from(savingsByModel.entries()).sort(([, a], [, b]) => b.savingsUSD - a.savingsUSD).slice(0, 5).map(([name, d]) => ({ name, ...d })),
byProvider: Array.from(savingsByProvider.entries()).sort(([, a], [, b]) => b.savingsUSD - a.savingsUSD).slice(0, 5).map(([name, d]) => ({ name, ...d })),
}
return {
tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })),
skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })),
subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })),
mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })),
localModelSavings,
}
})()

View file

@ -0,0 +1,76 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_TIMEOUT_MS = 10_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
HOMEPATH: home,
HOMEDRIVE: '',
},
encoding: 'utf-8',
})
}
function readConfig(home: string): Promise<Record<string, unknown>> {
return readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8')
.then(raw => JSON.parse(raw) as Record<string, unknown>)
}
describe('codeburn model-savings command', () => {
it('saves, lists, and removes a local-model savings mapping', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-'))
try {
const set = runCli(['model-savings', 'llama3.1:8b', 'gpt-4o'], home)
expect(set.status).toBe(0)
expect(set.stdout).toContain('llama3.1:8b -> gpt-4o')
const saved = await readConfig(home)
expect(saved.localModelSavings).toEqual({ 'llama3.1:8b': 'gpt-4o' })
const list = runCli(['model-savings', '--list'], home)
expect(list.status).toBe(0)
expect(list.stdout).toContain('llama3.1:8b -> gpt-4o')
const remove = runCli(['model-savings', '--remove', 'llama3.1:8b'], home)
expect(remove.status).toBe(0)
const after = await readConfig(home)
expect(after.localModelSavings).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
it('warns when the same model is also configured in modelAliases', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-'))
try {
expect(runCli(['model-alias', 'llama3.1:8b', 'gpt-4o'], home).status).toBe(0)
const set = runCli(['model-savings', 'llama3.1:8b', 'gpt-4o'], home)
expect(set.status).toBe(0)
expect(set.stdout).toContain('savings take precedence')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
it('rejects a remove for an unknown mapping', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-'))
try {
const result = runCli(['model-savings', '--remove', 'unknown:1b'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('No savings mapping found')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
})

View file

@ -4,11 +4,14 @@ import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { ProjectSummary } from '../src/types.js'
import {
addNewDays,
DAILY_CACHE_VERSION,
type DailyCache,
type DailyEntry,
ensureCacheHydrated,
getDaysInRange,
loadDailyCache,
saveDailyCache,
@ -19,6 +22,7 @@ function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
return {
date,
cost,
savingsUSD: 0,
calls,
sessions: 0,
inputTokens: 0,
@ -137,6 +141,7 @@ describe('loadDailyCache', () => {
it('round-trips a valid cache through save and load', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-hash-1',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
}
@ -150,6 +155,7 @@ describe('saveDailyCache', () => {
it('writes atomically so no temp file is left after a successful save', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-hash-1',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
@ -167,6 +173,7 @@ describe('addNewDays', () => {
it('returns a new cache with the added days sorted ascending by date', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
}
@ -178,6 +185,7 @@ describe('addNewDays', () => {
it('replaces existing days with incoming data (last write wins)', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-08', 5)],
}
@ -189,6 +197,7 @@ describe('addNewDays', () => {
it('does not regress lastComputedDate if incoming newestDate is older', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
@ -203,6 +212,7 @@ describe('addNewDays', () => {
// the entries untouched so the next valid run can prune normally.
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-08', 1), emptyDay('2026-04-09', 2), emptyDay('2026-04-10', 3)],
}
@ -215,6 +225,7 @@ describe('addNewDays', () => {
const recent = '2026-04-10'
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: recent,
days: [emptyDay(old, 1), emptyDay(recent, 2)],
}
@ -228,6 +239,7 @@ describe('addNewDays', () => {
describe('getDaysInRange', () => {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [
emptyDay('2026-04-05', 1),
@ -273,3 +285,39 @@ describe('withDailyCacheLock', () => {
}
})
})
describe('ensureCacheHydrated: savings config invalidation', () => {
it('discards cached days when the savingsConfigHash changes between calls', async () => {
// Seed a cache with a day OLDER than yesterday so the hydration window
// (which keeps `d.date < yesterdayStr`) actually retains it.
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}`
const seeded: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
}
await saveDailyCache(seeded)
const parseSessions = async (): Promise<ProjectSummary[]> => []
const aggregateDays = (): DailyEntry[] => []
// Hash mismatch → ensureCacheHydrated must drop the stale day and start fresh.
const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-B')
expect(rehydrated.savingsConfigHash).toBe('cfg-B')
expect(rehydrated.days).toEqual([])
// Same hash → cached days survive.
const seeded2: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-C',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
}
await saveDailyCache(seeded2)
const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-C')
expect(preserved.days).toHaveLength(1)
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
})
})

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
import type { ParsedApiCall, ProjectSummary, SessionSummary, Turn } from '../src/types.js'
function makeCall(timestamp: string, opts: { costUSD: number; savingsUSD?: number; savingsBaselineModel?: string; model?: string }): ParsedApiCall {
return {
provider: 'claude',
model: opts.model ?? 'local-model',
usage: {
inputTokens: 100,
outputTokens: 200,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 50,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: opts.costUSD,
savingsUSD: opts.savingsUSD,
savingsBaselineModel: opts.savingsBaselineModel,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp,
bashCommands: [],
deduplicationKey: `dk-${timestamp}-${opts.costUSD}-${opts.savingsUSD ?? 0}`,
}
}
function makeTurn(timestamp: string, calls: ParsedApiCall[], category: string = 'coding'): Turn {
return {
userMessage: 'u',
timestamp,
sessionId: 's',
category: category as Turn['category'],
retries: 0,
hasEdits: false,
assistantCalls: calls,
} as Turn
}
function makeSession(sessions: SessionSummary[]): ProjectSummary {
const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
const totalSavingsUSD = sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0)
const totalApiCalls = sessions.reduce((s, sess) => s + sess.apiCalls, 0)
return {
project: 'p',
projectPath: '/p',
sessions,
totalCostUSD,
totalSavingsUSD,
totalApiCalls,
}
}
describe('aggregateProjectsIntoDays: savings totals', () => {
it('rolls up day, model, category, and provider savings separately from cost', () => {
const turn = makeTurn('2026-04-10T10:00:00', [
makeCall('2026-04-10T10:00:00', { costUSD: 0, savingsUSD: 5, savingsBaselineModel: 'gpt-4o' }),
])
const turn2 = makeTurn('2026-04-10T10:01:00', [
makeCall('2026-04-10T10:01:00', { costUSD: 2, savingsUSD: 0, model: 'gpt-4o' }),
])
const project: ProjectSummary = {
project: 'p',
projectPath: '/p',
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-10T10:00:00',
lastTimestamp: '2026-04-10T10:01:00',
totalCostUSD: 2,
totalSavingsUSD: 5,
totalInputTokens: 200,
totalOutputTokens: 400,
totalCacheReadTokens: 100,
totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [turn, turn2],
modelBreakdown: { 'Local Model': { calls: 1, costUSD: 0, savingsUSD: 5, tokens: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } }, 'gpt-4o': { calls: 1, costUSD: 2, savingsUSD: 0, tokens: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } } },
toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: { coding: { turns: 1, costUSD: 2, savingsUSD: 5, retries: 0, editTurns: 0, oneShotTurns: 0 } },
skillBreakdown: {}, subagentBreakdown: {},
}],
totalCostUSD: 2,
totalSavingsUSD: 5,
totalApiCalls: 2,
}
const days = aggregateProjectsIntoDays([project])
expect(days).toHaveLength(1)
const day = days[0]!
expect(day.cost).toBe(2)
expect(day.savingsUSD).toBe(5)
expect(day.models['local-model']).toMatchObject({ calls: 1, cost: 0, savingsUSD: 5 })
expect(day.models['gpt-4o']).toMatchObject({ calls: 1, cost: 2, savingsUSD: 0 })
expect(day.providers['claude']).toMatchObject({ calls: 2, cost: 2, savingsUSD: 5 })
expect(day.categories.coding).toMatchObject({ turns: 2, cost: 2, savingsUSD: 5 })
})
})
describe('buildPeriodDataFromDays: savings totals', () => {
it('threads savings through to model and category rollups', () => {
const days = [
{
date: '2026-04-09',
cost: 2,
savingsUSD: 5,
calls: 1,
sessions: 1,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: { 'local-model': { calls: 1, cost: 0, savingsUSD: 5, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 1, cost: 0, savingsUSD: 5, editTurns: 0, oneShotTurns: 0 } },
providers: { claude: { calls: 1, cost: 0, savingsUSD: 5 } },
},
{
date: '2026-04-10',
cost: 3,
savingsUSD: 0,
calls: 1,
sessions: 1,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: { 'gpt-4o': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } },
providers: { claude: { calls: 1, cost: 3, savingsUSD: 0 } },
},
]
const pd = buildPeriodDataFromDays(days, '7 Days')
expect(pd.savingsUSD).toBe(5)
const coding = pd.categories.find(c => c.name === 'Coding')!
expect(coding.savingsUSD).toBe(5)
const local = pd.models.find(m => m.name === 'local-model')!
expect(local.savingsUSD).toBe(5)
expect(local.cost).toBe(0)
})
})

View file

@ -127,6 +127,7 @@ describe('aggregateProjectsIntoDays', () => {
expect(day.categories['coding']).toEqual({
turns: 1,
cost: 3,
savingsUSD: 0,
editTurns: 1,
oneShotTurns: 1,
})
@ -186,17 +187,17 @@ describe('aggregateProjectsIntoDays', () => {
const days = aggregateProjectsIntoDays(projects)
const day = days[0]!
expect(day.models['Opus 4.7']).toEqual({
calls: 1, cost: 7,
calls: 1, cost: 7, savingsUSD: 0,
inputTokens: 100, outputTokens: 200,
cacheReadTokens: 50, cacheWriteTokens: 0,
})
expect(day.models['gpt-5']).toEqual({
calls: 1, cost: 3,
calls: 1, cost: 3, savingsUSD: 0,
inputTokens: 100, outputTokens: 200,
cacheReadTokens: 50, cacheWriteTokens: 0,
})
expect(day.providers['claude']).toEqual({ calls: 1, cost: 7 })
expect(day.providers['codex']).toEqual({ calls: 1, cost: 3 })
expect(day.providers['claude']).toEqual({ calls: 1, cost: 7, savingsUSD: 0 })
expect(day.providers['codex']).toEqual({ calls: 1, cost: 3, savingsUSD: 0 })
})
})
@ -214,11 +215,11 @@ describe('buildPeriodDataFromDays', () => {
editTurns: 3,
oneShotTurns: 2,
models: {
'Opus 4.7': { calls: 8, cost: cost * 0.8, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
'Haiku 4.5': { calls: 2, cost: cost * 0.2, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
'Opus 4.7': { calls: 8, cost: cost * 0.8, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
'Haiku 4.5': { calls: 2, cost: cost * 0.2, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
categories: { 'coding': { turns: 2, cost: cost * 0.5, editTurns: 2, oneShotTurns: 1 } },
providers: { 'claude': { calls: 10, cost } },
categories: { 'coding': { turns: 2, cost: cost * 0.5, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } },
providers: { 'claude': { calls: 10, cost, savingsUSD: 0 } },
}
}

View file

@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
calculateCost,
calculateLocalModelSavings,
getLocalModelSavingsConfigHash,
getLocalSavingsBaseline,
loadPricing,
setLocalModelSavings,
} from '../src/models.js'
afterEach(() => setLocalModelSavings({}))
describe('setLocalModelSavings / getLocalSavingsBaseline', () => {
it('returns undefined when no mapping is configured', () => {
setLocalModelSavings({})
expect(getLocalSavingsBaseline('llama3.1:8b')).toBeUndefined()
})
it('returns the baseline name for a configured source model', () => {
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
expect(getLocalSavingsBaseline('llama3.1:8b')).toBe('gpt-4o')
})
it('uses Object.hasOwn so __proto__ cannot be coerced via the prototype chain', () => {
// Regression for the prototype-pollution test: a hostile model name
// like `__proto__` used to resolve to Object.prototype because plain
// object bracket lookup walks the prototype chain.
setLocalModelSavings({})
expect(getLocalSavingsBaseline('__proto__')).toBeUndefined()
expect(getLocalSavingsBaseline('constructor')).toBeUndefined()
expect(getLocalSavingsBaseline('toString')).toBeUndefined()
})
it('refuses non-string keys defensively', () => {
setLocalModelSavings({})
expect(getLocalSavingsBaseline('' as unknown as string)).toBeUndefined()
expect(getLocalSavingsBaseline(undefined as unknown as string)).toBeUndefined()
})
it('getLocalModelSavingsConfigHash is stable across sort order and empty for no mappings', () => {
setLocalModelSavings({})
expect(getLocalModelSavingsConfigHash()).toBe('')
setLocalModelSavings({ a: 'gpt-4o', b: 'claude-opus-4-6' })
const h1 = getLocalModelSavingsConfigHash()
setLocalModelSavings({ b: 'claude-opus-4-6', a: 'gpt-4o' })
const h2 = getLocalModelSavingsConfigHash()
expect(h1).toBe(h2)
expect(h1).not.toBe('')
})
it('changes the hash when the baseline mapping changes', () => {
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
const h1 = getLocalModelSavingsConfigHash()
setLocalModelSavings({ 'llama3.1:8b': 'gpt-5' })
const h2 = getLocalModelSavingsConfigHash()
expect(h1).not.toBe(h2)
})
})
describe('calculateLocalModelSavings', () => {
it('returns null when no mapping is configured for the model', () => {
setLocalModelSavings({})
const out = calculateLocalModelSavings('llama3.1:8b', 1_000_000, 200_000, 0, 0, 0)
expect(out).toBeNull()
})
it('returns null when the baseline model is unknown to the pricing snapshot', () => {
setLocalModelSavings({ 'llama3.1:8b': 'unknown-paid-model-xyz' })
const out = calculateLocalModelSavings('llama3.1:8b', 1_000, 1_000, 0, 0, 0)
expect(out).toBeNull()
})
it('returns the baseline cost as savings for a configured mapping', async () => {
await loadPricing()
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
const expected = calculateCost('gpt-4o', 1_000_000, 200_000, 50_000, 800_000, 0)
const out = calculateLocalModelSavings('llama3.1:8b', 1_000_000, 200_000, 50_000, 800_000, 0)
expect(out).not.toBeNull()
expect(out!.savingsUSD).toBeCloseTo(expected)
expect(out!.baselineModel).toBe('gpt-4o')
})
it('respects speed and web-search inputs in the baseline calculation', async () => {
await loadPricing()
setLocalModelSavings({ local: 'gpt-4o' })
const standard = calculateLocalModelSavings('local', 1_000, 500, 0, 0, 2, 'standard')
expect(standard).not.toBeNull()
// Web search is a flat $0.01 per request, so the standard path with 2
// web search requests should include 2 cents of counterfactual spend.
expect(standard!.savingsUSD).toBeGreaterThan(0.02)
})
})

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { buildMenubarPayload, type LocalModelSavings, type PeriodData } from '../src/menubar-json.js'
function basePeriod(overrides: Partial<PeriodData> = {}): PeriodData {
return {
label: '7 Days',
cost: 0,
savingsUSD: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
...overrides,
}
}
describe('buildMenubarPayload: local-model savings', () => {
it('defaults localModelSavings to an empty block when no breakdown is provided', () => {
const payload = buildMenubarPayload(basePeriod(), [], null)
expect(payload.current.localModelSavings).toEqual({ totalUSD: 0, calls: 0, byModel: [], byProvider: [] })
})
it('threads the localModelSavings breakdown into the payload when supplied', () => {
const breakdown: LocalModelSavings = {
totalUSD: 12.34,
calls: 7,
byModel: [
{ name: 'llama3.1:8b', calls: 4, actualUSD: 0, savingsUSD: 7.21, baselineModel: 'gpt-4o', inputTokens: 1234, outputTokens: 567 },
{ name: 'qwen2.5:32b', calls: 3, actualUSD: 0, savingsUSD: 5.13, baselineModel: 'claude-opus-4-6', inputTokens: 4321, outputTokens: 876 },
],
byProvider: [
{ name: 'ollama', calls: 7, savingsUSD: 12.34 },
],
}
const payload = buildMenubarPayload(basePeriod(), [], null, undefined, undefined, undefined, {
localModelSavings: breakdown,
})
expect(payload.current.localModelSavings).toEqual(breakdown)
})
it('exposes savingsUSD and savingsBaselineModel on top models', () => {
const payload = buildMenubarPayload(basePeriod({
models: [
{ name: 'Local Model', cost: 0, savingsUSD: 5, calls: 1 },
{ name: 'gpt-4o', cost: 2, savingsUSD: 0, calls: 1 },
],
}), [], null)
const local = payload.current.topModels.find(m => m.name === 'Local Model')!
expect(local.savingsUSD).toBe(5)
expect(local.cost).toBe(0)
const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')!
expect(paid.savingsUSD).toBe(0)
})
it('surfaces savingsUSD on top projects and top sessions', () => {
const payload = buildMenubarPayload(basePeriod({
cost: 2,
savingsUSD: 5,
projects: [
{ name: 'p', cost: 2, savingsUSD: 5, sessions: 1, sessionDetails: [{ cost: 2, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0, date: '2026-04-10', models: [{ name: 'Local Model', cost: 0, savingsUSD: 5 }] }] },
],
topSessions: [{ project: 'p', cost: 2, savingsUSD: 5, calls: 1, date: '2026-04-10' }],
}), [], null)
const proj = payload.current.topProjects[0]!
expect(proj.savingsUSD).toBe(5)
expect(proj.sessionDetails[0]!.savingsUSD).toBe(5)
expect(proj.sessionDetails[0]!.models[0]!.savingsUSD).toBe(5)
const session = payload.current.topSessions[0]!
expect(session.savingsUSD).toBe(5)
})
it('emits savingsUSD and per-model breakdown in history entries', () => {
const payload = buildMenubarPayload(basePeriod(), [], null, [
{ date: '2026-04-10', cost: 2, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [{ name: 'Local Model', cost: 0, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0 }] },
])
expect(payload.history.daily[0]!.savingsUSD).toBe(5)
expect(payload.history.daily[0]!.topModels[0]!.savingsUSD).toBe(5)
})
it('keeps topActivities savingsUSD aligned with category rollups', () => {
const payload = buildMenubarPayload(basePeriod({
categories: [{ name: 'Coding', cost: 0, savingsUSD: 5, turns: 1, editTurns: 0, oneShotTurns: 0 }],
}), [], null)
expect(payload.current.topActivities[0]!.savingsUSD).toBe(5)
})
})

View file

@ -228,6 +228,8 @@ describe('renderTable', () => {
cacheReadTokens: 0,
totalTokens: 0,
costUSD: 0,
savingsUSD: 0,
savingsBaselineModel: '',
calls: 0,
...partial,
}
@ -335,8 +337,8 @@ describe('renderMarkdown', () => {
]
const md = renderMarkdown(rows, { showTotals: false })
const lines = md.split('\n')
expect(lines[0]).toBe('| Provider | Model | Top Task | Input | Output | Cache Write | Cache Read | Total | Cost |')
expect(lines[1]).toBe('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |')
expect(lines[0]).toBe('| Provider | Model | Top Task | Input | Output | Cache Write | Cache Read | Total | Cost | Saved |')
expect(lines[1]).toBe('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
expect(lines[2]).toContain('| Claude |')
expect(lines[2]).toContain('`Sonnet 4.6`')
expect(lines[2]).toContain('Feature Dev (60%)')
@ -443,13 +445,14 @@ describe('renderCsv', () => {
cacheReadTokens: 0,
totalTokens: 150,
costUSD: 1.5,
savingsUSD: 0,
calls: 1,
},
]
const csv = renderCsv(rows)
const lines = csv.split('\n')
expect(lines[0]).toBe('provider,model,top_task,top_task_share,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,total_tokens,calls,cost_usd')
expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000')
expect(lines[0]).toBe('provider,model,top_task,top_task_share,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,total_tokens,calls,cost_usd,savings_usd,savings_baseline_model')
expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000,0.000000,')
})
it('escapes commas in provider/model cells', () => {
@ -468,6 +471,7 @@ describe('renderCsv', () => {
cacheReadTokens: 0,
totalTokens: 0,
costUSD: 0,
savingsUSD: 0,
calls: 0,
},
]

View file

@ -0,0 +1,138 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import {
setLocalModelSavings,
setModelAliases,
loadPricing,
} from '../src/models.js'
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
import type { DateRange } from '../src/types.js'
const FIXTURE_DAY = Date.UTC(2026, 3, 16)
const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000)
const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000)
function makeRange(): DateRange {
return { start: RANGE_START, end: RANGE_END }
}
let tmpDirs: string[] = []
let originalConfigDir: string | undefined
beforeAll(async () => {
await loadPricing()
})
beforeEach(() => {
originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
setLocalModelSavings({})
setModelAliases({})
})
afterEach(async () => {
delete (Object.prototype as Record<string, unknown>).calls
if (originalConfigDir === undefined) {
delete process.env['CLAUDE_CONFIG_DIR']
} else {
process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
}
clearSessionCache()
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupLocalModelSession(modelName: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-savings-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', 'p')
await mkdir(projectDir, { recursive: true })
// Use a synthetic local-style model name and a small known token count.
const file = join(projectDir, 's1.jsonl')
const ts = '2026-04-16T10:00:00.000Z'
const line = JSON.stringify({
type: 'assistant',
timestamp: ts,
sessionId: 's1',
message: {
type: 'message',
role: 'assistant',
model: modelName,
id: 'msg-1',
content: [],
usage: {
input_tokens: 1000,
output_tokens: 200,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
})
await writeFile(file, line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
return base
}
describe('local-model savings: end-to-end', () => {
it('keeps an unconfigured local model at $0 with no savings recorded', async () => {
await setupLocalModelSession('llama3.1:8b')
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
expect(allCalls.length).toBeGreaterThan(0)
for (const c of allCalls) {
expect(c.costUSD).toBe(0)
expect(c.savingsUSD ?? 0).toBe(0)
expect(c.isLocalSavings).toBeFalsy()
}
})
it('records savings and forces cost to 0 when a local model has a savings mapping', async () => {
await setupLocalModelSession('llama3.1:8b')
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
expect(allCalls.length).toBeGreaterThan(0)
for (const c of allCalls) {
expect(c.costUSD).toBe(0)
expect(c.savingsUSD).toBeGreaterThan(0)
expect(c.savingsBaselineModel).toBe('gpt-4o')
expect(c.isLocalSavings).toBe(true)
}
// Session and project rollups surface the savings total.
const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
expect(totalSavings).toBeGreaterThan(0)
})
it('does not apply savings for a model that has no mapping', async () => {
await setupLocalModelSession('qwen2.5:32b')
setLocalModelSavings({ 'unrelated:1b': 'gpt-4o' })
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
for (const c of allCalls) {
expect(c.savingsUSD ?? 0).toBe(0)
}
})
it('forces a $0 cost even when the same model is also in modelAliases', async () => {
// The local-savings path is meant to win for actual cost: spending
// config semantics say "this is local, track counterfactual", so
// even a stale modelAliases entry must not cause us to charge real
// dollars for a local call.
await setupLocalModelSession('llama3.1:8b')
setModelAliases({ 'llama3.1:8b': 'gpt-4o' })
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
for (const c of allCalls) {
expect(c.costUSD).toBe(0)
expect(c.savingsUSD).toBeGreaterThan(0)
}
})
})