diff --git a/src/main.ts b/src/main.ts index 57a55a8..b482760 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,23 +1,19 @@ -import { homedir } from 'node:os' import { Command } from 'commander' import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' import { loadPricing, setModelAliases } from './models.js' -import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from './parser.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' -import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js' -import { buildMenubarPayload } from './menubar-json.js' -import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' -import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' +import { toDateString } from './daily-cache.js' +import { dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { aggregateModelEfficiency } from './model-efficiency.js' -import { buildPeriodData } from './usage-aggregator.js' +import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js' import { renderDashboard } from './dashboard.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' -import { runOptimize, scanAndDetect } from './optimize.js' +import { runOptimize } from './optimize.js' import { renderCompare } from './compare.js' -import { getAllProviders } from './providers/index.js' import { installAntigravityStatusLineHook, runAgyStatusLineHook, @@ -32,17 +28,6 @@ const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' -async function hydrateCache() { - try { - return await ensureCacheHydrated( - (range) => parseAllSessions(range, 'all'), - aggregateProjectsIntoDays, - ) - } catch { - return emptyCache() - } -} - function collect(val: string, acc: string[]): string[] { acc.push(val) return acc @@ -444,281 +429,14 @@ program : customRange ? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) } : daySelection ?? getDateRange(opts.period) - const now = new Date() - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const todayRange: DateRange = { start: todayStart, end: now } - const todayStr = toDateString(todayStart) - const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) - const rangeStartStr = toDateString(periodInfo.range.start) - const rangeEndStr = toDateString(periodInfo.range.end) - const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr - const isAllProviders = pf === 'all' - - let todayAllProjects: ProjectSummary[] | null = null - let todayAllDays: ReturnType | null = null - - const getTodayAllProjects = async (): Promise => { - if (!todayAllProjects) { - todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) - } - return todayAllProjects - } - - const getTodayAllDays = async (): Promise> => { - if (!todayAllDays) { - todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) - } - return todayAllDays - } - - let currentData: PeriodData - let scanProjects: ProjectSummary[] - let scanRange: DateRange - let cache: DailyCache - let todayProviderData: PeriodData | null = null - - if (isAllProviders) { - cache = await hydrateCache() - const todayProjects = await getTodayAllProjects() - const todayDays = await getTodayAllDays() - const historicalDays = rangeStartStr <= historicalRangeEndStr - ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) - : [] - const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) - const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) - const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays - currentData = buildPeriodDataFromDays(allDays, periodInfo.label) - const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr - if (isTodayOnly) { - scanProjects = todayProjects - scanRange = todayRange - } else { - const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all')) - scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects - scanRange = periodInfo.range - } - } else { - cache = await loadDailyCache() - const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) - const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects - todayProviderData = buildPeriodData(periodInfo.label, fullProjects) - currentData = todayProviderData - scanProjects = fullProjects - scanRange = periodInfo.range - } - if (isAllProviders) { - currentData = buildPeriodData(periodInfo.label, scanProjects) - } - - // PROVIDERS - // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. - // For specific: just this single provider with its scoped cost. - const allProviders = await getAllProviders() - const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) - const providers: ProviderCost[] = [] - if (isAllProviders) { - const unfilteredProviderDays = [ - ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []), - ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr), - ] - const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays - const providerTotals: Record = {} - for (const d of allDaysForProviders) { - for (const [name, p] of Object.entries(d.providers)) { - providerTotals[name] = (providerTotals[name] ?? 0) + p.cost - } - } - for (const [name, cost] of Object.entries(providerTotals)) { - providers.push({ name: displayNameByName.get(name) ?? name, cost }) - } - for (const p of allProviders) { - if (providers.some(pc => pc.name === p.displayName)) continue - const sources = await p.discoverSessions() - if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) - } - } else { - const display = displayNameByName.get(pf) ?? pf - providers.push({ name: display, cost: currentData.cost }) - } - - // DAILY HISTORY (last 365 days) - // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive - // a provider-filtered history without re-parsing. Tokens aren't broken down per provider - // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). - const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) - const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) - - let dailyHistory - if (isAllProviders) { - const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr) - const fullHistory = [...allCacheDays, ...todayDays] - dailyHistory = fullHistory.map(d => { - const topModels = Object.entries(d.models) - .filter(([name]) => name !== '') - .sort(([, a], [, b]) => b.cost - a.cost) - .slice(0, 5) - .map(([name, m]) => ({ - name, - cost: m.cost, - calls: m.calls, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - })) - return { - date: d.date, - cost: d.cost, - calls: d.calls, - inputTokens: d.inputTokens, - outputTokens: d.outputTokens, - cacheReadTokens: d.cacheReadTokens, - cacheWriteTokens: d.cacheWriteTokens, - topModels, - } - }) - } else { - const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[] - const historyFromCache = allCacheDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: emptyModels, - } - }) - const todayFromParse = aggregateProjectsIntoDays(scanProjects) - .filter(d => d.date === todayStr) - .map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: emptyModels, - } - }) - dailyHistory = [...historyFromCache, ...todayFromParse] - } - - const home = homedir() - const friendlyProject = (p: ProjectSummary) => { - const resolved = p.projectPath || p.project - if (resolved === home || resolved === home + '/') return 'Home' - return resolved.split('/').filter(Boolean).pop() || p.project - } - - currentData.projects = scanProjects.map(p => ({ - name: friendlyProject(p), - cost: p.totalCostUSD, - sessions: p.sessions.length, - sessionDetails: [...p.sessions] - .sort((a, b) => b.totalCostUSD - a.totalCostUSD) - .slice(0, 10) - .map(s => ({ - cost: s.totalCostUSD, - calls: s.apiCalls, - inputTokens: s.totalInputTokens, - outputTokens: s.totalOutputTokens, - date: s.firstTimestamp?.split('T')[0] ?? '', - models: Object.entries(s.modelBreakdown) - .map(([name, m]) => ({ name, cost: m.costUSD })) - .sort((a, b) => b.cost - a.cost) - .slice(0, 3), - })), - })) - - const effMap = aggregateModelEfficiency(scanProjects) - currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ - name, - costPerEdit: eff.costPerEditUSD, - oneShotRate: eff.oneShotRate, - })) - - const retryTaxByModel = [...effMap.values()] - .filter(m => m.retries > 0 && m.editTurns > 0) - .map(m => ({ - name: m.model, - taxUSD: m.retries * (m.editCostUSD / m.editTurns), - retries: m.retries, - retriesPerEdit: m.retriesPerEdit, - })) - .sort((a, b) => b.taxUSD - a.taxUSD) - const retryTax = { - totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0), - retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0), - editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), - byModel: retryTaxByModel.slice(0, 5), - } - - currentData.topSessions = scanProjects.flatMap(p => - p.sessions.map(s => ({ - project: friendlyProject(p), - cost: s.totalCostUSD, - calls: s.apiCalls, - date: s.firstTimestamp?.split('T')[0] ?? '', - })) - ).sort((a, b) => b.cost - a.cost).slice(0, 5) - - // Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits), - // then compute how much each pricier model overpaid. - const reliableModels = [...effMap.values()] - .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 - && (m.costPerEditUSD ?? 0) >= 0.01) - .sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity)) - const baseline = reliableModels[0] - const routingWasteByModel = baseline - ? [...effMap.values()] - .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) - .map(m => { - const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) - return { - name: m.model, - costPerEdit: m.costPerEditUSD ?? 0, - editTurns: m.editTurns, - actualUSD: m.editCostUSD, - counterfactualUSD: counterfactual, - savingsUSD: m.editCostUSD - counterfactual, - } - }) - .filter(m => m.savingsUSD > 0) - .sort((a, b) => b.savingsUSD - a.savingsUSD) - : [] - const routingWaste = { - totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0), - baselineModel: baseline?.model ?? '', - baselineCostPerEdit: baseline?.costPerEditUSD ?? 0, - byModel: routingWasteByModel.slice(0, 5), - } - - const breakdowns: BreakdownArrays = (() => { - const toolMap: Record = {} - const skillMap: Record = {} - const subagentMap: Record = {} - const mcpMap: Record = {} - for (const p of scanProjects) for (const s of p.sessions) { - for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls } - for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e } - for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e } - for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls } - } - return { - tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), - skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), - subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), - mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), - } - })() - - const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) - console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns))) + const payload = await buildMenubarPayloadForRange(periodInfo, { + provider: pf, + project: opts.project, + exclude: opts.exclude, + daysSelection, + optimize: opts.optimize !== false, + }) + console.log(JSON.stringify(payload)) return } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index ebc2932..6f7483e 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -1,5 +1,12 @@ -import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' -import { type PeriodData } from './menubar-json.js' +import { homedir } from 'node:os' +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js' +import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDays } from './parser.js' +import { getAllProviders } from './providers/index.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { scanAndDetect } from './optimize.js' +import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) @@ -40,3 +47,310 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri .map(([name, d]) => ({ name, ...d })), } } + +async function hydrateCache(): Promise { + try { + return await ensureCacheHydrated( + (range) => parseAllSessions(range, 'all'), + aggregateProjectsIntoDays, + ) + } catch { + return emptyCache() + } +} + +export type PeriodInfo = { range: DateRange; label: string } +export type AggregateOpts = { + provider?: string + project?: string[] + exclude?: string[] + daysSelection?: { range: DateRange; label: string; days: Set } | null + optimize?: boolean +} + +/** + * Resolved-range aggregation shared by `status --format menubar-json` and the MCP server. + * Pricing must already be loaded (callers run loadPricing first). When opts.optimize is + * false, the expensive scanAndDetect pass is skipped (retryTax/routingWaste still computed). + */ +export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: AggregateOpts = {}): Promise { + const pf = opts.provider ?? 'all' + const daysSelection = opts.daysSelection ?? null + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? []) + + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayRange: DateRange = { start: todayStart, end: now } + const todayStr = toDateString(todayStart) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) + const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr + const isAllProviders = pf === 'all' + + let todayAllProjects: ProjectSummary[] | null = null + let todayAllDays: ReturnType | null = null + + const getTodayAllProjects = async (): Promise => { + if (!todayAllProjects) { + todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) + } + return todayAllProjects + } + + const getTodayAllDays = async (): Promise> => { + if (!todayAllDays) { + todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) + } + return todayAllDays + } + + let currentData: PeriodData + let scanProjects: ProjectSummary[] + let scanRange: DateRange + let cache: DailyCache + let todayProviderData: PeriodData | null = null + + if (isAllProviders) { + cache = await hydrateCache() + const todayProjects = await getTodayAllProjects() + const todayDays = await getTodayAllDays() + const historicalDays = rangeStartStr <= historicalRangeEndStr + ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) + : [] + const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) + const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) + const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays + currentData = buildPeriodDataFromDays(allDays, periodInfo.label) + const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr + if (isTodayOnly) { + scanProjects = todayProjects + scanRange = todayRange + } else { + const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all')) + scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects + scanRange = periodInfo.range + } + } else { + cache = await loadDailyCache() + const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) + const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects + todayProviderData = buildPeriodData(periodInfo.label, fullProjects) + currentData = todayProviderData + scanProjects = fullProjects + scanRange = periodInfo.range + } + if (isAllProviders) { + currentData = buildPeriodData(periodInfo.label, scanProjects) + } + + // PROVIDERS + // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. + // For specific: just this single provider with its scoped cost. + const allProviders = await getAllProviders() + const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) + const providers: ProviderCost[] = [] + if (isAllProviders) { + const unfilteredProviderDays = [ + ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []), + ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr), + ] + const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays + const providerTotals: Record = {} + for (const d of allDaysForProviders) { + for (const [name, p] of Object.entries(d.providers)) { + providerTotals[name] = (providerTotals[name] ?? 0) + p.cost + } + } + for (const [name, cost] of Object.entries(providerTotals)) { + providers.push({ name: displayNameByName.get(name) ?? name, cost }) + } + for (const p of allProviders) { + if (providers.some(pc => pc.name === p.displayName)) continue + const sources = await p.discoverSessions() + if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) + } + } else { + const display = displayNameByName.get(pf) ?? pf + providers.push({ name: display, cost: currentData.cost }) + } + + // DAILY HISTORY (last 365 days) + // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive + // a provider-filtered history without re-parsing. Tokens aren't broken down per provider + // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). + const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) + const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) + + let dailyHistory + if (isAllProviders) { + const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr) + const fullHistory = [...allCacheDays, ...todayDays] + dailyHistory = fullHistory.map(d => { + const topModels = Object.entries(d.models) + .filter(([name]) => name !== '') + .sort(([, a], [, b]) => b.cost - a.cost) + .slice(0, 5) + .map(([name, m]) => ({ + name, + cost: m.cost, + calls: m.calls, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + })) + return { + date: d.date, + cost: d.cost, + calls: d.calls, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheWriteTokens: d.cacheWriteTokens, + topModels, + } + }) + } else { + const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[] + const historyFromCache = allCacheDays.map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + return { + date: d.date, + cost: prov.cost, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } + }) + const todayFromParse = aggregateProjectsIntoDays(scanProjects) + .filter(d => d.date === todayStr) + .map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + return { + date: d.date, + cost: prov.cost, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } + }) + dailyHistory = [...historyFromCache, ...todayFromParse] + } + + const home = homedir() + const friendlyProject = (p: ProjectSummary) => { + const resolved = p.projectPath || p.project + if (resolved === home || resolved === home + '/') return 'Home' + return resolved.split('/').filter(Boolean).pop() || p.project + } + + currentData.projects = scanProjects.map(p => ({ + name: friendlyProject(p), + cost: p.totalCostUSD, + sessions: p.sessions.length, + sessionDetails: [...p.sessions] + .sort((a, b) => b.totalCostUSD - a.totalCostUSD) + .slice(0, 10) + .map(s => ({ + cost: s.totalCostUSD, + calls: s.apiCalls, + inputTokens: s.totalInputTokens, + outputTokens: s.totalOutputTokens, + date: s.firstTimestamp?.split('T')[0] ?? '', + models: Object.entries(s.modelBreakdown) + .map(([name, m]) => ({ name, cost: m.costUSD })) + .sort((a, b) => b.cost - a.cost) + .slice(0, 3), + })), + })) + + const effMap = aggregateModelEfficiency(scanProjects) + currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ + name, + costPerEdit: eff.costPerEditUSD, + oneShotRate: eff.oneShotRate, + })) + + const retryTaxByModel = [...effMap.values()] + .filter(m => m.retries > 0 && m.editTurns > 0) + .map(m => ({ + name: m.model, + taxUSD: m.retries * (m.editCostUSD / m.editTurns), + retries: m.retries, + retriesPerEdit: m.retriesPerEdit, + })) + .sort((a, b) => b.taxUSD - a.taxUSD) + const retryTax = { + totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0), + retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0), + editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), + byModel: retryTaxByModel.slice(0, 5), + } + + currentData.topSessions = scanProjects.flatMap(p => + p.sessions.map(s => ({ + project: friendlyProject(p), + cost: s.totalCostUSD, + calls: s.apiCalls, + date: s.firstTimestamp?.split('T')[0] ?? '', + })) + ).sort((a, b) => b.cost - a.cost).slice(0, 5) + + // Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits), + // then compute how much each pricier model overpaid. + const reliableModels = [...effMap.values()] + .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 + && (m.costPerEditUSD ?? 0) >= 0.01) + .sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity)) + const baseline = reliableModels[0] + const routingWasteByModel = baseline + ? [...effMap.values()] + .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) + .map(m => { + const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) + return { + name: m.model, + costPerEdit: m.costPerEditUSD ?? 0, + editTurns: m.editTurns, + actualUSD: m.editCostUSD, + counterfactualUSD: counterfactual, + savingsUSD: m.editCostUSD - counterfactual, + } + }) + .filter(m => m.savingsUSD > 0) + .sort((a, b) => b.savingsUSD - a.savingsUSD) + : [] + const routingWaste = { + totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0), + baselineModel: baseline?.model ?? '', + baselineCostPerEdit: baseline?.costPerEditUSD ?? 0, + byModel: routingWasteByModel.slice(0, 5), + } + + const breakdowns: BreakdownArrays = (() => { + const toolMap: Record = {} + const skillMap: Record = {} + const subagentMap: Record = {} + const mcpMap: Record = {} + for (const p of scanProjects) for (const s of p.sessions) { + for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls } + for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e } + for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e } + for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls } + } + return { + tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + } + })() + + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns) +} diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts new file mode 100644 index 0000000..529ca5f --- /dev/null +++ b/tests/usage-aggregator.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { getDateRange } from '../src/cli-date.js' +import { loadPricing } from '../src/models.js' + +describe('buildMenubarPayloadForRange', () => { + beforeAll(async () => { await loadPricing() }) + + it('returns a valid payload and skips optimize findings when optimize:false', async () => { + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }) + expect(typeof payload.current.label).toBe('string') + expect(payload.current.cost).toBeGreaterThanOrEqual(0) + expect(Array.isArray(payload.current.topProjects)).toBe(true) + expect(Array.isArray(payload.current.topModels)).toBe(true) + expect(Array.isArray(payload.history.daily)).toBe(true) + expect(payload.current.retryTax.totalUSD).toBeGreaterThanOrEqual(0) + // optimize:false => scanAndDetect skipped => empty optimize block regardless of data + expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] }) + }) +})