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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { homedir } from 'os'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens } from './format.js'
import { formatCost, formatTokens, markEstimated } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByName, setInteractiveScanUI } from './parser.js'
import { findUnpricedModels, loadPricing } from './models.js'
Expand Down Expand Up @@ -384,20 +384,22 @@ const MODEL_NAME_WIDTH = 14
const MIN_EDIT_TURNS_FOR_RATE = 5

function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const modelTotals: Record<string, { calls: number; costUSD: number; freshInput: number; cacheRead: number; cacheWrite: number }> = {}
const modelTotals: Record<string, { calls: number; costUSD: number; estimatedCostUSD: number; freshInput: number; cacheRead: number; cacheWrite: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, data] of Object.entries(session.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += data.calls
modelTotals[model].costUSD += data.costUSD
modelTotals[model].estimatedCostUSD += data.estimatedCostUSD ?? 0
modelTotals[model].freshInput += data.tokens.inputTokens
modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens
modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens
}
}
}
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
Expand All @@ -422,7 +424,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
<Text key={`${model}-${i}`} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
<Text> {fit(model, MODEL_NAME_WIDTH)}</Text>
<Text color={GOLD}>{formatCost(data.costUSD).padStart(MODEL_COL_COST)}</Text>
<Text color={GOLD}>{markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)}</Text>
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
Expand All @@ -434,6 +436,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`}
</Text>
)}
{anyEstimated && (
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
)}
</Panel>
)
}
Expand Down
8 changes: 8 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import type { ProjectSummary } from './types.js'
import { formatCost } from './currency.js'
export { formatCost }

/// Prefix a formatted cost with the estimated marker (`~`) when the figure is
/// priced from estimated tokens rather than metered. Keeps the marker identical
/// across the report, overview, and MCP surfaces so a legend line can explain it
/// once. `isEstimated` is typically `entry.estimatedCostUSD > 0`.
export function markEstimated(costStr: string, isEstimated: boolean): string {
return isEstimated ? `~${costStr}` : costStr
}

export function formatTokens(n: number): string {
// Guard against Infinity / NaN / negatives that would otherwise leak into
// the UI as "Infinity" or "NaN" strings when an upstream calculation glitches.
Expand Down
12 changes: 9 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:

const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalEstimatedUSD = projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0)
// Subscription-covered (proxied) portion of totalCostUSD, and the resulting
// out-of-pocket figure. `cost` stays the full billable/would-be amount.
const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
Expand Down Expand Up @@ -334,14 +335,15 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
sessions: p.sessions.length,
}))

const modelMap: Record<string, { calls: number; cost: number; savings: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; baselineModel: string }> = {}
const modelMap: Record<string, { calls: number; cost: number; savings: number; estimatedCost: 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, savings: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } }
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, estimatedCost: 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].estimatedCost += d.estimatedCostUSD ?? 0
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
Expand Down Expand Up @@ -371,13 +373,14 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([name, { cost, savings, baselineModel, ...rest }]) => {
.map(([name, { cost, savings, estimatedCost, baselineModel, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
savings: convertCost(savings),
estimatedCost: convertCost(estimatedCost),
savingsBaselineModel: baselineModel,
editTurns: efficiency?.editTurns ?? 0,
oneShotTurns: efficiency?.oneShotTurns ?? 0,
Expand Down Expand Up @@ -480,6 +483,9 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
proxiedCost: convertCost(totalProxiedUSD),
netCost: convertCost(netCostUSD),
savings: convertCost(totalSavingsUSD),
// Portion of `cost` priced from estimated tokens (issue #639). Display/
// metadata only; never subtracted from `cost`. 0 when nothing is estimated.
estimatedCost: convertCost(totalEstimatedUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,
Expand Down
12 changes: 6 additions & 6 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ const INSTRUCTIONS =
'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' +
'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.'

function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> {
function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number; estimatedCostUSD?: number }> {
const c = p.current
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost }))
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost, estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost }))
if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost }))
return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost }))
Expand Down Expand Up @@ -60,16 +60,16 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }):
outputSchema: {
period: z.string(),
empty: z.boolean(),
totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(),
totals: z.object({ costUSD: z.number(), estimatedCostUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number(), estimatedCostUSD: z.number().optional() })).nullable(),
},
annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
},
async ({ period, by, limit, include_project_names }) => {
try {
const payload = redactProjectNames(await getPayload(period, false), include_project_names)
const c = payload.current
const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
const totals = { costUSD: c.cost, estimatedCostUSD: c.estimatedCostUSD ?? 0, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
if (c.calls === 0) {
return {
content: [{ type: 'text' as const, text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }],
Expand All @@ -85,7 +85,7 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }):
} catch (err) {
return {
content: [{ type: 'text' as const, text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }],
structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null },
structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, estimatedCostUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null },
isError: true,
}
}
Expand Down
14 changes: 11 additions & 3 deletions src/mcp/tables.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { formatCost, formatTokens } from '../format.js'
import { formatCost, formatTokens, markEstimated } from '../format.js'
import type { MenubarPayload } from '../menubar-json.js'

const ESTIMATED_LEGEND = '_~ estimated cost (priced from estimated tokens)_'
const isEstimated = (m: { estimatedCostUSD?: number }) => (m.estimatedCostUSD ?? 0) > 0

export type BreakdownBy = 'project' | 'model' | 'task' | 'provider'

function mdTable(headers: string[], rows: string[][]): string {
Expand All @@ -24,7 +27,8 @@ export function renderSummaryTable(p: MenubarPayload): string {
: []),
'',
'_Top models_',
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])),
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, markEstimated(formatCost(m.cost), isEstimated(m)), String(m.calls)])),
...(c.topModels.slice(0, 5).some(isEstimated) ? [ESTIMATED_LEGEND] : []),
'',
'_Top projects_',
mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])),
Expand All @@ -33,7 +37,11 @@ export function renderSummaryTable(p: MenubarPayload): string {

export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string {
const c = p.current
if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)]))
if (by === 'model') {
const rows = c.topModels.slice(0, limit)
const table = mdTable(['Model', 'Cost', 'Calls'], rows.map(m => [m.name, markEstimated(formatCost(m.cost), isEstimated(m)), String(m.calls)]))
return rows.some(isEstimated) ? `${table}\n\n${ESTIMATED_LEGEND}` : table
}
if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)]))
if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)]))
return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)]))
Expand Down
17 changes: 15 additions & 2 deletions src/menubar-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export type PeriodData = {
/// separately from `cost` so the two never get summed into a "real
/// spend" number by accident.
savingsUSD: number
/// Portion of `cost` priced from estimated tokens (see ParsedApiCall.isEstimated).
/// Display/metadata only; never summed into `cost`. Optional so PeriodData
/// producers predating the field keep compiling.
estimatedCostUSD?: number
calls: number
sessions: number
inputTokens: number
Expand All @@ -20,7 +24,7 @@ export type PeriodData = {
/// non-menubar PeriodData producers don't have to compute it.
codexCredits?: 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 }>
models: Array<{ name: string; cost: number; savingsUSD: number; calls: number; estimatedCostUSD?: number }>
/// Models with usage in the period whose pricing lookup fails against the
/// current tables (#638): their calls contribute $0 to `cost`. Optional so
/// PeriodData producers that predate the field keep compiling.
Expand Down Expand Up @@ -146,6 +150,11 @@ export type MenubarPayload = {
cacheHitPercent: number
/// Codex credits consumed in the period; 0 when there is no Codex usage.
codexCredits: number
/// Portion of `cost` priced from estimated tokens (see ParsedApiCall.isEstimated).
/// Machine-readable signal that distinguishes guessed spend from metered spend;
/// display/metadata only, never summed into `cost`. Optional for compatibility
/// with payloads produced before the field existed.
estimatedCostUSD?: number
topActivities: Array<{
name: string
cost: number
Expand All @@ -159,6 +168,9 @@ export type MenubarPayload = {
savingsUSD: number
savingsBaselineModel: string
calls: number
/// Estimated portion of this model's `cost`; > 0 marks the row as priced
/// from estimated tokens. Optional for payload back-compat.
estimatedCostUSD?: number
}>
/// See PeriodData.unpricedModels: usage priced at $0 for lack of pricing
/// data. Empty when every model in the period resolved a price. Optional
Expand Down Expand Up @@ -291,7 +303,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, savingsUSD: m.savingsUSD, savingsBaselineModel: '' }))
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
}

function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
Expand Down Expand Up @@ -410,6 +422,7 @@ export function buildMenubarPayload(
cacheWriteTokens: current.cacheWriteTokens,
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
codexCredits: current.codexCredits ?? 0,
estimatedCostUSD: current.estimatedCostUSD ?? 0,
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
unpricedModels: current.unpricedModels ?? [],
Expand Down
11 changes: 8 additions & 3 deletions src/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { homedir } from 'os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost as baseCost } from './currency.js'
import { findUnpricedModels, getShortModelName } from './models.js'
import { markEstimated } from './format.js'
import { dateKey } from './day-aggregator.js'

// Display-only helpers. The shared formatters omit thousands separators and
Expand Down Expand Up @@ -91,7 +92,7 @@ export function renderOverview(
let cost = 0, savings = 0, calls = 0, sessions = 0
let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0
const byProvider = new Map<string, { cost: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number; estimatedCost: number }>()
const byCat = new Map<string, { cost: number; turns: number }>()
const byTool = new Map<string, number>()
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
Expand All @@ -113,9 +114,10 @@ export function renderOverview(
cacheR += s.totalCacheReadTokens
cacheW += s.totalCacheWriteTokens
for (const [m, d] of Object.entries(s.modelBreakdown)) {
const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0 }
const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0, estimatedCost: 0 }
e.cost += d.costUSD
e.calls += d.calls
e.estimatedCost += d.estimatedCostUSD ?? 0
e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens
byModel.set(m, e)
}
Expand Down Expand Up @@ -209,8 +211,11 @@ export function renderOverview(
out.push(heading('Top models'))
out.push(renderTable(c,
[{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }],
modelRows.map(([m, v]) => [getShortModelName(m), formatCost(v.cost), formatCount(v.calls), formatTokens(v.tokens)]),
modelRows.map(([m, v]) => [getShortModelName(m), markEstimated(formatCost(v.cost), v.estimatedCost > 0), formatCount(v.calls), formatTokens(v.tokens)]),
))
if (modelRows.some(([, v]) => v.estimatedCost > 0)) {
out.push(' ' + c.dim('~ estimated cost (priced from estimated tokens)'))
}
out.push('')
}

Expand Down
Loading