mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
* feat(codex): compute Codex credit usage (#408, #495) Codex/ChatGPT subscription users consume credits, a unit separate from API dollars: usage is billed as credits-per-million-tokens at per-model rates that differ from the API USD pricing CodeBurn uses for cost. So the reported dollar cost does not match what credits actually consume. Add a credit engine sourced from the official Codex credit rates (developers.openai.com/codex/pricing): GPT-5.5 125/12.5/750, GPT-5.4 62.5/6.25/375, GPT-5.4 mini 18.75/1.875/113 credits per 1M input/cached/output tokens. Surface per-model credit usage in `codeburn models` JSON output (credits field; null for non-Codex or unknown models). models-report already folds reasoning into output and keeps non-cached input + cached-read separately, which is exactly what the credit rates expect, so the figure is exact. Engine + computation are unit-tested. UI display surfaces (the models table, the TUI dashboard, the menubar "credits" view) are intentionally left for a follow-up so the display choice can be decided. * feat(menubar): opt-in Codex credits display metric (#408, #495) Surface Codex credit usage in the menubar as a selectable metric, without changing the default. Cost ($) stays the default in both the menubar and the CLI; credits only appear when explicitly chosen. - TS: buildMenubarPayloadForRange computes the period's Codex credits (via the tested aggregateModels, so reasoning/cached are handled) and exposes current.codexCredits in the menubar JSON. - Swift: new DisplayMetric.credits, a "Credits (Codex)" option in the metric picker, decodes codexCredits, and renders it in the menu-bar title. Default metric remains .cost.
513 lines
19 KiB
TypeScript
513 lines
19 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import chalk from 'chalk'
|
|
import stripAnsi from 'strip-ansi'
|
|
|
|
import { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv, type ModelReportRow } from '../src/models-report.js'
|
|
import type {
|
|
ProjectSummary,
|
|
SessionSummary,
|
|
ClassifiedTurn,
|
|
ParsedApiCall,
|
|
TokenUsage,
|
|
TaskCategory,
|
|
} from '../src/types.js'
|
|
|
|
function emptyTokens(): TokenUsage {
|
|
return {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
}
|
|
}
|
|
|
|
function makeCall(opts: {
|
|
provider: string
|
|
model: string
|
|
costUSD: number
|
|
input?: number
|
|
output?: number
|
|
cacheWrite?: number
|
|
cacheRead?: number
|
|
}): ParsedApiCall {
|
|
return {
|
|
provider: opts.provider,
|
|
model: opts.model,
|
|
usage: {
|
|
...emptyTokens(),
|
|
inputTokens: opts.input ?? 0,
|
|
outputTokens: opts.output ?? 0,
|
|
cacheCreationInputTokens: opts.cacheWrite ?? 0,
|
|
cacheReadInputTokens: opts.cacheRead ?? 0,
|
|
},
|
|
costUSD: opts.costUSD,
|
|
tools: [],
|
|
mcpTools: [],
|
|
skills: [],
|
|
hasAgentSpawn: false,
|
|
hasPlanMode: false,
|
|
speed: 'standard',
|
|
timestamp: '2026-05-09T00:00:00.000Z',
|
|
bashCommands: [],
|
|
deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`,
|
|
}
|
|
}
|
|
|
|
function makeTurn(category: TaskCategory, calls: ParsedApiCall[]): ClassifiedTurn {
|
|
return {
|
|
userMessage: 'test',
|
|
assistantCalls: calls,
|
|
timestamp: '2026-05-09T00:00:00.000Z',
|
|
sessionId: 's1',
|
|
category,
|
|
retries: 0,
|
|
hasEdits: false,
|
|
}
|
|
}
|
|
|
|
function makeSession(turns: ClassifiedTurn[]): SessionSummary {
|
|
return {
|
|
sessionId: 's1',
|
|
project: 'p',
|
|
firstTimestamp: '2026-05-09T00:00:00.000Z',
|
|
lastTimestamp: '2026-05-09T00:00:00.000Z',
|
|
totalCostUSD: 0,
|
|
totalInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0,
|
|
apiCalls: 0,
|
|
turns,
|
|
modelBreakdown: {},
|
|
toolBreakdown: {},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {},
|
|
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
|
skillBreakdown: {},
|
|
}
|
|
}
|
|
|
|
function makeProject(turns: ClassifiedTurn[]): ProjectSummary {
|
|
return {
|
|
project: 'p',
|
|
projectPath: '/tmp/p',
|
|
sessions: [makeSession(turns)],
|
|
totalCostUSD: 0,
|
|
totalApiCalls: 0,
|
|
}
|
|
}
|
|
|
|
describe('aggregateModels', () => {
|
|
it('groups by (provider, model) and sorts by cost descending in default mode', async () => {
|
|
const project = makeProject([
|
|
makeTurn('feature', [
|
|
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, cacheWrite: 500, cacheRead: 8000, costUSD: 5.0 }),
|
|
]),
|
|
makeTurn('debugging', [
|
|
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 800, output: 100, cacheWrite: 300, cacheRead: 5000, costUSD: 3.5 }),
|
|
]),
|
|
makeTurn('feature', [
|
|
makeCall({ provider: 'codex', model: 'gpt-5', input: 600, output: 80, costUSD: 1.2 }),
|
|
]),
|
|
])
|
|
const rows = await aggregateModels([project])
|
|
expect(rows.map(r => `${r.provider}:${r.model}`)).toEqual(['claude:claude-sonnet-4-6', 'codex:gpt-5'])
|
|
const claudeRow = rows[0]!
|
|
expect(claudeRow.inputTokens).toBe(1800)
|
|
expect(claudeRow.outputTokens).toBe(300)
|
|
expect(claudeRow.cacheWriteTokens).toBe(800)
|
|
expect(claudeRow.cacheReadTokens).toBe(13000)
|
|
expect(claudeRow.costUSD).toBeCloseTo(8.5, 6)
|
|
expect(claudeRow.calls).toBe(2)
|
|
expect(claudeRow.totalTokens).toBe(1800 + 300 + 800 + 13000)
|
|
})
|
|
|
|
it('computes Codex credits per model and leaves non-Codex / unknown models null', async () => {
|
|
const rows = await aggregateModels([makeProject([
|
|
// gpt-5.5: 1M non-cached input (125) + 1M cached read (12.5) + 1M output (750) = 887.5 credits
|
|
makeTurn('feature', [
|
|
makeCall({ provider: 'codex', model: 'gpt-5.5', input: 1_000_000, output: 1_000_000, cacheRead: 1_000_000, costUSD: 9 }),
|
|
]),
|
|
// codex but no known credit rate -> null
|
|
makeTurn('feature', [
|
|
makeCall({ provider: 'codex', model: 'gpt-5', input: 1000, output: 80, costUSD: 1.2 }),
|
|
]),
|
|
// non-codex provider -> null even if tokens present
|
|
makeTurn('feature', [
|
|
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, costUSD: 5 }),
|
|
]),
|
|
])])
|
|
const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}`, r]))
|
|
expect(byKey['codex:gpt-5.5']!.credits).toBeCloseTo(887.5, 6)
|
|
expect(byKey['codex:gpt-5']!.credits).toBeNull()
|
|
expect(byKey['claude:claude-sonnet-4-6']!.credits).toBeNull()
|
|
})
|
|
|
|
it('includes credits in the JSON output', async () => {
|
|
const rows = await aggregateModels([makeProject([
|
|
makeTurn('feature', [
|
|
makeCall({ provider: 'codex', model: 'gpt-5.5', input: 0, output: 1_000_000, cacheRead: 0, costUSD: 9 }),
|
|
]),
|
|
])])
|
|
const parsed = JSON.parse(renderJson(rows))
|
|
expect(parsed[0].credits).toBeCloseTo(750, 6)
|
|
})
|
|
|
|
it('does not double-count cache reads when a provider sets both cache fields', async () => {
|
|
// Providers like codex/mux/codebuff populate cacheReadInputTokens AND
|
|
// cachedInputTokens with the same value (Anthropic vs OpenAI vocabulary for
|
|
// the same tokens). The report must count them once, not sum them.
|
|
const call = makeCall({ provider: 'mux', model: 'claude-opus-4-8', input: 100, output: 50, cacheRead: 4000, costUSD: 2.0 })
|
|
call.usage.cachedInputTokens = 4000 // mirrors cacheReadInputTokens, as those providers do
|
|
|
|
const rows = await aggregateModels([makeProject([makeTurn('feature', [call])])])
|
|
expect(rows[0]!.cacheReadTokens).toBe(4000) // not 8000
|
|
})
|
|
|
|
it('reports the dominant task type with its cost share in default mode', async () => {
|
|
const project = makeProject([
|
|
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]),
|
|
makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
|
|
makeTurn('refactoring', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
|
|
])
|
|
const rows = await aggregateModels([project])
|
|
expect(rows[0]!.topCategory).toBe('feature')
|
|
expect(rows[0]!.topCategoryShare).toBeCloseTo(0.6, 3)
|
|
})
|
|
|
|
it('explodes rows by task in byTask mode and groups them so renderer can blank repeats', async () => {
|
|
const project = makeProject([
|
|
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]),
|
|
makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
|
|
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 1.0, input: 60, output: 10 })]),
|
|
])
|
|
const rows = await aggregateModels([project], { byTask: true })
|
|
expect(rows).toHaveLength(3)
|
|
// Group order: claude (8.0) before codex (1.0); within claude, feature (6.0) before debugging (2.0).
|
|
expect(rows.map(r => `${r.provider}:${r.model}:${r.category}`)).toEqual([
|
|
'claude:claude-sonnet-4-6:feature',
|
|
'claude:claude-sonnet-4-6:debugging',
|
|
'codex:gpt-5:feature',
|
|
])
|
|
})
|
|
|
|
it('respects taskFilter by excluding non-matching turns from every bucket', async () => {
|
|
const project = makeProject([
|
|
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]),
|
|
makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
|
|
])
|
|
const rows = await aggregateModels([project], { taskFilter: 'feature' })
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]!.costUSD).toBeCloseTo(5.0, 6)
|
|
})
|
|
|
|
it('applies topN and minCost filters', async () => {
|
|
const project = makeProject([
|
|
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]),
|
|
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 50, output: 10 })]),
|
|
makeTurn('feature', [makeCall({ provider: 'cursor', model: 'auto', costUSD: 0.001, input: 10, output: 1 })]),
|
|
])
|
|
const top = await aggregateModels([project], { topN: 1 })
|
|
expect(top).toHaveLength(1)
|
|
const above = await aggregateModels([project], { minCost: 0.01 })
|
|
expect(above.find(r => r.provider === 'cursor')).toBeUndefined()
|
|
})
|
|
|
|
it('counts reasoning tokens as output tokens', async () => {
|
|
const project = makeProject([
|
|
makeTurn('feature', [
|
|
{
|
|
provider: 'codex',
|
|
model: 'gpt-5',
|
|
usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 },
|
|
costUSD: 1.0,
|
|
tools: [],
|
|
mcpTools: [],
|
|
skills: [],
|
|
hasAgentSpawn: false,
|
|
hasPlanMode: false,
|
|
speed: 'standard',
|
|
timestamp: '2026-05-09T00:00:00.000Z',
|
|
bashCommands: [],
|
|
deduplicationKey: 'k',
|
|
},
|
|
]),
|
|
])
|
|
const rows = await aggregateModels([project])
|
|
expect(rows[0]!.outputTokens).toBe(250)
|
|
})
|
|
})
|
|
|
|
describe('renderTable', () => {
|
|
function visibleWidth(line: string): number {
|
|
return stripAnsi(line).length
|
|
}
|
|
|
|
function row(partial: Partial<ModelReportRow>): ModelReportRow {
|
|
return {
|
|
provider: 'claude',
|
|
providerDisplayName: 'Claude',
|
|
model: 'claude-sonnet-4-6',
|
|
modelDisplayName: 'Sonnet 4.6',
|
|
category: null,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 0,
|
|
costUSD: 0,
|
|
savingsUSD: 0,
|
|
savingsBaselineModel: '',
|
|
calls: 0,
|
|
credits: null,
|
|
...partial,
|
|
}
|
|
}
|
|
|
|
it('blanks repeated provider/model cells in byTask mode but keeps them in default mode', () => {
|
|
const rows: ModelReportRow[] = [
|
|
row({ category: 'feature', costUSD: 7.78, inputTokens: 512_000, outputTokens: 98_000, cacheWriteTokens: 1_400_000, cacheReadTokens: 6_200_000, totalTokens: 8_210_000 }),
|
|
row({ category: 'debugging', costUSD: 5.31, inputTokens: 380_000, outputTokens: 71_000, cacheWriteTokens: 920_000, cacheReadTokens: 4_100_000, totalTokens: 5_471_000 }),
|
|
]
|
|
const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 200 })
|
|
const lines = out.split('\n')
|
|
// Layout: top border, header, header-separator, data..., bottom border.
|
|
const dataLines = lines.slice(3, -1)
|
|
expect(dataLines[0]).toContain('Sonnet 4.6')
|
|
expect(dataLines[0]).toContain('Feature Dev')
|
|
expect(dataLines[1]).not.toContain('Sonnet 4.6')
|
|
expect(dataLines[1]).not.toContain('Claude')
|
|
expect(dataLines[1]).toContain('Debugging')
|
|
})
|
|
|
|
it('keeps provider/model cells on every row in default mode', () => {
|
|
const rows: ModelReportRow[] = [
|
|
row({ topCategory: 'feature', topCategoryShare: 0.6, costUSD: 5.0 }),
|
|
row({ provider: 'codex', providerDisplayName: 'Codex', model: 'gpt-5', modelDisplayName: 'GPT-5', topCategory: 'debugging', topCategoryShare: 0.4, costUSD: 1.2 }),
|
|
]
|
|
const out = renderTable(rows, { byTask: false, showTotals: false, terminalWidth: 200 })
|
|
const dataLines = out.split('\n').slice(3, -1)
|
|
expect(dataLines[0]).toContain('Sonnet 4.6')
|
|
expect(dataLines[1]).toContain('GPT-5')
|
|
})
|
|
|
|
it('drops cache columns when terminal is narrow', () => {
|
|
const rows: ModelReportRow[] = [row({ topCategory: 'feature', topCategoryShare: 1, costUSD: 1 })]
|
|
const wide = renderTable(rows, { showTotals: false, terminalWidth: 200 })
|
|
const narrow = renderTable(rows, { showTotals: false, terminalWidth: 80 })
|
|
expect(wide).toContain('Cache Write')
|
|
expect(narrow).not.toContain('Cache Write')
|
|
expect(narrow).not.toContain('Cache Read')
|
|
})
|
|
|
|
it('expands table borders to the available terminal width by default', () => {
|
|
const rows: ModelReportRow[] = [
|
|
row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }),
|
|
row({ category: 'delegation', costUSD: 0.5, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }),
|
|
]
|
|
const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 132 })
|
|
const lines = out.split('\n')
|
|
expect(visibleWidth(lines[0]!)).toBe(132)
|
|
expect(visibleWidth(lines[1]!)).toBe(132)
|
|
expect(visibleWidth(lines.at(-1)!)).toBe(132)
|
|
})
|
|
|
|
it('keeps every colored table row aligned to the same visible width', () => {
|
|
const originalLevel = chalk.level
|
|
chalk.level = 1
|
|
try {
|
|
const rows: ModelReportRow[] = [
|
|
row({ category: 'coding', costUSD: 978.89, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }),
|
|
row({ category: 'delegation', costUSD: 357.0, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }),
|
|
row({ category: 'exploration', costUSD: 324.86, inputTokens: 96_800, outputTokens: 1_600_000, cacheWriteTokens: 16_600_000, cacheReadTokens: 359_400_000, totalTokens: 377_800_000 }),
|
|
]
|
|
const out = renderTable(rows, { byTask: true, terminalWidth: 160 })
|
|
const widths = out.split('\n').map(visibleWidth)
|
|
expect(new Set(widths)).toEqual(new Set([160]))
|
|
} finally {
|
|
chalk.level = originalLevel
|
|
}
|
|
})
|
|
|
|
it('can render compact tables when fullWidth is disabled', () => {
|
|
const rows: ModelReportRow[] = [
|
|
row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, totalTokens: 1_589_800_000 }),
|
|
]
|
|
const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 160, fullWidth: false })
|
|
expect(visibleWidth(out.split('\n')[0]!)).toBeLessThan(160)
|
|
})
|
|
|
|
it('emits a footer totals row by default and suppresses it under showTotals=false', () => {
|
|
const rows: ModelReportRow[] = [row({ costUSD: 1.0, inputTokens: 100, totalTokens: 100 })]
|
|
expect(renderTable(rows, { showTotals: true })).toContain('Total')
|
|
expect(renderTable(rows, { showTotals: false })).not.toMatch(/^\s*Total/m)
|
|
})
|
|
})
|
|
|
|
describe('renderMarkdown', () => {
|
|
it('produces a GitHub-flavored markdown table with right-aligned numeric columns', () => {
|
|
const rows: ModelReportRow[] = [
|
|
{
|
|
provider: 'claude',
|
|
providerDisplayName: 'Claude',
|
|
model: 'claude-sonnet-4-6',
|
|
modelDisplayName: 'Sonnet 4.6',
|
|
category: null,
|
|
topCategory: 'feature',
|
|
topCategoryShare: 0.6,
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 150,
|
|
costUSD: 1.5,
|
|
calls: 1,
|
|
},
|
|
]
|
|
const md = renderMarkdown(rows, { showTotals: false })
|
|
const lines = md.split('\n')
|
|
expect(lines[0]).toBe('| Provider | Model | Top Task | Input | Output | Cache Write | Cache Read | Total | Cost | Saved |')
|
|
expect(lines[1]).toBe('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
|
|
expect(lines[2]).toContain('| Claude |')
|
|
expect(lines[2]).toContain('`Sonnet 4.6`')
|
|
expect(lines[2]).toContain('Feature Dev (60%)')
|
|
})
|
|
|
|
it('escapes pipe characters in provider/model names', () => {
|
|
const rows: ModelReportRow[] = [
|
|
{
|
|
provider: 'odd',
|
|
providerDisplayName: 'A|B',
|
|
model: 'm|n',
|
|
modelDisplayName: 'M|N',
|
|
category: null,
|
|
topCategory: 'feature',
|
|
topCategoryShare: 1,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 0,
|
|
costUSD: 0,
|
|
calls: 0,
|
|
},
|
|
]
|
|
const md = renderMarkdown(rows, { showTotals: false })
|
|
expect(md).toContain('A\\|B')
|
|
expect(md).toContain('M\\|N')
|
|
})
|
|
|
|
it('emits a bold totals row when showTotals is true', () => {
|
|
const rows: ModelReportRow[] = [
|
|
{
|
|
provider: 'p',
|
|
providerDisplayName: 'P',
|
|
model: 'm',
|
|
modelDisplayName: 'M',
|
|
category: null,
|
|
topCategory: 'feature',
|
|
topCategoryShare: 1,
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 150,
|
|
costUSD: 1.5,
|
|
calls: 1,
|
|
},
|
|
]
|
|
const md = renderMarkdown(rows)
|
|
expect(md).toContain('**Total**')
|
|
})
|
|
})
|
|
|
|
describe('renderJson', () => {
|
|
it('emits a JSON array with the documented field shape', () => {
|
|
const rows: ModelReportRow[] = [
|
|
{
|
|
provider: 'claude',
|
|
providerDisplayName: 'Claude',
|
|
model: 'claude-sonnet-4-6',
|
|
modelDisplayName: 'Sonnet 4.6',
|
|
category: null,
|
|
topCategory: 'feature',
|
|
topCategoryCost: 6.0,
|
|
topCategoryShare: 0.6,
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 150,
|
|
costUSD: 1.5,
|
|
calls: 1,
|
|
},
|
|
]
|
|
const parsed = JSON.parse(renderJson(rows)) as Array<Record<string, unknown>>
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0]).toMatchObject({
|
|
provider: 'claude',
|
|
model: 'claude-sonnet-4-6',
|
|
modelDisplayName: 'Sonnet 4.6',
|
|
topCategory: 'feature',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
calls: 1,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('renderCsv', () => {
|
|
it('produces a header row followed by one row per ModelReportRow', () => {
|
|
const rows: ModelReportRow[] = [
|
|
{
|
|
provider: 'claude',
|
|
providerDisplayName: 'Claude',
|
|
model: 'claude-sonnet-4-6',
|
|
modelDisplayName: 'Sonnet 4.6',
|
|
category: null,
|
|
topCategory: 'feature',
|
|
topCategoryShare: 0.6,
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 150,
|
|
costUSD: 1.5,
|
|
savingsUSD: 0,
|
|
calls: 1,
|
|
},
|
|
]
|
|
const csv = renderCsv(rows)
|
|
const lines = csv.split('\n')
|
|
expect(lines[0]).toBe('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')
|
|
expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000,0.000000,')
|
|
})
|
|
|
|
it('escapes commas in provider/model cells', () => {
|
|
const rows: ModelReportRow[] = [
|
|
{
|
|
provider: 'weird',
|
|
providerDisplayName: 'Weird, Co.',
|
|
model: 'm',
|
|
modelDisplayName: 'M',
|
|
category: null,
|
|
topCategory: 'feature',
|
|
topCategoryShare: 1.0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalTokens: 0,
|
|
costUSD: 0,
|
|
savingsUSD: 0,
|
|
calls: 0,
|
|
},
|
|
]
|
|
const csv = renderCsv(rows)
|
|
expect(csv.split('\n')[1]).toContain('"Weird, Co."')
|
|
})
|
|
})
|