mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 09:39:24 +00:00
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:
commit
fda117face
26 changed files with 1326 additions and 122 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
155
src/main.ts
155
src/main.ts
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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: [] }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? []),
|
||||
|
|
|
|||
|
|
@ -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(','))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
17
src/types.ts
17
src/types.ts
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})()
|
||||
|
||||
|
|
|
|||
76
tests/cli-model-savings.test.ts
Normal file
76
tests/cli-model-savings.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
150
tests/day-aggregator-savings.test.ts
Normal file
150
tests/day-aggregator-savings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 } },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
94
tests/local-model-savings.test.ts
Normal file
94
tests/local-model-savings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
91
tests/menubar-savings.test.ts
Normal file
91
tests/menubar-savings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
138
tests/parser-local-savings.test.ts
Normal file
138
tests/parser-local-savings.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue