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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion gnome/indicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -576,14 +576,17 @@ 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 || '');
this._heroAmount.set_text(this._fmt(cost));

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();
Expand Down Expand Up @@ -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);
Expand Down
121 changes: 118 additions & 3 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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
}
Expand All @@ -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) ?? []
Expand All @@ -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]
Expand All @@ -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
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -256,6 +370,7 @@ extension MenubarPayload {
cacheHitPercent: 0,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: [:],
topProjects: [],
modelEfficiency: [],
Expand Down
21 changes: 21 additions & 0 deletions mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ private struct ModelRow: View {

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)

Expand All @@ -49,6 +52,12 @@ private struct ModelRow: View {
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)

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()
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export type CodeburnConfig = {
plan?: Plan
plans?: PlanConfigMap
modelAliases?: Record<string, string>
/// Map raw local-model names (e.g. "llama3.1:8b") to the paid model
/// we'd 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 {
Expand Down
Loading