diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 09a5088cba..cd1eee80bb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -213,8 +213,8 @@ fn load_local_usage_summary_with_unknown_models( .cloned() .collect(); - let thirty_day_tokens = total_tokens(&thirty_day); - let latest_tokens = total_tokens(&today); + let thirty_day_tokens = total_tokens(provider_id, &thirty_day); + let latest_tokens = total_tokens(provider_id, &today); let has_usage = thirty_day.sessions_count > 0 || thirty_day.total_cost_usd > 0.0 || thirty_day_tokens > 0; if !has_usage { @@ -228,7 +228,7 @@ fn load_local_usage_summary_with_unknown_models( thirty_day_cost: non_zero_f64(thirty_day.total_cost_usd), thirty_day_tokens: non_zero_u64(thirty_day_tokens), latest_tokens: non_zero_u64(latest_tokens), - top_model: top_model(&thirty_day), + top_model: top_model(provider_id, &thirty_day), estimate_note: localized_estimate_note(provider_id, lang), token_cost_updated_at_ms: current_unix_ms(), }), @@ -433,8 +433,8 @@ fn scan_local_cost( } } -fn total_tokens(summary: &CostSummary) -> u64 { - summary.input_tokens + summary.output_tokens +fn total_tokens(provider_id: &str, summary: &CostSummary) -> u64 { + summary.total_tokens_for_provider(provider_id) } fn non_zero_f64(value: f64) -> Option { @@ -445,11 +445,11 @@ fn non_zero_u64(value: u64) -> Option { (value > 0).then_some(value) } -fn top_model(summary: &CostSummary) -> Option { +fn top_model(provider_id: &str, summary: &CostSummary) -> Option { summary .by_model_tokens .iter() - .max_by_key(|(_, counts)| counts.total()) + .max_by_key(|(_, counts)| counts.total_for_provider(provider_id)) .map(|(model, _)| model.clone()) .or_else(|| { summary diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index c1012551c0..6f66811b10 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -465,8 +465,8 @@ fn build_usage_spend_summary( "codex" => SpendValues { seven_day: codex_7_contract.known_cost_usd, thirty_day: codex_30_contract.known_cost_usd, - seven_day_tokens: total_token_mix(&codex_7_contract.token_mix), - thirty_day_tokens: total_token_mix(&codex_30_contract.token_mix), + seven_day_tokens: codex_7_contract.token_total, + thirty_day_tokens: codex_30_contract.token_total, source: if include_opencodex && !codex_30_contract.imports.is_empty() { "local logs + OpenCodex".to_string() } else { @@ -478,16 +478,8 @@ fn build_usage_spend_summary( "claude" => SpendValues { seven_day: Some(claude_7_summary.total_cost_usd), thirty_day: Some(claude_30_summary.total_cost_usd), - seven_day_tokens: Some( - claude_7_summary - .input_tokens - .saturating_add(claude_7_summary.output_tokens), - ), - thirty_day_tokens: Some( - claude_30_summary - .input_tokens - .saturating_add(claude_30_summary.output_tokens), - ), + seven_day_tokens: Some(claude_7_summary.total_tokens_for_provider("claude")), + thirty_day_tokens: Some(claude_30_summary.total_tokens_for_provider("claude")), source: "local logs".to_string(), refreshing: false, stale_updated_at: None, @@ -499,8 +491,8 @@ fn build_usage_spend_summary( SpendValues { seven_day: seven.known_cost_usd, thirty_day: thirty.known_cost_usd, - seven_day_tokens: total_token_mix(&seven.token_mix), - thirty_day_tokens: total_token_mix(&thirty.token_mix), + seven_day_tokens: seven.token_total, + thirty_day_tokens: thirty.token_total, source: if provider_id == "opencodego" { "local logs + OpenCodex".to_string() } else { @@ -614,21 +606,6 @@ fn build_usage_spend_summary( UsageSpendSummary { rows, contract } } -fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option { - let values = [ - mix.input_tokens, - mix.output_tokens, - mix.cache_creation_tokens, - ]; - let mut saw = false; - let mut total = 0u64; - for value in values.into_iter().flatten() { - saw = true; - total = total.saturating_add(value); - } - saw.then_some(total) -} - fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { let Some(snapshot) = snapshot else { return SpendValues { diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 68aac95a45..a68c67b4ed 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -122,12 +122,50 @@ 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). 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 reports cache read/creation as +/// separate classes. A total must not add the cache bucket for the former. +/// OpenCodex imports are not native rows and keep their own resolved totals +/// (see `spend_contract::opencodex`). +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) } + + /// Provider-aware window total, same rule as + /// [`ModelTokenCounts::total_for_provider`]. + pub fn total_tokens_for_provider(&self, provider: &str) -> u64 { + let total = self.input_tokens.saturating_add(self.output_tokens); + if cache_is_separate_from_input(provider) { + total.saturating_add(self.cached_tokens) + } else { + total + } + } } fn is_cancelled(cancel: Option<&AtomicBool>) -> bool { @@ -878,7 +916,8 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< result } -/// Daily token totals (input + output) for the Tokens chart mode, plus +/// Daily token totals (provider-aware, see [`cache_is_separate_from_input`]) +/// 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). @@ -914,7 +953,7 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> 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; + *slot = scratch.total_tokens_for_provider("codex"); } covered_days.insert(day_key.clone()); } @@ -976,6 +1015,10 @@ fn add_claude_record_to_daily_tokens( .format("%Y-%m-%d") .to_string(); if let Some(slot) = daily_tokens.get_mut(&date_str) { - *slot += record.input + record.output; + *slot = slot + .saturating_add(record.input) + .saturating_add(record.output) + .saturating_add(record.cache_read) + .saturating_add(record.cache_create); } } diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index c233d29835..2d65500c64 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -2820,3 +2820,88 @@ fn incomplete_or_buffered_empty_codex_fragment_is_not_marked_complete() { assert!(buffered_cache.codex_scan_incomplete); assert!(buffered_cache.codex_pending_paths.contains(&buffered_key)); } + +// Regression (PR #611 review): Codex `input_tokens` already contains cached +// input, Claude cache buckets are separate. Every total must follow the +// provider's rule, and the model, daily and window totals must agree. +#[test] +fn provider_totals_add_cache_only_when_it_is_separate_from_input() { + let counts = ModelTokenCounts { + input_tokens: 100, + output_tokens: 5, + cached_tokens: 90, + reasoning_tokens: None, + }; + assert_eq!(counts.total_for_provider("codex"), 105); + assert_eq!(counts.total_for_provider("claude"), 195); + + let summary = CostSummary { + input_tokens: 100, + output_tokens: 5, + cached_tokens: 90, + ..CostSummary::default() + }; + assert_eq!(summary.total_tokens_for_provider("codex"), 105); + assert_eq!(summary.total_tokens_for_provider("claude"), 195); +} + +#[test] +fn codex_day_total_excludes_cached_input_and_matches_model_totals() { + let day_key = "2026-08-18".to_string(); + let day = CostUsageDayRange::parse_day_key(&day_key).expect("day"); + let range = CostUsageDayRange::new(day, day); + let mut one_day = HashMap::new(); + one_day.insert( + day_key, + HashMap::from([("gpt-5".to_string(), vec![1_000, 900, 50])]), + ); + let mut scratch = CostSummary::default(); + add_codex_days_map_to_summary(&mut scratch, &one_day, &range); + + assert_eq!(scratch.input_tokens, 1_000); + assert_eq!(scratch.cached_tokens, 900); + // The value `get_daily_token_history("codex")` stores for the day. + let day_total = scratch.total_tokens_for_provider("codex"); + assert_eq!(day_total, 1_050, "cached input is already inside input"); + let model_total: u64 = scratch + .by_model_tokens + .values() + .map(|counts| counts.total_for_provider("codex")) + .sum(); + assert_eq!(model_total, day_total); +} + +#[test] +fn claude_day_total_includes_cache_and_matches_summary_and_model_totals() { + use chrono::TimeZone as _; + let timestamp = Local + .with_ymd_and_hms(2026, 8, 18, 12, 0, 0) + .single() + .expect("local time") + .with_timezone(&Utc); + let record = ClaudeUsageRecord { + model: "claude-sonnet-4-5".to_string(), + pricing_known: true, + timestamp: Some(timestamp), + dedup_key: None, + input: 10, + output: 20, + cache_create: 300, + cache_read: 4_000, + cost: 0.0, + }; + let mut summary = CostSummary::default(); + add_claude_record_to_summary(&mut summary, &record); + let mut daily = HashMap::from([("2026-08-18".to_string(), 0u64)]); + add_claude_record_to_daily_tokens(&mut daily, &record); + + let window_total = summary.total_tokens_for_provider("claude"); + assert_eq!(window_total, 4_330); + assert_eq!(daily["2026-08-18"], window_total); + let model_total: u64 = summary + .by_model_tokens + .values() + .map(|counts| counts.total_for_provider("claude")) + .sum(); + assert_eq!(model_total, window_total); +} diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index 9a2e928b34..f00a36015a 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -192,6 +192,12 @@ pub struct ImportedSpendSource { pub known_cost_usd: Option, pub provenance: CostProvenance, pub token_mix: SpendTokenMix, + /// Sum of the importer's resolved per-entry totals: the authoritative + /// `totalTokens` when present, else input + output + cache creation + /// (`cache_read` is already part of input). Same basis as `models` and + /// `daily`. Not part of the wire contract. + #[serde(skip)] + pub token_total: Option, pub coverage: CostCoverageCounts, pub models: Vec, pub daily: Vec, @@ -230,6 +236,11 @@ pub struct SpendContract { pub price_coverage_ratio: Option, pub history_coverage_established: bool, pub token_mix: SpendTokenMix, + /// Window token total with each source's own rule applied + /// (see [`resolve_token_total`]). Not part of the wire contract: the merged + /// `token_mix` above cannot express native and imported rules at once. + #[serde(skip)] + pub token_total: Option, pub conversation_count: u32, pub models: Vec, pub projects: Vec, @@ -399,6 +410,11 @@ pub fn build_local_spend_contract_from_summary( let imported = imports.first(); let replace_native = provider_id == "codex" && hide_native_codex_when_opencodex_present && imported.is_some(); + let token_total = resolve_token_total( + summary.total_tokens_for_provider(provider_id), + imported, + replace_native, + ); let resolved = resolve_spend( native_cost, native_provenance, @@ -450,6 +466,7 @@ pub fn build_local_spend_contract_from_summary( price_coverage: resolved.price_coverage, history_coverage_established: summary.history_coverage_established, token_mix: resolved.token_mix, + token_total, conversation_count, models: resolved.models, projects: native.projects, @@ -509,6 +526,26 @@ fn load_native_spend( } } +/// Totals native and imported sources separately, each with its own rule, then +/// combines them. The native total comes from +/// [`CostSummary::total_tokens_for_provider`], the same rule as the native +/// model and daily totals. The imported side uses the importer's resolved per-entry +/// totals (authoritative `totalTokens` when present) instead of re-deriving a +/// total from the merged `token_mix`, whose `cache_read` is already part of +/// input. `replace_native` mirrors [`resolve_spend`]: the imported source +/// replaces the native one entirely. +fn resolve_token_total( + native_total: u64, + imported: Option<&ImportedSpendSource>, + replace_native: bool, +) -> Option { + let imported_total = imported.and_then(|source| source.token_total); + if replace_native { + return imported_total; + } + Some(native_total.saturating_add(imported_total.unwrap_or(0))) +} + #[allow( clippy::too_many_arguments, reason = "signature mirrors the flat spend-contract config fields one-to-one" @@ -643,7 +680,7 @@ fn model_rows( input_tokens: counts.input_tokens, output_tokens: counts.output_tokens, cache_read_tokens: counts.cached_tokens, - total_tokens: counts.total(), + total_tokens: counts.total_for_provider(provider_id), custom_pricing: custom_rates.is_some(), } }) diff --git a/rust/src/spend_contract/opencodex.rs b/rust/src/spend_contract/opencodex.rs index 6298889ba3..9c50bf023d 100644 --- a/rust/src/spend_contract/opencodex.rs +++ b/rust/src/spend_contract/opencodex.rs @@ -135,6 +135,7 @@ fn aggregate( let mut conversations = HashSet::new(); let mut token_mix = SpendTokenMix::default(); + let mut token_total: Option = None; let mut coverage = CostCoverageCounts::default(); let mut activity: BTreeMap<(u8, u8), u32> = BTreeMap::new(); let mut models: HashMap = HashMap::new(); @@ -160,6 +161,10 @@ fn aggregate( add_optional(token_mix.cache_creation_tokens, entry.cache_creation_tokens); token_mix.reasoning_tokens = add_optional(token_mix.reasoning_tokens, entry.reasoning_tokens); + // Same per-entry basis and saturation as the daily and model totals below. + if let Some(total) = entry.resolved_total_tokens() { + token_total = Some(token_total.unwrap_or(0).saturating_add(total)); + } let cost = entry_cost(entry, custom, &pricing_snapshot); match entry.usage_status.as_str() { @@ -281,6 +286,7 @@ fn aggregate( known_cost_usd: saw_known_cost.then_some(known_cost), provenance, token_mix, + token_total, coverage, models: model_rows, daily: daily @@ -601,6 +607,43 @@ mod tests { assert_eq!(estimated.provenance, CostProvenance::ListPriceEstimate); } + // Regression (PR #611 review): cache_read is part of input for imports, and + // an authoritative `totalTokens` must not be re-derived. The window total + // must use the same per-entry totals as the model and daily rows. + #[test] + fn aggregate_window_total_matches_model_and_daily_totals() { + let now = DateTime::parse_from_rfc3339("2026-08-19T12:00:00Z") + .unwrap() + .with_timezone(&Utc); + let authoritative = entry("openai", "gpt-5"); + let mut derived = entry("openai", "gpt-5.6-sol"); + derived.request_id = "derived".to_string(); + derived.input_tokens = Some(50); + derived.output_tokens = Some(3); + derived.cache_read_tokens = Some(8); + derived.cache_creation_tokens = Some(2); + derived.total_tokens = None; + + let source = aggregate( + vec![authoritative, derived], + now, + 30, + &CustomPricing::default(), + ) + .expect("source"); + + // 105 (authoritative) + 50 + 3 + 2 (cache_read is inside input). + assert_eq!(source.token_total, Some(160)); + let model_total: u64 = source.models.iter().map(|row| row.total_tokens).sum(); + let daily_total: u64 = source + .daily + .iter() + .filter_map(|point| point.total_tokens) + .sum(); + assert_eq!(model_total, 160); + assert_eq!(daily_total, 160); + } + fn entry(provider: &str, model: &str) -> OpenCodexEntry { OpenCodexEntry { request_id: format!("{provider}:{model}"), diff --git a/rust/src/spend_contract/tests.rs b/rust/src/spend_contract/tests.rs index fb74ff09de..7adf7df071 100644 --- a/rust/src/spend_contract/tests.rs +++ b/rust/src/spend_contract/tests.rs @@ -230,6 +230,7 @@ fn resolve_spend_preserves_merged_report_details() { reasoning_tokens: Some(1), ..SpendTokenMix::default() }, + token_total: Some(5), coverage: CostCoverageCounts { priced: 2, unpriced: 1, @@ -465,6 +466,7 @@ fn spend_contract_serializes_provenance_for_tauri_and_cli() { price_coverage_ratio: None, history_coverage_established: true, token_mix: SpendTokenMix::default(), + token_total: None, conversation_count: 0, models: Vec::new(), projects: Vec::new(), @@ -518,3 +520,82 @@ fn coverage_for_models_counts_priced_rows_as_estimated() { assert_eq!(coverage.unpriced, 1); assert_eq!(coverage.total(), 3); } + +fn summary_with_model(model: &str, input: u64, output: u64, cached: u64) -> CostSummary { + CostSummary { + input_tokens: input, + output_tokens: output, + cached_tokens: cached, + by_model: HashMap::from([(model.to_string(), 1.0)]), + by_model_tokens: HashMap::from([( + model.to_string(), + ModelTokenCounts { + input_tokens: input, + output_tokens: output, + cached_tokens: cached, + reasoning_tokens: None, + }, + )]), + ..CostSummary::default() + } +} + +fn imported_with_total(token_total: Option) -> ImportedSpendSource { + ImportedSpendSource { + source_id: "opencodex".to_string(), + display_name: "OpenCodex".to_string(), + request_count: 1, + conversation_count: 1, + known_cost_usd: None, + provenance: CostProvenance::Unknown, + // The OpenCodex fixture row: cache_read is inside input, total is 105. + token_mix: SpendTokenMix { + input_tokens: Some(100), + output_tokens: Some(5), + cache_read_tokens: Some(10), + ..SpendTokenMix::default() + }, + token_total, + coverage: CostCoverageCounts::default(), + models: Vec::new(), + daily: Vec::new(), + hourly_activity: Vec::new(), + } +} + +// Regression (PR #611 review): the native side follows the provider's cache +// rule, so the window total agrees with the native model totals. +#[test] +fn native_window_total_matches_model_totals_for_each_provider() { + for (provider, expected) in [("codex", 1_050), ("claude", 1_950)] { + let summary = summary_with_model("m", 1_000, 50, 900); + let rows = model_rows(provider, &summary, &CustomPricing::default()); + let model_total: u64 = rows.iter().map(|row| row.total_tokens).sum(); + let window_total = + resolve_token_total(summary.total_tokens_for_provider(provider), None, false); + assert_eq!(model_total, expected, "{provider} model total"); + assert_eq!(window_total, Some(expected), "{provider} window total"); + } +} + +// Regression (PR #611 review): the imported side uses the importer's resolved +// total (105), not a total re-derived from its token mix (115). +#[test] +fn imported_window_total_uses_resolved_import_total() { + let imported = imported_with_total(Some(105)); + assert_eq!( + resolve_token_total(1_050, Some(&imported), false), + Some(1_155) + ); + assert_eq!( + resolve_token_total(1_050, Some(&imported), true), + Some(105), + "replace_native drops the native side entirely" + ); + assert_eq!(resolve_token_total(1_050, None, false), Some(1_050)); + assert_eq!( + resolve_token_total(1_050, Some(&imported_with_total(None)), true), + None, + "an import without token data stays unknown" + ); +}