codeburn/src/format.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

71 lines
3.2 KiB
TypeScript

import chalk from 'chalk'
import type { ProjectSummary } from './types.js'
// Re-exported from currency.ts so existing imports from './format.js' keep working.
// The currency-aware version applies exchange rate and symbol automatically.
// Imported locally too since renderStatusBar below uses it directly.
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.
if (!Number.isFinite(n)) return '?'
if (n < 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return Math.round(n).toString()
}
/// Returns YYYY-MM-DD for the given date in the process-local timezone. Cheaper than shelling
/// out to Intl.DateTimeFormat for every turn in a loop and avoids the UTC drift that bites
/// `Date.toISOString().slice(0,10)` whenever the user runs this between local midnight and
/// UTC midnight.
function localDateString(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function renderStatusBar(projects: ProjectSummary[]): string {
const now = new Date()
const today = localDateString(now)
const monthStart = `${today.slice(0, 7)}-01`
let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
// Bucket by the first assistant call's local date -- the moment the cost was
// incurred. Bucketing by `turn.timestamp` (the user message time) drops turns
// that straddle midnight (user asked at 23:58, response arrived at 00:30) and
// disagrees with parseAllSessions' dateRange filter which is also on assistant
// time.
const bucketTs = turn.assistantCalls[0]!.timestamp
if (!bucketTs) continue
const day = localDateString(new Date(bucketTs))
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
const turnCalls = turn.assistantCalls.length
if (day === today) { todayCost += turnCost; todayCalls += turnCalls }
if (day >= monthStart) { monthCost += turnCost; monthCalls += turnCalls }
}
}
}
const lines: string[] = ['']
lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCost(todayCost))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCost(monthCost))} ${chalk.dim(`${monthCalls} calls`)}`)
lines.push('')
return lines.join('\n')
}