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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ export type MenubarPayload = {
savingsUSD: number
savingsBaselineModel: string
calls: number
// Per-model token counts (src/menubar-json.ts buildTopModels): billable
// output, cache read = reused input, cache write separate. Optional:
// older CLIs omit them, and a row whose contributing legacy data lacked
// counts omits them even on a new CLI. Absent means unknown — render a
// dash, never zero, and never substitute a period-wide figure.
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
}>
unpricedModels?: Array<{ model: string; calls: number; tokens: number }>
localModelSavings: LocalModelSavings
Expand Down
11 changes: 9 additions & 2 deletions app/renderer/sections/Models.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,21 @@ describe('Models', () => {
expect(screen.queryByText('add alias ›')).not.toBeInTheDocument()
})

it('renders unpriced proxy rows as dim with alias affordance and dashes', async () => {
it('renders unpriced proxy rows as dim with alias affordance, keeping observed tokens visible', async () => {
getModels.mockResolvedValue([rows[3]])

render(<Models period="30days" provider="all" />)

expect(await screen.findByText('my-proxy-model')).toHaveClass('dim')
expect(screen.getByText('add alias ›')).toHaveClass('alias')
expect(screen.getAllByText('—')).toHaveLength(5)
// Tokens are observed usage, not a pricing artifact: they render even
// though the model has no pricing entry. Cache read shows its known zero.
expect(screen.getByText('4.8M')).toBeInTheDocument()
expect(screen.getByText('400K')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('4.8M')).not.toHaveClass('dim')
// Only cost and saved collapse to dashes.
expect(screen.getAllByText('—')).toHaveLength(2)
expect(screen.queryByText('$0.00')).not.toBeInTheDocument()
})

Expand Down
19 changes: 11 additions & 8 deletions app/renderer/sections/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,10 @@ function ModelsByTaskTable({ rows, onAddAlias }: { rows: ModelReportRow[]; onAdd
function ModelTableRow({ row, onAddAlias }: { row: ModelReportRow; onAddAlias: () => void }) {
const unpriced = row.costUSD === 0 && row.savingsUSD === 0
const cellClass = unpriced ? 'dim' : undefined
const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value))
// Token columns are observed usage, not a pricing artifact: a model with no
// pricing entry still burned real input/output/cache-read tokens, so they
// render regardless. Only cost/saved collapse to dashes behind the alias
// affordance — there is no attributed cost to show for them.
const dotStyle = {
display: 'inline-block',
background: seriesColorForModel(row.modelDisplayName || row.model),
Expand All @@ -292,9 +295,9 @@ function ModelTableRow({ row, onAddAlias }: { row: ModelReportRow; onAddAlias: (
<span style={{ ...providerTagStyle, display: 'block', marginTop: 2, paddingLeft: 16 }}>{row.providerDisplayName}</span>
</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td>{formatCompact(row.inputTokens)}</td>
<td>{formatCompact(row.outputTokens)}</td>
<td>{formatCompact(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
Expand Down Expand Up @@ -336,15 +339,15 @@ function ModelGroupRow({ rows, onAddAlias }: { rows: ModelReportRow[]; onAddAlia
function ModelTaskRow({ row }: { row: ModelReportRow }) {
const unpriced = row.costUSD === 0 && row.savingsUSD === 0
const cellClass = unpriced ? 'dim' : undefined
const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value))

return (
<tr className="model-task-row">
<td className={cellClass}>{row.category ?? 'general'}</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
{/* Observed usage renders even for unpriced models — see ModelTableRow. */}
<td>{formatCompact(row.inputTokens)}</td>
<td>{formatCompact(row.outputTokens)}</td>
<td>{formatCompact(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
Expand Down
51 changes: 49 additions & 2 deletions app/renderer/sections/Overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,8 +624,55 @@ describe('Overview', () => {
expect(rows[1]).toHaveTextContent('$120.00')
expect(rows[1]).toHaveTextContent('240')
expect(rows[2]).toHaveTextContent('claude-opus-4')
// current.topModels carries no per-model tokens → both token cells show a dash.
expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(2)
// This legacy-shaped payload carries no per-model counts → all three token
// cells (input, output, cache read) show a dash.
expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(3)
})

it('prefers current.topModels for the models table when the payload carries per-model counts', async () => {
const now = new Date()
const payload = makePayload(now)
// New-CLI payload: per-model counts ride on current.topModels, including
// cache read. history.daily still carries different (per-day, truncated)
// aggregates that the table must NOT fall back to.
payload.current.topModels = [
{ name: 'claude-opus-4', cost: 200, savingsUSD: 0, savingsBaselineModel: '', calls: 100, inputTokens: 1_200_000, outputTokens: 340_000, cacheReadTokens: 56_000_000, cacheWriteTokens: 7_000 },
{ name: 'claude-haiku-4', cost: 4, savingsUSD: 0, savingsBaselineModel: '', calls: 12, inputTokens: 0, outputTokens: 0, cacheReadTokens: 900, cacheWriteTokens: 0 },
]

render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)

const modelsTable = await screen.findByRole('table', { name: 'Models this period' })
expect(within(modelsTable).getByRole('columnheader', { name: 'Cache read' })).toBeInTheDocument()
const rows = within(modelsTable).getAllByRole('row')
// Counts come from current.topModels (1.2M in), not the daily aggregation (40M in).
expect(rows[1]).toHaveTextContent('claude-opus-4')
expect(rows[1]).toHaveTextContent('1.2M')
expect(rows[1]).toHaveTextContent('340K')
expect(rows[1]).toHaveTextContent('56M')
expect(within(modelsTable).queryByText('40M')).not.toBeInTheDocument()
// Known zeros stay zeros: haiku's fresh input/output render as 0, its cache
// read as the real 900.
expect(within(rows[2] as HTMLElement).getAllByText('0')).toHaveLength(2)
expect(within(rows[2] as HTMLElement).getByText('900')).toBeInTheDocument()
})

it('falls back to aggregating history.daily when the payload predates per-model counts', async () => {
const now = new Date()
const payload = makePayload(now)
// Legacy all-provider payload: current.topModels has no counts, history.daily
// does (input/output only — the CLI never emitted per-model cache read there).

render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)

const modelsTable = await screen.findByRole('table', { name: 'Models this period' })
const rows = within(modelsTable).getAllByRole('row')
// Input/output still come from the daily aggregation (30 days × 40M/2M) ...
expect(rows[1]).toHaveTextContent('claude-opus-4')
expect(rows[1]).toHaveTextContent('1.2B')
expect(rows[1]).toHaveTextContent('60M')
// ... and the absent per-model cache read shows as a dash, not zero.
expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(1)
})

it('suppresses the week-over-week signal and MTD card for a custom range', async () => {
Expand Down
31 changes: 25 additions & 6 deletions app/renderer/sections/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,16 +464,25 @@ type AggregatedModel = {
name: string
cost: number
calls: number
// Absent in provider-filtered mode: `current.topModels` carries no per-model
// token counts, so the table shows "—" rather than a misleading zero.
// Absent when the payload carries no count for the row (an older CLI, or a
// row whose contributing legacy data lacked counts): the table shows "—"
// rather than a misleading zero.
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
}

/** Provider-filtered source: `current.topModels` is already period/range/provider-scoped by the CLI. */
function topModelsToAggregated(models: MenubarPayload['current']['topModels']): AggregatedModel[] {
return models
.map(model => ({ name: model.name, cost: model.cost, calls: model.calls }))
.map(model => ({
name: model.name,
cost: model.cost,
calls: model.calls,
...(model.inputTokens === undefined ? {} : { inputTokens: model.inputTokens }),
...(model.outputTokens === undefined ? {} : { outputTokens: model.outputTokens }),
...(model.cacheReadTokens === undefined ? {} : { cacheReadTokens: model.cacheReadTokens }),
}))
.sort((a, b) => b.cost - a.cost)
}

Expand Down Expand Up @@ -509,6 +518,8 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
<th>Model</th>
<th className="num">Input tok</th>
<th className="num">Output tok</th>
{/* Reused input tokens: prompts the provider served from cache. */}
<th className="num" title="Reused input tokens served from the provider's cache">Cache read</th>
<th className="num">Cost</th>
<th className="num">Calls</th>
</tr>
Expand All @@ -519,6 +530,7 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
<td className="ov-model-name">{model.name}</td>
<td className="num mono">{model.inputTokens === undefined ? '—' : formatCompact(model.inputTokens)}</td>
<td className="num mono">{model.outputTokens === undefined ? '—' : formatCompact(model.outputTokens)}</td>
<td className="num mono">{model.cacheReadTokens === undefined ? '—' : formatCompact(model.cacheReadTokens)}</td>
<td className="num mono">{formatUsd(model.cost)}</td>
<td className="num">{model.calls.toLocaleString('en-US')}</td>
</tr>
Expand Down Expand Up @@ -780,9 +792,16 @@ export function OverviewContent({
periodDaily[0] && periodDaily[0].date < defaultChartStart ? periodDaily[0].date : defaultChartStart,
localDateKey(now),
)
// Provider-filtered history.daily has empty topModels, so source the models
// table from current.topModels (already period/range/provider-scoped) instead.
const models = provider !== 'all'
// Models this period come from `current.topModels` — period/range/provider-
// scoped by the CLI, and (on CLIs that emit per-model counts) carrying input/
// output/cache-read counts for every model in the period, including days
// whose per-day top-5 history list no longer names them. history.daily is
// the fallback for payloads from older CLIs: its rows know input/output but
// not cache read, so the cache column shows "—" there.
const topModelsCarryCounts = data.current.topModels.some(model =>
model.inputTokens !== undefined || model.outputTokens !== undefined,
)
const models = provider !== 'all' || topModelsCarryCounts
? topModelsToAggregated(data.current.topModels)
: aggregateModels(rangeActive ? sliceDailyToRange(data.history.daily, range.from, range.to) : periodDaily)
const recent14 = data.history.daily.slice(-14)
Expand Down
40 changes: 40 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,41 @@ struct ModelEntry: Codable, Sendable {
let savingsUSD: Double
let savingsBaselineModel: String
let calls: Int
/// Per-model token counts: input, output, cache read (reused input), and
/// cache write, kept separate so the two cache flavors are never summed.
/// Nil on every CLI up to the token-breakdown release and on any row whose
/// contributing legacy data lacked counts: absent means "unknown", which
/// renders as a dash — never as zero, and never as a period-wide figure.
let inputTokens: Int?
let outputTokens: Int?
let cacheReadTokens: Int?
let cacheWriteTokens: Int?

/// Whether any per-model count arrived. A row with none (legacy payload)
/// renders without the secondary token line rather than as a run of dashes.
var hasTokenCounts: Bool {
inputTokens != nil || outputTokens != nil || cacheReadTokens != nil
}

init(name: String,
cost: Double,
savingsUSD: Double,
savingsBaselineModel: String,
calls: Int,
inputTokens: Int? = nil,
outputTokens: Int? = nil,
cacheReadTokens: Int? = nil,
cacheWriteTokens: Int? = nil) {
self.name = name
self.cost = cost
self.savingsUSD = savingsUSD
self.savingsBaselineModel = savingsBaselineModel
self.calls = calls
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheWriteTokens = cacheWriteTokens
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
Expand All @@ -496,10 +531,15 @@ struct ModelEntry: Codable, Sendable {
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)
inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens)
outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens)
cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens)
cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens)
}

private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, savingsBaselineModel, calls
case inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens
}
}

Expand Down
Loading
Loading