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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +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 --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)`. The default `--min-cost 0.01` hides sub-cent agent buckets; use `--min-cost 0` for a complete inventory. |
| `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
39 changes: 38 additions & 1 deletion src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function escCsv(s: string): string {
return sanitized
}

type Row = Record<string, string | number>
type Row = Record<string, string | number | undefined>

function rowsToCsv(rows: Row[]): string {
if (rows.length === 0) return ''
Expand Down Expand Up @@ -83,6 +83,35 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
}))
}

function buildRecordRows(projects: ProjectSummary[]): Row[] {
const rows: Row[] = []
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
rows.push({
project: project.projectPath,
sessionId: session.sessionId,
timestamp: call.timestamp || turn.timestamp || undefined,
category: turn.category,
provider: call.provider,
subagentType: session.agentType?.trim() || undefined,
model: call.model || undefined,
inputTokens: call.usage.inputTokens,
outputTokens: call.usage.outputTokens,
reasoningTokens: call.usage.reasoningTokens,
cacheWriteTokens: call.usage.cacheCreationInputTokens,
cacheReadTokens: Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens),
cost: roundForActiveCurrency(convertCost(call.costUSD)),
savings: roundForActiveCurrency(convertCost(call.savingsUSD ?? 0)),
})
}
}
}
}
return rows
}

