From 5218e64a7ac3a06ee50b3806935aa16abdea9f4f Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 16 Jul 2026 18:38:58 +0200 Subject: [PATCH 1/3] 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). --- 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 | 12 +++ src/session-cache.ts | 27 ++++-- src/types.ts | 15 +++- src/usage-aggregator.ts | 8 +- tests/format-mark-estimated.test.ts | 13 +++ tests/overview.test.ts | 37 +++++++- tests/parser-estimated-cost.test.ts | 129 ++++++++++++++++++++++++++++ tests/session-cache.test.ts | 26 ++++++ 15 files changed, 322 insertions(+), 32 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 19c236e40..9f3cd6d92 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 826c04c64..b50d478a5 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 3037a550a..8a6e8984f 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 41dd39cae..000c51e7d 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 6ce27bec0..9db92b722 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 07b7a1a49..c59bc1767 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 074d8b017..aed5b8d91 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 f8af6a6e1..5f36d58d0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1412,6 +1412,7 @@ function buildSessionSummary( let totalCost = 0 let totalSavings = 0 + let totalEstimated = 0 let totalInput = 0 let totalOutput = 0 let totalReasoning = 0 @@ -1453,8 +1454,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 @@ -1468,12 +1471,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 @@ -1512,6 +1517,7 @@ function buildSessionSummary( lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '', totalCostUSD: totalCost, totalSavingsUSD: totalSavings, + totalEstimatedCostUSD: totalEstimated, totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalReasoningTokens: totalReasoning, @@ -1821,6 +1827,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, } @@ -1853,6 +1860,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { timestamp: call.timestamp, bashCommands: call.bashCommands, deduplicationKey: call.deduplicationKey, + isEstimated: call.costIsEstimated, }) return { @@ -1880,6 +1888,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, @@ -1911,6 +1920,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, @@ -1991,6 +2001,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, @@ -2639,6 +2650,7 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s 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 }) diff --git a/src/session-cache.ts b/src/session-cache.ts index aa61d21f1..030301d2b 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 @@ 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 is +// intentionally excluded: it is a durable provider, so changing its env +// fingerprint would discard OTel cache entries whose source rows may already be +// pruned (permanent data loss); its flag surfaces naturally as new data arrives. 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: 'otel-durable-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 4491d8a89..220fb290f 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 1b8c3e1c8..f37bf5ead 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 }))), } diff --git a/tests/format-mark-estimated.test.ts b/tests/format-mark-estimated.test.ts new file mode 100644 index 000000000..8dbc1f0ad --- /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 89d6d758e..937b4a1f7 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 000000000..803464257 --- /dev/null +++ b/tests/parser-estimated-cost.test.ts @@ -0,0 +1,129 @@ +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) + }) +}) diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts index b14ab0662..59ac85619 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 }) From 21f7deb97066c07f93f37ebda2d5029d1637b7ce Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 16 Jul 2026 18:44:44 +0200 Subject: [PATCH 2/3] 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. --- src/parser.ts | 44 ++++++++++++++++++----------- tests/parser-estimated-cost.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index 5f36d58d0..aca60cd33 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2526,6 +2526,32 @@ 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). Additive totals must ALL be +// summed here; a field summed at the session level but forgotten in this merge +// silently under-reports for exactly the multi-provider users (this bit +// totalEstimatedCostUSD once, caught in #639 verification). +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) { @@ -2639,23 +2665,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.totalEstimatedCostUSD = (existing.totalEstimatedCostUSD ?? 0) + (p.totalEstimatedCostUSD ?? 0) - 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/tests/parser-estimated-cost.test.ts b/tests/parser-estimated-cost.test.ts index 803464257..fcd51074d 100644 --- a/tests/parser-estimated-cost.test.ts +++ b/tests/parser-estimated-cost.test.ts @@ -127,3 +127,39 @@ describe('estimated cost propagation (#639)', () => { 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) + }) +}) From 563aa757a5ca2fe9f3925c0a19b3a8650641869f Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 16 Jul 2026 19:13:08 +0200 Subject: [PATCH 3/3] 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/parser.ts | 10 ++++++---- src/usage-aggregator.ts | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index aca60cd33..e1609290c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2527,10 +2527,12 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set { const crossProviderKey = (p: ProjectSummary): string => { const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase() diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index f37bf5ead..03e72451a 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -262,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)