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
43 changes: 37 additions & 6 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,31 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 17: range and day filters now slice a midnight-straddling turn
// instead of filtering it as a unit (issue #852), so call-derived values
// (cost, calls, savings, tokens) bucket under each call's own local day. A
// v15 rollup finalized by the pre-fix binary holds the WHOLE turn on its
// start day, and the new slicing then also puts the post-midnight half on
// the next day — the same cost twice. Nothing downstream can notice on its
// own: `usage-aggregator` serves every day before today from this cache, and
// retention is ten years, so an upgrading user would keep the stale
// whole-turn day N forever while day N+1 grew the sliced half. Raising
// MIN_SUPPORTED_VERSION forces the one-time re-derivation: the new version
// mints a fresh filename, adoption marks the result incomplete, and the next
// hydration rebuilds every day within the retention window whose sources
// survive under per-call bucketing — the re-derive parses the FULL retention
// window, not the 365-day product backfill, so an old-but-still-sourced day
// gets corrected too (days whose sources are gone are carried forward).
//
// v16 is SKIPPED: main already spent it on the codex structural-discovery
// fix (eece4cf, #873/#626), which raised these same two constants to 16 with
// a DIFFERENT meaning. Claiming 16 here too would make this binary load a
// main-built v16 cache — which holds only the codex fix — as current and
// complete, so the straddle re-derivation would never fire for anyone who
// ever ran a main build. The next bump must take the next free number, not
// the last one main used.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
// history outlives the session files, like models and categories already do.
// This bump is the first to ride the v14 carry-forward: the old cache is
Expand Down Expand Up @@ -57,8 +81,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down Expand Up @@ -691,10 +715,17 @@ export async function ensureCacheHydrated(
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
const baseline = c.days
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
// Re-derive the WHOLE retention window, not just the 365-day product
// backfill (BACKFILL_DAYS): these triggers invalidate ALL cached days, and
// a day older than the backfill whose sources still survive must be
// corrected too — otherwise the v17 straddle double-count (or a stale
// savings/tz bucketing) lingers on it for the rest of retention. The cost
// is bounded by the surviving session files, and the path only runs on
// the rare invalidations, never on the daily gap parse.
const rederiveStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - DAILY_CACHE_RETENTION_DAYS)
let freshDays: DailyEntry[] = []
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd }))
if (rederiveStart.getTime() <= yesterdayEnd.getTime()) {
freshDays = aggregateDays(await parseSessions({ start: rederiveStart, end: yesterdayEnd }))
}
const parseWasComplete = sessionComplete()
// A PARTIAL parse must not overwrite finalized baseline days with
Expand Down
59 changes: 40 additions & 19 deletions packages/cli/src/day-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,30 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr

for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
// Turn-anchored bucketing: attribute the WHOLE turn — every one of its
// calls — to the day of the turn's user-message timestamp, matching the
// live headline/report rollup (main.ts daily). Falls back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions that begin mid-conversation). Previously the calls were
// bucketed per-call by each call's own timestamp, so a midnight-
// straddling turn split across two days and history.daily / the provider
// breakdown never reconciled to current.cost (a constant offset).
// Two bucketing rules, deliberately different per level:
// - Turn-level judgments (category, editTurns, oneShotTurns) stay
// anchored to the turn's day (its timestamp — the user-message time,
// or the re-anchored first surviving call when the parser sliced
// the turn to a range, and falling back to the first assistant call
// when the user line is missing). They describe the whole exchange,
// not a per-call sum, so a sliced straddling turn reports them on
// each side's anchor day — summed across days they inflate, which
// is the accepted, documented semantics (see review on #852).
// Unsliced it is the opposite: the judgments stay entirely on the
// turn's start day and never reach the tail, so the tail day emits
// the post-midnight call's cost with zero turn counts (and no
// category entry) — cost without turns, the mirror of the sliced
// case's turns on both sides.
// - Call-derived values (cost/savings/calls/tokens and the model,
// project, and provider-slice rollups built from them) bucket under
// EACH CALL's own local day (the per-call loop below). The parser
// slices straddling turns per range (issue #852), so every parse
// only holds in-range calls and per-call bucketing keeps day-N +
// day-N+1 equal to the whole range — and history.daily reconciled
// to the headline built from the same days. (Before the parser
// sliced per call, per-call bucketing here was what caused the
// constant offset against the whole-turn headline; the slice is
// what makes it exact now.)
const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp)
const turnDay = ensure(turnDate)

Expand Down Expand Up @@ -140,21 +156,26 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr

for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0
// Call-derived values bucket under the call's OWN day (see the
// two-rule comment above). An unparseable call timestamp falls back
// to the turn's anchor day rather than producing a garbage date key.
const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKey(call.timestamp)
const callDay = ensure(callDate)

turnDay.cost += call.costUSD
turnDay.savingsUSD += callSavings
turnDay.calls += 1
turnDay.inputTokens += call.usage.inputTokens
turnDay.outputTokens += call.usage.outputTokens
turnDay.cacheReadTokens += call.usage.cacheReadInputTokens
turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
callDay.cost += call.costUSD
callDay.savingsUSD += callSavings
callDay.calls += 1
callDay.inputTokens += call.usage.inputTokens
callDay.outputTokens += call.usage.outputTokens
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens

const dayProject = ensureProject(turnDay, session.project, project.projectPath)
const dayProject = ensureProject(callDay, session.project, project.projectPath)
dayProject.cost += call.costUSD
dayProject.calls += 1
dayProject.savingsUSD += callSavings

const model = turnDay.models[call.model] ?? {
const model = callDay.models[call.model] ?? {
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
Expand All @@ -166,9 +187,9 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
turnDay.models[call.model] = model
callDay.models[call.model] = model

const slice = ensureSlice(turnDay, call.provider)
const slice = ensureSlice(callDay, call.provider)
slice.calls += 1
slice.cost += call.costUSD
slice.savingsUSD += callSavings
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,9 +497,17 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].savings += call.savingsUSD ?? 0
dailyMap[day].calls += 1
// Cost/savings/calls bucket under each call's OWN day — the same
// per-call rule as the durable day set (day-aggregator.ts), so this
// fallback and durable.days never diverge on a midnight-straddling
// turn (issue #852). Turn counts/edit stats stay anchored on the
// turn's day above. An unparseable call timestamp falls back to the
// turn's day rather than producing a garbage date key.
const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp)
if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[callDay].cost += call.costUSD
dailyMap[callDay].savings += call.savingsUSD ?? 0
dailyMap[callDay].calls += 1
}
}
}
Expand Down
Loading
Loading