//! Local cost-usage scanner for Codex and Claude //! //! Scans local JSONL log files to aggregate token usage and calculate costs. //! //! Codex production path loads/saves [`crate::core::CostUsageCache`] under //! `{cache}/CodexBar/cost-usage/`, skips unchanged files by mtime+size, resumes //! partial files from `parsed_bytes`, honors [`crate::core::CostScanOptions`] //! debounce (default 60s; `app_driven` forces a fresh inspection), and checks //! cancel flags between files. use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; use serde::Deserialize; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; 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, }; use crate::codex_sessions::{codex_sessions_dir_candidates, default_wsl_roots}; use crate::core::{ CachedCostReport, CodexScanPauseReason, CostScanOptions, CostUsageCache, CostUsageDayRange, CostUsageFileUsage, JsonlScanner, ProviderId, }; use crate::providers::opencodego::local as opencodego_local; use crate::settings::Settings; mod claude_pricing; mod codex; mod read_receipt; mod stats; use claude_pricing::ClaudeScanPricingResolver; #[cfg(test)] use claude_pricing::{ClaudePricing, FALLBACK_CLAUDE_MODEL}; pub use read_receipt::CodexScanReadReceipt; pub use stats::CostScanStats; /// Completeness of the pricing coverage in a [`CostSummary`] (upstream 0.48.0 F18). /// /// `Complete` means every billed model resolved a canonical or fast-rate price. /// `Partial` means at least one model was deliberately unpriced (routing rows like /// `codex-auto-review`) or fell back to a legacy default; the breakdown is still /// shown but the total is labeled partial. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum ModelPricingCompleteness { /// Every model resolved a canonical price. #[default] Complete, /// At least one model was unpriced or used a fallback rate. Partial { /// Model IDs that were deliberately unpriced (routing rows). unpriced_models: Vec, }, } impl ModelPricingCompleteness { pub fn is_partial(&self) -> bool { matches!(self, Self::Partial { .. }) } } /// Cost summary from scanning local logs #[derive(Debug, Clone, Default)] pub struct CostSummary { /// Total cost in USD for the period pub total_cost_usd: f64, /// Total input tokens pub input_tokens: u64, /// Total output tokens pub output_tokens: u64, /// Total cached input tokens pub cached_tokens: u64, /// Total reasoning tokens when every contributing row reports them pub reasoning_tokens: Option, /// Number of sessions/conversations scanned pub sessions_count: u32, /// Cost breakdown by model pub by_model: HashMap, /// Token breakdown by model pub by_model_tokens: HashMap, /// Codex cost split by speed/tier when local logs expose it. pub by_speed: HashMap, /// Codex token split by speed/tier when local logs expose it. pub by_speed_tokens: HashMap, /// Model IDs that were priced with fallback rates because no canonical rate is available. pub unknown_models: HashSet, /// Completeness of pricing coverage (Complete vs Partial). Surfaced in the CLI /// cost JSON so callers can label a partial breakdown (upstream 0.48.0 F18). pub model_pricing_completeness: ModelPricingCompleteness, /// Whether the scan's coverage of the requested history window is established /// (not pending a catch-up re-scan). `true` when the cache is fresh (within the /// debounce window) or the scan just completed; `false` when the cache is stale /// or empty and a re-scan would be required (upstream 0.48.0 A16). pub history_coverage_established: bool, /// True when the scan completed with zero results — a *known* zero, not a /// missing scan. Set only when `history_coverage_established` is true and /// the scan found no sessions/tokens (upstream 0.50.1 #2932). Never /// fabricated on incomplete scans. pub known_zero: bool, /// Period start date pub period_start: Option, /// Period end date pub period_end: Option, } /// Per-model token counts #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ModelTokenCounts { pub input_tokens: u64, pub output_tokens: u64, pub cached_tokens: u64, pub reasoning_tokens: Option, } impl ModelTokenCounts { pub fn total(&self) -> u64 { self.input_tokens.saturating_add(self.output_tokens) } /// Total for sources that report cache reads/writes as classes separate /// from `input_tokens` (Claude JSONL, OpenCodex imports). Codex already /// includes cached input in `input_tokens`, so use [`Self::total`] there. pub fn total_with_separate_cache(&self) -> u64 { self.total().saturating_add(self.cached_tokens) } /// Provider-aware total; see [`cache_is_separate_from_input`]. pub fn total_for_provider(&self, provider: &str) -> u64 { if cache_is_separate_from_input(provider) { self.total_with_separate_cache() } else { self.total() } } } /// Local JSONL sources disagree about whether cache reads are already part of /// `input_tokens`. Codex reports `input_tokens` with cached input included /// (`CodexTokenCounts::from_values` clamps `cached` to `input`, and codex /// pricing subtracts it back out); Claude and the OpenCodex imports report /// cache read/creation as separate classes. A total must not add the cache /// bucket for the former. pub fn cache_is_separate_from_input(provider: &str) -> bool { provider != "codex" } impl CostSummary { pub fn format_total(&self) -> String { format!("${:.2}", self.total_cost_usd) } } fn is_cancelled(cancel: Option<&AtomicBool>) -> bool { cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) } fn unix_now_ms() -> i64 { // Duration is clamped to i64::MAX before casting, so the value fits i64. #[allow( clippy::cast_possible_truncation, reason = "clamped to i64::MAX before casting" )] let millis = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis().min(i64::MAX as u128) as i64) .unwrap_or(0); millis } fn system_time_to_unix_ms(modified: Option) -> i64 { // Duration is clamped to i64::MAX before casting, so the value fits i64. #[allow( clippy::cast_possible_truncation, reason = "clamped to i64::MAX before casting" )] let millis = modified .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_millis().min(i64::MAX as u128) as i64) .unwrap_or(0); millis } /// JSONL event structures for Codex #[allow( dead_code, reason = "JSONL event fields are deserialized for parsing but not all are read" )] #[derive(Debug, Deserialize)] struct CodexEvent { #[serde(rename = "type")] event_type: Option, event_msg: Option, } #[allow( dead_code, reason = "event message fields are deserialized for parsing but not all are read" )] #[derive(Debug, Deserialize)] struct CodexEventMsg { #[serde(rename = "type")] msg_type: Option, input_tokens: Option, cached_input_tokens: Option, output_tokens: Option, } /// JSONL event structures for Claude transcripts. /// /// The flattened values retain otherwise-unknown metadata long enough to /// distinguish Anthropic rows from Vertex AI rows. Claude's local transcript /// format can contain both shapes, and counting Vertex rows with Anthropic /// pricing would misstate both cost and token history. #[derive(Debug, Deserialize)] struct ClaudeEvent { #[serde(rename = "type")] event_type: Option, timestamp: Option, #[serde(rename = "requestId", alias = "request_id")] request_id: Option, message: Option, #[serde(flatten)] extra: HashMap, } impl ClaudeEvent { fn parsed_timestamp(&self) -> Option> { let timestamp = self.timestamp.as_deref()?; DateTime::parse_from_rfc3339(timestamp) .ok() .map(|ts| ts.with_timezone(&Utc)) } fn is_vertex_ai_usage_entry(&self) -> bool { // Vertex AI message/request identifiers use the `_vrtx_` marker. if self .message .as_ref() .and_then(|message| message.id.as_deref()) .is_some_and(|id| id.contains("_vrtx_")) || self .request_id .as_deref() .is_some_and(|request_id| request_id.contains("_vrtx_")) { return true; } // Vertex AI model names use `@` as the version separator. if self .message .as_ref() .and_then(|message| message.model.as_deref()) .is_some_and(model_name_looks_vertex) { return true; } if contains_claude_vertex_metadata_entries(self.extra.iter()) { return true; } self.message .as_ref() .is_some_and(ClaudeMessage::contains_vertex_metadata) } } #[derive(Debug, Deserialize)] struct ClaudeMessage { id: Option, model: Option, usage: Option, #[serde(flatten)] extra: HashMap, } impl ClaudeMessage { fn contains_vertex_metadata(&self) -> bool { if contains_claude_vertex_metadata_entries(self.extra.iter()) { return true; } self.usage .as_ref() .is_some_and(ClaudeUsage::contains_vertex_metadata) } } #[derive(Debug, Deserialize)] struct ClaudeUsage { input_tokens: Option, output_tokens: Option, cache_creation_input_tokens: Option, cache_read_input_tokens: Option, cache_creation: Option, #[serde(flatten)] extra: HashMap, } impl ClaudeUsage { fn contains_vertex_metadata(&self) -> bool { if contains_claude_vertex_metadata_entries(self.extra.iter()) { return true; } self.cache_creation .as_ref() .is_some_and(ClaudeCacheCreation::contains_vertex_metadata) } } impl ClaudeUsage { /// One-hour cache-write tokens, clamped to the total cache-write count. fn one_hour_cache_creation_tokens(&self, total: u64) -> u64 { self.cache_creation .as_ref() .and_then(|cache_creation| cache_creation.ephemeral_1h_input_tokens) .unwrap_or(0) .min(total) } } /// TTL breakdown of cache writes reported by the API. #[derive(Debug, Deserialize)] struct ClaudeCacheCreation { ephemeral_1h_input_tokens: Option, #[serde(flatten)] extra: HashMap, } impl ClaudeCacheCreation { fn contains_vertex_metadata(&self) -> bool { contains_claude_vertex_metadata_entries(self.extra.iter()) } } const CLAUDE_VERTEX_PROVIDER_KEYS: &[&str] = &[ "provider", "platform", "backend", "api_provider", "apiprovider", "api_type", "apitype", "source", "vendor", "client", ]; fn model_name_looks_vertex(model: &str) -> bool { model.starts_with("claude-") && model.contains('@') } /// Match the upstream Claude classifier's recursive metadata rules. Marker /// keys (`vertex`/`gcp`) classify regardless of value; provider-key values /// classify only when their text contains `vertex` (not merely `gcp`). fn contains_claude_vertex_metadata(value: &Value) -> bool { match value { Value::Object(object) => contains_claude_vertex_metadata_entries(object.iter()), Value::Array(array) => array.iter().any(contains_claude_vertex_metadata), Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => false, } } fn contains_claude_vertex_metadata_entries<'a, I>(entries: I) -> bool where I: IntoIterator, { entries.into_iter().any(|(key, value)| { contains_claude_vertex_marker(key, true) || (CLAUDE_VERTEX_PROVIDER_KEYS .iter() .any(|candidate| key.eq_ignore_ascii_case(candidate)) && value .as_str() .is_some_and(|text| contains_claude_vertex_marker(text, false))) || contains_claude_vertex_metadata(value) }) } fn contains_claude_vertex_marker(value: &str, include_gcp: bool) -> bool { let bytes = value.as_bytes(); let has_marker = |marker: &[u8]| { bytes.windows(marker.len()).any(|window| { window .iter() .zip(marker) .all(|(byte, expected)| byte.to_ascii_lowercase() == *expected) }) }; if has_marker(b"vertex") || (include_gcp && has_marker(b"gcp")) { return true; } // ASCII folding above is enough for the common path. Unicode lowercasing // preserves the historical classifier's behavior for non-ASCII strings. if value.is_ascii() { return false; } let lower = value.to_lowercase(); lower.contains("vertex") || (include_gcp && lower.contains("gcp")) } #[derive(Debug)] struct ClaudeUsageRecord { model: String, pricing_known: bool, timestamp: Option>, dedup_key: Option, input: u64, output: u64, cache_create: u64, cache_read: u64, cost: f64, } #[derive(Debug, Clone)] pub struct CostScanner { days: u32, options: CostScanOptions, cache_root: Option, /// When set, bypass normal sessions-dir discovery (tests / inject roots). sessions_dirs_override: Option>, } impl CostScanner { /// Create a new scanner for the last N days (default 60s cache debounce). pub fn new(days: u32) -> Self { Self { days, options: CostScanOptions::default(), cache_root: None, sessions_dirs_override: None, } } /// Override scan options (e.g. [`CostScanOptions::app_driven`] for force refresh). pub fn with_options(mut self, options: CostScanOptions) -> Self { self.options = options; self } /// Override on-disk cache root (`{root}/cost-usage/…`). pub fn with_cache_root(mut self, root: impl Into) -> Self { self.cache_root = Some(root.into()); self } /// Override Codex sessions roots (primarily for tests). pub fn with_sessions_dirs(mut self, dirs: Vec) -> Self { self.sessions_dirs_override = Some(dirs); self } /// Scan Codex local logs pub fn scan_claude(&self) -> CostSummary { self.scan_claude_with_cancel(None) } /// Scan Claude local logs, stopping early when the caller cancels the scan. pub fn scan_claude_with_cancel(&self, cancel: Option<&AtomicBool>) -> 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); summary.period_start = Some(start_date); summary.period_end = Some(today); // Walk through projects directory, de-duplicating usage records // that appear across multiple files. if projects_dir.exists() { let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut handle_file = |path: &Path| { let counted = for_each_claude_usage_record_with_pricing( path, &cutoff, &mut seen, cancel, &mut pricing, |record| { add_claude_record_to_summary(&mut summary, record); }, ); if counted > 0 { summary.sessions_count += 1; } }; self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file); } // OMP / pi-compatible anthropic rows, deduped across shared files. let mut seen_pi = HashSet::new(); crate::pi_session_cost::scan_pi_compatible_into( &mut summary, crate::pi_session_cost::PiMappedProvider::Claude, self.days, cancel, &mut seen_pi, ); summary } /// Scan OpenCode Go local SQLite usage (upstream #2649 per-model cost breakdown). /// /// Reads the local `opencode.db` and maps rows onto the shared `CostSummary` /// (`total_cost_usd`, `by_model`, `sessions_count`, period) so the chart's /// local-usage summary treats OpenCode Go like Codex/Claude. No token counts /// are available from the SQLite reader, so token fields stay zero. pub fn scan_opencodego_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { if is_cancelled(cancel) { return CostSummary::default(); } let now = Utc::now(); let Some(local) = opencodego_local::model_cost_summary_scan(now, self.days) else { return CostSummary::default(); }; CostSummary { total_cost_usd: local.total_cost_usd, by_model: local.by_model, sessions_count: local.request_count, period_start: local.period_start, period_end: local.period_end, ..CostSummary::default() } } fn get_claude_projects_dir(&self) -> PathBuf { if let Ok(claude_config) = std::env::var("CLAUDE_CONFIG_DIR") { let trimmed = claude_config.trim(); if !trimmed.is_empty() { return PathBuf::from(trimmed).join("projects"); } } // Try ~/.claude/projects first let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let claude_dir = home.join(".claude").join("projects"); if claude_dir.exists() { return claude_dir; } // Fallback to ~/.config/claude/projects home.join(".config").join("claude").join("projects") } fn walk_claude_files( &self, dir: &Path, cutoff: &DateTime, cancel: Option<&AtomicBool>, on_file: &mut F, ) where F: FnMut(&Path), { if is_cancelled(cancel) { return; } let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return, }; for entry in entries.flatten() { if is_cancelled(cancel) { break; } let path = entry.path(); if path.is_dir() { self.walk_claude_files(&path, cutoff, cancel, on_file); } else if path.extension().is_some_and(|e| e == "jsonl") { // Check file modification time if let Ok(metadata) = fs::metadata(&path) && let Ok(modified) = metadata.modified() { let modified_dt: DateTime = modified.into(); if modified_dt >= *cutoff { on_file(&path); } } } } } } /// Stream the de-duplicated, in-window usage records from one transcript /// file into `on_record`. Both the summary scan and the daily-history scan /// consume this single reader, so Claude log semantics live in one place. /// Returns the number of records consumed, so callers can tell whether the /// file contributed anything. #[cfg(test)] fn for_each_claude_usage_record( path: &Path, cutoff: &DateTime, seen: &mut HashSet, cancel: Option<&AtomicBool>, on_record: F, ) -> usize where F: FnMut(&ClaudeUsageRecord), { let mut pricing = ClaudeScanPricingResolver::default(); for_each_claude_usage_record_with_pricing(path, cutoff, seen, cancel, &mut pricing, on_record) } fn for_each_claude_usage_record_with_pricing( path: &Path, cutoff: &DateTime, seen: &mut HashSet, cancel: Option<&AtomicBool>, pricing: &mut ClaudeScanPricingResolver, mut on_record: F, ) -> usize where F: FnMut(&ClaudeUsageRecord), { let Ok(file) = File::open(path) else { return 0; }; let mut counted = 0; // Use read_until so a final incomplete line (no trailing newline) is still // processed when it is valid UTF-8 JSON, and so a single bad line does not // stop the walk the way `lines().map_while(Result::ok)` would. for_each_jsonl_text_line(BufReader::new(file), |line| { if is_cancelled(cancel) { return false; } if let Ok(event) = serde_json::from_str::(line) && !event.is_vertex_ai_usage_entry() && let Some(record) = claude_usage_record_from_event_with_pricing(&event, pricing) && should_count_claude_record(&record, cutoff, seen) { counted += 1; on_record(&record); } true }); counted } /// Walk JSONL text lines from `reader`, including a final incomplete line at EOF. /// Continues past invalid UTF-8 segments. `on_line` returns `false` to stop early. fn for_each_jsonl_text_line(mut reader: R, mut on_line: F) where R: BufRead, F: FnMut(&str) -> bool, { let mut buf = Vec::new(); loop { buf.clear(); match reader.read_until(b'\n', &mut buf) { Ok(0) => break, Ok(_) => {} Err(_) => break, } while matches!(buf.last(), Some(b'\n' | b'\r')) { buf.pop(); } let Ok(line) = std::str::from_utf8(&buf) else { continue; }; if !on_line(line) { break; } } } #[cfg(test)] fn claude_usage_record_from_event(event: &ClaudeEvent) -> Option { let mut pricing = ClaudeScanPricingResolver::default(); claude_usage_record_from_event_with_pricing(event, &mut pricing) } fn claude_usage_record_from_event_with_pricing( event: &ClaudeEvent, pricing: &mut ClaudeScanPricingResolver, ) -> Option { if event.event_type.as_deref() != Some("assistant") { return None; } let message = event.message.as_ref()?; let usage = message.usage.as_ref()?; let model = message.model.as_deref().unwrap_or("claude-3-5-sonnet"); let input = usage.input_tokens.unwrap_or(0); let output = usage.output_tokens.unwrap_or(0); let cache_create = usage.cache_creation_input_tokens.unwrap_or(0); let cache_read = usage.cache_read_input_tokens.unwrap_or(0); if input == 0 && output == 0 && cache_create == 0 && cache_read == 0 { return None; } let cache_create_1h = usage.one_hour_cache_creation_tokens(cache_create); let pricing_known = pricing.is_known(model); let cost = pricing.cost_usd_with_cache_ttl( model, input, cache_create, cache_create_1h, cache_read, output, ); Some(ClaudeUsageRecord { model: model.to_string(), pricing_known, timestamp: event.parsed_timestamp(), dedup_key: claude_usage_dedup_key(message.id.as_deref(), event.request_id.as_deref()), input, output, cache_create, cache_read, cost, }) } fn claude_usage_dedup_key(message_id: Option<&str>, request_id: Option<&str>) -> Option { match (message_id, request_id) { (Some(message_id), Some(request_id)) => Some(format!("{message_id}:{request_id}")), (Some(message_id), None) => Some(format!("message:{message_id}")), (None, Some(request_id)) => Some(format!("request:{request_id}")), (None, None) => None, } } fn should_count_claude_record( record: &ClaudeUsageRecord, cutoff: &DateTime, seen: &mut HashSet, ) -> bool { if let Some(timestamp) = record.timestamp && timestamp < *cutoff { return false; } if let Some(key) = &record.dedup_key && !seen.insert(key.clone()) { return false; } true } fn add_claude_record_to_summary(summary: &mut CostSummary, record: &ClaudeUsageRecord) { if !record.pricing_known { summary.unknown_models.insert(record.model.clone()); } summary.input_tokens += record.input; summary.output_tokens += record.output; summary.cached_tokens += record.cache_create + record.cache_read; summary.total_cost_usd += record.cost; *summary.by_model.entry(record.model.clone()).or_insert(0.0) += record.cost; let model_tokens = summary .by_model_tokens .entry(record.model.clone()) .or_default(); model_tokens.input_tokens += record.input; model_tokens.output_tokens += record.output; model_tokens.cached_tokens += record.cache_create + record.cache_read; } /// Add one usage record to the per-day cost buckets, keyed by the record's /// own timestamp in the local timezone. Records outside the initialized /// date range (or without a timestamp) are ignored. fn add_claude_record_to_daily_costs( daily_costs: &mut HashMap>, record: &ClaudeUsageRecord, ) { let Some(timestamp) = record.timestamp else { return; }; let date_str = timestamp .with_timezone(&Local) .date_naive() .format("%Y-%m-%d") .to_string(); if let Some(cost) = daily_costs.get_mut(&date_str) { *cost = Some(cost.unwrap_or(0.0) + record.cost); } } /// Check if any cost usage sources are available #[allow( dead_code, reason = "utility probe for cost-usage availability; not yet wired into all call sites" )] pub fn has_cost_usage_sources() -> bool { let scanner = CostScanner::new(1); scanner .get_codex_sessions_dirs() .iter() .any(|dir| dir.exists()) || scanner.get_claude_projects_dir().exists() || crate::pi_session_cost::pi_compatible_session_roots(dirs::home_dir()) .iter() .any(|dir| dir.exists()) } /// Get daily cost history for the last N days /// Returns calendar-preserving daily costs sorted by date. `None` means the day /// 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 mut daily_costs: HashMap> = HashMap::new(); // Initialize all days with 0 for days_ago in 0..days { let date = today - Duration::days(days_ago as i64); let date_str = date.format("%Y-%m-%d").to_string(); daily_costs.insert(date_str, (provider != "codex").then_some(0.0)); } match provider { "codex" => { // Warm/refresh the disk cache, then price from packed days. v0.56.1 // preserves every calendar slot and distinguishes covered zero from // unscanned/unpriced history. let (_summary, _stats, cache) = scanner.scan_codex_detailed_with_cache(None); if cache.previous_report.is_none() && !cache.codex_scan_incomplete { for (day_key, slot) in &mut daily_costs { if cache .scan_since_key .as_deref() .is_some_and(|since| day_key.as_str() >= since) && cache .scan_until_key .as_deref() .is_some_and(|until| day_key.as_str() <= until) { *slot = Some(0.0); } } } for (day_key, models) in &cache.days { let Some(slot) = daily_costs.get_mut(day_key) else { continue; }; let Some(day) = CostUsageDayRange::parse_day_key(day_key) else { continue; }; let day_range = CostUsageDayRange::new(day, day); let mut one_day = HashMap::new(); one_day.insert(day_key.clone(), models.clone()); let mut scratch = CostSummary::default(); let (cost, _) = add_codex_days_map_to_summary(&mut scratch, &one_day, &day_range); *slot = (!scratch.model_pricing_completeness.is_partial()).then_some(cost); } } "claude" => { // Real per-day breakdown: walk the project logs once, // de-duplicating records across files. let projects_dir = scanner.get_claude_projects_dir(); if projects_dir.exists() { let cutoff = Utc::now() - Duration::days(days as i64); let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut handle_file = |path: &Path| { for_each_claude_usage_record_with_pricing( path, &cutoff, &mut seen, None, &mut pricing, |record| { add_claude_record_to_daily_costs(&mut daily_costs, record); }, ); }; scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); } } "opencodego" => { // Per-day cost from the local OpenCode SQLite reader (upstream #2649). // Rows are grouped by local calendar day to match Codex/Claude keying. for (day_key, cost) in opencodego_local::daily_cost_series(Utc::now(), days) { if let Some(slot) = daily_costs.get_mut(&day_key) { *slot = Some(slot.unwrap_or(0.0) + cost); } } } _ => {} } // Convert to sorted vector let mut result: Vec<(String, Option)> = daily_costs.into_iter().collect(); result.sort_by(|a, b| a.0.cmp(&b.0)); result } /// Daily token totals (input + output) for the Tokens chart mode, plus /// whether local history looks incomplete at the old edge of the window /// (Codex backfill still in progress → the chart shows a "Refreshing" /// 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 mut daily_tokens: HashMap = HashMap::new(); let mut covered_days: HashSet = HashSet::new(); // Initialize all days with 0 for days_ago in 0..days { let date = today - Duration::days(days_ago as i64); let date_str = date.format("%Y-%m-%d").to_string(); daily_tokens.insert(date_str, 0); } match provider { "codex" => { // Warm/refresh the disk cache, then read exact local token totals // from packed days through the same summary path the cost chart // uses. let (_summary, _stats, cache) = scanner.scan_codex_detailed_with_cache(None); for (day_key, models) in &cache.days { if !daily_tokens.contains_key(day_key) { continue; } let Some(day) = CostUsageDayRange::parse_day_key(day_key) else { continue; }; let day_range = CostUsageDayRange::new(day, day); let mut one_day = HashMap::new(); one_day.insert(day_key.clone(), models.clone()); let mut scratch = CostSummary::default(); add_codex_days_map_to_summary(&mut scratch, &one_day, &day_range); if let Some(slot) = daily_tokens.get_mut(day_key) { *slot = scratch.input_tokens + scratch.output_tokens; } covered_days.insert(day_key.clone()); } } "claude" => { // Per-day token breakdown from the same de-duplicated record walk // as the cost chart. The full walk is authoritative, so the // Refreshing marker never applies here. let projects_dir = scanner.get_claude_projects_dir(); if projects_dir.exists() { let cutoff = Utc::now() - Duration::days(days as i64); let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut handle_file = |path: &Path| { for_each_claude_usage_record_with_pricing( path, &cutoff, &mut seen, None, &mut pricing, |record| { add_claude_record_to_daily_tokens(&mut daily_tokens, record); }, ); }; scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); } } _ => {} } // Convert to sorted vector let mut result: Vec<(String, u64)> = daily_tokens.into_iter().collect(); result.sort_by(|a, b| a.0.cmp(&b.0)); // Codex only: the bounded catch-up may not have reached the requested // depth yet. Incomplete = history exists but the oldest quarter of the // window has no scanned day. let incomplete = provider == "codex" && !covered_days.is_empty() && covered_days.len() < days as usize && result[..(result.len() / 4).max(1)] .iter() .any(|(date, _)| !covered_days.contains(date)); (result, incomplete) } fn add_claude_record_to_daily_tokens( daily_tokens: &mut HashMap, record: &ClaudeUsageRecord, ) { let Some(timestamp) = record.timestamp else { return; }; let date_str = timestamp .with_timezone(&Local) .date_naive() .format("%Y-%m-%d") .to_string(); if let Some(slot) = daily_tokens.get_mut(&date_str) { *slot = slot .saturating_add(record.input) .saturating_add(record.output) .saturating_add(record.cache_read) .saturating_add(record.cache_create); } }