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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ Sync sends token counts, costs, models, and projects — never prompts or code.
|---------|--------------|
| `codeburn models` | Per-model token + cost table (last 30 days) |
| `codeburn models --by-task` | Break each model into per-task-type rows |
| `codeburn models --by-agent` | Break each model into per-agent rows (which agent drove which model's spend); Claude subagent transcripts only, other providers and main sessions bucket under "main" |
| `codeburn models --top 10` | Only the 10 most expensive models |
| `codeburn models --format markdown` | Emit a paste-friendly markdown table |
| `codeburn models --task feature` | Filter to feature-development work |
Expand Down
14 changes: 10 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1895,19 +1895,24 @@ program

program
.command('models')
.description('Per-model token + cost table, optionally exploded by task type')
.description('Per-model token + cost table, optionally exploded by task type or agent')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--task <category>', 'Filter to one task type (e.g. feature, debugging, refactoring)')
.option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)')
.option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"')
.option('--top <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
.option('--no-totals', 'Suppress the footer totals row')
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
assertProvider(opts.provider, 'models')
if (opts.byTask && opts.byAgent) {
process.stderr.write('codeburn: --by-task and --by-agent cannot be combined. Pick one breakdown.\n')
process.exit(1)
}
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
await loadPricing()

Expand All @@ -1926,6 +1931,7 @@ program
const projects = await parseAllSessions(range, opts.provider)
const rows = await aggregateModels(projects, {
byTask: !!opts.byTask,
byAgent: !!opts.byAgent,
taskFilter: opts.task,
topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
Expand All @@ -1939,11 +1945,11 @@ program
if (fmt === 'json') {
process.stdout.write(renderJson(rows) + '\n')
} else if (fmt === 'csv') {
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n')
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n')
} else if (fmt === 'markdown' || fmt === 'md') {
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)
Expand Down
120 changes: 86 additions & 34 deletions src/models-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export type ModelReportRow = {
model: string
modelDisplayName: string
category: TaskCategory | null
/// Set only in `byAgent` mode: the Claude subagent type this row's spend ran
/// under (`general-purpose`, `Explore`, a workflow agent, …), or `'main'` for
/// ordinary non-agent sessions and every non-Claude provider. `null` in the
/// default and `byTask` views.
agentType?: string | null
inputTokens: number
outputTokens: number
cacheWriteTokens: number
Expand All @@ -31,6 +36,10 @@ export type ModelReportRow = {

export type AggregateOptions = {
byTask?: boolean
/// One row per (provider, model, agent). Mutually exclusive with `byTask`;
/// the caller enforces that. Non-Claude providers and ordinary sessions bucket
/// under `'main'`.
byAgent?: boolean
taskFilter?: TaskCategory
topN?: number
/// Threshold for the `cost`-based filter. The default `0.01` would
Expand All @@ -44,6 +53,7 @@ type Bucket = {
provider: string
model: string
category: TaskCategory | null
agentType: string | null
inputTokens: number
outputTokens: number
cacheWriteTokens: number
Expand All @@ -57,18 +67,21 @@ type Bucket = {
type ModelKey = string
type CategoryKey = TaskCategory

function bucketKey(provider: string, model: string, category: TaskCategory | null): string {
return `${provider} ${model} ${category ?? ''}`
function bucketKey(provider: string, model: string, category: TaskCategory | null, agentType: string | null): string {
return `${provider} ${model} ${category ?? ''} ${agentType ?? ''}`
}

/// Walks every parsed turn, attributes each assistant call to a
/// (provider, model, category) bucket, and returns rows keyed by either
/// (provider, model) when `byTask` is false or (provider, model, category) when true.
/// (provider, model, category, agent) bucket, and returns rows keyed by
/// (provider, model) by default, (provider, model, category) under `byTask`, or
/// (provider, model, agent) under `byAgent`.
///
/// Default view: rows sorted by cost descending.
/// byTask view: rows grouped by (provider, model) so the renderer can blank
/// repeated provider/model cells. Group order follows total cost across that
/// model; within each group, rows go by cost descending.
/// byTask / byAgent view: rows grouped by (provider, model) so the renderer can
/// blank repeated provider/model cells. Group order follows total cost across
/// that model; within each group, rows go by cost descending. The agent label
/// comes from the subagent transcript's own `session.agentType`; ordinary
/// sessions and every non-Claude provider bucket under `'main'`.
export async function aggregateModels(projects: ProjectSummary[], opts: AggregateOptions = {}): Promise<ModelReportRow[]> {
const buckets = new Map<string, Bucket>()
const perModelCategoryCost = new Map<ModelKey, Map<CategoryKey, number>>()
Expand All @@ -82,13 +95,18 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
const provider = call.provider || 'unknown'
const model = call.model || 'unknown'
const category: TaskCategory | null = opts.byTask ? turn.category : null
const key = bucketKey(provider, model, category)
// Non-Claude sessions and ordinary main sessions have no agentType, so
// their spend all lands under 'main'. That is correct: the report never
// fabricates an agent for calls that were not driven by one.
const agentType: string | null = opts.byAgent ? (session.agentType ?? 'main') : null
const key = bucketKey(provider, model, category, agentType)
let bucket = buckets.get(key)
if (!bucket) {
bucket = {
provider,
model,
category,
agentType,
inputTokens: 0,
outputTokens: 0,
cacheWriteTokens: 0,
Expand Down Expand Up @@ -151,6 +169,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
model: bucket.model,
modelDisplayName: meta.formatModel(bucket.model),
category: bucket.category,
agentType: bucket.agentType,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cacheWriteTokens: bucket.cacheWriteTokens,
Expand All @@ -172,7 +191,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
: null,
}

if (!opts.byTask) {
if (!opts.byTask && !opts.byAgent) {
const perCat = perModelCategoryCost.get(`${bucket.provider} ${bucket.model}`)
if (perCat && perCat.size > 0) {
let topCat: TaskCategory = 'general'
Expand All @@ -194,7 +213,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
rows.push(row)
}

if (opts.byTask) {
if (opts.byTask || opts.byAgent) {
rows.sort((a, b) => {
const aTotal = perModelTotalCost.get(`${a.provider} ${a.model}`) ?? 0
const bTotal = perModelTotalCost.get(`${b.provider} ${b.model}`) ?? 0
Expand Down Expand Up @@ -263,19 +282,27 @@ type Column = {

type TableRenderOptions = {
byTask?: boolean
byAgent?: boolean
showTotals?: boolean
terminalWidth?: number
fullWidth?: boolean
}

/// The third column reuses the `task` slot for all three modes: 'Top Task' in
/// the default view, 'Task' under byTask, 'Agent' under byAgent.
function thirdColumnHeader(byTask: boolean, byAgent: boolean): string {
if (byAgent) return 'Agent'
return byTask ? 'Task' : 'Top Task'
}

const DROP_COLUMN_GROUPS: Array<Array<Column['key']>> = [
['cacheWrite', 'cacheRead'],
['input', 'output'],
['task'],
['saved'],
]

function defaultColumns(byTask: boolean, showSaved: boolean): Column[] {
function defaultColumns(byTask: boolean, byAgent: 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. The Saved column only appears
Expand All @@ -284,9 +311,9 @@ function defaultColumns(byTask: boolean, showSaved: boolean): Column[] {
// 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 },
{ key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 },
{ key: 'task', header: byTask ? 'Task' : 'Top Task', align: 'left', width: 8, priority: 1 },
{ key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 },
{ key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 },
{ key: 'task', header: thirdColumnHeader(byTask, byAgent), align: 'left', width: 8, priority: 1 },
{ key: 'input', header: 'Input', align: 'right', width: 6, priority: 2 },
{ key: 'output', header: 'Output', align: 'right', width: 6, priority: 2 },
{ key: 'cacheWrite', header: 'Cache Write', align: 'right', width: 11, priority: 3 },
Expand Down Expand Up @@ -318,8 +345,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, showSaved: boolean): Column[] {
const all = defaultColumns(byTask, showSaved)
function chooseColumns(byTask: boolean, byAgent: boolean, available: number, showSaved: boolean): Column[] {
const all = defaultColumns(byTask, byAgent, showSaved)
if (frameWidth(all) <= available) return all

// Drop in this order so the table degrades sensibly. Cache columns drop as
Expand Down Expand Up @@ -417,6 +444,10 @@ export function renderTable(
opts: TableRenderOptions = {},
): string {
const byTask = opts.byTask ?? false
const byAgent = opts.byAgent ?? false
// Both byTask and byAgent group rows under (provider, model) and blank the
// repeated provider/model cells; the default view keeps them on every row.
const grouped = byTask || byAgent
const showTotals = opts.showTotals ?? true
const available = opts.terminalWidth ?? defaultTerminalWidth()
const fullWidth = opts.fullWidth ?? true
Expand All @@ -428,6 +459,7 @@ export function renderTable(
case 'provider': return isNewGroup ? row.providerDisplayName : ''
case 'model': return isNewGroup ? row.modelDisplayName : ''
case 'task':
if (byAgent) return row.agentType ?? ''
if (byTask) return row.category ? categoryLabel(row.category) : ''
return row.topCategory
? `${categoryLabel(row.topCategory)} ${chalk.dim(`(${Math.round((row.topCategoryShare ?? 0) * 100)}%)`)}`
Expand All @@ -448,9 +480,9 @@ export function renderTable(
let prevProviderModel = ''
for (const row of rows) {
const groupKey = `${row.provider} ${row.model}`
const isNewGroup = !byTask || groupKey !== prevProviderModel
const isNewGroup = !grouped || groupKey !== prevProviderModel
prevProviderModel = groupKey
const allCells = defaultColumns(byTask, showSaved).map(col => {
const allCells = defaultColumns(byTask, byAgent, showSaved).map(col => {
const raw = valueOf(row, col.key, isNewGroup)
if (col.key === 'provider' && raw) return chalk.dim(raw)
return raw
Expand All @@ -473,7 +505,7 @@ export function renderTable(
},
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
)
const cells = defaultColumns(byTask, showSaved).map(col => {
const cells = defaultColumns(byTask, byAgent, showSaved).map(col => {
switch (col.key) {
case 'provider': return ''
case 'model': return chalk.yellow.bold('Total')
Expand All @@ -493,9 +525,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, showSaved).map(c => c.key)
const allKeys = defaultColumns(byTask, byAgent, showSaved).map(c => c.key)
const indexByKey = new Map(allKeys.map((k, i) => [k, i]))
const columns = chooseColumns(byTask, available, showSaved)
const columns = chooseColumns(byTask, byAgent, available, showSaved)
const projectColumns = (cols: Column[], entry: RowCells) =>
cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '')
const cellMatrix = [
Expand Down Expand Up @@ -532,7 +564,7 @@ export function renderTable(

let isFirstRow = true
for (const entry of rowEntries) {
if (byTask && entry.isNewGroup && !isFirstRow) {
if (grouped && entry.isNewGroup && !isFirstRow) {
lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT))
}
isFirstRow = false
Expand All @@ -556,6 +588,7 @@ export function renderJson(rows: ModelReportRow[]): string {
model: r.model,
modelDisplayName: r.modelDisplayName,
category: r.category ?? r.topCategory ?? null,
agentType: r.agentType ?? null,
topCategory: r.topCategory ?? null,
topCategoryShare: r.topCategoryShare ?? null,
inputTokens: r.inputTokens,
Expand Down Expand Up @@ -591,25 +624,26 @@ function mdEscape(value: string): string {
/// chat platforms that understand markdown. Always shows provider/model on
/// every row (no blank-repeat trick) so the table remains useful when copied
/// into a context that loses whitespace alignment.
export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; showTotals?: boolean } = {}): string {
export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; byAgent?: boolean; showTotals?: boolean } = {}): string {
const byTask = opts.byTask ?? false
const byAgent = opts.byAgent ?? false
const showTotals = opts.showTotals ?? true

const header = byTask
? ['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 header = ['Provider', 'Model', thirdColumnHeader(byTask, byAgent), 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved']
const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:']

const lines: string[] = []
lines.push(`| ${header.join(' | ')} |`)
lines.push(`| ${align.join(' | ')} |`)

for (const row of rows) {
const taskCell = byTask
? row.category ? categoryLabel(row.category) : ''
: row.topCategory
? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)`
: '-'
const taskCell = byAgent
? row.agentType ?? ''
: byTask
? row.category ? categoryLabel(row.category) : ''
: row.topCategory
? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)`
: '-'
const cells = [
mdEscape(row.providerDisplayName),
`\`${mdEscape(row.modelDisplayName)}\``,
Expand Down Expand Up @@ -657,16 +691,34 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
return lines.join('\n')
}

export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {}): string {
export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean; byAgent?: boolean } = {}): string {
const byTask = opts.byTask ?? false
const byAgent = opts.byAgent ?? false
// CSV intentionally repeats provider/model on every row so downstream
// consumers can sort/filter without first reconstructing the grouping.
const header = byTask
const header = byAgent
? ['provider', 'model', 'agent', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
: byTask
? ['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
const cells = byAgent
? [
csvEscape(r.providerDisplayName),
csvEscape(r.modelDisplayName),
csvEscape(r.agentType ?? ''),
String(r.inputTokens),
String(r.outputTokens),
String(r.cacheWriteTokens),
String(r.cacheReadTokens),
String(r.totalTokens),
String(r.calls),
r.costUSD.toFixed(6),
(r.savingsUSD ?? 0).toFixed(6),
csvEscape(r.savingsBaselineModel),
]
: byTask
? [
csvEscape(r.providerDisplayName),
csvEscape(r.modelDisplayName),
Expand Down
Loading