mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 03:15:30 +00:00
* fix(report): surface estimated costs distinctly Providers that estimate tokens or price (kiro, cursor, warp, copilot, grok, hermes, codewhale, and the codex proxy path) set costIsEstimated on their parsed calls, but the flag died at the parser boundary: it was never carried onto ParsedApiCall or into the session aggregates, so a figure whose tokens were synthesized from content length rendered with the same authority as a metered one. Plumb the truth through, mirroring the savingsUSD/isLocalSavings pattern: a call-level boolean (ParsedApiCall.isEstimated, CachedCall.isEstimated, persisted so it survives the session-cache round trip) and an additive aggregate amount (estimatedCostUSD on the model breakdown, session, and project totals, plus PeriodData and the menubar payload). The amount is carried rather than a bare boolean so a row that is mostly metered with a small estimated slice is not indistinguishable from a fully guessed one. Display: report (TUI) and overview per-model rows prefix the cost with a tilde and print one legend line; the MCP tables carry the same marker and legend, and the machine surfaces (report --json, MCP get_usage, menubar / web payload) expose estimatedCostUSD. Totals math is unchanged; the flag is display/metadata only. Bump PROVIDER_PARSE_VERSIONS for every provider that sets the flag so already-cached sessions reparse once and pick it up. Copilot is excluded: it is a durable provider, so changing its env fingerprint would discard OTel cache entries whose source rows may already be pruned. Also fix the cross-provider project merge, which summed totalCostUSD but dropped merged-in projects' totalEstimatedCostUSD, undercounting the project/period estimated total (the same latent gap still affects totalSavingsUSD, left untouched here). * test(parser): pin estimated dollars through the cross-provider merge The merge fix for dropped totalEstimatedCostUSD was not covered: deleting the summing line left every test green. Extract the merge into an exported mergeProjectsByCrossProviderKey (no behavior change) and pin both the measured-plus-estimated and both-estimated merge cases. * docs(parser): honest merge-comment scope and load-bearing overwrite note Re-review nits: the merge doc claimed all additive totals are summed there while totalSavingsUSD still is not (pre-existing gap, tracked separately) and totalProxiedCostUSD is re-derived post-merge; say so. Mark the buildPeriodData overwrite in usage-aggregator as load-bearing for the estimated marker so nobody optimizes it away trusting the daily cache.
186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
|
||
import { renderOverview } from '../src/overview.js'
|
||
import type { ProjectSummary } from '../src/types.js'
|
||
|
||
function makeProject(opts: {
|
||
project: string
|
||
projectPath: string
|
||
cost: number
|
||
calls: number
|
||
model: string
|
||
provider: string
|
||
tokens: { input: number; output: number; cacheR: number; cacheW: number }
|
||
estimated?: boolean
|
||
}): ProjectSummary {
|
||
const usage = {
|
||
inputTokens: opts.tokens.input,
|
||
outputTokens: opts.tokens.output,
|
||
cacheReadInputTokens: opts.tokens.cacheR,
|
||
cacheCreationInputTokens: opts.tokens.cacheW,
|
||
}
|
||
return {
|
||
project: opts.project,
|
||
projectPath: opts.projectPath,
|
||
totalCostUSD: opts.cost,
|
||
totalSavingsUSD: 0,
|
||
totalProxiedCostUSD: 0,
|
||
totalApiCalls: opts.calls,
|
||
sessions: [{
|
||
sessionId: 's1',
|
||
project: opts.project,
|
||
totalInputTokens: opts.tokens.input,
|
||
totalOutputTokens: opts.tokens.output,
|
||
totalCacheReadTokens: opts.tokens.cacheR,
|
||
totalCacheWriteTokens: opts.tokens.cacheW,
|
||
apiCalls: opts.calls,
|
||
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, estimatedCostUSD: opts.estimated ? opts.cost : 0, tokens: usage } },
|
||
categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } },
|
||
toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } },
|
||
mcpBreakdown: {},
|
||
bashBreakdown: {},
|
||
skillBreakdown: {},
|
||
subagentBreakdown: {},
|
||
turns: [{
|
||
userMessage: 'hi',
|
||
timestamp: '2026-06-15T10:00:00Z',
|
||
sessionId: 's1',
|
||
category: 'coding',
|
||
retries: 0,
|
||
hasEdits: true,
|
||
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }],
|
||
}],
|
||
}],
|
||
} as unknown as ProjectSummary
|
||
}
|
||
|
||
describe('renderOverview', () => {
|
||
it('renders the detailed sections from real aggregation', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'myproject',
|
||
projectPath: '/Users/test/myproject',
|
||
cost: 12.5,
|
||
calls: 3,
|
||
model: 'claude-opus-4-8',
|
||
provider: 'claude',
|
||
tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) {
|
||
expect(out).toContain(section)
|
||
}
|
||
expect(out).toContain('Opus 4.8') // model display name
|
||
expect(out).toContain('claude') // provider in By tool
|
||
expect(out).toContain('myproject') // clean project name from path basename
|
||
expect(out).toContain('$12.50')
|
||
expect(out).toContain('2026-06-15')
|
||
expect(out).toContain('Coding')
|
||
expect(out).toContain('Bash')
|
||
})
|
||
|
||
it('uses thousands separators and a B unit, and strips color in no-color mode', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'big',
|
||
projectPath: '/Users/test/big',
|
||
cost: 1234.56,
|
||
calls: 10,
|
||
model: 'claude-opus-4-8',
|
||
provider: 'claude',
|
||
tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).toContain('$1,234.56')
|
||
// tokens render as full, comma-grouped numbers (not abbreviated)
|
||
expect(out).toContain('2,002,000,000')
|
||
// no-color mode must not emit ANSI escape codes
|
||
// eslint-disable-next-line no-control-regex
|
||
expect(out).not.toMatch(/\[/)
|
||
})
|
||
|
||
it('marks an estimated model row with a tilde and prints the legend once', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'warpish',
|
||
projectPath: '/Users/test/warpish',
|
||
cost: 4.2,
|
||
calls: 2,
|
||
model: 'kiro-auto',
|
||
provider: 'kiro',
|
||
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
|
||
estimated: true,
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
// The estimated cost carries the tilde marker in the Top models table...
|
||
expect(out).toContain('~$4.20')
|
||
// ...and the legend explains it exactly once.
|
||
expect(out).toContain('~ estimated cost (priced from estimated tokens)')
|
||
})
|
||
|
||
it('does not mark a measured model row', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'metered',
|
||
projectPath: '/Users/test/metered',
|
||
cost: 4.2,
|
||
calls: 2,
|
||
model: 'claude-opus-4-8',
|
||
provider: 'claude',
|
||
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).toContain('$4.20')
|
||
expect(out).not.toContain('~$4.20')
|
||
expect(out).not.toContain('estimated cost (priced from estimated tokens)')
|
||
})
|
||
|
||
it('reports no usage for an empty range', () => {
|
||
const out = renderOverview([], { label: 'June 2026', color: false })
|
||
expect(out).toContain('No usage found for June 2026')
|
||
})
|
||
|
||
it('does not split a slug-only Claude project path into fake path segments', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'Projects-Content-OS',
|
||
projectPath: 'Projects/Content/OS',
|
||
cost: 3.25,
|
||
calls: 1,
|
||
model: 'claude-sonnet-4-5',
|
||
provider: 'claude',
|
||
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).toContain('Projects-Content-OS')
|
||
expect(out).not.toContain(' OS ')
|
||
})
|
||
})
|
||
|
||
describe('renderOverview unpriced models', () => {
|
||
it('warns when a model with usage has no pricing data', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'mystery',
|
||
projectPath: '/Users/test/mystery',
|
||
cost: 0,
|
||
calls: 4,
|
||
model: 'zz-mystery-paid-model-999',
|
||
provider: 'claude',
|
||
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).toContain('Unpriced')
|
||
expect(out).toContain('1 model at $0')
|
||
expect(out).toContain('zz-mystery-paid-model-999')
|
||
expect(out).toContain('codeburn model-alias')
|
||
})
|
||
|
||
it('stays silent when every model is priced', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'priced',
|
||
projectPath: '/Users/test/priced',
|
||
cost: 5,
|
||
calls: 2,
|
||
model: 'claude-opus-4-8',
|
||
provider: 'claude',
|
||
tokens: { input: 100, output: 50, cacheR: 0, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).not.toContain('Unpriced')
|
||
})
|
||
})
|