Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions apps/desktop-tauri/src-tauri/src/commands/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
}),
Expand Down Expand Up @@ -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<f64> {
Expand All @@ -445,11 +445,11 @@ fn non_zero_u64(value: u64) -> Option<u64> {
(value > 0).then_some(value)
}

fn top_model(summary: &CostSummary) -> Option<String> {
fn top_model(provider_id: &str, summary: &CostSummary) -> Option<String> {
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
Expand Down
35 changes: 6 additions & 29 deletions apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -614,21 +606,6 @@ fn build_usage_spend_summary(
UsageSpendSummary { rows, contract }
}

fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option<u64> {
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 {
Expand Down
49 changes: 46 additions & 3 deletions rust/src/cost_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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);
}
}
85 changes: 85 additions & 0 deletions rust/src/cost_scanner/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
39 changes: 38 additions & 1 deletion rust/src/spend_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ pub struct ImportedSpendSource {
pub known_cost_usd: Option<f64>,
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<u64>,
pub coverage: CostCoverageCounts,
pub models: Vec<SpendModelRow>,
pub daily: Vec<SpendDailyPoint>,
Expand Down Expand Up @@ -230,6 +236,11 @@ pub struct SpendContract {
pub price_coverage_ratio: Option<f64>,
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<u64>,
pub conversation_count: u32,
pub models: Vec<SpendModelRow>,
pub projects: Vec<ProjectUsage>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<u64> {
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"
Expand Down Expand Up @@ -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(),
}
})
Expand Down
Loading