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

// Bumped to 23: Codex discovery is structural instead of originator-gated
// Bumped to 24: Codex discovery is structural instead of originator-gated
// (#873/#626), so rollouts written by third-party frontends driving
// `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now
// contribute usage that older rollups never contained. Those files were
Expand All @@ -16,11 +16,17 @@ import type { DateRange, ProjectSummary } from './types.js'
// with it. Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation.
// This branch was authored against v15/16, but main shipped 17 in v0.9.20 and
// has since moved to 20, with 21 (#946) and 22 (#1056) claimed on the
// main-side pipeline; this bump takes 23 so no real user's cache file — built
// main-side pipeline; this bump takes 24 so no real user's cache file — built
// by any binary on either line of history — can be adopted as current without
// the widened-discovery re-derivation firing. A lower number would let a
// main-built cache pass isMigratableCache() unchanged and the fix would never
// take effect for that user.
// This branch also carries the midnight-straddle fix (#852): range and day
// filters now slice a straddling turn per call instead of keeping it whole on
// its anchor day, so a historical day already finalized by any earlier binary
// holds calls that now belong to the next day. That is a second reason the
// rollups must be re-derived once, and it is why this bump takes 24 — the next
// free number after #926's 23 — rather than riding on that version.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
Expand Down Expand Up @@ -74,8 +80,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 = 23
const MIN_SUPPORTED_VERSION = 23
export const DAILY_CACHE_VERSION = 24
const MIN_SUPPORTED_VERSION = 24
// 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 @@ -708,10 +714,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
Loading
Loading