From bf3cd3416f11471a332148a151feec4b0bdb4ccb Mon Sep 17 00:00:00 2001 From: Pyaoya <200496925+Pyaoya@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:53:37 +0900 Subject: [PATCH 1/4] Include cache tokens in local usage totals The menu-card token rows ("30d tokens" / "latest tokens") and the matching CLI/spend totals only summed input + output, dropping cache read and cache write. On cache-heavy Claude Code usage that understates the real total by orders of magnitude: 3.5M shown vs 1.08B over the same 30-day window. Changed call sites (all use saturating_add): - apps/desktop-tauri/src-tauri/src/commands/chart.rs: total_tokens() now adds summary.cached_tokens (feeds thirtyDayTokens and latestTokens). - rust/src/cost_scanner.rs: ModelTokenCounts::total() now adds cached_tokens (per-model rows in the spend contract). - rust/src/cost_scanner.rs: get_daily_token_history() codex branch now adds scratch.cached_tokens. - rust/src/cost_scanner.rs: add_claude_record_to_daily_tokens() now adds record.cache_read + record.cache_create. - apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs: total_token_mix() now includes cache_read_tokens (it already counted cache_creation_tokens). Cost/pricing (total_cost_usd), quota windows and the de-duplication walk are untouched. De-duplication happens per record before aggregation (requestId/messageId for Claude, monotonic totals for Codex), so counting the cache buckets cannot double count. Verified locally on Windows (MSVC 14.44, Rust stable 1.98.1, tag v0.60.3): before: codexbar-cli cost --provider claude -> daily totalTokens sum = 3,492,054 after: cargo build -p codexbar --release -> daily totalTokens sum = 1,079,676,812 Matching an independent recount from ~/.claude/projects/**/*.jsonl (5,120 unique rows, 4,222 duplicates skipped). The 30-day cost stayed at $818.19 in both builds. --- .../src-tauri/src/commands/chart.rs | 8 +++++++- .../src-tauri/src/commands/usage_spend.rs | 1 + rust/src/cost_scanner.rs | 18 +++++++++++++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 09a5088cba..4cec499161 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -434,7 +434,13 @@ fn scan_local_cost( } fn total_tokens(summary: &CostSummary) -> u64 { - summary.input_tokens + summary.output_tokens + // Local token accounting counts cache reads/writes too (see the matching + // change in codexbar::cost_scanner). Without them the menu-card + // "30d tokens" / "latest tokens" rows are off by orders of magnitude. + summary + .input_tokens + .saturating_add(summary.output_tokens) + .saturating_add(summary.cached_tokens) } fn non_zero_f64(value: f64) -> Option { 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..9553db06be 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -618,6 +618,7 @@ fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option let values = [ mix.input_tokens, mix.output_tokens, + mix.cache_read_tokens, mix.cache_creation_tokens, ]; let mut saw = false; diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 68aac95a45..a9f290e344 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -120,7 +120,12 @@ pub struct ModelTokenCounts { impl ModelTokenCounts { pub fn total(&self) -> u64 { - self.input_tokens.saturating_add(self.output_tokens) + // Local token accounting counts cache reads/writes too; a local JSONL + // scan is the only place those tokens are visible, and omitting them + // understates cache-heavy Claude Code sessions by orders of magnitude. + self.input_tokens + .saturating_add(self.output_tokens) + .saturating_add(self.cached_tokens) } } @@ -914,7 +919,10 @@ 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 + .input_tokens + .saturating_add(scratch.output_tokens) + .saturating_add(scratch.cached_tokens); } covered_days.insert(day_key.clone()); } @@ -976,6 +984,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); } } From 537741426c92482a68d56b0cfb7d340ec05af7c9 Mon Sep 17 00:00:00 2001 From: Pyaoya <200496925+Pyaoya@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:15:12 +0900 Subject: [PATCH 2/4] Do not add cache tokens for sources whose input already includes them Codex reports `input_tokens` with cached input already included: `CodexTokenCounts::from_values` clamps `cached` to `input`, and `codex_cost_usd_for_day` subtracts it back out (`non_cached = input - cached`) before pricing. Adding `cached_tokens` on top therefore double counted Codex per-model and menu-card totals (gpt-5.6-sol showed 564,718,634 instead of 288,770,730). Claude and the OpenCodex imports report cache read/creation as classes separate from `input_tokens`, so they must keep adding them. - `ModelTokenCounts::total()` goes back to `input + output`, and gains `total_with_separate_cache()` / `total_for_provider(provider)`. - New single source of truth `cache_is_separate_from_input(provider)`; only `codex` returns false. - Applied at the read sites that know the provider: spend contract model rows, the desktop menu-card `total_tokens()` and `top_model()`, and the usage-spend token mix (codex call sites pass false, OpenCodex ones true). - `get_daily_token_history` codex branch reverts to `input + output`; the Claude branch keeps adding cache read/creation. - Also fixes the Claude 7d/30d token columns in the usage-spend view, which were summing `input + output` only. Verified against a local 30-day window (tag v0.60.3, MSVC 14.44, Rust 1.98.1): Claude spendContract.daily[] sum: 3,492,054 -> 1,079,676,812 (cost $818.1882 unchanged) Codex gpt-5.6-sol totalTokens: 564,718,634 -> 288,770,730 (matches 0.60.3 output exactly) Codex cost $471.4885 unchanged; cache read is still reported as its own bucket. Cache tokens, cost and quota windows are otherwise untouched. --- .../src-tauri/src/commands/chart.rs | 25 ++++++------ .../src-tauri/src/commands/usage_spend.rs | 25 ++++++++---- rust/src/cost_scanner.rs | 38 ++++++++++++++----- rust/src/spend_contract.rs | 2 +- 4 files changed, 58 insertions(+), 32 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 4cec499161..f9533b33b4 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,14 +433,13 @@ fn scan_local_cost( } } -fn total_tokens(summary: &CostSummary) -> u64 { - // Local token accounting counts cache reads/writes too (see the matching - // change in codexbar::cost_scanner). Without them the menu-card - // "30d tokens" / "latest tokens" rows are off by orders of magnitude. - summary - .input_tokens - .saturating_add(summary.output_tokens) - .saturating_add(summary.cached_tokens) +fn total_tokens(provider_id: &str, summary: &CostSummary) -> u64 { + let base = summary.input_tokens.saturating_add(summary.output_tokens); + if codexbar::cost_scanner::cache_is_separate_from_input(provider_id) { + base.saturating_add(summary.cached_tokens) + } else { + base + } } fn non_zero_f64(value: f64) -> Option { @@ -451,11 +450,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 9553db06be..0a0cd48f02 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: total_token_mix(&codex_7_contract.token_mix, false), + thirty_day_tokens: total_token_mix(&codex_30_contract.token_mix, false), source: if include_opencodex && !codex_30_contract.imports.is_empty() { "local logs + OpenCodex".to_string() } else { @@ -481,12 +481,14 @@ fn build_usage_spend_summary( seven_day_tokens: Some( claude_7_summary .input_tokens - .saturating_add(claude_7_summary.output_tokens), + .saturating_add(claude_7_summary.output_tokens) + .saturating_add(claude_7_summary.cached_tokens), ), thirty_day_tokens: Some( claude_30_summary .input_tokens - .saturating_add(claude_30_summary.output_tokens), + .saturating_add(claude_30_summary.output_tokens) + .saturating_add(claude_30_summary.cached_tokens), ), source: "local logs".to_string(), refreshing: false, @@ -499,8 +501,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: total_token_mix(&seven.token_mix, true), + thirty_day_tokens: total_token_mix(&thirty.token_mix, true), source: if provider_id == "opencodego" { "local logs + OpenCodex".to_string() } else { @@ -614,11 +616,18 @@ fn build_usage_spend_summary( UsageSpendSummary { rows, contract } } -fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option { +fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix, cache_is_separate: bool) -> Option { let values = [ mix.input_tokens, mix.output_tokens, - mix.cache_read_tokens, + // Codex reports cached input inside `input_tokens`; only sources whose + // cache classes are separate may add the bucket. Cache creation is + // always a separate class where it is reported at all. + if cache_is_separate { + mix.cache_read_tokens + } else { + None + }, mix.cache_creation_tokens, ]; let mut saw = false; diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index a9f290e344..bd13b6662f 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -120,13 +120,34 @@ pub struct ModelTokenCounts { impl ModelTokenCounts { pub fn total(&self) -> u64 { - // Local token accounting counts cache reads/writes too; a local JSONL - // scan is the only place those tokens are visible, and omitting them - // understates cache-heavy Claude Code sessions by orders of magnitude. - self.input_tokens - .saturating_add(self.output_tokens) - .saturating_add(self.cached_tokens) + 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 { @@ -919,10 +940,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 - .saturating_add(scratch.output_tokens) - .saturating_add(scratch.cached_tokens); + *slot = scratch.input_tokens + scratch.output_tokens; } covered_days.insert(day_key.clone()); } diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index 9a2e928b34..5e397a7ea6 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -643,7 +643,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(), } }) From 9a4c907a839b46d98f5e0f94286a107dee985340 Mon Sep 17 00:00:00 2001 From: Pyaoya <200496925+Pyaoya@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:31:52 +0900 Subject: [PATCH 3/4] Total native and imported spend tokens separately so imported cache is counted Codex native rows already include cached input in input_tokens, while the OpenCodex imported rows report cache read/creation as classes of their own. The resolved spend contract merges both into one token_mix, so a single "add cache or not" flag could only ever be right for one of them: passing false silently dropped the imported cache tokens. SpendContract now carries a token_total (skipped on the wire) computed by resolve_token_total, which totals each source with its own cache rule - spend_token_total(mix, false) for native Codex, spend_token_total(mix, true) for the import - and combines the results, mirroring replace_native. The usage-spend view now reads that field instead of re-summing the merged mix. No effective local change: token numbers are identical, costs differ only by f64 accumulation order in the model map. --- .../src-tauri/src/commands/usage_spend.rs | 31 ++--------- rust/src/spend_contract.rs | 52 +++++++++++++++++++ rust/src/spend_contract/tests.rs | 1 + 3 files changed, 57 insertions(+), 27 deletions(-) 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 0a0cd48f02..3e2f14cc01 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, false), - thirty_day_tokens: total_token_mix(&codex_30_contract.token_mix, false), + 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 { @@ -501,8 +501,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, true), - thirty_day_tokens: total_token_mix(&thirty.token_mix, true), + seven_day_tokens: seven.token_total, + thirty_day_tokens: thirty.token_total, source: if provider_id == "opencodego" { "local logs + OpenCodex".to_string() } else { @@ -616,29 +616,6 @@ fn build_usage_spend_summary( UsageSpendSummary { rows, contract } } -fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix, cache_is_separate: bool) -> Option { - let values = [ - mix.input_tokens, - mix.output_tokens, - // Codex reports cached input inside `input_tokens`; only sources whose - // cache classes are separate may add the bucket. Cache creation is - // always a separate class where it is reported at all. - if cache_is_separate { - mix.cache_read_tokens - } else { - None - }, - 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/spend_contract.rs b/rust/src/spend_contract.rs index 5e397a7ea6..ebacbde373 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -230,6 +230,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 cache 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 +404,7 @@ 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(&native_token_mix, imported, replace_native); let resolved = resolve_spend( native_cost, native_provenance, @@ -450,6 +456,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, @@ -513,6 +520,51 @@ fn load_native_spend( clippy::too_many_arguments, reason = "signature mirrors the flat spend-contract config fields one-to-one" )] +/// Window token total for one source. `cache_is_separate` is false for sources +/// whose `input_tokens` already includes cached input (Codex), true for sources +/// that report cache read/creation as classes of their own (OpenCodex imports). +pub fn spend_token_total(mix: &SpendTokenMix, cache_is_separate: bool) -> Option { + let values = [ + mix.input_tokens, + mix.output_tokens, + if cache_is_separate { + mix.cache_read_tokens + } else { + None + }, + 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) +} + +/// Totals native and imported sources separately, each with its own cache rule, +/// then combines them. Codex native rows already include cached input in +/// `input_tokens`, so adding the cache bucket there would double count; the +/// OpenCodex imported rows need it added. `replace_native` mirrors +/// [`resolve_spend`]: the imported source replaces the native one entirely. +fn resolve_token_total( + native_mix: &SpendTokenMix, + imported: Option<&ImportedSpendSource>, + replace_native: bool, +) -> Option { + let native_total = if replace_native { + None + } else { + spend_token_total(native_mix, false) + }; + let imported_total = imported.and_then(|source| spend_token_total(&source.token_mix, true)); + match (native_total, imported_total) { + (None, None) => None, + (native, imported) => Some(native.unwrap_or(0).saturating_add(imported.unwrap_or(0))), + } +} + fn resolve_spend( native_cost: Option, native_provenance: CostProvenance, diff --git a/rust/src/spend_contract/tests.rs b/rust/src/spend_contract/tests.rs index fb74ff09de..38ca7e03d8 100644 --- a/rust/src/spend_contract/tests.rs +++ b/rust/src/spend_contract/tests.rs @@ -465,6 +465,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(), From 8176eb5643e5f9c54dc6af0673111f72ba6e6647 Mon Sep 17 00:00:00 2001 From: Pyaoya <200496925+Pyaoya@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:40:15 +0900 Subject: [PATCH 4/4] Use one provider-aware token total everywhere; keep imported totals authoritative - Native: add CostSummary::total_tokens_for_provider (same rule as ModelTokenCounts::total_for_provider) and use it for the spend contract, Codex daily totals, the desktop chart and the Claude Usage & Spend row. Fixes Claude contract token_total dropping cache tokens. - Imported (OpenCodex): cache_read is already part of input, and entries may carry an authoritative totalTokens. Sum the importer's resolved per-entry totals into ImportedSpendSource.token_total instead of re-deriving from the merged token_mix (fixture: 105, was 115), matching model and daily rows. - Move the misplaced clippy allow back onto resolve_spend. - Cost calculation unchanged. Regression tests for each review finding. Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/commands/chart.rs | 7 +- .../src-tauri/src/commands/usage_spend.rs | 14 +-- rust/src/cost_scanner.rs | 27 ++++-- rust/src/cost_scanner/tests.rs | 85 +++++++++++++++++++ rust/src/spend_contract.rs | 73 +++++++--------- rust/src/spend_contract/opencodex.rs | 43 ++++++++++ rust/src/spend_contract/tests.rs | 80 +++++++++++++++++ 7 files changed, 260 insertions(+), 69 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index f9533b33b4..cd1eee80bb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -434,12 +434,7 @@ fn scan_local_cost( } fn total_tokens(provider_id: &str, summary: &CostSummary) -> u64 { - let base = summary.input_tokens.saturating_add(summary.output_tokens); - if codexbar::cost_scanner::cache_is_separate_from_input(provider_id) { - base.saturating_add(summary.cached_tokens) - } else { - base - } + summary.total_tokens_for_provider(provider_id) } fn non_zero_f64(value: f64) -> Option { 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 3e2f14cc01..6f66811b10 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -478,18 +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) - .saturating_add(claude_7_summary.cached_tokens), - ), - thirty_day_tokens: Some( - claude_30_summary - .input_tokens - .saturating_add(claude_30_summary.output_tokens) - .saturating_add(claude_30_summary.cached_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, diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index bd13b6662f..a68c67b4ed 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -124,8 +124,8 @@ impl ModelTokenCounts { } /// 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. + /// 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) } @@ -143,9 +143,10 @@ impl ModelTokenCounts { /// 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. +/// 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" } @@ -154,6 +155,17 @@ 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 { @@ -904,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). @@ -940,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()); } 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 ebacbde373..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,7 +236,7 @@ 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 cache rule applied + /// 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)] @@ -404,7 +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(&native_token_mix, imported, replace_native); + let token_total = resolve_token_total( + summary.total_tokens_for_provider(provider_id), + imported, + replace_native, + ); let resolved = resolve_spend( native_cost, native_provenance, @@ -516,55 +526,30 @@ fn load_native_spend( } } -#[allow( - clippy::too_many_arguments, - reason = "signature mirrors the flat spend-contract config fields one-to-one" -)] -/// Window token total for one source. `cache_is_separate` is false for sources -/// whose `input_tokens` already includes cached input (Codex), true for sources -/// that report cache read/creation as classes of their own (OpenCodex imports). -pub fn spend_token_total(mix: &SpendTokenMix, cache_is_separate: bool) -> Option { - let values = [ - mix.input_tokens, - mix.output_tokens, - if cache_is_separate { - mix.cache_read_tokens - } else { - None - }, - 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) -} - -/// Totals native and imported sources separately, each with its own cache rule, -/// then combines them. Codex native rows already include cached input in -/// `input_tokens`, so adding the cache bucket there would double count; the -/// OpenCodex imported rows need it added. `replace_native` mirrors -/// [`resolve_spend`]: the imported source replaces the native one entirely. +/// 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_mix: &SpendTokenMix, + native_total: u64, imported: Option<&ImportedSpendSource>, replace_native: bool, ) -> Option { - let native_total = if replace_native { - None - } else { - spend_token_total(native_mix, false) - }; - let imported_total = imported.and_then(|source| spend_token_total(&source.token_mix, true)); - match (native_total, imported_total) { - (None, None) => None, - (native, imported) => Some(native.unwrap_or(0).saturating_add(imported.unwrap_or(0))), + 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" +)] fn resolve_spend( native_cost: Option, native_provenance: CostProvenance, 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 38ca7e03d8..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, @@ -519,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" + ); +}