feat(export): subagentType and model on per-record exports; --by-agent follow-ups (#712)

Follow-ups to #710, completing issue #708's second ask plus the two review
notes:

- export: new records.csv with one row per call incl. subagentType and
  model; sessions.csv gains both fields APPENDED after all legacy columns
  so by-index consumers are unaffected; JSON fields additive; formula-
  injection escaping preserved
- models --by-agent: the main-session bucket sentinel becomes '(main)' -
  agentType is free-form text, so a real subagent literally named 'main'
  no longer merges into the main bucket (test-pinned across all formats)
- README: note that the --min-cost 0.01 default can hide sub-cent agent
  buckets; --min-cost 0 shows the complete agent inventory
This commit is contained in:
ozymandiashh 2026-07-17 02:35:33 +03:00 committed by GitHub
parent 6e66d34582
commit a55251ce8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 128 additions and 20 deletions

View file

@ -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 |

View file

@ -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 ''
@ -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) {
@ -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,
@ -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,
})
}
}
@ -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.',
@ -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')
@ -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),

View file

@ -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
@ -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
@ -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>>()
@ -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) {

View file

@ -16,7 +16,7 @@ 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,
@ -24,6 +24,7 @@ function makeProject(projectPath: string): ProjectSummary {
{
sessionId: 'sess-001',
project: projectPath,
agentType,
firstTimestamp: '2026-04-14T10:00:00Z',
lastTimestamp: '2026-04-14T10:01:00Z',
totalCostUSD: 1.23,
@ -57,6 +58,7 @@ function makeProject(projectPath: string): ProjectSummary {
tools: ['Read'],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
@ -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 } }

View file

@ -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: [
@ -272,18 +272,21 @@ 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
@ -291,13 +294,31 @@ describe('aggregateModels byAgent', () => {
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 () => {