From 3163ccb5c44ad207c35aaf53f0c818eaab438231 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:46:21 -0700 Subject: [PATCH] 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. --- src/dashboard.tsx | 13 ++- src/format.ts | 8 ++ src/main.ts | 12 +- src/mcp/server.ts | 12 +- src/mcp/tables.ts | 14 ++- src/menubar-json.ts | 17 ++- src/overview.ts | 11 +- src/parser.ts | 56 +++++++--- src/session-cache.ts | 27 ++++- src/types.ts | 15 ++- src/usage-aggregator.ts | 12 +- tests/format-mark-estimated.test.ts | 13 +++ tests/overview.test.ts | 37 ++++++- tests/parser-estimated-cost.test.ts | 165 ++++++++++++++++++++++++++++ tests/session-cache.test.ts | 26 +++++ 15 files changed, 390 insertions(+), 48 deletions(-) create mode 100644 tests/format-mark-estimated.test.ts create mode 100644 tests/parser-estimated-cost.test.ts diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 19c236e4..9f3cd6d9 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -3,7 +3,7 @@ import { homedir } from 'os' import React, { useState, useCallback, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' -import { formatCost, formatTokens } from './format.js' +import { formatCost, formatTokens, markEstimated } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { parseAllSessions, filterProjectsByName, setInteractiveScanUI } from './parser.js' import { findUnpricedModels, loadPricing } from './models.js' @@ -384,20 +384,22 @@ const MODEL_NAME_WIDTH = 14 const MIN_EDIT_TURNS_FOR_RATE = 5 function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const modelTotals: Record = {} + const modelTotals: Record = {} const modelEfficiency = aggregateModelEfficiency(projects) for (const project of projects) { for (const session of project.sessions) { for (const [model, data] of Object.entries(session.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 } + if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 } modelTotals[model].calls += data.calls modelTotals[model].costUSD += data.costUSD + modelTotals[model].estimatedCostUSD += data.estimatedCostUSD ?? 0 modelTotals[model].freshInput += data.tokens.inputTokens modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens } } } + const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0) const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ @@ -422,7 +424,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {fit(model, MODEL_NAME_WIDTH)} - {formatCost(data.costUSD).padStart(MODEL_COL_COST)} + {markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)} {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} {oneShotLabel.padStart(MODEL_COL_ONESHOT)} @@ -434,6 +436,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`} )} + {anyEstimated && ( + ~ estimated cost (priced from estimated tokens) + )} ) } diff --git a/src/format.ts b/src/format.ts index 826c04c6..b50d478a 100644 --- a/src/format.ts +++ b/src/format.ts @@ -7,6 +7,14 @@ import type { ProjectSummary } from './types.js' import { formatCost } from './currency.js' export { formatCost } +/// Prefix a formatted cost with the estimated marker (`~`) when the figure is +/// priced from estimated tokens rather than metered. Keeps the marker identical +/// across the report, overview, and MCP surfaces so a legend line can explain it +/// once. `isEstimated` is typically `entry.estimatedCostUSD > 0`. +export function markEstimated(costStr: string, isEstimated: boolean): string { + return isEstimated ? `~${costStr}` : costStr +} + export function formatTokens(n: number): string { // Guard against Infinity / NaN / negatives that would otherwise leak into // the UI as "Infinity" or "NaN" strings when an upstream calculation glitches. diff --git a/src/main.ts b/src/main.ts index dd012077..c07e5f21 100644 --- a/src/main.ts +++ b/src/main.ts @@ -261,6 +261,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0) + const totalEstimatedUSD = projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0) // Subscription-covered (proxied) portion of totalCostUSD, and the resulting // out-of-pocket figure. `cost` stays the full billable/would-be amount. const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) @@ -334,14 +335,15 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: sessions: p.sessions.length, })) - const modelMap: Record = {} + const modelMap: Record = {} const modelEfficiency = aggregateModelEfficiency(projects) for (const sess of sessions) { for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } } + if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, estimatedCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } } modelMap[model].calls += d.calls modelMap[model].cost += d.costUSD modelMap[model].savings += d.savingsUSD + modelMap[model].estimatedCost += d.estimatedCostUSD ?? 0 modelMap[model].inputTokens += d.tokens.inputTokens modelMap[model].outputTokens += d.tokens.outputTokens modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens @@ -371,13 +373,14 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: } const models = Object.entries(modelMap) .sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)) - .map(([name, { cost, savings, baselineModel, ...rest }]) => { + .map(([name, { cost, savings, estimatedCost, baselineModel, ...rest }]) => { const efficiency = modelEfficiency.get(name) return { name, ...rest, cost: convertCost(cost), savings: convertCost(savings), + estimatedCost: convertCost(estimatedCost), savingsBaselineModel: baselineModel, editTurns: efficiency?.editTurns ?? 0, oneShotTurns: efficiency?.oneShotTurns ?? 0, @@ -480,6 +483,9 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: proxiedCost: convertCost(totalProxiedUSD), netCost: convertCost(netCostUSD), savings: convertCost(totalSavingsUSD), + // Portion of `cost` priced from estimated tokens (issue #639). Display/ + // metadata only; never subtracted from `cost`. 0 when nothing is estimated. + estimatedCost: convertCost(totalEstimatedUSD), calls: totalCalls, sessions: totalSessions, cacheHitPercent, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 41dd39ca..000c51e7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -20,9 +20,9 @@ const INSTRUCTIONS = 'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' + 'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.' -function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> { +function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number; estimatedCostUSD?: number }> { const c = p.current - if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost })) + if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost, estimatedCostUSD: m.estimatedCostUSD ?? 0 })) if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost })) if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost })) return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost })) @@ -60,8 +60,8 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }): outputSchema: { period: z.string(), empty: z.boolean(), - totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }), - breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(), + totals: z.object({ costUSD: z.number(), estimatedCostUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }), + breakdown: z.array(z.object({ name: z.string(), costUSD: z.number(), estimatedCostUSD: z.number().optional() })).nullable(), }, annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, }, @@ -69,7 +69,7 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }): try { const payload = redactProjectNames(await getPayload(period, false), include_project_names) const c = payload.current - const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate } + const totals = { costUSD: c.cost, estimatedCostUSD: c.estimatedCostUSD ?? 0, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate } if (c.calls === 0) { return { content: [{ type: 'text' as const, text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], @@ -85,7 +85,7 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }): } catch (err) { return { content: [{ type: 'text' as const, text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], - structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null }, + structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, estimatedCostUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null }, isError: true, } } diff --git a/src/mcp/tables.ts b/src/mcp/tables.ts index 6ce27bec..9db92b72 100644 --- a/src/mcp/tables.ts +++ b/src/mcp/tables.ts @@ -1,6 +1,9 @@ -import { formatCost, formatTokens } from '../format.js' +import { formatCost, formatTokens, markEstimated } from '../format.js' import type { MenubarPayload } from '../menubar-json.js' +const ESTIMATED_LEGEND = '_~ estimated cost (priced from estimated tokens)_' +const isEstimated = (m: { estimatedCostUSD?: number }) => (m.estimatedCostUSD ?? 0) > 0 + export type BreakdownBy = 'project' | 'model' | 'task' | 'provider' function mdTable(headers: string[], rows: string[][]): string { @@ -24,7 +27,8 @@ export function renderSummaryTable(p: MenubarPayload): string { : []), '', '_Top models_', - mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])), + mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, markEstimated(formatCost(m.cost), isEstimated(m)), String(m.calls)])), + ...(c.topModels.slice(0, 5).some(isEstimated) ? [ESTIMATED_LEGEND] : []), '', '_Top projects_', mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])), @@ -33,7 +37,11 @@ export function renderSummaryTable(p: MenubarPayload): string { export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string { const c = p.current - if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)])) + if (by === 'model') { + const rows = c.topModels.slice(0, limit) + const table = mdTable(['Model', 'Cost', 'Calls'], rows.map(m => [m.name, markEstimated(formatCost(m.cost), isEstimated(m)), String(m.calls)])) + return rows.some(isEstimated) ? `${table}\n\n${ESTIMATED_LEGEND}` : table + } if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)])) if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)])) return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)])) diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 07b7a1a4..c59bc176 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -10,6 +10,10 @@ export type PeriodData = { /// separately from `cost` so the two never get summed into a "real /// spend" number by accident. savingsUSD: number + /// Portion of `cost` priced from estimated tokens (see ParsedApiCall.isEstimated). + /// Display/metadata only; never summed into `cost`. Optional so PeriodData + /// producers predating the field keep compiling. + estimatedCostUSD?: number calls: number sessions: number inputTokens: number @@ -20,7 +24,7 @@ export type PeriodData = { /// non-menubar PeriodData producers don't have to compute it. codexCredits?: number categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> - models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }> + models: Array<{ name: string; cost: number; savingsUSD: number; calls: number; estimatedCostUSD?: number }> /// Models with usage in the period whose pricing lookup fails against the /// current tables (#638): their calls contribute $0 to `cost`. Optional so /// PeriodData producers that predate the field keep compiling. @@ -146,6 +150,11 @@ export type MenubarPayload = { cacheHitPercent: number /// Codex credits consumed in the period; 0 when there is no Codex usage. codexCredits: number + /// Portion of `cost` priced from estimated tokens (see ParsedApiCall.isEstimated). + /// Machine-readable signal that distinguishes guessed spend from metered spend; + /// display/metadata only, never summed into `cost`. Optional for compatibility + /// with payloads produced before the field existed. + estimatedCostUSD?: number topActivities: Array<{ name: string cost: number @@ -159,6 +168,9 @@ export type MenubarPayload = { savingsUSD: number savingsBaselineModel: string calls: number + /// Estimated portion of this model's `cost`; > 0 marks the row as priced + /// from estimated tokens. Optional for payload back-compat. + estimatedCostUSD?: number }> /// See PeriodData.unpricedModels: usage priced at $0 for lack of pricing /// data. Empty when every model in the period resolved a price. Optional @@ -291,7 +303,7 @@ function buildTopModels(models: PeriodData['models']): MenubarPayload['current'] return models .filter(m => m.name !== SYNTHETIC_MODEL_NAME) .slice(0, TOP_MODELS_LIMIT) - .map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '' })) + .map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 })) } function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { @@ -410,6 +422,7 @@ export function buildMenubarPayload( cacheWriteTokens: current.cacheWriteTokens, cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens), codexCredits: current.codexCredits ?? 0, + estimatedCostUSD: current.estimatedCostUSD ?? 0, topActivities: buildTopActivities(current.categories), topModels: buildTopModels(current.models), unpricedModels: current.unpricedModels ?? [], diff --git a/src/overview.ts b/src/overview.ts index 074d8b01..aed5b8d9 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -5,6 +5,7 @@ import { homedir } from 'os' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost as baseCost } from './currency.js' import { findUnpricedModels, getShortModelName } from './models.js' +import { markEstimated } from './format.js' import { dateKey } from './day-aggregator.js' // Display-only helpers. The shared formatters omit thousands separators and @@ -91,7 +92,7 @@ export function renderOverview( let cost = 0, savings = 0, calls = 0, sessions = 0 let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0 const byProvider = new Map() - const byModel = new Map() + const byModel = new Map() const byCat = new Map() const byTool = new Map() const byDay = new Map }>() @@ -113,9 +114,10 @@ export function renderOverview( cacheR += s.totalCacheReadTokens cacheW += s.totalCacheWriteTokens for (const [m, d] of Object.entries(s.modelBreakdown)) { - const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0 } + const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0, estimatedCost: 0 } e.cost += d.costUSD e.calls += d.calls + e.estimatedCost += d.estimatedCostUSD ?? 0 e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens byModel.set(m, e) } @@ -209,8 +211,11 @@ export function renderOverview( out.push(heading('Top models')) out.push(renderTable(c, [{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }], - modelRows.map(([m, v]) => [getShortModelName(m), formatCost(v.cost), formatCount(v.calls), formatTokens(v.tokens)]), + modelRows.map(([m, v]) => [getShortModelName(m), markEstimated(formatCost(v.cost), v.estimatedCost > 0), formatCount(v.calls), formatTokens(v.tokens)]), )) + if (modelRows.some(([, v]) => v.estimatedCost > 0)) { + out.push(' ' + c.dim('~ estimated cost (priced from estimated tokens)')) + } out.push('') } diff --git a/src/parser.ts b/src/parser.ts index 47f8cc76..f5232047 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1413,6 +1413,7 @@ function buildSessionSummary( let totalCost = 0 let totalSavings = 0 + let totalEstimated = 0 let totalInput = 0 let totalOutput = 0 let totalReasoning = 0 @@ -1454,8 +1455,10 @@ function buildSessionSummary( for (const call of turn.assistantCalls) { const callSavings = call.savingsUSD ?? 0 + const callEstimated = call.isEstimated ? call.costUSD : 0 totalCost += call.costUSD totalSavings += callSavings + totalEstimated += callEstimated totalInput += call.usage.inputTokens totalOutput += call.usage.outputTokens totalReasoning += call.usage.reasoningTokens @@ -1469,12 +1472,14 @@ function buildSessionSummary( calls: 0, costUSD: 0, savingsUSD: 0, + estimatedCostUSD: 0, tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, } } modelBreakdown[modelKey].calls++ modelBreakdown[modelKey].costUSD += call.costUSD modelBreakdown[modelKey].savingsUSD += callSavings + modelBreakdown[modelKey].estimatedCostUSD = (modelBreakdown[modelKey].estimatedCostUSD ?? 0) + callEstimated modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens @@ -1513,6 +1518,7 @@ function buildSessionSummary( lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '', totalCostUSD: totalCost, totalSavingsUSD: totalSavings, + totalEstimatedCostUSD: totalEstimated, totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalReasoningTokens: totalReasoning, @@ -1822,6 +1828,7 @@ function summarizeProject(project: string, projectPath: string, sessions: Sessio sessions, totalCostUSD, totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0), + totalEstimatedCostUSD: sessions.reduce((s, sess) => s + (sess.totalEstimatedCostUSD ?? 0), 0), totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), totalProxiedCostUSD: isProxiedPath(projectPath) ? totalCostUSD : 0, } @@ -1854,6 +1861,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { timestamp: call.timestamp, bashCommands: call.bashCommands, deduplicationKey: call.deduplicationKey, + isEstimated: call.costIsEstimated, }) return { @@ -1881,6 +1889,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { cacheCreationOneHourTokens: 0, }, costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale') ? call.costUSD : undefined, + isEstimated: call.costIsEstimated || undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, @@ -1912,6 +1921,7 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall { provider: call.provider, model: call.model, usage: { ...call.usage, cacheCreationOneHourTokens: call.cacheCreationOneHourTokens ?? 0 }, + isEstimated: call.isEstimated || undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, @@ -1992,6 +2002,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall { webSearchRequests: u.webSearchRequests, }, costUSD: call.costUSD ?? costUSD, + isEstimated: call.isEstimated, tools: call.tools, mcpTools: extractMcpTools(call.tools), skills: call.skills, @@ -2528,6 +2539,34 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set b.totalCostUSD - a.totalCostUSD) } +// Merge projects that resolve to the same repository across providers (the +// same repo used with Claude Code + Codex, say). An additive total summed at +// the session level but forgotten here silently under-reports for exactly the +// multi-provider users (this bit totalEstimatedCostUSD once, caught in #639 +// verification). Known gaps, deliberate: totalSavingsUSD is still not summed +// (pre-existing, tracked separately) and totalProxiedCostUSD is re-derived +// after the merge rather than summed here. +export function mergeProjectsByCrossProviderKey(projects: ProjectSummary[]): Map { + const crossProviderKey = (p: ProjectSummary): string => { + const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase() + return path.includes('/') ? path : p.project.toLowerCase() + } + const mergedMap = new Map() + for (const p of projects) { + const key = crossProviderKey(p) + const existing = mergedMap.get(key) + if (existing) { + existing.sessions.push(...p.sessions) + existing.totalCostUSD += p.totalCostUSD + existing.totalEstimatedCostUSD = (existing.totalEstimatedCostUSD ?? 0) + (p.totalEstimatedCostUSD ?? 0) + existing.totalApiCalls += p.totalApiCalls + } else { + mergedMap.set(key, { ...p }) + } + } + return mergedMap +} + export function filterProjectsByClaudeConfigSource(projects: ProjectSummary[], sourceId: string): ProjectSummary[] { const filtered: ProjectSummary[] = [] for (const project of projects) { @@ -2641,22 +2680,7 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s return { ...p, project: projectNameFromPath(canonical.path, p.project), projectPath: canonical.path } })) - const crossProviderKey = (p: ProjectSummary): string => { - const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase() - return path.includes('/') ? path : p.project.toLowerCase() - } - const mergedMap = new Map() - for (const p of [...claudeProjects, ...resolvedOtherProjects]) { - const key = crossProviderKey(p) - const existing = mergedMap.get(key) - if (existing) { - existing.sessions.push(...p.sessions) - existing.totalCostUSD += p.totalCostUSD - existing.totalApiCalls += p.totalApiCalls - } else { - mergedMap.set(key, { ...p }) - } - } + const mergedMap = mergeProjectsByCrossProviderKey([...claudeProjects, ...resolvedOtherProjects]) // Re-derive proxy attribution on the merged total: the merge above sums // totalCostUSD across providers that share a canonical path but never diff --git a/src/session-cache.ts b/src/session-cache.ts index 6c8ab39e..f696ced9 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -24,6 +24,9 @@ export type CachedCall = { model: string usage: CachedUsage costUSD?: number + /// True when `costUSD` (or the tokens it is priced from) is estimated rather + /// than metered. Persisted so the estimated-cost marker survives the cache. + isEstimated?: boolean speed: 'standard' | 'fast' timestamp: string tools: string[] @@ -113,25 +116,32 @@ export const PROVIDER_ENV_VARS: Record = { // disappear — they are preserved so month-to-date totals never drop. export const DURABLE_PROVIDER_NAMES: ReadonlySet = new Set(['copilot']) +// Estimated-cost surfacing (#639): providers that set `costIsEstimated` carry a +// `-est-cost` suffix (or a new entry) so their already-cached sessions reparse +// once and the flag lands, instead of silently reading as measured. Copilot +// needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one +// re-parse, which lands the flag too, and durable orphans now survive +// fingerprint changes (the carry-forward in getOrCreateProviderSection). export const PROVIDER_PARSE_VERSIONS: Record = { claude: 'advisor-usage-v1', cline: 'worktree-project-grouping-v1', - codewhale: 'aggregate-session-v1', + codewhale: 'aggregate-session-v1-est-cost', // Bump when the Codex parser changes attribution so unchanged, already-cached // session files re-parse (session-cache.json serves them without invoking the // provider parser otherwise). Covers native mcp_tool_call_end (#513) and // CLI-wrapped `mcp-cli call` (#478) MCP attribution. - codex: 'mcp-attribution-v2', - cursor: 'composer-anchored-crediting-v1', + codex: 'mcp-attribution-v2-est-cost', + cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', copilot: 'cli-shutdown-cost-v1', - hermes: 'reasoning-output-accounting-v1', + grok: 'estimated-cost-v1', + hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', - kiro: 'ide-parsing-v1', + kiro: 'ide-parsing-v1-est-cost', 'kilo-code': 'worktree-project-grouping-v1', 'roo-code': 'worktree-project-grouping-v1', - warp: 'worktree-project-grouping-v1', + warp: 'worktree-project-grouping-v1-est-cost', antigravity: 'worktree-project-grouping-v5', } @@ -177,6 +187,10 @@ function isOptionalNum(v: unknown): boolean { return v === undefined || isNum(v) } +function isOptionalBool(v: unknown): boolean { + return v === undefined || typeof v === 'boolean' +} + function isToolCall(v: unknown): boolean { if (!v || typeof v !== 'object') return false const o = v as Record @@ -213,6 +227,7 @@ function validateCall(c: unknown): c is CachedCall { && typeof o['timestamp'] === 'string' && (o['speed'] === 'standard' || o['speed'] === 'fast') && isOptionalNum(o['costUSD']) + && isOptionalBool(o['isEstimated']) && isStringArray(o['tools']) && isStringArray(o['bashCommands']) && isStringArray(o['skills']) diff --git a/src/types.ts b/src/types.ts index 4491d8a8..220fb290 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,6 +116,12 @@ export type ParsedApiCall = { savingsUSD?: number savingsBaselineModel?: string isLocalSavings?: boolean + /// True when this call's `costUSD` is priced from estimated token counts or + /// otherwise synthesized by the provider (e.g. Warp/Kiro/Cursor derive tokens + /// from content length). Carried from `ParsedProviderCall.costIsEstimated` + /// across the parser/cache boundary. Aggregates roll the estimated portion up + /// as `estimatedCostUSD`; it is display/metadata only and never changes totals. + isEstimated?: boolean } export type ToolCall = { @@ -165,6 +171,10 @@ export type SessionSummary = { lastTimestamp: string totalCostUSD: number totalSavingsUSD: number + /// Portion of `totalCostUSD` contributed by calls whose price is estimated + /// (see `ParsedApiCall.isEstimated`). Optional so SessionSummary fixtures and + /// producers predating the field keep compiling; the parser always sets it. + totalEstimatedCostUSD?: number totalInputTokens: number totalOutputTokens: number totalReasoningTokens: number @@ -172,7 +182,7 @@ export type SessionSummary = { totalCacheWriteTokens: number apiCalls: number turns: ClassifiedTurn[] - modelBreakdown: Record + modelBreakdown: Record toolBreakdown: Record mcpBreakdown: Record bashBreakdown: Record @@ -193,6 +203,9 @@ export type ProjectSummary = { sessions: SessionSummary[] totalCostUSD: number totalSavingsUSD: number + /// Portion of `totalCostUSD` priced from estimated tokens (see + /// `SessionSummary.totalEstimatedCostUSD`). Optional for the same reason. + totalEstimatedCostUSD?: number totalApiCalls: number // Portion of `totalCostUSD` served through a subscription-backed proxy // (config `proxyPaths`). `totalCostUSD` is left at the full API rate (the diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 1b8c3e1c..03e72451 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -16,7 +16,7 @@ import { buildGranularHistory } from './granular-history.js' export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) const catTotals: Record = {} - const modelTotals: Record = {} + const modelTotals: Record = {} let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 for (const sess of sessions) { @@ -33,10 +33,11 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri catTotals[cat].oneShotTurns += d.oneShotTurns } for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, tokens: 0 } + if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, estimatedCostUSD: 0, tokens: 0 } modelTotals[model].calls += d.calls modelTotals[model].cost += d.costUSD modelTotals[model].savingsUSD += d.savingsUSD + modelTotals[model].estimatedCostUSD += d.estimatedCostUSD ?? 0 modelTotals[model].tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens } } @@ -45,6 +46,7 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri label, cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), savingsUSD: projects.reduce((s, p) => s + p.totalSavingsUSD, 0), + estimatedCostUSD: projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0), calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), sessions: projects.reduce((s, p) => s + p.sessions.length, 0), inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, @@ -53,7 +55,7 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), models: Object.entries(modelTotals) .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD })), + .map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD, estimatedCostUSD: d.estimatedCostUSD })), unpricedModels: findUnpricedModels(Object.entries(modelTotals) .map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))), } @@ -260,6 +262,10 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } } if (isAllProviders) { + // Load-bearing overwrite: the daily-cache path above never carries + // estimatedCostUSD (DailyEntry has no such field), so this fresh-parse + // rebuild is what keeps the estimated marker alive on cached periods. + // Removing it as redundant silently drops the flag. currentData = buildPeriodData(periodInfo.label, scanProjects) } claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null) diff --git a/tests/format-mark-estimated.test.ts b/tests/format-mark-estimated.test.ts new file mode 100644 index 00000000..8dbc1f0a --- /dev/null +++ b/tests/format-mark-estimated.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest' + +import { markEstimated } from '../src/format.js' + +describe('markEstimated', () => { + it('prefixes the estimated marker when the figure is estimated', () => { + expect(markEstimated('$1.23', true)).toBe('~$1.23') + }) + + it('leaves a measured figure untouched', () => { + expect(markEstimated('$1.23', false)).toBe('$1.23') + }) +}) diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 89d6d758..937b4a1f 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -11,6 +11,7 @@ function makeProject(opts: { model: string provider: string tokens: { input: number; output: number; cacheR: number; cacheW: number } + estimated?: boolean }): ProjectSummary { const usage = { inputTokens: opts.tokens.input, @@ -33,7 +34,7 @@ function makeProject(opts: { totalCacheReadTokens: opts.tokens.cacheR, totalCacheWriteTokens: opts.tokens.cacheW, apiCalls: opts.calls, - modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } }, + 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: {}, @@ -96,6 +97,40 @@ describe('renderOverview', () => { 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') diff --git a/tests/parser-estimated-cost.test.ts b/tests/parser-estimated-cost.test.ts new file mode 100644 index 00000000..fcd51074 --- /dev/null +++ b/tests/parser-estimated-cost.test.ts @@ -0,0 +1,165 @@ +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 | null; model?: string }): Promise { + const sessions = join(home, 'sessions') + await mkdir(sessions, { recursive: true }) + const metadata: Record = { + 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) + }) +}) diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts index b14ab066..59ac8561 100644 --- a/tests/session-cache.test.ts +++ b/tests/session-cache.test.ts @@ -132,6 +132,32 @@ describe('loadCache / saveCache', () => { expect(loaded.providers['pi']?.files['/path/to/bad.jsonl']?.turns).toEqual([]) }) + it('preserves the estimated-cost flag through save/load; a measured call stays unflagged', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + warp: { + envFingerprint: 'abc123', + files: { + '/path/to/warp.sqlite': makeCachedFile({ + turns: [makeTurn({ calls: [ + makeCall({ deduplicationKey: 'est', costUSD: 0.5, isEstimated: true }), + makeCall({ deduplicationKey: 'measured', costUSD: 0.5 }), + ] })], + }), + }, + }, + }, + } + + await saveCache(cache) + const loaded = await loadCache() + const calls = loaded.providers['warp']?.files['/path/to/warp.sqlite']?.turns[0]?.calls + expect(calls?.[0]?.isEstimated).toBe(true) + // A call with no flag round-trips as undefined, not silently coerced to true. + expect(calls?.[1]?.isEstimated).toBeUndefined() + }) + it('returns empty cache on version mismatch', async () => { const bad: SessionCache = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } } await mkdir(TMP_DIR, { recursive: true })