Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, ModelDayStats> = {}
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<string, unknown>[]): DailyEntry[] {
return days
.filter(d => d && typeof d === 'object' && typeof d.date === 'string' && DATE_KEY_RE.test(d.date))
Expand Down Expand Up @@ -738,6 +762,7 @@ function migrateDays(days: Record<string, unknown>[]): 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/daily-cache-carry-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading