mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 10:55:35 +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.
165 lines
6.5 KiB
TypeScript
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)
|
|
})
|
|
})
|