From ab4d3adee5e16ad277de5eba72f4a883d1c34aed Mon Sep 17 00:00:00 2001 From: kevin9327 Date: Mon, 31 Aug 2026 19:39:02 +0900 Subject: [PATCH] fix(model): stop token accumulation from erasing counts a later call omits `Usage::add_assign` folds every field through fn sum(left: Option, right: Option) -> Option { left?.checked_add(right?) } so `sum(Some(900), None)` is `None`, not `Some(900)`. Adding a record that does not report a field therefore does not leave the running total alone - it wipes it. Both accumulators are per-turn and run over every provider call in the turn: `run/engine.rs::accumulate_usage` (the compaction call plus each model cycle) and `cursor/conversation/output.rs::turn_usage`, which is what `turn_ended` reports to Cursor. Providers report these fields inconsistently *across calls of one turn*, which is all it takes. `openai_usage` reads `cache_read_tokens` from `prompt_tokens_details`, an object most OpenAI-compatible gateways omit until the prompt cache warms: call 1 (cold) yields `None`, call 2 yields `Some(1024)`, and the turn reports `None`. The same happens to `reasoning_tokens` when only some cycles reason, and to `total_tokens` on gateways that omit it from the streaming usage chunk. Any tool-using turn makes more than one call, so this is the normal case rather than an edge case. Treat an unreported count as zero and keep the field unknown only when neither side reported it. `checked_add` also becomes `saturating_add`: with the new rule, silently turning an overflow into `None` would be the same erasure by another route, and token counts never approach `u64::MAX` anyway. `context_input_tokens` is untouched. Before (with `left?.checked_add(right?)` restored): cargo test -p cursor-server --lib model::observability -> 2 failed: assertion `left == right` failed: left: None, right: Some(900) After: cargo test -p cursor-server --lib model::observability -> 3 passed --- server/src/model/observability.rs | 66 ++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/server/src/model/observability.rs b/server/src/model/observability.rs index 7fd8fb9..f50d541 100644 --- a/server/src/model/observability.rs +++ b/server/src/model/observability.rs @@ -44,8 +44,72 @@ mod usage { } } + /// Adds two optional counts, treating an unreported count as zero. + /// The result is only unknown when neither side reported the field. fn sum(left: Option, right: Option) -> Option { - left?.checked_add(right?) + if left.is_none() && right.is_none() { + return None; + } + Some( + left.unwrap_or_default() + .saturating_add(right.unwrap_or_default()), + ) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_record_without_cache_details_keeps_the_counts_already_accumulated() { + let mut total = Usage { + input_tokens: Some(1_000), + output_tokens: Some(20), + total_tokens: Some(1_020), + cache_read_tokens: Some(900), + ..Default::default() + }; + // A second provider call that omits `prompt_tokens_details`. + total += Usage { + input_tokens: Some(1_200), + output_tokens: Some(30), + total_tokens: Some(1_230), + ..Default::default() + }; + assert_eq!(total.input_tokens, Some(2_200)); + assert_eq!(total.output_tokens, Some(50)); + assert_eq!(total.total_tokens, Some(2_250)); + assert_eq!(total.cache_read_tokens, Some(900)); + } + + #[test] + fn a_late_reported_count_is_not_discarded() { + let mut total = Usage { + input_tokens: Some(1_000), + ..Default::default() + }; + total += Usage { + input_tokens: Some(1_200), + cache_read_tokens: Some(900), + reasoning_tokens: Some(64), + ..Default::default() + }; + assert_eq!(total.cache_read_tokens, Some(900)); + assert_eq!(total.reasoning_tokens, Some(64)); + } + + #[test] + fn a_field_no_call_reported_stays_unknown() { + let mut total = Usage { + input_tokens: Some(1_000), + ..Default::default() + }; + total += Usage { + input_tokens: Some(1_200), + ..Default::default() + }; + assert_eq!(total.cache_write_tokens, None); + } } } pub use usage::*;