Skip to content
Merged
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
24 changes: 23 additions & 1 deletion mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@ 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",
isExpanded: $isExpanded,
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))
Expand All @@ -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()
Expand All @@ -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)

Expand All @@ -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()
Expand Down
Loading
Loading