function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
const catTotals: Record<string, { turns: number; cost: number }> = {}
for (const project of projects) {
Expand Down Expand Up @@ -231,6 +260,9 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] {
const rows: Row[] = []
for (const p of projects) {
for (const s of p.sessions) {
const models = new Set(
s.turns.flatMap(turn => turn.assistantCalls.map(call => call.model).filter(Boolean)),
)
rows.push({
Project: p.projectPath,
'Session ID': s.sessionId,
Expand All @@ -239,6 +271,8 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] {
[`Saved (${code})`]: roundForActiveCurrency(convertCost(s.totalSavingsUSD)),
'API Calls': s.apiCalls,
Turns: s.turns.length,
subagentType: s.agentType?.trim() || undefined,
model: models.size === 1 ? [...models][0] : undefined,
})
}
}
Expand Down Expand Up @@ -286,6 +320,7 @@ function buildReadme(periods: PeriodExport[]): string {
' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
' models.csv Spend per model with token totals and cache usage.',
' records.csv One row per API call, including optional subagentType and model.',
' projects.csv Spend per project folder for the selected detail period.',
' sessions.csv One row per session for the selected detail period.',
' tools.csv Tool invocations and share for the selected detail period.',
Expand Down Expand Up @@ -355,6 +390,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr
await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8')
await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8')
await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8')
await writeFile(join(folder, 'records.csv'), rowsToCsv(buildRecordRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8')
Expand Down Expand Up @@ -382,6 +418,7 @@ export async function exportJson(periods: PeriodExport[], outputPath: string): P
})),
projects: buildProjectRows(thirtyDayProjects),
sessions: buildSessionRows(thirtyDayProjects),
records: buildRecordRows(thirtyDayProjects),
tools: buildToolRows(thirtyDayProjects),
mcp: buildMcpRows(thirtyDayProjects),
shellCommands: buildBashRows(thirtyDayProjects),
Expand Down
10 changes: 5 additions & 5 deletions src/models-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type ModelReportRow = {
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
/// 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
Expand All @@ -38,7 +38,7 @@ 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'`.
/// under `'(main)'`.
byAgent?: boolean
taskFilter?: TaskCategory
topN?: number
Expand Down Expand Up @@ -81,7 +81,7 @@ function bucketKey(provider: string, model: string, category: TaskCategory | nul
/// 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'`.
/// 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 @@ -96,9 +96,9 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
const model = call.model || 'unknown'
const category: TaskCategory | null = opts.byTask ? turn.category : null
// Non-Claude sessions and ordinary main sessions have no agentType, so
// their spend all lands under 'main'. That is correct: the report never
// 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 agentType: string | null = opts.byAgent ? (session.agentType ?? '(main)') : null
const key = bucketKey(provider, model, category, agentType)
let bucket = buckets.get(key)
if (!bucket) {
Expand Down
52 changes: 51 additions & 1 deletion tests/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})

function makeProject(projectPath: string): ProjectSummary {
function makeProject(projectPath: string, agentType?: string): ProjectSummary {
return {
project: projectPath,
projectPath,
sessions: [
{
sessionId: 'sess-001',
project: projectPath,
agentType,
firstTimestamp: '2026-04-14T10:00:00Z',
lastTimestamp: '2026-04-14T10:01:00Z',
totalCostUSD: 1.23,
Expand Down Expand Up @@ -57,6 +58,7 @@ function makeProject(projectPath: string): ProjectSummary {
tools: ['Read'],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
Expand Down Expand Up @@ -205,9 +207,57 @@ describe('exportCsv', () => {
expect(mcp).toContain('Server,Calls,Share (%)')
expect(mcp).toContain('node_repl,5,100')
})

it('writes optional subagentType and model fields to per-call records.csv', async () => {
const periods: PeriodExport[] = [{ label: '30 Days', projects: [makeProject('app', 'planner')] }]

const folder = await exportCsv(periods, join(tmpDir, 'records.csv'))
const records = await readFile(join(folder, 'records.csv'), 'utf-8')
const lines = records.trimEnd().split('\n')

expect(lines[0]).toContain('subagentType,model')
expect(lines[1]).toContain('planner')
expect(lines[1]).toContain("'+danger-model")
})

it('adds optional subagentType and unambiguous model fields to sessions.csv', async () => {
const periods: PeriodExport[] = [{ label: '30 Days', projects: [makeProject('app', 'planner')] }]

const folder = await exportCsv(periods, join(tmpDir, 'sessions.csv'))
const sessions = await readFile(join(folder, 'sessions.csv'), 'utf-8')

expect(sessions.split('\n')[0]).toBe('Project,Session ID,Started At,Cost (USD),Saved (USD),API Calls,Turns,subagentType,model')
expect(sessions.split('\n')[1]).toContain('planner')
expect(sessions.split('\n')[1]).toContain("'+danger-model")
})
})

describe('exportJson', () => {
it('adds per-call records with optional subagentType and model fields', async () => {
const periods: PeriodExport[] = [{
label: '30 Days',
projects: [makeProject('agent-project', 'planner'), makeProject('main-project')],
}]

const outputPath = join(tmpDir, 'records.json')
const saved = await exportJson(periods, outputPath)
const data = JSON.parse(await readFile(saved, 'utf-8'))

expect(data.records[0]).toMatchObject({
project: 'agent-project',
subagentType: 'planner',
model: '+danger-model',
inputTokens: 100,
outputTokens: 50,
cost: 1.23,
})
expect(data.records[1]).toMatchObject({ project: 'main-project', model: '+danger-model' })
expect(data.records[1]).not.toHaveProperty('subagentType')
expect(data.sessions[0]).toMatchObject({ subagentType: 'planner', model: '+danger-model' })
expect(data.sessions[1]).toMatchObject({ model: '+danger-model' })
expect(data.sessions[1]).not.toHaveProperty('subagentType')
})

it('includes an mcp section with per-server usage', async () => {
const project = makeProject('app')
project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 3 }, github: { calls: 1 } }
Expand Down
45 changes: 33 additions & 12 deletions tests/models-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ describe('aggregateModels', () => {
})

describe('aggregateModels byAgent', () => {
// One project, four sessions: a planner agent on two models, a reviewer agent
// sharing one of those models, an ordinary main session, and a non-Claude
// provider session (no agentType).
// One project: a planner agent on two models, a reviewer agent sharing one of
// those models, a real agent named main, an ordinary main session, and a
// non-Claude provider session (no agentType).
function crossProject(): ProjectSummary {
return projectFromSessions([
makeAgentSession({ sessionId: 'a', agentType: 'planner', turns: [
Expand All @@ -272,32 +272,53 @@ describe('aggregateModels byAgent', () => {
makeAgentSession({ sessionId: 'b', agentType: 'reviewer', turns: [
makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 3.0, input: 40, output: 8 })]),
] }),
makeAgentSession({ sessionId: 'real-main', agentType: 'main', turns: [
makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 0.75, input: 25, output: 4 })]),
] }),
// no agentType -> ordinary main session
makeAgentSession({ sessionId: 'c', turns: [
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 1.0, input: 30, output: 5 })]),
] }),
// non-Claude provider, no agentType -> also 'main'
// non-Claude provider, no agentType -> also '(main)'
makeAgentSession({ sessionId: 'd', turns: [
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 20, output: 4 })]),
] }),
])
}

