1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::core::gain::model_pricing::{ModelQuote, PricingMatchKind};
5
6#[derive(Serialize, Deserialize, Default, Clone)]
8pub struct StatsStore {
9 pub total_commands: u64,
10 pub total_input_tokens: u64,
11 pub total_output_tokens: u64,
12 pub first_use: Option<String>,
13 pub last_use: Option<String>,
14 pub commands: HashMap<String, CommandStats>,
15 pub daily: Vec<DayStats>,
16 #[serde(default)]
17 pub cep: CepStats,
18 #[serde(default)]
21 pub command_classes: HashMap<String, TrafficClass>,
22 #[serde(default)]
24 pub first_inject_tokens_saved: u64,
25 #[serde(default)]
27 pub reread_tokens_saved: u64,
28 #[serde(default)]
30 pub active_tool_result_tokens_saved: u64,
31 #[serde(default)]
33 pub last_tool_result_turn: u64,
34 #[serde(default)]
37 pub stream_tracked_results: u64,
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum TrafficClass {
44 Compressible,
45 Passthrough,
46}
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub(crate) struct CompressionTotals {
51 pub(crate) input_tokens: u64,
52 pub(crate) output_tokens: u64,
53}
54
55impl CompressionTotals {
56 pub(crate) fn saved_tokens(self) -> u64 {
57 self.input_tokens.saturating_sub(self.output_tokens)
58 }
59
60 pub(crate) fn compression_pct(self) -> f64 {
61 if self.input_tokens == 0 {
62 0.0
63 } else {
64 self.saved_tokens() as f64 / self.input_tokens as f64 * 100.0
65 }
66 }
67}
68
69impl StatsStore {
70 pub(crate) fn record_tool_result_savings(&mut self, saved: u64, turn: u64) {
74 if turn > 0 && self.last_tool_result_turn > 0 && turn > self.last_tool_result_turn {
75 let elapsed = turn - self.last_tool_result_turn;
76 self.reread_tokens_saved = self
77 .reread_tokens_saved
78 .saturating_add(self.active_tool_result_tokens_saved.saturating_mul(elapsed));
79 }
80 if turn > 0 {
81 self.last_tool_result_turn = self.last_tool_result_turn.max(turn);
82 self.active_tool_result_tokens_saved =
83 self.active_tool_result_tokens_saved.saturating_add(saved);
84 }
85 self.first_inject_tokens_saved = self.first_inject_tokens_saved.saturating_add(saved);
86 self.stream_tracked_results = self.stream_tracked_results.saturating_add(1);
87 }
88
89 pub(crate) fn stream_savings(&self, observed_turn: u64) -> (u64, u64) {
92 if self.stream_tracked_results == 0 {
93 return (self.compression_totals().saved_tokens(), 0);
94 }
95 let pending_turns = observed_turn.saturating_sub(self.last_tool_result_turn);
96 let pending = self
97 .active_tool_result_tokens_saved
98 .saturating_mul(pending_turns);
99 (
100 self.first_inject_tokens_saved,
101 self.reread_tokens_saved.saturating_add(pending),
102 )
103 }
104
105 pub(crate) fn compression_totals(&self) -> CompressionTotals {
107 self.commands
108 .iter()
109 .filter(|(command, _)| {
110 self.command_classes
111 .get(*command)
112 .copied()
113 .unwrap_or_else(|| classify_command(command))
114 == TrafficClass::Compressible
115 })
116 .fold(CompressionTotals::default(), |mut totals, (_, stats)| {
117 totals.input_tokens = totals.input_tokens.saturating_add(stats.input_tokens);
118 totals.output_tokens = totals.output_tokens.saturating_add(stats.output_tokens);
119 totals
120 })
121 }
122
123 pub(crate) fn total_reduction_pct(&self) -> f64 {
125 if self.total_input_tokens == 0 {
126 0.0
127 } else {
128 self.total_input_tokens
129 .saturating_sub(self.total_output_tokens) as f64
130 / self.total_input_tokens as f64
131 * 100.0
132 }
133 }
134}
135
136pub(crate) fn classify_command(command: &str) -> TrafficClass {
140 match command {
141 "cli_full" | "cli_raw" | "cli_glob" | "cli_find" | "cli_deps" | "cli_ls"
142 | "ctx_compose" | "ctx_glob" | "ctx_tree" => TrafficClass::Passthrough,
143 c if c.starts_with("cli_") => TrafficClass::Compressible,
144 "ctx_shell" | "ctx_search" | "ctx_semantic_search" => TrafficClass::Compressible,
145 c if c.starts_with("ctx_read")
146 || c.starts_with("ctx_multi_read")
147 || c == "ctx_smart_read"
148 || c == "ctx_git_read"
149 || c == "ctx_url_read" =>
150 {
151 TrafficClass::Compressible
152 }
153 _ => TrafficClass::Passthrough,
154 }
155}
156
157#[derive(Serialize, Deserialize, Clone, Default)]
159pub struct CepStats {
160 pub sessions: u64,
161 pub total_cache_hits: u64,
162 pub total_cache_reads: u64,
163 pub total_tokens_original: u64,
164 pub total_tokens_compressed: u64,
165 pub modes: HashMap<String, u64>,
166 pub scores: Vec<CepSessionSnapshot>,
167 #[serde(default)]
168 pub last_session_pid: Option<u32>,
169 #[serde(default)]
170 pub last_session_original: Option<u64>,
171 #[serde(default)]
172 pub last_session_compressed: Option<u64>,
173 #[serde(default)]
178 pub last_session_cache_hits: Option<u64>,
179 #[serde(default)]
180 pub last_session_cache_reads: Option<u64>,
181}
182
183#[derive(Serialize, Deserialize, Clone)]
185pub struct CepSessionSnapshot {
186 pub timestamp: String,
187 pub score: u32,
188 pub cache_hit_rate: u32,
189 pub mode_diversity: u32,
190 pub compression_rate: u32,
191 pub tool_calls: u64,
192 pub tokens_saved: u64,
193 pub complexity: String,
194}
195
196#[derive(Serialize, Deserialize, Clone, Default, Debug)]
198pub struct CommandStats {
199 pub count: u64,
200 pub input_tokens: u64,
201 pub output_tokens: u64,
202}
203
204#[derive(Serialize, Deserialize, Clone, Default)]
206pub struct DayStats {
207 pub date: String,
208 pub commands: u64,
209 pub input_tokens: u64,
210 pub output_tokens: u64,
211 #[serde(default)]
215 pub version: String,
216}
217
218pub struct GainSummary {
220 pub total_saved: u64,
221 pub total_calls: u64,
222}
223
224pub const DEFAULT_INPUT_PRICE_PER_M: f64 = 2.50;
226pub const DEFAULT_OUTPUT_PRICE_PER_M: f64 = 10.0;
227
228pub struct CostModel {
230 pub model_key: String,
231 pub pricing_match_kind: PricingMatchKind,
232 pub input_price_per_m: f64,
233 pub output_price_per_m: f64,
234 pub avg_verbose_output_per_call: u64,
235 pub avg_concise_output_per_call: u64,
236}
237
238impl Default for CostModel {
239 fn default() -> Self {
240 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
241 let quote = pricing.quote(resolved_gain_model().as_deref());
242 Self::from_quote(quote)
243 }
244}
245
246fn resolved_gain_model() -> Option<String> {
247 std::env::var("LEAN_CTX_MODEL")
248 .or_else(|_| std::env::var("LCTX_MODEL"))
249 .ok()
250 .filter(|s| !s.trim().is_empty())
251 .or_else(|| {
252 crate::core::config::Config::load()
253 .cost
254 .model_for_client("cli")
255 })
256 .or_else(crate::proxy::usage_meter::persisted_dominant_model)
257}
258
259impl CostModel {
260 fn from_quote(quote: ModelQuote) -> Self {
261 Self {
262 model_key: quote.model_key,
263 pricing_match_kind: quote.match_kind,
264 input_price_per_m: quote.cost.input_per_m,
265 output_price_per_m: quote.cost.output_per_m,
266 avg_verbose_output_per_call: 180,
267 avg_concise_output_per_call: 120,
268 }
269 }
270}
271
272pub struct CostBreakdown {
274 pub input_cost_without: f64,
275 pub input_cost_with: f64,
276 pub output_cost_without: f64,
277 pub output_cost_with: f64,
278 pub total_cost_without: f64,
279 pub total_cost_with: f64,
280 pub total_saved: f64,
281 pub estimated_output_tokens_without: u64,
282 pub estimated_output_tokens_with: u64,
283 pub output_tokens_saved: u64,
284}
285
286impl CostModel {
287 pub fn calculate(&self, store: &StatsStore) -> CostBreakdown {
289 let input_cost_without =
290 store.total_input_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
291 let input_cost_with =
292 store.total_output_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
293
294 let input_saved = store
295 .total_input_tokens
296 .saturating_sub(store.total_output_tokens);
297 let compression_rate = if store.total_input_tokens > 0 {
298 input_saved as f64 / store.total_input_tokens as f64
299 } else {
300 0.0
301 };
302 let est_output_without = store.total_commands * self.avg_verbose_output_per_call;
303 let est_output_with = if compression_rate > 0.01 {
304 store.total_commands * self.avg_concise_output_per_call
305 } else {
306 est_output_without
307 };
308 let output_saved = est_output_without.saturating_sub(est_output_with);
309
310 let output_cost_without = est_output_without as f64 / 1_000_000.0 * self.output_price_per_m;
311 let output_cost_with = est_output_with as f64 / 1_000_000.0 * self.output_price_per_m;
312
313 let total_without = input_cost_without + output_cost_without;
314 let total_with = input_cost_with + output_cost_with;
315
316 CostBreakdown {
317 input_cost_without,
318 input_cost_with,
319 output_cost_without,
320 output_cost_with,
321 total_cost_without: total_without,
322 total_cost_with: total_with,
323 total_saved: total_without - total_with,
324 estimated_output_tokens_without: est_output_without,
325 estimated_output_tokens_with: est_output_with,
326 output_tokens_saved: output_saved,
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::{CompressionTotals, CostModel, StatsStore, TrafficClass, classify_command};
334 use crate::core::gain::model_pricing::PricingMatchKind;
335
336 #[test]
337 fn known_tools_are_classified_by_delivery_contract() {
338 assert_eq!(classify_command("ctx_read"), TrafficClass::Compressible);
339 assert_eq!(classify_command("ctx_shell"), TrafficClass::Compressible);
340 assert_eq!(classify_command("ctx_search"), TrafficClass::Compressible);
341 assert_eq!(classify_command("ctx_compose"), TrafficClass::Passthrough);
342 assert_eq!(classify_command("ctx_glob"), TrafficClass::Passthrough);
343 assert_eq!(classify_command("cli_full"), TrafficClass::Passthrough);
344 }
345
346 #[test]
347 fn cost_model_uses_resolved_model_pricing() {
348 let _lock = crate::core::data_dir::test_env_lock();
349 let old_lean_ctx_model = std::env::var("LEAN_CTX_MODEL").ok();
350 let old_lctx_model = std::env::var("LCTX_MODEL").ok();
351 unsafe {
353 std::env::set_var("LEAN_CTX_MODEL", "claude-opus-4.5");
354 std::env::remove_var("LCTX_MODEL");
355 }
356
357 let model = CostModel::default();
358
359 assert_eq!(model.model_key, "claude-opus-4.5");
360 assert_eq!(model.pricing_match_kind, PricingMatchKind::Exact);
361 assert_eq!(model.input_price_per_m, 5.0);
362 assert_eq!(model.output_price_per_m, 25.0);
363
364 unsafe {
366 match old_lean_ctx_model {
367 Some(value) => std::env::set_var("LEAN_CTX_MODEL", value),
368 None => std::env::remove_var("LEAN_CTX_MODEL"),
369 }
370 match old_lctx_model {
371 Some(value) => std::env::set_var("LCTX_MODEL", value),
372 None => std::env::remove_var("LCTX_MODEL"),
373 }
374 }
375 }
376
377 #[test]
378 fn compression_totals_fall_back_for_legacy_stats() {
379 let mut store = StatsStore::default();
380 store.commands.insert(
381 "ctx_read".into(),
382 super::CommandStats {
383 count: 1,
384 input_tokens: 1_000,
385 output_tokens: 400,
386 },
387 );
388 store.commands.insert(
389 "ctx_glob".into(),
390 super::CommandStats {
391 count: 1,
392 input_tokens: 500,
393 output_tokens: 500,
394 },
395 );
396
397 assert_eq!(store.compression_totals().saved_tokens(), 600);
398 assert_eq!(store.compression_totals().compression_pct(), 60.0);
399 }
400
401 #[test]
402 fn explicit_command_tag_overrides_legacy_inference() {
403 let mut store = StatsStore::default();
404 store.commands.insert(
405 "custom_tool".into(),
406 super::CommandStats {
407 count: 1,
408 input_tokens: 100,
409 output_tokens: 25,
410 },
411 );
412 store
413 .command_classes
414 .insert("custom_tool".into(), TrafficClass::Compressible);
415
416 assert_eq!(store.compression_totals().input_tokens, 100);
417 assert_eq!(store.total_reduction_pct(), 0.0);
418 }
419
420 #[test]
421 fn compression_totals_handle_zero_input_without_nan() {
422 let totals = CompressionTotals::default();
423 assert_eq!(totals.saved_tokens(), 0);
424 assert_eq!(totals.compression_pct(), 0.0);
425 assert_eq!(StatsStore::default().total_reduction_pct(), 0.0);
426 }
427
428 #[test]
429 fn first_result_is_first_inject_only() {
430 let mut store = StatsStore::default();
431 store.record_tool_result_savings(1_000, 7);
432 assert_eq!(store.stream_savings(7), (1_000, 0));
433 assert_eq!(store.last_tool_result_turn, 7);
434 }
435
436 #[test]
437 fn next_turn_rereads_all_prior_results() {
438 let mut store = StatsStore::default();
439 store.record_tool_result_savings(1_000, 7);
440 store.record_tool_result_savings(500, 8);
441 assert_eq!(store.stream_savings(8), (1_500, 1_000));
442 }
443
444 #[test]
445 fn parallel_results_on_same_turn_do_not_reread_each_other() {
446 let mut store = StatsStore::default();
447 store.record_tool_result_savings(1_000, 7);
448 store.record_tool_result_savings(500, 7);
449 assert_eq!(store.stream_savings(7), (1_500, 0));
450 assert_eq!(store.stream_savings(8), (1_500, 1_500));
451 }
452
453 #[test]
454 fn skipped_turns_multiply_resident_savings() {
455 let mut store = StatsStore::default();
456 store.record_tool_result_savings(2_000, 3);
457 assert_eq!(store.stream_savings(6), (2_000, 6_000));
458 }
459
460 #[test]
461 fn daemon_free_result_never_guesses_rereads() {
462 let mut store = StatsStore::default();
463 store.record_tool_result_savings(2_000, 0);
464 assert_eq!(store.stream_savings(100), (2_000, 0));
465 assert_eq!(store.active_tool_result_tokens_saved, 0);
466 }
467
468 #[test]
469 fn legacy_stats_fall_back_to_effective_compression() {
470 let mut store = StatsStore::default();
471 store.commands.insert(
472 "ctx_read".into(),
473 super::CommandStats {
474 count: 1,
475 input_tokens: 10_000,
476 output_tokens: 2_500,
477 },
478 );
479 assert_eq!(store.stream_savings(50), (7_500, 0));
480 }
481
482 #[test]
483 fn stream_counters_saturate_instead_of_wrapping() {
484 let mut store = StatsStore::default();
485 store.record_tool_result_savings(u64::MAX, 1);
486 store.record_tool_result_savings(u64::MAX, u64::MAX);
487 assert_eq!(store.first_inject_tokens_saved, u64::MAX);
488 assert_eq!(store.reread_tokens_saved, u64::MAX);
489 }
490}