diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 3ded9cc7b..ea1609b99 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -673,6 +673,7 @@ const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/ export const CARRIED_MODEL_NAME = 'Unknown (carried)' const REMAINDER_KEYS = ['calls', 'cost', 'savingsUSD', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const +const TOKEN_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const /// Money is compared at the cent every surface reports it in. Summing a day's /// model rows in a different order than its total was accumulated leaves a @@ -704,6 +705,29 @@ function creditCarriedRemainder( setOwn(models, CARRIED_MODEL_NAME, acc) } +/// A token-only slice used to read as a placeholder, so every merge over it +/// added its tokens to the day again while the slice itself stayed whole. A day +/// whose slices all carry token figures never holds more than their sum; one +/// holding a pre-v14 slice without them keeps its own totals. +function healTokenTotals(day: DailyEntry): void { + const slices = Object.values(day.providers) + if (slices.length === 0 || slices.some(s => TOKEN_KEYS.every(k => s[k] === undefined))) return + const sums = TOKEN_KEYS.map(k => slices.reduce((sum, s) => sum + (s[k] ?? 0), 0)) + if (TOKEN_KEYS.every((k, i) => day[k] <= sums[i]!)) return + TOKEN_KEYS.forEach((k, i) => { day[k] = Math.min(day[k], sums[i]!) }) + const rows: Record = {} + for (const s of slices) { + for (const [name, m] of Object.entries(s.models ?? {})) { + const acc = Object.hasOwn(rows, name) ? rows[name]! : emptyModelStats() + for (const k of TOKEN_KEYS) acc[k] += m[k] + setOwn(rows, name, acc) + } + } + for (const [name, r] of Object.entries(rows)) { + if (Object.hasOwn(day.models, name)) for (const k of TOKEN_KEYS) day.models[name]![k] = Math.min(day.models[name]![k], r[k]) + } +} + function migrateDays(days: Record[]): DailyEntry[] { return days .filter(d => d && typeof d === 'object' && typeof d.date === 'string' && DATE_KEY_RE.test(d.date)) @@ -738,6 +762,7 @@ function migrateDays(days: Record[]): DailyEntry[] { // split while one of its slices was written before slices carried one (or // the reverse), and a provider-scoped view reads the slice's map alone. .map(day => { + healTokenTotals(day) for (const slice of Object.values(day.providers)) creditCarriedRemainder(slice, slice.models ??= {}) creditCarriedRemainder(day, day.models) return day @@ -983,8 +1008,11 @@ function applyRetention(days: DailyEntry[], newestDate: string): DailyEntry[] { return days.filter(d => d.date >= cutoff) } +/// Tokens count: a slice with usage but no call or cost (a Hermes session +/// total) is already in the day's totals, so merging over it as a placeholder +/// counts it twice. function hasSliceData(slice: ProviderDaySlice): boolean { - return slice.cost > 0 || slice.calls > 0 || (slice.savingsUSD ?? 0) > 0 + return slice.cost > 0 || slice.calls > 0 || (slice.savingsUSD ?? 0) > 0 || TOKEN_KEYS.some(k => (slice[k] ?? 0) > 0) } /// A day from a pre-v5-era cache: day-level totals exist but the providers map @@ -1180,7 +1208,8 @@ function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderD ...(categories ? { categories } : {}), ...(projects ? { projects } : {}), } - return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null + // A token-only remainder is drift between two scans of the same calls, not usage. + return calls > 0 || cost > 0 || savingsUSD > 0 || sessions > 0 ? out : null } /// Entries are subtracted key by key, so content the fresh parse files under a diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index 5c36288e4..86972d589 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -761,6 +761,47 @@ describe('adoption union across older cache files', () => { }) }) +describe('token-only slices across migrations', () => { + const tokens = (inputTokens: number, outputTokens: number) => ({ inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 }) + const model = (calls: number, cost: number, inputTokens: number, outputTokens: number) => ({ calls, cost, savingsUSD: 0, ...tokens(inputTokens, outputTokens) }) + const tokenOnlyDay = (inputScale = 1) => day('2026-08-28', { + claude: slice(650, 3607, { sessions: 4, ...tokens(16569, 2000), models: { 'Opus 4.8': model(3607, 650, 16569, 2000) } }), + hermes: slice(0, 0, { sessions: 1, ...tokens(2898477, 16118), models: { 'minimax/MiniMax-M3': model(0, 0, 2898477, 16118) } }), + }, { + sessions: 5, + ...tokens(16569 + 2898477 * inputScale, 2000 + 16118 * inputScale), + models: { 'Opus 4.8': model(3607, 650, 16569, 2000), 'minimax/MiniMax-M3': model(0, 0, 2898477 * inputScale, 16118 * inputScale) }, + }) + const fileFor = (version: number, days: DailyEntry[]) => ({ version, savingsConfigHash: '', tzKey: currentTzKey(), lastComputedDate: '2026-08-28', complete: true, days }) + const totals = (d: DailyEntry) => ({ cost: d.cost, calls: d.calls, inputTokens: d.inputTokens, outputTokens: d.outputTokens, minimax: d.models['minimax/MiniMax-M3'], hermes: d.providers['hermes'] }) + + it('keeps a carried day with a 0-call model row identical over two version bumps', async () => { + for (const v of [DAILY_CACHE_VERSION - 3, DAILY_CACHE_VERSION - 2]) { + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${v}.json`), JSON.stringify(fileFor(v, [tokenOnlyDay()])), 'utf-8') + } + const expected = totals(tokenOnlyDay()) + + const first = await loadDailyCache() + expect(totals(first.days[0]!)).toEqual(expected) + + // The next bump: the file just written becomes an older generation too. + const written = JSON.parse(await readFile(dailyCachePath(), 'utf-8')) + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`), JSON.stringify({ ...written, version: DAILY_CACHE_VERSION - 1 }), 'utf-8') + await rm(dailyCachePath()) + const second = await loadDailyCache() + expect(totals(second.days[0]!)).toEqual(expected) + }) + + it('heals a day already inflated by it and leaves a pre-v14 day alone', async () => { + const preV14 = day('2026-06-16', { claude: slice(1.34, 267) }, { ...tokens(53249, 74664), models: { 'claude-sonnet-4-6': model(252, 1.34, 53249, 74664) } }) + await writeFile(dailyCachePath(), JSON.stringify(fileFor(DAILY_CACHE_VERSION, [preV14, tokenOnlyDay(10)])), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days[0]!.inputTokens).toBe(53249) + expect(cache.days[0]!.models['claude-sonnet-4-6']!.inputTokens).toBe(53249) + expect(totals(cache.days[1]!)).toEqual(totals(tokenOnlyDay())) + }) +}) + // ═══════════════════════════════════════════════════════════════════════════ // #946 validation round 5, item 2: the migration carried the copilot slice // verbatim instead of re-deriving it.