it('crosses model x agent, labelling main/undefined and non-Claude sessions "main"', async () => {
it('keeps a real agent named main distinct from the (main) sentinel across all formats', async () => {
const rows = await aggregateModels([crossProject()], { byAgent: true })
const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}:${r.agentType}`, r]))
// one agent (planner) split across two models
expect(byKey['claude:claude-opus-4-8:planner']!.costUSD).toBeCloseTo(6.0, 6)
expect(byKey['claude:claude-sonnet-4-6:planner']!.costUSD).toBeCloseTo(2.0, 6)
// two agents (planner + reviewer) on the same model
expect(byKey['claude:claude-opus-4-8:reviewer']!.costUSD).toBeCloseTo(3.0, 6)
// ordinary main session buckets under 'main'
expect(byKey['claude:claude-opus-4-8:main']!.costUSD).toBeCloseTo(1.0, 6)
// non-Claude provider (no agentType) also buckets under 'main'
expect(byKey['codex:gpt-5:main']!.agentType).toBe('main')
expect(byKey['codex:gpt-5:main']!.costUSD).toBeCloseTo(0.5, 6)
// three distinct agent rows share claude-opus-4-8
expect(rows.filter(r => r.model === 'claude-opus-4-8')).toHaveLength(3)
// real agent named main and the ordinary-session sentinel remain separate
expect(byKey['claude:claude-opus-4-8:main']!.costUSD).toBeCloseTo(0.75, 6)
expect(byKey['claude:claude-opus-4-8:(main)']!.costUSD).toBeCloseTo(1.0, 6)
// non-Claude provider (no agentType) also buckets under '(main)'
expect(byKey['codex:gpt-5:(main)']!.agentType).toBe('(main)')
expect(byKey['codex:gpt-5:(main)']!.costUSD).toBeCloseTo(0.5, 6)
// four distinct agent rows share claude-opus-4-8
const collisionRows = rows.filter(r => r.model === 'claude-opus-4-8')
expect(collisionRows).toHaveLength(4)

const table = stripAnsi(renderTable(collisionRows, { byAgent: true, showTotals: false, terminalWidth: 200 }))
expect(table.split('\n').some(line => /│\s*\(main\)\s*│/.test(line))).toBe(true)
expect(table.split('\n').some(line => /│\s*main\s*│/.test(line))).toBe(true)

const markdown = renderMarkdown(collisionRows, { byAgent: true, showTotals: false })
expect(markdown).toContain('| (main) |')
expect(markdown).toContain('| main |')

const jsonAgents = (JSON.parse(renderJson(collisionRows)) as Array<{ agentType: string }>).map(row => row.agentType)
expect(jsonAgents).toContain('(main)')
expect(jsonAgents).toContain('main')

const csvAgents = renderCsv(collisionRows, { byAgent: true }).trimEnd().split('\n').slice(1).map(line => line.split(',')[2])
expect(csvAgents).toContain('(main)')
expect(csvAgents).toContain('main')
})

it('groups rows by (provider, model) ordered by total model cost, agents by cost desc within a group', async () => {
Expand Down