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 })