From a9c501b99827ac044a144a958bb37969eb6f49a4 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:54:31 +0700 Subject: [PATCH 1/3] Port upstream 0.67.0: add CostReportingPeriod core and range-aware cost scanners --- rust/src/cost_reporting_period.rs | 456 ++++++++++++++++++ rust/src/cost_scanner.rs | 69 ++- rust/src/cost_scanner/codex.rs | 23 + rust/src/cost_scanner/codex/scan.rs | 14 +- rust/src/cost_scanner/tests.rs | 4 + rust/src/cost_scanner/tests/period.rs | 158 ++++++ rust/src/cost_scanner/window.rs | 61 +++ rust/src/lib.rs | 1 + rust/src/pi_session_cost.rs | 20 +- .../providers/antigravity/local_sessions.rs | 4 +- .../src/providers/antigravity/local_sqlite.rs | 4 +- rust/src/providers/bedrock/mod.rs | 79 ++- rust/src/providers/muse/local_usage/mod.rs | 2 +- rust/src/providers/opencodego/local.rs | 4 +- rust/src/settings.rs | 7 + rust/src/settings/raw.rs | 4 + rust/src/settings/tests.rs | 36 ++ 17 files changed, 900 insertions(+), 46 deletions(-) create mode 100644 rust/src/cost_reporting_period.rs create mode 100644 rust/src/cost_scanner/tests/period.rs create mode 100644 rust/src/cost_scanner/window.rs diff --git a/rust/src/cost_reporting_period.rs b/rust/src/cost_reporting_period.rs new file mode 100644 index 0000000000..c1721afaa3 --- /dev/null +++ b/rust/src/cost_reporting_period.rs @@ -0,0 +1,456 @@ +//! Calendar selection shared by local cost surfaces (upstream 0.67.0). +//! +//! A [`CostReportingPeriod`] is a *semantic* choice (rolling days, month to +//! date, or all available history). Every operation resolves it again into +//! inclusive local-day bounds at its own boundary, so a saved selection never +//! goes stale across midnight or a month rollover. All arithmetic runs on +//! calendar dates, never on 24-hour multiples, so leap years and 23/25-hour +//! daylight-saving days need no special cases. + +use std::fmt; + +use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Largest rolling window a user can select. +pub const MAX_ROLLING_DAYS: u32 = 365; +/// Rolling window used when nothing is saved. +pub const DEFAULT_ROLLING_DAYS: u32 = 30; +/// Sanity ceiling for a resolved day count handed to a scanner (100 years). +/// +/// Rolling selections stop at [`MAX_ROLLING_DAYS`], but *all available* history +/// resolves to many more days. The ceiling only keeps date arithmetic far from +/// `NaiveDate` overflow when a caller passes an arbitrary `u32`. +pub const MAX_WINDOW_DAYS: u32 = 36_500; + +/// First day considered by an all-available window when no earliest source day +/// is known. Nothing this app reads predates it. +const ALL_AVAILABLE_FLOOR: NaiveDate = match NaiveDate::from_ymd_opt(1970, 1, 1) { + Some(date) => date, + None => panic!("valid epoch date"), +}; + +/// Clamp a resolved day count into `1..=MAX_WINDOW_DAYS`. +pub fn clamp_window_days(days: u32) -> u32 { + days.clamp(1, MAX_WINDOW_DAYS) +} + +/// Zone whose midnights bound a reporting day. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CostTimeZone { + /// The machine's local zone (what the local scanners bucket by today). + Local, + /// A pinned IANA zone. + Named(chrono_tz::Tz), +} + +impl CostTimeZone { + /// Zone identifier used in cache identities. + pub fn identifier(&self) -> String { + match self { + Self::Local => crate::core::local_timezone_name(), + Self::Named(tz) => tz.name().to_string(), + } + } + + /// The calendar date `now` falls on in this zone. + pub fn date(&self, now: DateTime) -> NaiveDate { + match self { + Self::Local => now.with_timezone(&Local).date_naive(), + Self::Named(tz) => now.with_timezone(tz).date_naive(), + } + } + + /// The instant a calendar date begins in this zone. + /// + /// A midnight skipped by a DST gap begins at the first valid instant of + /// that date instead. + pub fn start_of_day_utc(&self, date: NaiveDate) -> DateTime { + match self { + Self::Local => Self::resolve(&Local, date), + Self::Named(tz) => Self::resolve(tz, date), + } + } + + fn resolve(tz: &Tz, date: NaiveDate) -> DateTime { + let midnight = date.and_time(chrono::NaiveTime::MIN); + // A gap at midnight (a few zones spring forward at 00:00) has no local + // midnight; the first valid instant is at most a few hours later. + (0..=6) + .find_map(|hours| { + tz.from_local_datetime(&(midnight + Duration::hours(hours))) + .earliest() + }) + .map_or_else( + || Utc.from_utc_datetime(&midnight), + |local| local.with_timezone(&Utc), + ) + } +} + +/// Inclusive local-day bounds resolved from a [`CostReportingPeriod`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CostDayBounds { + pub start: NaiveDate, + pub end: NaiveDate, +} + +impl CostDayBounds { + /// Number of calendar days covered, counting both ends. + pub fn days(&self) -> u32 { + u32::try_from((self.end - self.start).num_days() + 1).unwrap_or(u32::MAX) + } + + /// Whether a `YYYY-MM-DD` day key falls inside the bounds. + pub fn contains_day_key(&self, key: &str) -> bool { + NaiveDate::parse_from_str(key, "%Y-%m-%d") + .is_ok_and(|day| day >= self.start && day <= self.end) + } +} + +/// Which slice of local history a cost surface reports. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CostReportingPeriod { + /// The last N calendar days including today. + /// + /// Parsing and [`Self::rolling`] keep N within `1..=365`; the scanner + /// entry point [`crate::cost_scanner::CostScanner::new`] wraps whatever + /// day count its existing callers pass. + Rolling(u32), + /// From midnight on the first of the current month through today. + MonthToDate, + /// Every day with available source history. Not clamped to a year. + AllAvailable, +} + +impl Default for CostReportingPeriod { + fn default() -> Self { + Self::Rolling(DEFAULT_ROLLING_DAYS) + } +} + +impl CostReportingPeriod { + /// A rolling selection clamped to `1..=365`. + pub fn rolling(days: u32) -> Self { + Self::Rolling(days.clamp(1, MAX_ROLLING_DAYS)) + } + + /// Parse the persisted form: `rolling:N`, `month-to-date`, or `all`. + /// + /// `rolling:0` and non-numeric counts are rejected; counts above 365 clamp. + pub fn parse(raw: &str) -> Option { + match raw.trim() { + "month-to-date" => Some(Self::MonthToDate), + "all" => Some(Self::AllAvailable), + other => { + let days = other.strip_prefix("rolling:")?.parse::().ok()?; + (days > 0).then(|| Self::rolling(days)) + } + } + } + + /// The persisted form, the inverse of [`Self::parse`]. + pub fn raw(&self) -> String { + match self { + Self::Rolling(days) => format!("rolling:{days}"), + Self::MonthToDate => "month-to-date".to_string(), + Self::AllAvailable => "all".to_string(), + } + } + + /// Resolve a saved selection, falling back to a legacy day count and then + /// to the 30-day default. An unreadable saved value migrates like a + /// missing one. + pub fn migrated(raw: Option<&str>, legacy_days: Option) -> Self { + raw.and_then(Self::parse) + .unwrap_or_else(|| Self::rolling(legacy_days.unwrap_or(DEFAULT_ROLLING_DAYS))) + } + + /// Inclusive day bounds for `now` in `tz`. + /// + /// `earliest` only affects [`Self::AllAvailable`]: it is the first day + /// with source data. Without it the window opens at 1970-01-01, which + /// callers that walk day by day must avoid by supplying the real first + /// day. The start never passes today. + pub fn bounds( + &self, + now: DateTime, + tz: CostTimeZone, + earliest: Option, + ) -> CostDayBounds { + let end = tz.date(now); + let start = match self { + Self::Rolling(days) => end - Duration::days(i64::from((*days).max(1) - 1)), + Self::MonthToDate => end.with_day(1).unwrap_or(end), + Self::AllAvailable => earliest.unwrap_or(ALL_AVAILABLE_FLOOR), + }; + CostDayBounds { + start: start.min(end), + end, + } + } + + /// Number of days in the resolved window. + pub fn days(&self, now: DateTime, tz: CostTimeZone, earliest: Option) -> u32 { + self.bounds(now, tz, earliest).days() + } + + /// Keep the entries whose day key lies inside the resolved window. + pub fn entries( + &self, + entries: Vec, + day_key: impl Fn(&T) -> &str, + now: DateTime, + tz: CostTimeZone, + ) -> Vec { + let bounds = self.bounds(now, tz, None); + entries + .into_iter() + .filter(|entry| bounds.contains_day_key(day_key(entry))) + .collect() + } + + /// Cache identity `raw|zone|start|end`. + /// + /// It changes with the selection, the pinned zone, and the resolved dates, + /// so a month rollover or a zone change never reuses a previous window. + pub fn identity(&self, now: DateTime, tz: CostTimeZone) -> String { + let bounds = self.bounds(now, tz, None); + format!( + "{}|{}|{}|{}", + self.raw(), + tz.identifier(), + bounds.start.format("%Y-%m-%d"), + bounds.end.format("%Y-%m-%d"), + ) + } +} + +impl fmt::Display for CostReportingPeriod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.raw()) + } +} + +impl Serialize for CostReportingPeriod { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.raw()) + } +} + +/// Settings files must keep loading, so an unreadable value reads as the default. +impl<'de> Deserialize<'de> for CostReportingPeriod { + fn deserialize>(deserializer: D) -> Result { + let raw = Option::::deserialize(deserializer)?; + Ok(Self::migrated(raw.as_deref(), None)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono_tz::{America::Los_Angeles, Asia::Tokyo}; + + const LA: CostTimeZone = CostTimeZone::Named(Los_Angeles); + const UTC: CostTimeZone = CostTimeZone::Named(chrono_tz::UTC); + + fn at(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .expect("fixture instant") + .with_timezone(&Utc) + } + + fn day(value: &str) -> NaiveDate { + NaiveDate::parse_from_str(value, "%Y-%m-%d").expect("fixture day") + } + + #[test] + fn month_starts_and_day_counts_follow_the_zone_calendar() { + // (now, days into the month, month start): copied in spirit from + // upstream CostReportingPeriodTests (Los Angeles bucket calendar). + let cases = [ + ("2026-01-31T20:00:00Z", 31, "2026-01-01"), + ("2026-02-01T08:00:00Z", 1, "2026-02-01"), + ("2026-02-28T20:00:00Z", 28, "2026-02-01"), + ("2024-02-29T20:00:00Z", 29, "2024-02-01"), + // 23-hour spring-forward day (2026-03-08) earlier in the month. + ("2026-03-09T07:00:00Z", 9, "2026-03-01"), + // 25-hour fall-back day (2026-11-01) earlier in the month. + ("2026-11-02T08:00:00Z", 2, "2026-11-01"), + ]; + for (now, days, start) in cases { + let now = at(now); + let period = CostReportingPeriod::MonthToDate; + assert_eq!(period.days(now, LA, None), days, "{now}"); + assert_eq!(period.bounds(now, LA, None).start, day(start), "{now}"); + assert_eq!(period.bounds(now, LA, None).end, LA.date(now), "{now}"); + } + } + + #[test] + fn identity_distinguishes_periods_rollover_and_zones() { + let january = at("2026-02-01T07:59:59Z"); + let february = january + Duration::seconds(1); + let month = CostReportingPeriod::MonthToDate; + assert_ne!(month.identity(january, LA), month.identity(february, LA)); + assert_ne!( + month.identity(february, LA), + CostReportingPeriod::Rolling(1).identity(february, LA) + ); + assert_eq!(month.days(january, LA, None), 31); + assert_eq!(month.days(january, UTC, None), 1); + assert_ne!(month.identity(january, LA), month.identity(january, UTC)); + assert_eq!( + month.identity(january, LA), + "month-to-date|America/Los_Angeles|2026-01-01|2026-01-31" + ); + } + + #[test] + fn rolling_bounds_include_today_and_never_use_24_hour_math() { + let now = at("2026-03-09T07:00:00Z"); + // Seven days back from Mar 9 crosses the 2026-03-08 spring-forward day. + let bounds = CostReportingPeriod::Rolling(7).bounds(now, LA, None); + assert_eq!( + (bounds.start, bounds.end), + (day("2026-03-03"), day("2026-03-09")) + ); + assert_eq!(bounds.days(), 7); + let today = CostReportingPeriod::Rolling(1).bounds(now, LA, None); + assert_eq!( + (today.start, today.end), + (day("2026-03-09"), day("2026-03-09")) + ); + // A degenerate zero-day rolling window still covers today. + assert_eq!(CostReportingPeriod::Rolling(0).days(now, LA, None), 1); + } + + #[test] + fn month_to_date_on_the_first_covers_only_today() { + let now = at("2026-02-01T08:00:00Z"); + let bounds = CostReportingPeriod::MonthToDate.bounds(now, LA, None); + assert_eq!(bounds.start, bounds.end); + assert_eq!(bounds.start, day("2026-02-01")); + } + + #[test] + fn month_start_follows_the_pinned_zone_across_the_date_line() { + let now = at("2026-02-28T16:00:00Z"); + // Tokyo is already on March 1; Los Angeles is still on February 28. + assert_eq!( + CostReportingPeriod::MonthToDate + .bounds(now, CostTimeZone::Named(Tokyo), None) + .start, + day("2026-03-01") + ); + assert_eq!( + CostReportingPeriod::MonthToDate.bounds(now, LA, None).start, + day("2026-02-01") + ); + } + + #[test] + fn all_available_uses_the_earliest_day_and_is_not_clamped_to_a_year() { + let now = at("2026-07-15T12:00:00Z"); + let earliest = day("2023-05-04"); + let bounds = CostReportingPeriod::AllAvailable.bounds(now, UTC, Some(earliest)); + assert_eq!((bounds.start, bounds.end), (earliest, day("2026-07-15"))); + assert!(bounds.days() > MAX_ROLLING_DAYS); + // Without a known earliest day the window opens at the epoch floor. + assert_eq!( + CostReportingPeriod::AllAvailable + .bounds(now, UTC, None) + .start, + day("1970-01-01") + ); + // A future earliest day cannot push the start past today. + let future = CostReportingPeriod::AllAvailable.bounds(now, UTC, Some(day("2030-01-01"))); + assert_eq!(future.start, future.end); + } + + #[test] + fn raw_forms_round_trip_and_reject_invalid_input() { + for (raw, period) in [ + ("rolling:30", CostReportingPeriod::Rolling(30)), + ("rolling:1", CostReportingPeriod::Rolling(1)), + ("month-to-date", CostReportingPeriod::MonthToDate), + ("all", CostReportingPeriod::AllAvailable), + ] { + assert_eq!(CostReportingPeriod::parse(raw), Some(period)); + assert_eq!(period.raw(), raw); + } + assert_eq!( + CostReportingPeriod::parse("rolling:999"), + Some(CostReportingPeriod::Rolling(365)) + ); + for invalid in [ + "", + "rolling:0", + "rolling:", + "rolling:-3", + "rolling:x", + "week", + "ALL", + ] { + assert_eq!(CostReportingPeriod::parse(invalid), None, "{invalid:?}"); + } + } + + #[test] + fn legacy_windows_migrate_unchanged() { + let migrated = CostReportingPeriod::migrated; + assert_eq!(migrated(None, Some(90)), CostReportingPeriod::Rolling(90)); + assert_eq!(migrated(None, None), CostReportingPeriod::Rolling(30)); + assert_eq!( + migrated(Some("invalid"), Some(999)), + CostReportingPeriod::Rolling(365) + ); + assert_eq!( + migrated(Some("month-to-date"), Some(90)), + CostReportingPeriod::MonthToDate + ); + } + + #[test] + fn entries_filter_by_the_resolved_window() { + let now = at("2026-02-02T20:00:00Z"); + let rows = vec![ + ("2026-01-31", 100), + ("2026-02-01", 3), + ("2026-02-02", 4), + ("2026-02-03", 9), + ("not-a-day", 1), + ]; + let kept = CostReportingPeriod::MonthToDate.entries(rows, |row| row.0, now, LA); + assert_eq!(kept.iter().map(|row| row.1).sum::(), 7); + } + + #[test] + fn start_of_day_is_the_local_midnight_instant() { + assert_eq!( + LA.start_of_day_utc(day("2026-02-01")), + at("2026-02-01T08:00:00Z") + ); + // Daylight time: the first of November 2026 is still PDT (UTC-7). + assert_eq!( + LA.start_of_day_utc(day("2026-11-01")), + at("2026-11-01T07:00:00Z") + ); + assert_eq!( + UTC.start_of_day_utc(day("2026-02-01")), + at("2026-02-01T00:00:00Z") + ); + } + + #[test] + fn serde_uses_the_raw_string_and_tolerates_unreadable_values() { + assert_eq!( + serde_json::to_string(&CostReportingPeriod::MonthToDate).unwrap(), + "\"month-to-date\"" + ); + let read = |json: &str| serde_json::from_str::(json).unwrap(); + assert_eq!(read("\"all\""), CostReportingPeriod::AllAvailable); + assert_eq!(read("\"nonsense\""), CostReportingPeriod::Rolling(30)); + assert_eq!(read("null"), CostReportingPeriod::Rolling(30)); + } +} diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 619f9ba7b0..ce1c98ebe2 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -21,14 +21,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(test)] use crate::codex_costs::scan_codex_file_cost; use crate::codex_costs::{ - add_codex_days_map_to_summary, add_codex_records_to_summary, codex_period_start, - codex_scan_dates, merge_codex_records_into_days, + add_codex_days_map_to_summary, add_codex_records_to_summary, codex_scan_dates, + merge_codex_records_into_days, }; use crate::codex_sessions::{codex_sessions_dir_candidates, default_wsl_roots}; use crate::core::{ CachedCostReport, CodexScanPauseReason, CostScanOptions, CostUsageCache, CostUsageDayRange, CostUsageFileUsage, JsonlScanner, ProviderId, }; +use crate::cost_reporting_period::{CostReportingPeriod, MAX_ROLLING_DAYS}; use crate::providers::claude::quota_history::{ ClaudeHistoryAttribution, ClaudeQuotaDedupKey, ClaudeQuotaHistoryRecord, }; @@ -39,6 +40,7 @@ mod claude_usage; mod codex; mod read_receipt; mod stats; +mod window; use claude_pricing::ClaudeScanPricingResolver; #[cfg(test)] use claude_pricing::{ClaudePricing, FALLBACK_CLAUDE_MODEL}; @@ -487,7 +489,8 @@ impl ClaudeFileScanResult { #[derive(Debug, Clone)] pub struct CostScanner { - days: u32, + /// The window every scan resolves at its own start (never cached as dates). + period: CostReportingPeriod, options: CostScanOptions, cache_root: Option, /// When set, bypass normal sessions-dir discovery (tests / inject roots). @@ -497,14 +500,25 @@ pub struct CostScanner { impl CostScanner { /// Create a new scanner for the last N days (default 60s cache debounce). pub fn new(days: u32) -> Self { + Self::for_period(CostReportingPeriod::Rolling(days)) + } + + /// Create a scanner for a reporting period. `new(days)` is + /// `for_period(CostReportingPeriod::Rolling(days))`. + pub fn for_period(period: CostReportingPeriod) -> Self { Self { - days, + period, options: CostScanOptions::default(), cache_root: None, sessions_dirs_override: None, } } + /// The reporting period this scanner resolves on every scan. + pub fn period(&self) -> CostReportingPeriod { + self.period + } + /// Override scan options (e.g. [`CostScanOptions::app_driven`] for force refresh). pub fn with_options(mut self, options: CostScanOptions) -> Self { self.options = options; @@ -533,16 +547,17 @@ impl CostScanner { } pub fn scan_pi_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { - let today = Utc::now().date_naive(); + let now = Utc::now(); + let window = self.transcript_window(now, now.date_naive()); let mut summary = CostSummary { - period_start: Some(today - Duration::days(self.days as i64)), - period_end: Some(today), + period_start: Some(window.start), + period_end: Some(window.end), ..CostSummary::default() }; let mut seen_entries = HashSet::new(); let evidence = crate::pi_session_cost::scan_pi_into( &mut summary, - self.days, + window.cutoff, cancel, &mut seen_entries, ); @@ -577,12 +592,12 @@ impl CostScanner { ) -> CostSummary { let projects_dir = self.get_claude_projects_dir(); let mut summary = CostSummary::default(); - let today = Utc::now().date_naive(); - let start_date = today - Duration::days(self.days as i64); - let cutoff = Utc::now() - Duration::days(self.days as i64); + let now = Utc::now(); + let window = self.transcript_window(now, now.date_naive()); + let cutoff = window.cutoff; - summary.period_start = Some(start_date); - summary.period_end = Some(today); + summary.period_start = Some(window.start); + summary.period_end = Some(window.end); // Walk through projects directory, de-duplicating usage records // that appear across multiple files. @@ -615,7 +630,7 @@ impl CostScanner { crate::pi_session_cost::scan_pi_compatible_into( &mut summary, crate::pi_session_cost::PiMappedProvider::Claude, - self.days, + cutoff, cancel, &mut seen_pi, ); @@ -643,17 +658,26 @@ impl CostScanner { cancel: Option<&AtomicBool>, ) -> ClaudeChartSnapshot { let projects_dir = self.get_claude_projects_dir(); - let today = Local::now().date_naive(); - let cutoff = Utc::now() - Duration::days(self.days as i64); + let now = Utc::now(); + let window = self.transcript_window(now, Local::now().date_naive()); + let cutoff = window.cutoff; let mut summary = CostSummary { - period_start: Some(today - Duration::days(self.days as i64)), - period_end: Some(today), + period_start: Some(window.start), + period_end: Some(window.end), ..CostSummary::default() }; let mut daily_cost = HashMap::new(); let mut daily_tokens = HashMap::new(); - for days_ago in 0..self.days { - let date = today - Duration::days(days_ago as i64); + // The chart never shows more than a year of daily slots, so an + // all-available window does not allocate one per day since 1970. + let slot_days = match self.period { + CostReportingPeriod::Rolling(days) => days, + CostReportingPeriod::MonthToDate | CostReportingPeriod::AllAvailable => { + window.days.min(MAX_ROLLING_DAYS) + } + }; + for days_ago in 0..slot_days { + let date = window.end - Duration::days(days_ago as i64); let key = date.format("%Y-%m-%d").to_string(); daily_cost.insert(key.clone(), None); daily_tokens.insert(key, 0); @@ -695,7 +719,7 @@ impl CostScanner { crate::pi_session_cost::scan_pi_compatible_into( &mut summary, crate::pi_session_cost::PiMappedProvider::Claude, - self.days, + cutoff, cancel, &mut HashSet::new(), ); @@ -763,7 +787,8 @@ impl CostScanner { return CostSummary::default(); } let now = Utc::now(); - let Some(local) = opencodego_local::model_cost_summary_scan(now, self.days) else { + let days = self.calendar_window(now, None).days; + let Some(local) = opencodego_local::model_cost_summary_scan(now, days) else { return CostSummary::default(); }; CostSummary { diff --git a/rust/src/cost_scanner/codex.rs b/rust/src/cost_scanner/codex.rs index f054c6eb8f..4491c87bf3 100644 --- a/rust/src/cost_scanner/codex.rs +++ b/rust/src/cost_scanner/codex.rs @@ -102,6 +102,29 @@ fn codex_parent_baseline( baseline } +/// First day that can hold a Codex date partition: January 1 of the earliest +/// `YYYY` directory under any sessions root (upstream `firstPartitionDate`). +/// +/// An all-available window starts here so the `YYYY/MM/DD` walk never probes +/// empty history. Years before 1970 are stray directories, not sessions. +pub(super) fn first_codex_partition_date(sessions_dirs: &[PathBuf]) -> Option { + sessions_dirs + .iter() + .filter_map(|root| fs::read_dir(root).ok()) + .flat_map(|entries| entries.flatten()) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + (name.len() == 4 && name.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| name.parse::().ok()) + .flatten() + }) + .filter(|year| *year >= 1970) + .min() + .and_then(|year| NaiveDate::from_ymd_opt(year, 1, 1)) +} + fn is_codex_path_in_scan_window( path: &Path, sessions_dirs: &[PathBuf], diff --git a/rust/src/cost_scanner/codex/scan.rs b/rust/src/cost_scanner/codex/scan.rs index e983adfd6a..a1a87533f0 100644 --- a/rust/src/cost_scanner/codex/scan.rs +++ b/rust/src/cost_scanner/codex/scan.rs @@ -89,8 +89,15 @@ pub(super) fn scan_codex_detailed_with_cache( ) -> (CostSummary, CostScanStats, CostUsageCache) { let mut summary = CostSummary::default(); let mut stats = CostScanStats::default(); + let sessions_dirs = scanner.get_codex_sessions_dirs(); + let now = Utc::now(); let today = Local::now().date_naive(); - let start_date = codex_period_start(today, scanner.days); + // All-available history opens at the first existing partition; with no + // partitions it is just today, so an empty tree costs no directory probes. + let earliest = (scanner.period == CostReportingPeriod::AllAvailable) + .then(|| first_codex_partition_date(&sessions_dirs).unwrap_or(today)); + let window = scanner.calendar_window(now, earliest); + let (start_date, today) = (window.start, window.end); let range = CostUsageDayRange::new(start_date, today); let now_ms = unix_now_ms(); @@ -99,7 +106,6 @@ pub(super) fn scan_codex_detailed_with_cache( let cache_root = scanner.cache_root.as_deref(); let mut cache = JsonlScanner::load_cache(ProviderId::Codex, cache_root); - let sessions_dirs = scanner.get_codex_sessions_dirs(); let pending_scan = CodexPendingScanContext::new( &cache, &range, @@ -452,11 +458,13 @@ fn append_pi_compatible_costs( return; } + let now = Utc::now(); + let cutoff = scanner.transcript_window(now, now.date_naive()).cutoff; let mut seen_pi = HashSet::new(); crate::pi_session_cost::scan_pi_compatible_into( summary, crate::pi_session_cost::PiMappedProvider::Codex, - scanner.days, + cutoff, cancel, &mut seen_pi, ); diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 3b16c92a1c..19acf2a9d0 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::codex_costs::codex_period_start; use crate::core::{CodexSessionLineage, CostUsagePricing}; use std::io::Write; @@ -3039,3 +3040,6 @@ fn incomplete_or_buffered_empty_codex_fragment_is_not_marked_complete() { #[cfg(test)] #[path = "tests/paginated.rs"] mod paginated; + +#[path = "tests/period.rs"] +mod period; diff --git a/rust/src/cost_scanner/tests/period.rs b/rust/src/cost_scanner/tests/period.rs new file mode 100644 index 0000000000..68d84c72a0 --- /dev/null +++ b/rust/src/cost_scanner/tests/period.rs @@ -0,0 +1,158 @@ +//! Reporting-period behavior of the scanner: window resolution and the Codex +//! partition walk for month-to-date and all-available history. + +use super::*; +use crate::cost_reporting_period::CostReportingPeriod; +use chrono::{Datelike, TimeZone}; + +fn utc(y: i32, m: u32, d: u32, h: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, 0, 0).single().unwrap() +} + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Writes one Codex session under the `YYYY/MM/DD` partition for `day`. +fn write_session_on(sessions_root: &Path, day: NaiveDate, input_tokens: u64) { + let day_dir = sessions_root + .join(day.format("%Y").to_string()) + .join(day.format("%m").to_string()) + .join(day.format("%d").to_string()); + std::fs::create_dir_all(&day_dir).unwrap(); + // Local noon keeps the record on `day` in the local zone. + let local_noon = Local + .from_local_datetime(&day.and_hms_opt(12, 0, 0).unwrap()) + .earliest() + .unwrap() + .with_timezone(&Utc); + let ts = local_noon.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let body = format!( + r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":{input_tokens},"cached_input_tokens":0,"output_tokens":5}}}}}}}} +"# + ); + std::fs::write(day_dir.join(format!("s-{input_tokens}.jsonl")), body).unwrap(); +} + +fn scan_input_tokens(period: CostReportingPeriod, sessions: &Path, cache: &Path) -> u64 { + let scanner = CostScanner::for_period(period) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(cache) + .with_sessions_dirs(vec![sessions.to_path_buf()]); + let (summary, _) = scanner.scan_codex_detailed(None); + summary.input_tokens +} + +#[test] +fn for_period_rolling_matches_new() { + assert_eq!( + CostScanner::new(14).period(), + CostScanner::for_period(CostReportingPeriod::Rolling(14)).period() + ); +} + +#[test] +fn zero_day_scanner_still_resolves_a_one_day_window() { + let now = Utc::now(); + let window = CostScanner::new(0).calendar_window(now, None); + assert_eq!(window.days, 1); + assert_eq!(window.start, window.end); +} + +#[test] +fn transcript_window_rolling_keeps_the_legacy_shape() { + let now = utc(2026, 5, 15, 12); + let today = date(2026, 5, 15); + let window = CostScanner::new(30).transcript_window(now, today); + assert_eq!(window.start, date(2026, 4, 15)); + assert_eq!(window.end, today); + assert_eq!(window.cutoff, now - Duration::days(30)); + assert_eq!(window.days, 30); +} + +#[test] +fn transcript_window_month_to_date_starts_at_the_month() { + let now = Utc::now(); + let scanner = CostScanner::for_period(CostReportingPeriod::MonthToDate); + let window = scanner.transcript_window(now, now.date_naive()); + let local_today = Local::now().date_naive(); + assert_eq!(window.start.day(), 1); + assert_eq!(window.start.month(), local_today.month()); + assert!(window.cutoff <= now); + assert!(window.days >= 1 && window.days <= 31); +} + +#[test] +fn calendar_window_all_uses_the_earliest_day_without_clamping() { + let now = Utc::now(); + let earliest = date(2000, 1, 1); + let scanner = CostScanner::for_period(CostReportingPeriod::AllAvailable); + let window = scanner.calendar_window(now, Some(earliest)); + assert_eq!(window.start, earliest); + assert!(window.days > 365 * 20); +} + +#[test] +fn first_partition_skips_years_before_the_first_existing_one() { + let root = tempfile::tempdir().unwrap(); + for name in ["2024", "2023", "0500", "abcd", "20240"] { + std::fs::create_dir_all(root.path().join(name)).unwrap(); + } + std::fs::write(root.path().join("1999"), b"file, not a partition").unwrap(); + assert_eq!( + codex::first_codex_partition_date(&[root.path().to_path_buf()]), + Some(date(2023, 1, 1)) + ); +} + +#[test] +fn first_partition_is_none_for_empty_or_missing_roots() { + let root = tempfile::tempdir().unwrap(); + assert_eq!( + codex::first_codex_partition_date(&[root.path().to_path_buf()]), + None + ); + assert_eq!( + codex::first_codex_partition_date(&[root.path().join("missing")]), + None + ); +} + +#[test] +fn codex_all_available_reads_history_beyond_a_year() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let today = Local::now().date_naive(); + write_session_on(&sessions, today, 100); + write_session_on(&sessions, today - Duration::days(500), 1_000); + + let rolling = scan_input_tokens( + CostReportingPeriod::Rolling(30), + &sessions, + &root.path().join("cache-rolling"), + ); + let all = scan_input_tokens( + CostReportingPeriod::AllAvailable, + &sessions, + &root.path().join("cache-all"), + ); + assert_eq!(rolling, 100, "rolling 30 excludes the 500 day old session"); + assert_eq!(all, 1_100, "all available includes the old partition"); +} + +#[test] +fn codex_month_to_date_excludes_the_previous_month() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let today = Local::now().date_naive(); + let month_start = today.with_day(1).unwrap(); + write_session_on(&sessions, today, 100); + write_session_on(&sessions, month_start - Duration::days(3), 1_000); + + let mtd = scan_input_tokens( + CostReportingPeriod::MonthToDate, + &sessions, + &root.path().join("cache-mtd"), + ); + assert_eq!(mtd, 100); +} diff --git a/rust/src/cost_scanner/window.rs b/rust/src/cost_scanner/window.rs new file mode 100644 index 0000000000..e108b7e063 --- /dev/null +++ b/rust/src/cost_scanner/window.rs @@ -0,0 +1,61 @@ +//! Resolves a scanner's [`CostReportingPeriod`] into concrete scan windows. + +use chrono::{DateTime, Duration, NaiveDate, Utc}; + +use super::CostScanner; +use crate::cost_reporting_period::{CostReportingPeriod, CostTimeZone, clamp_window_days}; + +/// Inclusive local-day window plus the instant transcript scanners cut off at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ScanWindow { + /// First day of the window (also reported as `period_start`). + pub start: NaiveDate, + /// Last day of the window (today). + pub end: NaiveDate, + /// Records before this instant are outside the window. + pub cutoff: DateTime, + /// Resolved day count for scanners that take a day count. + pub days: u32, +} + +impl CostScanner { + /// The window in the local zone, resolved from the calendar. + /// + /// `earliest` is the first day with source data; it only matters for + /// [`CostReportingPeriod::AllAvailable`]. Rolling(N) covers N days + /// including today, and the cutoff is the start of the first day. + pub(super) fn calendar_window( + &self, + now: DateTime, + earliest: Option, + ) -> ScanWindow { + let tz = CostTimeZone::Local; + let bounds = self.period.bounds(now, tz, earliest); + ScanWindow { + start: bounds.start, + end: bounds.end, + cutoff: tz.start_of_day_utc(bounds.start), + days: clamp_window_days(bounds.days()), + } + } + + /// The window used by the Claude and Pi transcript scanners. + /// + /// A rolling window keeps its historical shape (a `now - N * 24h` cutoff + /// and `today - N` as the reported start, with `today` supplied by the + /// caller); month to date and all available history use exact calendar + /// midnights so no record from the previous month leaks in. + pub(super) fn transcript_window(&self, now: DateTime, today: NaiveDate) -> ScanWindow { + match self.period { + CostReportingPeriod::Rolling(days) => ScanWindow { + start: today - Duration::days(i64::from(days)), + end: today, + cutoff: now - Duration::days(i64::from(days)), + days, + }, + CostReportingPeriod::MonthToDate | CostReportingPeriod::AllAvailable => { + self.calendar_window(now, None) + } + } + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1dfa17cff5..17e12e3554 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,6 +11,7 @@ pub mod codex_accounts; pub mod codex_cli; pub mod codex_workspaces; pub mod core; +pub mod cost_reporting_period; pub mod cost_scanner; pub mod host; pub mod locale; diff --git a/rust/src/pi_session_cost.rs b/rust/src/pi_session_cost.rs index a9f8f1b82c..4ff684f8d5 100644 --- a/rust/src/pi_session_cost.rs +++ b/rust/src/pi_session_cost.rs @@ -113,13 +113,13 @@ fn resolve_environment_path(value: &str, cwd: &Path) -> Option { pub fn scan_pi_compatible_into( summary: &mut CostSummary, target: PiMappedProvider, - days: u32, + cutoff: DateTime, cancel: Option<&AtomicBool>, seen_entries: &mut HashSet, ) -> PiScanEvidence { scan_roots_into( summary, - days, + cutoff, cancel, seen_entries, pi_compatible_session_roots(dirs::home_dir()), @@ -135,13 +135,13 @@ pub fn scan_pi_compatible_into( /// both roots so standalone Pi history is not double-counted. pub fn scan_pi_into( summary: &mut CostSummary, - days: u32, + cutoff: DateTime, cancel: Option<&AtomicBool>, seen_entries: &mut HashSet, ) -> PiScanEvidence { scan_roots_into( summary, - days, + cutoff, cancel, seen_entries, pi_compatible_session_roots(dirs::home_dir()), @@ -209,13 +209,12 @@ fn scan_pi_daily_from_roots( fn scan_roots_into( summary: &mut CostSummary, - days: u32, + cutoff: DateTime, cancel: Option<&AtomicBool>, seen_entries: &mut HashSet, roots: Vec, target: Option, ) -> PiScanEvidence { - let cutoff = Utc::now() - Duration::days(days as i64); let mut sessions = 0u32; let mut evidence = PiScanEvidence::default(); for root in roots { @@ -895,7 +894,14 @@ mod tests { let mut summary = CostSummary::default(); let mut seen = HashSet::new(); - let evidence = scan_roots_into(&mut summary, 365, None, &mut seen, vec![sessions], None); + let evidence = scan_roots_into( + &mut summary, + Utc::now() - Duration::days(365), + None, + &mut seen, + vec![sessions], + None, + ); assert!(!evidence.complete); assert_eq!(summary.input_tokens, 11); } diff --git a/rust/src/providers/antigravity/local_sessions.rs b/rust/src/providers/antigravity/local_sessions.rs index 458bb97f69..5e785b8c0c 100644 --- a/rust/src/providers/antigravity/local_sessions.rs +++ b/rust/src/providers/antigravity/local_sessions.rs @@ -141,7 +141,9 @@ fn summarize_paths( truncated: bool, ) -> LocalSessionSummary { let first_day = now.with_timezone(&Local).date_naive() - - Duration::days(i64::from(days.clamp(1, 365).saturating_sub(1))); + - Duration::days(i64::from( + crate::cost_reporting_period::clamp_window_days(days).saturating_sub(1), + )); let mut total_tokens = 0_u64; let mut sessions_with_usage = HashSet::new(); let mut seen_response_ids = HashSet::new(); diff --git a/rust/src/providers/antigravity/local_sqlite.rs b/rust/src/providers/antigravity/local_sqlite.rs index b050660411..fb229feeca 100644 --- a/rust/src/providers/antigravity/local_sqlite.rs +++ b/rust/src/providers/antigravity/local_sqlite.rs @@ -139,7 +139,9 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL } let first_day = now.with_timezone(&Local).date_naive() - - Duration::days(i64::from(days.clamp(1, 365).saturating_sub(1))); + - Duration::days(i64::from( + crate::cost_reporting_period::clamp_window_days(days).saturating_sub(1), + )); let mut complete = discovery_complete && budget.check(); let mut events = Vec::new(); let mut authoritative_database = false; diff --git a/rust/src/providers/bedrock/mod.rs b/rust/src/providers/bedrock/mod.rs index ad5fdef00b..4068eab03b 100644 --- a/rust/src/providers/bedrock/mod.rs +++ b/rust/src/providers/bedrock/mod.rs @@ -3,7 +3,7 @@ //! Fetches current-month Bedrock spend from AWS Cost Explorer using SigV4. use async_trait::async_trait; -use chrono::{Datelike, Duration, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Duration, Months, NaiveDate, TimeZone, Utc}; use reqwest::Client; use serde_json::{Value, json}; @@ -11,6 +11,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, hex, hmac_sha256, sha256_hex, }; +use crate::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; const COST_EXPLORER_URL: &str = "https://ce.us-east-1.amazonaws.com"; const COST_EXPLORER_TARGET: &str = "AWSInsightsIndexService.GetCostAndUsage"; @@ -656,19 +657,36 @@ fn parse_aws_profile_credentials(stdout: &[u8]) -> Result (String, String) { - let now = Utc::now(); - let start = Utc - .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) - .single() - .unwrap_or(now); - let tomorrow = (now + Duration::days(1)).date_naive(); +/// Cost Explorer `TimePeriod` for daily buckets (upstream `dailyRange`). +/// +/// Cost Explorer buckets are UTC, so the month is resolved in UTC. It exposes +/// the current month plus thirteen earlier months, so `since` never reaches +/// further back than that; the exclusive end is tomorrow. An all-available +/// request passes any early `since` and gets the whole exposed range. +fn daily_range(since: NaiveDate, now: DateTime) -> (String, String) { + let month_start = utc_month_start(now); + let earliest = month_start + .checked_sub_months(Months::new(13)) + .unwrap_or(month_start); + let tomorrow = now.date_naive() + Duration::days(1); ( - start.format("%Y-%m-%d").to_string(), + since.max(earliest).format("%Y-%m-%d").to_string(), tomorrow.format("%Y-%m-%d").to_string(), ) } +fn utc_month_start(now: DateTime) -> NaiveDate { + CostReportingPeriod::MonthToDate + .bounds(now, CostTimeZone::Named(chrono_tz::UTC), None) + .start +} + +/// Current-month range: month to date through tomorrow (exclusive). +fn current_month_range() -> (String, String) { + let now = Utc::now(); + daily_range(utc_month_start(now), now) +} + fn end_of_current_month() -> Option> { let now = Utc::now(); let (year, month) = if now.month() == 12 { @@ -805,6 +823,49 @@ fn sanitized_body(body: &str) -> String { mod tests { use super::*; + fn utc(y: i32, m: u32, d: u32, h: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, 0, 0).single().unwrap() + } + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn daily_range_month_to_date_starts_at_utc_month_start() { + let now = utc(2026, 5, 15, 12); + let (start, end) = daily_range(utc_month_start(now), now); + assert_eq!(start, "2026-05-01"); + assert_eq!(end, "2026-05-16"); + } + + #[test] + fn daily_range_month_start_uses_utc_not_local_time() { + // 23:30 UTC on the last day of April is still April in Cost Explorer. + let now = Utc + .with_ymd_and_hms(2026, 4, 30, 23, 30, 0) + .single() + .unwrap(); + let (start, end) = daily_range(utc_month_start(now), now); + assert_eq!(start, "2026-04-01"); + assert_eq!(end, "2026-05-01"); + } + + #[test] + fn daily_range_all_is_capped_at_current_month_plus_thirteen() { + let now = utc(2026, 5, 15, 12); + let (start, end) = daily_range(date(2000, 1, 1), now); + assert_eq!(start, "2025-04-01"); + assert_eq!(end, "2026-05-16"); + } + + #[test] + fn daily_range_keeps_a_recent_since() { + let now = utc(2026, 5, 15, 12); + let (start, _) = daily_range(date(2026, 3, 10), now); + assert_eq!(start, "2026-03-10"); + } + #[test] fn parses_bedrock_cost_only() { let page = json!({ diff --git a/rust/src/providers/muse/local_usage/mod.rs b/rust/src/providers/muse/local_usage/mod.rs index 49a2f7ec34..9ae2ce6d30 100644 --- a/rust/src/providers/muse/local_usage/mod.rs +++ b/rust/src/providers/muse/local_usage/mod.rs @@ -155,7 +155,7 @@ fn cache_root() -> PathBuf { } fn day_window(days: u32) -> (String, String) { - let days = days.clamp(1, 365); + let days = crate::cost_reporting_period::clamp_window_days(days); let today = Local::now().date_naive(); ( (today - chrono::Duration::days(i64::from(days - 1))).to_string(), diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 5777706864..0af1b3b2ad 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -378,7 +378,7 @@ pub fn daily_model_costs( now: DateTime, history_days: u32, ) -> Vec { - let clamped = history_days.clamp(1, 365); + let clamped = crate::cost_reporting_period::clamp_window_days(history_days); let today = local_today_from_utc(now); let since = today - Duration::days(clamped as i64 - 1); let since_ms = Local @@ -431,7 +431,7 @@ pub fn model_cost_summary_from_rows( now: DateTime, days: u32, ) -> ModelCostSummary { - let clamped = days.clamp(1, 365); + let clamped = crate::cost_reporting_period::clamp_window_days(days); let today = local_today_from_utc(now); let since = today - Duration::days(clamped as i64 - 1); let since_ms = Local diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 9863f194ce..f1e9b19d23 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -16,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use crate::core::ProviderId; +use crate::cost_reporting_period::CostReportingPeriod; /// Stable namespace used by the desktop bridge for quota metric rows. pub const USAGE_ITEM_METRIC_PREFIX: &str = "metric:"; @@ -369,6 +370,11 @@ pub struct Settings { /// Hide native Codex spend rows when an OpenCodex import is present. #[serde(default)] pub hide_native_codex_cost_when_open_codex_present: bool, + + /// History window for local cost surfaces: `rolling:N`, `month-to-date`, or + /// `all` (upstream 0.67.0). Missing or unreadable values read as `rolling:30`. + #[serde(default)] + pub cost_reporting_period: CostReportingPeriod, } fn default_window_scale_percent() -> u16 { @@ -595,6 +601,7 @@ impl Default for Settings { cost_summary_display_style: CostSummaryDisplayStyle::default(), open_codex_usage_logs_enabled: false, hide_native_codex_cost_when_open_codex_present: false, + cost_reporting_period: CostReportingPeriod::default(), } } } diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 850c90e960..6cd64e957f 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -183,6 +183,8 @@ pub(super) struct RawSettings { open_codex_usage_logs_enabled: bool, #[serde(default)] hide_native_codex_cost_when_open_codex_present: bool, + #[serde(default)] + cost_reporting_period: CostReportingPeriod, } impl Default for RawSettings { @@ -290,6 +292,7 @@ impl Default for RawSettings { open_codex_usage_logs_enabled: s.open_codex_usage_logs_enabled, hide_native_codex_cost_when_open_codex_present: s .hide_native_codex_cost_when_open_codex_present, + cost_reporting_period: s.cost_reporting_period, } } } @@ -600,6 +603,7 @@ impl From for Settings { hide_native_codex_cost_when_open_codex_present: raw .hide_native_codex_cost_when_open_codex_present, codex_external_oauth_sources_allowed: raw.codex_external_oauth_sources_allowed, + cost_reporting_period: raw.cost_reporting_period, } } } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index a6db4aa05b..531ab3052f 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -115,6 +115,42 @@ fn open_codex_usage_logs_default_off_and_round_trip() { assert!(loaded.hide_native_codex_cost_when_open_codex_present); } +#[test] +fn cost_reporting_period_defaults_to_thirty_days_and_round_trips() { + let defaulted: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) + .expect("missing cost_reporting_period defaults"); + assert_eq!( + defaulted.cost_reporting_period, + CostReportingPeriod::Rolling(30) + ); + + for period in [ + CostReportingPeriod::Rolling(90), + CostReportingPeriod::MonthToDate, + CostReportingPeriod::AllAvailable, + ] { + let settings = Settings { + cost_reporting_period: period, + ..Settings::default() + }; + let json = serde_json::to_string(&settings).expect("serialize cost period"); + assert!(json.contains(&format!(r#""cost_reporting_period":"{}""#, period.raw()))); + let loaded: Settings = serde_json::from_str(&json).expect("deserialize cost period"); + assert_eq!(loaded.cost_reporting_period, period); + } +} + +#[test] +fn unreadable_cost_reporting_period_loads_as_the_default() { + let loaded: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "cost_reporting_period": "weekly" }"#) + .expect("an unreadable period must not fail the whole settings file"); + assert_eq!( + loaded.cost_reporting_period, + CostReportingPeriod::Rolling(30) + ); +} + #[test] fn notification_sound_paths_round_trip_and_default_for_existing_settings() { let settings = Settings { From 195870a6f3e79003bc2c9dd948b6fb94bfdc4f0d Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:07:40 +0700 Subject: [PATCH 2/3] Address thermo review --- rust/src/cost_reporting_period.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/rust/src/cost_reporting_period.rs b/rust/src/cost_reporting_period.rs index c1721afaa3..8f0e41e3a4 100644 --- a/rust/src/cost_reporting_period.rs +++ b/rust/src/cost_reporting_period.rs @@ -241,7 +241,17 @@ impl Serialize for CostReportingPeriod { /// Settings files must keep loading, so an unreadable value reads as the default. impl<'de> Deserialize<'de> for CostReportingPeriod { fn deserialize>(deserializer: D) -> Result { - let raw = Option::::deserialize(deserializer)?; + #[derive(Deserialize)] + #[serde(untagged)] + enum SavedPeriod { + Text(String), + Unreadable(serde::de::IgnoredAny), + } + + let raw = match SavedPeriod::deserialize(deserializer)? { + SavedPeriod::Text(raw) => Some(raw), + SavedPeriod::Unreadable(_) => None, + }; Ok(Self::migrated(raw.as_deref(), None)) } } @@ -452,5 +462,7 @@ mod tests { assert_eq!(read("\"all\""), CostReportingPeriod::AllAvailable); assert_eq!(read("\"nonsense\""), CostReportingPeriod::Rolling(30)); assert_eq!(read("null"), CostReportingPeriod::Rolling(30)); + assert_eq!(read("42"), CostReportingPeriod::Rolling(30)); + assert_eq!(read(r#"{"a":1}"#), CostReportingPeriod::Rolling(30)); } } From 72fafbfa49b62d2c6a33aea2e1984602013281f3 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:33:53 +0700 Subject: [PATCH 3/3] Pin the cost bucket time zone Port upstream 0.67.0 tokenCostUsageBucketTimeZone: a saved IANA zone (settings cost_usage_bucket_time_zone) buckets local cost history for Codex, Claude, Pi, Muse, Antigravity and OpenCodex, so day keys and month-to-date bounds survive a machine zone change. Codex caches record the zone and rebuild when it changes; unstamped caches are kept. Grok and OpenCode Go stay on the machine calendar as upstream. --- rust/src/cli/serve/dashboard/source.rs | 7 +- rust/src/codex_costs.rs | 9 +- rust/src/codex_costs/quota_windows.rs | 26 ++--- rust/src/codex_workspaces/indexer.rs | 5 +- rust/src/core/jsonl_scanner.rs | 10 +- rust/src/core/jsonl_scanner/codex.rs | 19 ++- rust/src/core/jsonl_scanner/codex/helpers.rs | 7 +- rust/src/core/jsonl_scanner/tests.rs | 13 ++- .../core/jsonl_scanner/tests/cache_zone.rs | 106 +++++++++++++++++ rust/src/core/mod.rs | 2 +- rust/src/core/timezone.rs | 10 +- rust/src/cost_reporting_period.rs | 104 ++++++++++++++++- rust/src/cost_scanner.rs | 22 ++-- rust/src/cost_scanner/codex/pending_range.rs | 2 +- rust/src/cost_scanner/codex/scan.rs | 2 +- rust/src/cost_scanner/tests.rs | 1 + rust/src/cost_scanner/window.rs | 6 +- rust/src/pi_session_cost.rs | 7 +- .../providers/antigravity/local_sessions.rs | 7 +- .../src/providers/antigravity/local_sqlite.rs | 7 +- rust/src/providers/muse/local_usage/cache.rs | 4 +- rust/src/providers/muse/local_usage/mod.rs | 4 +- rust/src/providers/muse/local_usage/parse.rs | 4 +- rust/src/settings.rs | 8 ++ rust/src/settings/cost_time_zone.rs | 110 ++++++++++++++++++ rust/src/settings/raw.rs | 6 + rust/src/spend_contract/opencodex.rs | 11 +- 27 files changed, 442 insertions(+), 77 deletions(-) create mode 100644 rust/src/core/jsonl_scanner/tests/cache_zone.rs create mode 100644 rust/src/settings/cost_time_zone.rs diff --git a/rust/src/cli/serve/dashboard/source.rs b/rust/src/cli/serve/dashboard/source.rs index 8d28758df4..0eac57178b 100644 --- a/rust/src/cli/serve/dashboard/source.rs +++ b/rust/src/cli/serve/dashboard/source.rs @@ -11,7 +11,7 @@ use std::collections::{BTreeSet, HashMap}; use std::pin::Pin; use std::time::Duration; -use chrono::{Local, Utc}; +use chrono::Utc; use crate::core::{CostScanOptions, FetchContext, ProviderId, SourceMode, instantiate_provider}; use crate::cost_scanner::{self, CostScanner}; @@ -218,7 +218,10 @@ async fn collect_costs(pi_selected: bool) -> HashMap { let pi = scanner.scan_pi_with_cancel(None); let pi_contract = build_local_spend_contract_from_summary("pi", 30, false, false, false, pi); - let today = Local::now().date_naive().format("%Y-%m-%d").to_string(); + let today = crate::cost_reporting_period::cost_bucket_zone() + .date(Utc::now()) + .format("%Y-%m-%d") + .to_string(); let today_of = |provider: &str| { cost_scanner::get_daily_cost_history(provider, 30) .into_iter() diff --git a/rust/src/codex_costs.rs b/rust/src/codex_costs.rs index cf80594c02..547869ec3a 100644 --- a/rust/src/codex_costs.rs +++ b/rust/src/codex_costs.rs @@ -15,7 +15,7 @@ pub(crate) use summary_contract::{ decode_remote_codex_summary, }; -use chrono::{Duration, Local, NaiveDate, Utc}; +use chrono::{Duration, NaiveDate, Utc}; use std::collections::HashSet; use std::path::Path; @@ -23,6 +23,7 @@ use crate::core::{ CodexUsageRecord, CostUsageCache, CostUsageDayRange, CostUsagePricing, JsonlScanner, is_unpriced_codex_routing_model, }; +use crate::cost_reporting_period::cost_bucket_zone; use crate::cost_scanner::{CostSummary, ModelPricingCompleteness, ModelTokenCounts}; use crate::spend_contract::CostCoverageCounts; @@ -40,12 +41,12 @@ pub(crate) fn build_codex_cost_summary( &today, history_days, Utc::now(), - crate::core::local_timezone_name(), + cost_bucket_zone().identifier(), ) } fn codex_today_summary(history: &CostSummary, cache: &CostUsageCache) -> CostSummary { - let today = Local::now().date_naive(); + let today = cost_bucket_zone().date(Utc::now()); let range = CostUsageDayRange::new(today, today); let mut summary = CostSummary { period_start: Some(today), @@ -228,7 +229,7 @@ pub(crate) fn scan_codex_file_cost_for_range(path: &Path, range: &CostUsageDayRa #[cfg(test)] pub(crate) fn scan_codex_file_cost(path: &Path) -> f64 { - let today = Local::now().date_naive(); + let today = chrono::Local::now().date_naive(); let range = CostUsageDayRange::new(codex_period_start(today, 30), today); scan_codex_file_cost_for_range(path, &range) } diff --git a/rust/src/codex_costs/quota_windows.rs b/rust/src/codex_costs/quota_windows.rs index 7119676488..346530acb7 100644 --- a/rust/src/codex_costs/quota_windows.rs +++ b/rust/src/codex_costs/quota_windows.rs @@ -4,13 +4,14 @@ //! window describes the account now; these rows describe local historical //! evidence for a future display/transport surface. -use chrono::{DateTime, Duration, Local, NaiveDate, TimeZone, Utc}; +use chrono::{DateTime, Duration, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use crate::core::{ CodexSourceRowCache, CodexSourceUsageRow, CostUsageCache, CostUsagePricing, RateWindow, }; +use crate::cost_reporting_period::cost_bucket_zone; const NOMINAL_WEEK_MINUTES: i64 = 7 * 24 * 60; const RESET_TOLERANCE_SECONDS: i64 = 2 * 60; @@ -146,7 +147,7 @@ pub fn codex_quota_windows_from_cache( let history_start = cache .scan_since_key .as_deref() - .and_then(local_day_start) + .and_then(bucket_day_start) .unwrap_or_else(|| { let count = i32::try_from(count).expect("quota window count is capped"); current_end - duration * (count + 1) @@ -359,9 +360,9 @@ fn cache_slices(cache: &CostUsageCache) -> Vec { } fn slice_from_row(row: &CodexSourceUsageRow) -> Slice { - let timestamp = row.timestamp.or_else(|| local_day_start(&row.day_key)); + let timestamp = row.timestamp.or_else(|| bucket_day_start(&row.day_key)); let end = row.timestamp.map(|_| None).unwrap_or_else(|| { - local_day_start(&row.day_key).and_then(|start| start.checked_add_signed(Duration::days(1))) + bucket_day_start(&row.day_key).and_then(|start| start.checked_add_signed(Duration::days(1))) }); let input = u64::try_from(row.input.max(0)).unwrap_or(0); let output = u64::try_from(row.output.max(0)).unwrap_or(0); @@ -374,7 +375,7 @@ fn slice_from_row(row: &CodexSourceUsageRow) -> Slice { } else { model.to_string() }; - let date = timestamp.map(|value| value.with_timezone(&Local).date_naive())?; + let date = timestamp.map(|value| cost_bucket_zone().date(value))?; CostUsagePricing::codex_cost_usd_at_date( &model, input, @@ -398,7 +399,7 @@ fn legacy_day_slices(cache: &CostUsageCache) -> Vec { let mut days: Vec<_> = cache.days.iter().collect(); days.sort_by_key(|(day, _)| *day); for (day, models) in days { - let Some(start) = local_day_start(day) else { + let Some(start) = bucket_day_start(day) else { continue; }; let Some(end) = start.checked_add_signed(Duration::days(1)) else { @@ -425,7 +426,7 @@ fn legacy_day_slices(cache: &CostUsageCache) -> Vec { output, NaiveDate::parse_from_str(day, "%Y-%m-%d") .ok() - .unwrap_or_else(|| start.with_timezone(&Local).date_naive()), + .unwrap_or_else(|| cost_bucket_zone().date(start)), ); slices.push(Slice { start, @@ -440,15 +441,10 @@ fn legacy_day_slices(cache: &CostUsageCache) -> Vec { slices } -fn local_day_start(day: &str) -> Option> { +/// The instant a `YYYY-MM-DD` day key begins in the pinned bucket zone. +fn bucket_day_start(day: &str) -> Option> { let date = NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()?; - let naive = date.and_hms_opt(0, 0, 0)?; - Local - .from_local_datetime(&naive) - .single() - .or_else(|| Local.from_local_datetime(&naive).earliest()) - .or_else(|| Local.from_local_datetime(&naive).latest()) - .map(|value| value.with_timezone(&Utc)) + Some(cost_bucket_zone().start_of_day_utc(date)) } #[cfg(test)] diff --git a/rust/src/codex_workspaces/indexer.rs b/rust/src/codex_workspaces/indexer.rs index 993b695412..87cf50abba 100644 --- a/rust/src/codex_workspaces/indexer.rs +++ b/rust/src/codex_workspaces/indexer.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc}; +use chrono::{DateTime, NaiveDate, TimeZone, Utc}; use rusqlite::{Connection, OpenFlags}; use crate::agent_sessions::CodexRolloutFirstLineParser; @@ -156,7 +156,7 @@ impl CodexWorkspacesIndex { } progress(Progress::phase(ProgressPhase::ScanningLogs)); - let today = Local::now().date_naive(); + let today = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now()); let since = codex_period_start(today, self.history_days); let range = CostUsageDayRange::new(since, today); @@ -745,6 +745,7 @@ fn meta_day_out_of_range(path: &Path, range: &CostUsageDayRange) -> bool { mod tests { use super::*; use crate::codex_workspaces::types::SourceStatus; + use chrono::Local; use rusqlite::Connection; use std::fs::File; use std::io::Write; diff --git a/rust/src/core/jsonl_scanner.rs b/rust/src/core/jsonl_scanner.rs index 883a31d89f..91ea02cc36 100755 --- a/rust/src/core/jsonl_scanner.rs +++ b/rust/src/core/jsonl_scanner.rs @@ -43,6 +43,8 @@ struct CachedCostReadStatusProjection { previous_report: Option, #[serde(default)] codex_scan_pause_reason: Option, + #[serde(default)] + bucket_time_zone: Option, } fn deserialize_nonempty_object<'de, D>(deserializer: D) -> Result @@ -239,6 +241,11 @@ pub struct CostUsageCache { /// caches remain valid and can be upgraded lazily. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub codex_source_rows: HashMap, + /// Zone the day keys were bucketed in (upstream `timeZoneIdentifier`). + /// A cache from another zone is rebuilt; caches written before the stamp + /// existed are kept. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bucket_time_zone: Option, /// Content stamp of the decoded on-disk baseline. This is process-local /// and omitted from JSON so a stale reader cannot replace a newer cache. #[serde(skip)] @@ -592,7 +599,8 @@ impl JsonlScanner { return CachedCostReadStatus::default(); }; if provider == ProviderId::Codex - && !codex::codex_cache_schema_is_current(projection.codex_cache_schema_version) + && (!codex::codex_cache_schema_is_current(projection.codex_cache_schema_version) + || !codex::codex_cache_zone_is_current(projection.bucket_time_zone.as_deref())) { return CachedCostReadStatus::default(); } diff --git a/rust/src/core/jsonl_scanner/codex.rs b/rust/src/core/jsonl_scanner/codex.rs index 30f159c0ca..e31eb59201 100644 --- a/rust/src/core/jsonl_scanner/codex.rs +++ b/rust/src/core/jsonl_scanner/codex.rs @@ -10,6 +10,8 @@ use helpers::{ }; use parser::CodexParserState; +use crate::cost_reporting_period::cost_bucket_zone; + /// Persisted Codex cache schema version. Version 0 predates 64-bit totals; /// version 1 can retain a terminal pause after treating a paginated v2 /// subagent's independent counters as an inherited fork. Version 3 adds @@ -23,7 +25,14 @@ pub(crate) fn codex_cache_schema_is_current(schema_version: u32) -> bool { schema_version == CODEX_CACHE_SCHEMA_VERSION } -/// Apply the Codex cache schema version policy to a freshly decoded artifact. +/// Whether a persisted Codex cache bucketed its days in the zone now in +/// effect. Artifacts written before the zone was stamped are kept. +pub(crate) fn codex_cache_zone_is_current(bucket_time_zone: Option<&str>) -> bool { + bucket_time_zone.is_none_or(|zone| zone == cost_bucket_zone().identifier()) +} + +/// Apply the Codex cache schema version and bucket zone policy to a freshly +/// decoded artifact. /// /// A mismatched artifact is invalidated: a fresh, current-version cache is /// returned with the decoded baseline stamp retained so the caller stays @@ -33,7 +42,9 @@ pub(crate) fn codex_cache_apply_load_policy( mut cache: CostUsageCache, stamp: CacheStamp, ) -> CostUsageCache { - if !codex_cache_schema_is_current(cache.codex_cache_schema_version) { + if !codex_cache_schema_is_current(cache.codex_cache_schema_version) + || !codex_cache_zone_is_current(cache.bucket_time_zone.as_deref()) + { return CostUsageCache { codex_cache_schema_version: CODEX_CACHE_SCHEMA_VERSION, loaded_stamp: Some(Some(stamp)), @@ -44,9 +55,11 @@ pub(crate) fn codex_cache_apply_load_policy( cache } -/// Stamp the current schema version before a Codex cache is persisted. +/// Stamp the current schema version and bucket zone before a Codex cache is +/// persisted. pub(crate) fn codex_cache_stamp_schema_version(cache: &mut CostUsageCache) { cache.codex_cache_schema_version = CODEX_CACHE_SCHEMA_VERSION; + cache.bucket_time_zone = Some(cost_bucket_zone().identifier()); } #[cfg(test)] diff --git a/rust/src/core/jsonl_scanner/codex/helpers.rs b/rust/src/core/jsonl_scanner/codex/helpers.rs index e559700cbb..e9e26b9bd1 100644 --- a/rust/src/core/jsonl_scanner/codex/helpers.rs +++ b/rust/src/core/jsonl_scanner/codex/helpers.rs @@ -1,5 +1,5 @@ use super::CodexTotals; -use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone}; +use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone, Utc}; use serde::Deserialize; use serde_json::Value; use std::io::BufRead; @@ -346,9 +346,8 @@ impl ParsedCodexTimestamp { self.parsed .as_ref() .map(|timestamp| { - timestamp - .with_timezone(&Local) - .date_naive() + crate::cost_reporting_period::cost_bucket_zone() + .date(timestamp.with_timezone(&Utc)) .format("%Y-%m-%d") .to_string() }) diff --git a/rust/src/core/jsonl_scanner/tests.rs b/rust/src/core/jsonl_scanner/tests.rs index bcb1129cb1..715078e4db 100644 --- a/rust/src/core/jsonl_scanner/tests.rs +++ b/rust/src/core/jsonl_scanner/tests.rs @@ -1603,8 +1603,10 @@ fn save_cache_at_exact_limit_is_accepted() { let root = tempfile::tempdir().unwrap(); let cache_root = root.path().to_path_buf(); - let cache = CostUsageCache::default(); - // Serialize to learn the actual encoded size for this exact struct. + let mut cache = CostUsageCache::default(); + // Saving stamps the schema version and bucket zone first; serialize the + // stamped struct to learn the exact encoded size. + codex_cache_stamp_schema_version(&mut cache); let json = serde_json::to_string(&cache).unwrap(); let exact_limit = json.len(); @@ -1630,7 +1632,8 @@ fn save_cache_one_over_limit_is_refused_and_removes_destination() { let root = tempfile::tempdir().unwrap(); let cache_root = root.path().to_path_buf(); - let cache = CostUsageCache::default(); + let mut cache = CostUsageCache::default(); + codex_cache_stamp_schema_version(&mut cache); let json = serde_json::to_string(&cache).unwrap(); // One byte short of the encoded size forces refusal on the next attempt. let under_by_one = json.len().saturating_sub(1); @@ -1653,3 +1656,7 @@ fn save_cache_one_over_limit_is_refused_and_removes_destination() { #[cfg(test)] #[path = "tests/codex_metadata.rs"] mod codex_metadata; + +#[cfg(test)] +#[path = "tests/cache_zone.rs"] +mod cache_zone; diff --git a/rust/src/core/jsonl_scanner/tests/cache_zone.rs b/rust/src/core/jsonl_scanner/tests/cache_zone.rs new file mode 100644 index 0000000000..dfd516f539 --- /dev/null +++ b/rust/src/core/jsonl_scanner/tests/cache_zone.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::cost_reporting_period::cost_bucket_zone; + +fn codex_cache_with_one_day(bucket_time_zone: Option) -> CostUsageCache { + CostUsageCache { + codex_cache_schema_version: CODEX_CACHE_SCHEMA_VERSION, + bucket_time_zone, + days: HashMap::from([( + "2026-09-09".to_string(), + HashMap::from([("gpt-5.6-luna".to_string(), vec![1, 2, 3])]), + )]), + ..CostUsageCache::default() + } +} + +/// A valid IANA zone that differs from the one days are bucketed in now. +fn another_bucket_zone() -> String { + let current = cost_bucket_zone().identifier(); + ["Pacific/Kiritimati", "Pacific/Pago_Pago"] + .into_iter() + .find(|zone| *zone != current) + .expect("two distinct zones") + .to_string() +} + +#[test] +fn codex_cache_from_another_bucket_zone_is_rebuilt() { + let stamp = CacheStamp::from_bytes(b"baseline"); + + let moved = codex_cache_with_one_day(Some(another_bucket_zone())); + let rebuilt = codex_cache_apply_load_policy(moved, stamp.clone()); + assert!(rebuilt.days.is_empty()); + assert!(rebuilt.files.is_empty()); + assert_eq!( + rebuilt.codex_cache_schema_version, + CODEX_CACHE_SCHEMA_VERSION + ); + assert!(rebuilt.loaded_stamp.is_some()); + + // Caches written before the zone stamp existed were bucketed in the + // machine zone, which is what first launch pins, so they are kept. + for kept_zone in [None, Some(cost_bucket_zone().identifier())] { + let cache = codex_cache_with_one_day(kept_zone.clone()); + let kept = codex_cache_apply_load_policy(cache, stamp.clone()); + assert_eq!( + kept.days["2026-09-09"]["gpt-5.6-luna"], + vec![1, 2, 3], + "{kept_zone:?}" + ); + assert_eq!(kept.bucket_time_zone, kept_zone); + } +} + +#[test] +fn saved_codex_caches_record_their_bucket_zone() { + let mut cache = CostUsageCache::default(); + codex_cache_stamp_schema_version(&mut cache); + assert_eq!( + cache.bucket_time_zone, + Some(cost_bucket_zone().identifier()) + ); + + assert!(codex_cache_zone_is_current(None)); + assert!(codex_cache_zone_is_current(Some( + &cost_bucket_zone().identifier() + ))); + assert!(!codex_cache_zone_is_current(Some(&another_bucket_zone()))); +} + +#[test] +fn codex_cache_status_ignores_history_from_another_bucket_zone() { + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path(); + let mut cache = codex_cache_with_one_day(None); + cache.previous_report = Some(CachedCostReport { + total_cost_usd: 1.0, + input_tokens: 11, + cached_tokens: 2, + output_tokens: 3, + reasoning_tokens: None, + sessions_count: 1, + updated_at: Some("2026-09-16T10:00:00Z".to_string()), + partial: false, + }); + JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(cache_root)); + + let cache_path = JsonlScanner::cache_path(ProviderId::Codex, Some(cache_root)); + let mut saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&cache_path).unwrap()).unwrap(); + assert_eq!( + saved["bucket_time_zone"], + serde_json::json!(cost_bucket_zone().identifier()) + ); + let status = JsonlScanner::load_cache_status(ProviderId::Codex, Some(cache_root)); + assert!(status.has_days); + assert!(status.previous_report.is_some()); + + saved["bucket_time_zone"] = serde_json::json!(another_bucket_zone()); + std::fs::write(&cache_path, serde_json::to_vec(&saved).unwrap()).unwrap(); + let status = JsonlScanner::load_cache_status(ProviderId::Codex, Some(cache_root)); + assert!(!status.has_days); + assert!(status.previous_report.is_none()); + let reloaded = JsonlScanner::load_cache(ProviderId::Codex, Some(cache_root)); + assert!(reloaded.days.is_empty()); + assert!(reloaded.previous_report.is_none()); +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 436c7d5e0a..560550658d 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -52,7 +52,7 @@ pub use redactor::*; pub use session_equivalent_forecast::*; pub use session_quota::*; pub use sqlite::*; -pub use timezone::local_timezone_name; +pub use timezone::{local_timezone_name, try_local_timezone_name}; pub use token_accounts::*; pub use usage_pace::*; pub use usage_snapshot::*; diff --git a/rust/src/core/timezone.rs b/rust/src/core/timezone.rs index 2052585a03..9af80f9df8 100644 --- a/rust/src/core/timezone.rs +++ b/rust/src/core/timezone.rs @@ -32,6 +32,12 @@ static GLOBALIZATION_PINNED: LazyLock = LazyLock::new(pin_globalization_dl /// Returns the IANA name of the system timezone, or `"UTC"` if it cannot be /// determined safely. pub fn local_timezone_name() -> String { + try_local_timezone_name().unwrap_or_else(|| FALLBACK_TIMEZONE.to_string()) +} + +/// Returns the IANA name of the system timezone, or `None` when it cannot be +/// read safely. Use this when a fallback name must not be persisted. +pub fn try_local_timezone_name() -> Option { #[cfg(windows)] { // Deref runs the load+pin to completion on one thread while all @@ -40,10 +46,10 @@ pub fn local_timezone_name() -> String { if !*GLOBALIZATION_PINNED { // Unpinned: `get_timezone` might fault on an unloadable cached // factory, and an AV is uncatchable. Refuse to call it. - return FALLBACK_TIMEZONE.to_string(); + return None; } } - iana_time_zone::get_timezone().unwrap_or_else(|_| FALLBACK_TIMEZONE.to_string()) + iana_time_zone::get_timezone().ok() } /// Loads `Windows.Globalization.dll` from System32 and pins it so no later diff --git a/rust/src/cost_reporting_period.rs b/rust/src/cost_reporting_period.rs index 8f0e41e3a4..0cef28014a 100644 --- a/rust/src/cost_reporting_period.rs +++ b/rust/src/cost_reporting_period.rs @@ -6,8 +6,12 @@ //! goes stale across midnight or a month rollover. All arithmetic runs on //! calendar dates, never on 24-hour multiples, so leap years and 23/25-hour //! daylight-saving days need no special cases. +//! +//! Days are bucketed in one pinned zone ([`cost_bucket_zone`]) so history +//! keeps its day boundaries when the machine's zone changes. use std::fmt; +use std::sync::{PoisonError, RwLock}; use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Utc}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -38,13 +42,68 @@ pub fn clamp_window_days(days: u32) -> u32 { /// Zone whose midnights bound a reporting day. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CostTimeZone { - /// The machine's local zone (what the local scanners bucket by today). + /// The machine's zone, used while no zone is pinned. Local, /// A pinned IANA zone. Named(chrono_tz::Tz), } +/// Pinned zone that local cost history is bucketed in. `None` buckets in the +/// machine zone. +static COST_BUCKET_ZONE: RwLock> = RwLock::new(None); + +/// The zone local cost history is bucketed in (upstream's pinned bucket +/// calendar, `tokenCostUsageBucketTimeZone`). +/// +/// The desktop app and the CLI apply the saved +/// `Settings::cost_usage_bucket_time_zone` at startup. Until then, and while +/// nothing is pinned, history is bucketed in the machine zone. +pub fn cost_bucket_zone() -> CostTimeZone { + let pinned = *COST_BUCKET_ZONE + .read() + .unwrap_or_else(PoisonError::into_inner); + pinned.map_or(CostTimeZone::Local, CostTimeZone::Named) +} + +/// Bucket local cost history in the zone `identifier` names, or in the +/// machine zone when it is empty or not an IANA zone. Returns the zone now in +/// effect. +pub fn set_cost_bucket_zone(identifier: &str) -> CostTimeZone { + let zone = CostTimeZone::from_identifier(identifier); + let pinned = match zone { + CostTimeZone::Named(tz) => Some(tz), + CostTimeZone::Local => None, + }; + *COST_BUCKET_ZONE + .write() + .unwrap_or_else(PoisonError::into_inner) = pinned; + zone +} + impl CostTimeZone { + /// Resolve a saved identifier (upstream + /// `CostUsageBucketTimeZone.timeZone(identifier:)`): a trimmed IANA name + /// selects that zone; an empty or unknown name selects the machine zone. + pub fn from_identifier(identifier: &str) -> Self { + identifier + .trim() + .parse::() + .map_or(Self::Local, Self::Named) + } + + /// Whether `identifier` names an IANA zone (upstream `isValidIdentifier`). + pub fn is_valid_identifier(identifier: &str) -> bool { + identifier.parse::().is_ok() + } + + /// The machine zone's IANA name to pin (upstream `pinIdentifier()`). + /// + /// `None` when the system zone cannot be read safely or is not a known + /// IANA name, so a fallback zone is never persisted as the pin. + pub fn pin_identifier() -> Option { + crate::core::try_local_timezone_name().filter(|name| Self::is_valid_identifier(name)) + } + /// Zone identifier used in cache identities. pub fn identifier(&self) -> String { match self { @@ -465,4 +524,47 @@ mod tests { assert_eq!(read("42"), CostReportingPeriod::Rolling(30)); assert_eq!(read(r#"{"a":1}"#), CostReportingPeriod::Rolling(30)); } + + #[test] + fn saved_zone_identifiers_resolve_like_upstream() { + assert_eq!( + CostTimeZone::from_identifier(" Asia/Tokyo\n"), + CostTimeZone::Named(Tokyo) + ); + for fallback in ["", " ", "Mars/Olympus", "local"] { + assert_eq!( + CostTimeZone::from_identifier(fallback), + CostTimeZone::Local, + "{fallback:?}" + ); + } + assert!(CostTimeZone::is_valid_identifier("America/Los_Angeles")); + assert!(CostTimeZone::is_valid_identifier("UTC")); + assert!(!CostTimeZone::is_valid_identifier("")); + assert!(!CostTimeZone::is_valid_identifier("Mars/Olympus")); + } + + #[test] + fn pin_identifier_is_the_machine_zone_or_nothing() { + if let Some(name) = CostTimeZone::pin_identifier() { + assert!(CostTimeZone::is_valid_identifier(&name), "{name}"); + assert_eq!(name, crate::core::local_timezone_name()); + } + } + + #[test] + fn the_bucket_zone_applies_a_saved_identifier_process_wide() { + // Other tests bucket through `cost_bucket_zone()` in parallel, so this + // only ever applies the machine's own zone, which keeps their days. + let Some(machine) = CostTimeZone::pin_identifier() else { + return; + }; + let applied = set_cost_bucket_zone(&machine); + assert_eq!(applied, CostTimeZone::from_identifier(&machine)); + assert_eq!(cost_bucket_zone(), applied); + assert_eq!(cost_bucket_zone().identifier(), machine); + assert_eq!(set_cost_bucket_zone("Mars/Olympus"), CostTimeZone::Local); + assert_eq!(cost_bucket_zone(), CostTimeZone::Local); + assert_eq!(set_cost_bucket_zone(""), CostTimeZone::Local); + } } diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index ce1c98ebe2..360f697f7b 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -8,7 +8,7 @@ //! debounce (default 60s; `app_driven` forces a fresh inspection), and checks //! cancel flags between files. -use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; +use chrono::{DateTime, Duration, NaiveDate, Utc}; use serde::Deserialize; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -29,7 +29,7 @@ use crate::core::{ CachedCostReport, CodexScanPauseReason, CostScanOptions, CostUsageCache, CostUsageDayRange, CostUsageFileUsage, JsonlScanner, ProviderId, }; -use crate::cost_reporting_period::{CostReportingPeriod, MAX_ROLLING_DAYS}; +use crate::cost_reporting_period::{CostReportingPeriod, MAX_ROLLING_DAYS, cost_bucket_zone}; use crate::providers::claude::quota_history::{ ClaudeHistoryAttribution, ClaudeQuotaDedupKey, ClaudeQuotaHistoryRecord, }; @@ -659,7 +659,7 @@ impl CostScanner { ) -> ClaudeChartSnapshot { let projects_dir = self.get_claude_projects_dir(); let now = Utc::now(); - let window = self.transcript_window(now, Local::now().date_naive()); + let window = self.transcript_window(now, cost_bucket_zone().date(now)); let cutoff = window.cutoff; let mut summary = CostSummary { period_start: Some(window.start), @@ -1114,7 +1114,7 @@ fn quota_history_record_from_usage(record: &ClaudeUsageRecord) -> Option>, @@ -1123,9 +1123,8 @@ fn add_claude_record_to_daily_costs( let Some(timestamp) = record.timestamp else { return; }; - let date_str = timestamp - .with_timezone(&Local) - .date_naive() + let date_str = cost_bucket_zone() + .date(timestamp) .format("%Y-%m-%d") .to_string(); if let Some(cost) = daily_costs.get_mut(&date_str) { @@ -1155,7 +1154,7 @@ pub fn has_cost_usage_sources() -> bool { /// is unscanned or contains unpriced Codex usage; `Some(0)` is a known zero. pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option)> { let scanner = CostScanner::new(days); - let today = Local::now().date_naive(); + let today = cost_bucket_zone().date(Utc::now()); let mut daily_costs: HashMap> = HashMap::new(); // Initialize all days with 0 @@ -1275,7 +1274,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< /// marker; upstream 0.50.0 #2930). pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)>, bool) { let scanner = CostScanner::new(days); - let today = Local::now().date_naive(); + let today = cost_bucket_zone().date(Utc::now()); let mut daily_tokens: HashMap = HashMap::new(); let mut covered_days: HashSet = HashSet::new(); @@ -1378,9 +1377,8 @@ fn add_claude_record_to_daily_tokens( let Some(timestamp) = record.timestamp else { return; }; - let date_str = timestamp - .with_timezone(&Local) - .date_naive() + let date_str = cost_bucket_zone() + .date(timestamp) .format("%Y-%m-%d") .to_string(); if let Some(slot) = daily_tokens.get_mut(&date_str) { diff --git a/rust/src/cost_scanner/codex/pending_range.rs b/rust/src/cost_scanner/codex/pending_range.rs index 2ebe36bdb4..58a55d0c6a 100644 --- a/rust/src/cost_scanner/codex/pending_range.rs +++ b/rust/src/cost_scanner/codex/pending_range.rs @@ -88,7 +88,7 @@ impl CodexPendingScanContext { is_app_driven: bool, ) -> Self { let root_paths = codex_scan_root_keys(sessions_dirs); - let timezone = crate::core::local_timezone_name(); + let timezone = crate::cost_reporting_period::cost_bucket_zone().identifier(); let is_compatible = !is_app_driven && cache.codex_scan_incomplete && codex_pending_scan_context_is_compatible( diff --git a/rust/src/cost_scanner/codex/scan.rs b/rust/src/cost_scanner/codex/scan.rs index a1a87533f0..c9cfd1939e 100644 --- a/rust/src/cost_scanner/codex/scan.rs +++ b/rust/src/cost_scanner/codex/scan.rs @@ -91,7 +91,7 @@ pub(super) fn scan_codex_detailed_with_cache( let mut stats = CostScanStats::default(); let sessions_dirs = scanner.get_codex_sessions_dirs(); let now = Utc::now(); - let today = Local::now().date_naive(); + let today = crate::cost_reporting_period::cost_bucket_zone().date(now); // All-available history opens at the first existing partition; with no // partitions it is just today, so an empty tree costs no directory probes. let earliest = (scanner.period == CostReportingPeriod::AllAvailable) diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 19acf2a9d0..39f7b22ab8 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::codex_costs::codex_period_start; use crate::core::{CodexSessionLineage, CostUsagePricing}; +use chrono::Local; use std::io::Write; #[test] diff --git a/rust/src/cost_scanner/window.rs b/rust/src/cost_scanner/window.rs index e108b7e063..644569f1f6 100644 --- a/rust/src/cost_scanner/window.rs +++ b/rust/src/cost_scanner/window.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Duration, NaiveDate, Utc}; use super::CostScanner; -use crate::cost_reporting_period::{CostReportingPeriod, CostTimeZone, clamp_window_days}; +use crate::cost_reporting_period::{CostReportingPeriod, clamp_window_days, cost_bucket_zone}; /// Inclusive local-day window plus the instant transcript scanners cut off at. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -19,7 +19,7 @@ pub(super) struct ScanWindow { } impl CostScanner { - /// The window in the local zone, resolved from the calendar. + /// The window in the pinned bucket zone, resolved from the calendar. /// /// `earliest` is the first day with source data; it only matters for /// [`CostReportingPeriod::AllAvailable`]. Rolling(N) covers N days @@ -29,7 +29,7 @@ impl CostScanner { now: DateTime, earliest: Option, ) -> ScanWindow { - let tz = CostTimeZone::Local; + let tz = cost_bucket_zone(); let bounds = self.period.bounds(now, tz, earliest); ScanWindow { start: bounds.start, diff --git a/rust/src/pi_session_cost.rs b/rust/src/pi_session_cost.rs index 4ff684f8d5..b00fedd04d 100644 --- a/rust/src/pi_session_cost.rs +++ b/rust/src/pi_session_cost.rs @@ -4,7 +4,7 @@ //! openai-codex / anthropic assistant rows into cost summaries without //! double-counting the same entry id across shared files. -use chrono::{DateTime, Duration, Local, Utc}; +use chrono::{DateTime, Duration, Utc}; use serde_json::Value; use std::collections::HashSet; use std::fs::File; @@ -184,9 +184,8 @@ fn scan_pi_daily_from_roots( missing_timestamp = true; return; }; - let day = timestamp - .with_timezone(&Local) - .date_naive() + let day = crate::cost_reporting_period::cost_bucket_zone() + .date(timestamp) .format("%Y-%m-%d") .to_string(); if !entry.pricing_known { diff --git a/rust/src/providers/antigravity/local_sessions.rs b/rust/src/providers/antigravity/local_sessions.rs index 5e785b8c0c..870817ee45 100644 --- a/rust/src/providers/antigravity/local_sessions.rs +++ b/rust/src/providers/antigravity/local_sessions.rs @@ -3,7 +3,7 @@ use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use chrono::{DateTime, Duration, Local, TimeZone, Utc}; +use chrono::{DateTime, Duration, TimeZone, Utc}; use serde_json::Value; const MAX_SESSION_FILES: usize = 2048; @@ -140,7 +140,8 @@ fn summarize_paths( days: u32, truncated: bool, ) -> LocalSessionSummary { - let first_day = now.with_timezone(&Local).date_naive() + let zone = crate::cost_reporting_period::cost_bucket_zone(); + let first_day = zone.date(now) - Duration::days(i64::from( crate::cost_reporting_period::clamp_window_days(days).saturating_sub(1), )); @@ -202,7 +203,7 @@ fn summarize_paths( let Some(at) = Utc.timestamp_millis_opt(timestamp_ms).single() else { continue; }; - if at > now || at.with_timezone(&Local).date_naive() < first_day { + if at > now || zone.date(at) < first_day { continue; } diff --git a/rust/src/providers/antigravity/local_sqlite.rs b/rust/src/providers/antigravity/local_sqlite.rs index fb229feeca..05d8beb545 100644 --- a/rust/src/providers/antigravity/local_sqlite.rs +++ b/rust/src/providers/antigravity/local_sqlite.rs @@ -6,7 +6,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration as StdDuration, Instant}; -use chrono::{DateTime, Duration, Local, TimeZone, Utc}; +use chrono::{DateTime, Duration, TimeZone, Utc}; use rusqlite::{Connection, OpenFlags, TransactionBehavior, types::ValueRef}; use self::local_bot_id::{ExactStepTimestamp, embedded_timestamps_agree, record_exact_bot_id}; @@ -138,7 +138,8 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL return SQLiteScan::NoDatabases; } - let first_day = now.with_timezone(&Local).date_naive() + let zone = crate::cost_reporting_period::cost_bucket_zone(); + let first_day = zone.date(now) - Duration::days(i64::from( crate::cost_reporting_period::clamp_window_days(days).saturating_sub(1), )); @@ -226,7 +227,7 @@ pub(super) fn summarize(roots: &[PathBuf], now: DateTime, days: u32) -> SQL continue; }; rows.insert(row_key, event.clone()); - if at > now || at.with_timezone(&Local).date_naive() < first_day { + if at > now || zone.date(at) < first_day { continue; } match total_tokens.checked_add(event.total) { diff --git a/rust/src/providers/muse/local_usage/cache.rs b/rust/src/providers/muse/local_usage/cache.rs index d9338d53c4..2b89dc6ea4 100644 --- a/rust/src/providers/muse/local_usage/cache.rs +++ b/rust/src/providers/muse/local_usage/cache.rs @@ -105,7 +105,7 @@ pub(crate) fn load_cache(root: &Path, sessions: &Path, since: &str, until: &str) && cache.sessions_root == sessions.to_string_lossy() && cache.since_day == since && cache.until_day == until - && cache.timezone == Local::now().offset().to_string() + && cache.timezone == crate::cost_reporting_period::cost_bucket_zone().identifier() { cache } else { @@ -117,7 +117,7 @@ pub(crate) fn save_cache(root: &Path, sessions: &Path, since: &str, until: &str, cache.sessions_root = sessions.to_string_lossy().into_owned(); cache.since_day = since.to_string(); cache.until_day = until.to_string(); - cache.timezone = Local::now().offset().to_string(); + cache.timezone = crate::cost_reporting_period::cost_bucket_zone().identifier(); let Ok(bytes) = serde_json::to_vec(&cache) else { return; }; diff --git a/rust/src/providers/muse/local_usage/mod.rs b/rust/src/providers/muse/local_usage/mod.rs index 9ae2ce6d30..70b44c7ec4 100644 --- a/rust/src/providers/muse/local_usage/mod.rs +++ b/rust/src/providers/muse/local_usage/mod.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use chrono::{Local, TimeZone, Utc}; +use chrono::{TimeZone, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -156,7 +156,7 @@ fn cache_root() -> PathBuf { fn day_window(days: u32) -> (String, String) { let days = crate::cost_reporting_period::clamp_window_days(days); - let today = Local::now().date_naive(); + let today = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now()); ( (today - chrono::Duration::days(i64::from(days - 1))).to_string(), today.to_string(), diff --git a/rust/src/providers/muse/local_usage/parse.rs b/rust/src/providers/muse/local_usage/parse.rs index 57dcb41c40..c81fd1720f 100644 --- a/rust/src/providers/muse/local_usage/parse.rs +++ b/rust/src/providers/muse/local_usage/parse.rs @@ -110,7 +110,9 @@ pub(crate) fn parse_line(line: &[u8]) -> Result, ()> { let timestamp = Utc.timestamp_opt(seconds, nanos).single().ok_or(())?; Ok(Some(Event { id: id.to_string(), - day: timestamp.with_timezone(&Local).date_naive().to_string(), + day: crate::cost_reporting_period::cost_bucket_zone() + .date(timestamp) + .to_string(), model, input_tokens: input, output_tokens: output, diff --git a/rust/src/settings.rs b/rust/src/settings.rs index f1e9b19d23..03fd894e6a 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -27,6 +27,7 @@ pub const CODEX_SPARK_USAGE_ITEM_IDS: [&str; 2] = [ pub const CLAUDE_DAILY_ROUTINES_USAGE_ITEM_ID: &str = "metric:extra-claude-routines"; mod api_keys; +mod cost_time_zone; mod manual_cookies; mod provider_workspace; mod raw; @@ -34,6 +35,7 @@ mod status; mod types; pub use api_keys::*; +pub use cost_time_zone::*; pub use manual_cookies::*; pub use provider_workspace::*; use raw::RawSettings; @@ -375,6 +377,11 @@ pub struct Settings { /// `all` (upstream 0.67.0). Missing or unreadable values read as `rolling:30`. #[serde(default)] pub cost_reporting_period: CostReportingPeriod, + + /// IANA zone local cost history is bucketed in; empty means the machine + /// zone (upstream 0.67.0 `tokenCostUsageBucketTimeZone`). + #[serde(default)] + pub cost_usage_bucket_time_zone: String, } fn default_window_scale_percent() -> u16 { @@ -602,6 +609,7 @@ impl Default for Settings { open_codex_usage_logs_enabled: false, hide_native_codex_cost_when_open_codex_present: false, cost_reporting_period: CostReportingPeriod::default(), + cost_usage_bucket_time_zone: String::new(), } } } diff --git a/rust/src/settings/cost_time_zone.rs b/rust/src/settings/cost_time_zone.rs new file mode 100644 index 0000000000..f50e43a466 --- /dev/null +++ b/rust/src/settings/cost_time_zone.rs @@ -0,0 +1,110 @@ +//! Pinned zone for local cost history (upstream 0.67.0 +//! `tokenCostUsageBucketTimeZone`). +//! +//! Day buckets, month-to-date bounds, and cost caches use one saved IANA zone +//! so history keeps its day boundaries when the machine's zone changes. An +//! empty value means the machine zone. There is no settings control for it, +//! as upstream: the desktop app pins the machine zone on first launch. + +use super::Settings; +use crate::cost_reporting_period::{CostTimeZone, set_cost_bucket_zone}; + +/// Trim a saved bucket zone; anything but an IANA zone name reads as unpinned +/// (`""`). +pub fn normalize_cost_usage_bucket_time_zone(value: &str) -> String { + let trimmed = value.trim(); + if CostTimeZone::is_valid_identifier(trimmed) { + trimmed.to_string() + } else { + String::new() + } +} + +impl Settings { + /// The zone local cost history is bucketed in: the pinned zone, or the + /// machine zone while nothing valid is pinned. + pub fn cost_usage_bucket_zone(&self) -> CostTimeZone { + CostTimeZone::from_identifier(&self.cost_usage_bucket_time_zone) + } + + /// Pin the machine's current zone unless a valid zone is already saved. + /// + /// Returns whether the setting changed, so callers know to save it. Leaves + /// the setting unpinned when the machine zone cannot be read safely. + pub fn pin_cost_usage_bucket_time_zone(&mut self) -> bool { + if CostTimeZone::is_valid_identifier(self.cost_usage_bucket_time_zone.trim()) { + return false; + } + match CostTimeZone::pin_identifier() { + Some(zone) => { + self.cost_usage_bucket_time_zone = zone; + true + } + None => false, + } + } + + /// Bucket this process's local cost history in the saved zone. + pub fn apply_cost_usage_bucket_zone(&self) -> CostTimeZone { + set_cost_bucket_zone(&self.cost_usage_bucket_time_zone) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saved_zones_are_trimmed_and_unknown_zones_read_as_unpinned() { + assert_eq!( + normalize_cost_usage_bucket_time_zone(" Asia/Tokyo "), + "Asia/Tokyo" + ); + assert_eq!(normalize_cost_usage_bucket_time_zone("Mars/Olympus"), ""); + assert_eq!(normalize_cost_usage_bucket_time_zone(""), ""); + } + + #[test] + fn settings_files_normalize_the_saved_zone() { + let read = |json: &str| serde_json::from_str::(json).expect("settings"); + assert_eq!(read("{}").cost_usage_bucket_time_zone, ""); + let pinned = read(r#"{"cost_usage_bucket_time_zone":" America/Los_Angeles "}"#); + assert_eq!(pinned.cost_usage_bucket_time_zone, "America/Los_Angeles"); + assert_eq!( + pinned.cost_usage_bucket_zone(), + CostTimeZone::Named(chrono_tz::America::Los_Angeles) + ); + let invalid = read(r#"{"cost_usage_bucket_time_zone":"Mars/Olympus"}"#); + assert_eq!(invalid.cost_usage_bucket_time_zone, ""); + assert_eq!(invalid.cost_usage_bucket_zone(), CostTimeZone::Local); + + let saved = serde_json::to_value(&pinned).expect("serialize"); + assert_eq!(saved["cost_usage_bucket_time_zone"], "America/Los_Angeles"); + } + + #[test] + fn pinning_keeps_a_saved_zone_and_fills_an_empty_one() { + let mut settings = Settings { + cost_usage_bucket_time_zone: "Asia/Tokyo".to_string(), + ..Settings::default() + }; + assert!(!settings.pin_cost_usage_bucket_time_zone()); + assert_eq!(settings.cost_usage_bucket_time_zone, "Asia/Tokyo"); + + for unpinned in ["", "Mars/Olympus"] { + let mut settings = Settings { + cost_usage_bucket_time_zone: unpinned.to_string(), + ..Settings::default() + }; + let machine = CostTimeZone::pin_identifier(); + assert_eq!( + settings.pin_cost_usage_bucket_time_zone(), + machine.is_some() + ); + assert_eq!( + settings.cost_usage_bucket_time_zone, + machine.unwrap_or_else(|| unpinned.to_string()) + ); + } + } +} diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 6cd64e957f..a11ce3defb 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -185,6 +185,8 @@ pub(super) struct RawSettings { hide_native_codex_cost_when_open_codex_present: bool, #[serde(default)] cost_reporting_period: CostReportingPeriod, + #[serde(default)] + cost_usage_bucket_time_zone: String, } impl Default for RawSettings { @@ -293,6 +295,7 @@ impl Default for RawSettings { hide_native_codex_cost_when_open_codex_present: s .hide_native_codex_cost_when_open_codex_present, cost_reporting_period: s.cost_reporting_period, + cost_usage_bucket_time_zone: s.cost_usage_bucket_time_zone, } } } @@ -604,6 +607,9 @@ impl From for Settings { .hide_native_codex_cost_when_open_codex_present, codex_external_oauth_sources_allowed: raw.codex_external_oauth_sources_allowed, cost_reporting_period: raw.cost_reporting_period, + cost_usage_bucket_time_zone: super::normalize_cost_usage_bucket_time_zone( + &raw.cost_usage_bucket_time_zone, + ), } } } diff --git a/rust/src/spend_contract/opencodex.rs b/rust/src/spend_contract/opencodex.rs index 6649e07e87..caaac24757 100644 --- a/rust/src/spend_contract/opencodex.rs +++ b/rust/src/spend_contract/opencodex.rs @@ -113,8 +113,8 @@ fn aggregate( history_days: u32, custom: &CustomPricing, ) -> Option { - let first_day = now.with_timezone(&Local).date_naive() - - Duration::days(i64::from(history_days.saturating_sub(1))); + let zone = crate::cost_reporting_period::cost_bucket_zone(); + let first_day = zone.date(now) - Duration::days(i64::from(history_days.saturating_sub(1))); // requestId is authoritative: a later row replaces an earlier row with the same id. let mut unique: HashMap = HashMap::new(); @@ -123,10 +123,7 @@ fn aggregate( } let mut entries: Vec<_> = unique .into_values() - .filter(|entry| { - entry.timestamp <= now - && entry.timestamp.with_timezone(&Local).date_naive() >= first_day - }) + .filter(|entry| entry.timestamp <= now && zone.date(entry.timestamp) >= first_day) .collect(); entries.sort_by(|left, right| { left.timestamp @@ -203,7 +200,7 @@ fn aggregate( ); let day = daily - .entry(local.date_naive().format("%Y-%m-%d").to_string()) + .entry(zone.date(entry.timestamp).format("%Y-%m-%d").to_string()) .or_default(); if let Some(cost) = cost { day.cost += cost;