codeburn/tests/parser-estimated-cost.test.ts
Resham Joshi 3163ccb5c4
fix(report): surface estimated costs distinctly (#688)
* 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.
2026-07-16 10:46:21 -07:00

165 lines
6.5 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { loadPricing } from '../src/models.js'
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
// End-to-end proof for issue #639: a provider that sets `costIsEstimated` on its
// parsed calls must carry that truth all the way onto ParsedApiCall and into the
// session/model aggregates, surviving the session-cache round trip. CodeWhale is
// used because the same provider yields a measured call (metadata carries a real
// `cost`) or an estimated one (no `cost`, so tokens are priced) purely from the
// fixture, which keeps the measured-vs-estimated contrast in one code path.
const FIXTURE_DAY = Date.UTC(2026, 6, 14)
const RANGE: DateRange = {
start: new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000),
end: new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000),
}
let home: string
let cacheDir: string
let prevHome: string | undefined
let prevCache: string | undefined
beforeAll(async () => {
await loadPricing()
})
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-est-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-est-cache-'))
prevHome = process.env['CODEWHALE_HOME']
prevCache = process.env['CODEBURN_CACHE_DIR']
process.env['CODEWHALE_HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
if (prevHome === undefined) delete process.env['CODEWHALE_HOME']
else process.env['CODEWHALE_HOME'] = prevHome
if (prevCache === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = prevCache
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
async function writeSession(id: string, opts: { cost?: Record<string, number> | null; model?: string }): Promise<void> {
const sessions = join(home, 'sessions')
await mkdir(sessions, { recursive: true })
const metadata: Record<string, unknown> = {
id,
title: 'estimated-cost fixture',
created_at: '2026-07-14T10:00:00.000Z',
updated_at: '2026-07-14T11:00:00.000Z',
message_count: 0,
total_tokens: 200_000,
model: opts.model ?? 'gpt-4o',
model_provider: 'openai',
workspace: '/repos/est-fixture',
mode: 'agent',
}
if (opts.cost !== null && opts.cost !== undefined) metadata.cost = opts.cost
await writeFile(join(sessions, `${id}.json`), JSON.stringify({ schema_version: 1, metadata, messages: [] }))
}
function onlyCall(projects: ProjectSummary[]) {
const calls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
expect(calls).toHaveLength(1)
return calls[0]!
}
function onlySession(projects: ProjectSummary[]) {
const sessions = projects.flatMap(p => p.sessions)
expect(sessions).toHaveLength(1)
return sessions[0]!
}
describe('estimated cost propagation (#639)', () => {
it('flags an estimated call and rolls the estimated dollars up through session/model/project', async () => {
await writeSession('estimated', { cost: null })
const projects = await parseAllSessions(RANGE, 'codewhale')
const call = onlyCall(projects)
expect(call.costUSD).toBeGreaterThan(0)
expect(call.isEstimated).toBe(true)
const session = onlySession(projects)
expect(session.totalEstimatedCostUSD).toBeCloseTo(call.costUSD)
const modelEntry = Object.values(session.modelBreakdown)[0]!
expect(modelEntry.estimatedCostUSD).toBeCloseTo(call.costUSD)
expect(projects[0]!.totalEstimatedCostUSD).toBeCloseTo(call.costUSD)
// Metadata only: the estimated portion is never subtracted from cost.
expect(session.totalCostUSD).toBeCloseTo(call.costUSD)
})
it('does not flag a measured call (real cost in metadata)', async () => {
await writeSession('measured', { cost: { session_cost_usd: 0.25 } })
const projects = await parseAllSessions(RANGE, 'codewhale')
const call = onlyCall(projects)
expect(call.costUSD).toBeCloseTo(0.25)
expect(call.isEstimated).toBeFalsy()
const session = onlySession(projects)
expect(session.totalEstimatedCostUSD ?? 0).toBe(0)
expect(Object.values(session.modelBreakdown)[0]!.estimatedCostUSD ?? 0).toBe(0)
expect(projects[0]!.totalEstimatedCostUSD ?? 0).toBe(0)
})
it('keeps the flag after the session-cache round trip (second parse is cache-served)', async () => {
await writeSession('estimated', { cost: null })
const first = await parseAllSessions(RANGE, 'codewhale')
const firstCost = onlyCall(first).costUSD
// Do NOT clear the cache: the second parse hydrates the call from
// session-cache.json, exercising CachedCall.isEstimated persistence.
const second = await parseAllSessions(RANGE, 'codewhale')
const call = onlyCall(second)
expect(call.isEstimated).toBe(true)
expect(call.costUSD).toBeCloseTo(firstCost)
expect(onlySession(second).totalEstimatedCostUSD).toBeCloseTo(firstCost)
})
})
describe('cross-provider project merge (#639 regression)', () => {
function summaryFor(path: string, opts: { cost: number; estimated?: number }): ProjectSummary {
return {
project: path.split('/').pop()!,
projectPath: path,
sessions: [],
totalCostUSD: opts.cost,
totalSavingsUSD: 0,
totalApiCalls: 1,
totalProxiedCostUSD: 0,
...(opts.estimated !== undefined ? { totalEstimatedCostUSD: opts.estimated } : {}),
} as ProjectSummary
}
it('keeps merged-in estimated dollars when two providers share a repo', async () => {
const { mergeProjectsByCrossProviderKey } = await import('../src/parser.js')
const merged = mergeProjectsByCrossProviderKey([
summaryFor('/repos/shared', { cost: 10 }), // measured provider, no estimate
summaryFor('/repos/shared', { cost: 5, estimated: 5 }), // estimated provider
])
expect(merged.size).toBe(1)
const project = [...merged.values()][0]!
expect(project.totalCostUSD).toBe(15)
expect(project.totalEstimatedCostUSD).toBe(5)
})
it('sums estimated dollars when both merged sides carry them', async () => {
const { mergeProjectsByCrossProviderKey } = await import('../src/parser.js')
const merged = mergeProjectsByCrossProviderKey([
summaryFor('/repos/shared', { cost: 3, estimated: 3 }),
summaryFor('/repos/shared', { cost: 4, estimated: 4 }),
])
expect([...merged.values()][0]!.totalEstimatedCostUSD).toBe(7)
})
})