1use std::collections::HashMap;
15use std::sync::{Mutex, OnceLock};
16
17use serde::{Deserialize, Serialize};
18
19use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
24pub struct ModelUsage {
25 pub requests: u64,
26 pub input_tokens: u64,
27 pub output_tokens: u64,
28 pub cache_read_tokens: u64,
29 pub cache_write_tokens: u64,
30 pub reasoning_tokens: u64,
31 #[serde(default)]
35 pub counterfactual_requests: u64,
36 #[serde(default)]
39 pub counterfactual_input_tokens: u64,
40 #[serde(default)]
43 pub counterfactual_billed_tokens: u64,
44}
45
46impl ModelUsage {
47 fn add(&mut self, u: &super::usage::RealUsage) {
48 self.requests += 1;
49 self.input_tokens += u.input_tokens;
50 self.output_tokens += u.output_tokens;
51 self.cache_read_tokens += u.cache_read_tokens;
52 self.cache_write_tokens += u.cache_write_tokens;
53 self.reasoning_tokens += u.reasoning_tokens;
54 if let Some(counted) = u
57 .wire
58 .as_deref()
59 .and_then(|w| w.counterfactual.as_ref())
60 .and_then(super::counterfactual::CounterfactualSlot::get)
61 {
62 self.counterfactual_requests += 1;
63 self.counterfactual_input_tokens += counted;
64 self.counterfactual_billed_tokens +=
65 u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
66 }
67 }
68
69 fn billable_tokens(&self) -> u64 {
70 self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
71 }
72}
73
74#[derive(Debug, Clone, Serialize, PartialEq)]
76pub struct ModelSpend {
77 pub model: String,
78 pub requests: u64,
79 pub input_tokens: u64,
80 pub output_tokens: u64,
81 pub cache_read_tokens: u64,
82 pub cache_write_tokens: u64,
83 pub reasoning_tokens: u64,
84 pub cost_usd: f64,
85 pub pricing_estimated: bool,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
93pub struct CohortUsage {
94 pub requests: u64,
95 pub input_tokens: u64,
96 pub output_tokens: u64,
97 #[serde(default)]
101 pub sum_sq_output: u64,
102}
103
104impl CohortUsage {
105 fn add(&mut self, u: &super::usage::RealUsage) {
106 self.requests += 1;
107 self.input_tokens += u.input_tokens;
108 self.output_tokens += u.output_tokens;
109 self.sum_sq_output += u.output_tokens.saturating_mul(u.output_tokens);
110 }
111
112 #[must_use]
114 pub fn avg_output(&self) -> Option<f64> {
115 if self.requests == 0 {
116 None
117 } else {
118 #[allow(clippy::cast_precision_loss)]
119 Some(self.output_tokens as f64 / self.requests as f64)
120 }
121 }
122
123 #[must_use]
127 pub fn variance_output(&self) -> Option<f64> {
128 if self.requests < 2 {
129 return None;
130 }
131 #[allow(clippy::cast_precision_loss)]
132 let n = self.requests as f64;
133 #[allow(clippy::cast_precision_loss)]
134 let sum = self.output_tokens as f64;
135 #[allow(clippy::cast_precision_loss)]
136 let sum_sq = self.sum_sq_output as f64;
137 let var = (sum_sq - sum * sum / n) / (n - 1.0);
138 Some(var.max(0.0))
139 }
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct PersistedUsage {
145 pub ts: u64,
146 pub models: HashMap<String, ModelUsage>,
147 #[serde(default)]
150 pub cohorts: HashMap<String, CohortUsage>,
151}
152
153const MAX_TRACKED_MODELS: usize = 256;
156
157const PROXY_USAGE_FILE: &str = "proxy_usage.json";
158
159fn store() -> &'static Mutex<HashMap<String, ModelUsage>> {
160 static STORE: OnceLock<Mutex<HashMap<String, ModelUsage>>> = OnceLock::new();
161 STORE.get_or_init(|| Mutex::new(HashMap::new()))
162}
163
164fn cohort_store() -> &'static Mutex<HashMap<String, CohortUsage>> {
165 static STORE: OnceLock<Mutex<HashMap<String, CohortUsage>>> = OnceLock::new();
166 STORE.get_or_init(|| Mutex::new(HashMap::new()))
167}
168
169pub fn resume_from_disk() {
173 let Some(persisted) = load_persisted() else {
174 return;
175 };
176 let mut map = store()
177 .lock()
178 .unwrap_or_else(std::sync::PoisonError::into_inner);
179 for (model, usage) in persisted.models {
180 let acc = map.entry(model).or_default();
181 acc.requests += usage.requests;
182 acc.input_tokens += usage.input_tokens;
183 acc.output_tokens += usage.output_tokens;
184 acc.cache_read_tokens += usage.cache_read_tokens;
185 acc.cache_write_tokens += usage.cache_write_tokens;
186 acc.reasoning_tokens += usage.reasoning_tokens;
187 acc.counterfactual_requests += usage.counterfactual_requests;
188 acc.counterfactual_input_tokens += usage.counterfactual_input_tokens;
189 acc.counterfactual_billed_tokens += usage.counterfactual_billed_tokens;
190 }
191 drop(map);
192 let mut cohorts = cohort_store()
193 .lock()
194 .unwrap_or_else(std::sync::PoisonError::into_inner);
195 for (arm, usage) in persisted.cohorts {
196 let acc = cohorts.entry(arm).or_default();
197 acc.requests += usage.requests;
198 acc.input_tokens += usage.input_tokens;
199 acc.output_tokens += usage.output_tokens;
200 }
201}
202
203pub fn record(u: &super::usage::RealUsage) {
206 super::usage_sink::push(u);
209
210 if let Some(wire) = u.wire.as_deref()
215 && (wire.person.is_some() || wire.project.is_some())
216 {
217 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
218 let baseline = crate::core::config::Config::load().proxy.baseline.clone();
219 #[allow(clippy::cast_precision_loss)]
220 let cost_usd = if wire.is_local {
221 let billable =
222 u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
223 baseline.effective_local_shadow_rate() / 1_000_000.0 * billable as f64
224 } else {
225 pricing.quote(Some(&u.model)).cost.estimate_usd(
226 u.input_tokens,
227 u.output_tokens,
228 u.cache_write_tokens,
229 u.cache_read_tokens,
230 )
231 };
232 super::policy_gate::record_spend(wire.person.as_deref(), wire.project.as_deref(), cost_usd);
233 }
234
235 if let Some(wire) = u.wire.as_deref()
240 && let Some(routed_from) = wire.routed_from.as_deref()
241 {
242 crate::core::savings_ledger::record_routing_event(routed_from, &u.model, u.input_tokens);
243 }
244 if u.cache_read_tokens > 0 {
245 let cost = crate::core::gain::model_pricing::ModelPricing::load()
246 .quote(Some(&u.model))
247 .cost;
248 #[allow(clippy::cast_precision_loss)]
249 let discount_usd =
250 (cost.input_per_m - cost.cache_read_per_m) / 1_000_000.0 * u.cache_read_tokens as f64;
251 crate::core::savings_ledger::record_caching_event(
252 &u.model,
253 u.cache_read_tokens,
254 discount_usd,
255 );
256 }
257
258 let key = normalize_key(&u.model);
259 {
260 let mut map = store()
261 .lock()
262 .unwrap_or_else(std::sync::PoisonError::into_inner);
263 let key = if !map.contains_key(&key) && map.len() >= MAX_TRACKED_MODELS {
264 "unknown".to_string()
265 } else {
266 key
267 };
268 map.entry(key).or_default().add(u);
269 }
270 if let Some(arm) = u.cohort {
271 let mut cohorts = cohort_store()
272 .lock()
273 .unwrap_or_else(std::sync::PoisonError::into_inner);
274 cohorts.entry(arm.as_str().to_string()).or_default().add(u);
275 }
276 persist();
277}
278
279#[must_use]
281pub fn cohort_snapshot() -> HashMap<String, CohortUsage> {
282 cohort_store()
283 .lock()
284 .unwrap_or_else(std::sync::PoisonError::into_inner)
285 .clone()
286}
287
288#[must_use]
290pub fn persisted_cohorts() -> HashMap<String, CohortUsage> {
291 load_persisted().map(|p| p.cohorts).unwrap_or_default()
292}
293
294fn normalize_key(model: &str) -> String {
295 let m = model.trim();
296 if m.is_empty() {
297 "unknown".to_string()
298 } else {
299 m.to_string()
300 }
301}
302
303pub fn snapshot() -> Vec<ModelSpend> {
305 let map = store()
306 .lock()
307 .unwrap_or_else(std::sync::PoisonError::into_inner);
308 price_models(&map)
309}
310
311pub fn total_cost_usd() -> f64 {
313 snapshot().iter().map(|m| m.cost_usd).sum()
314}
315
316#[derive(Debug, Clone, Serialize, PartialEq)]
318pub struct VerifiedSavings {
319 pub requests: u64,
321 pub counterfactual_input_tokens: u64,
324 pub billed_input_tokens: u64,
327 pub verified_saved_tokens: i64,
330}
331
332pub fn verified_savings() -> Option<VerifiedSavings> {
337 let map = store()
338 .lock()
339 .unwrap_or_else(std::sync::PoisonError::into_inner);
340 verified_of(&map)
341}
342
343#[allow(clippy::cast_possible_wrap)]
345fn verified_of(map: &HashMap<String, ModelUsage>) -> Option<VerifiedSavings> {
346 let (mut requests, mut counterfactual, mut billed) = (0u64, 0u64, 0u64);
347 for usage in map.values() {
348 requests += usage.counterfactual_requests;
349 counterfactual += usage.counterfactual_input_tokens;
350 billed += usage.counterfactual_billed_tokens;
351 }
352 (requests > 0).then(|| VerifiedSavings {
353 requests,
354 counterfactual_input_tokens: counterfactual,
355 billed_input_tokens: billed,
356 verified_saved_tokens: counterfactual as i64 - billed as i64,
357 })
358}
359
360pub fn price_models(map: &HashMap<String, ModelUsage>) -> Vec<ModelSpend> {
363 let pricing = ModelPricing::load();
364 let mut rows: Vec<ModelSpend> = map
365 .iter()
366 .map(|(model, usage)| price_one(&pricing, model, usage))
367 .collect();
368 rows.sort_by(|a, b| {
369 b.cost_usd
370 .partial_cmp(&a.cost_usd)
371 .unwrap_or(std::cmp::Ordering::Equal)
372 });
373 rows
374}
375
376fn price_one(pricing: &ModelPricing, model: &str, usage: &ModelUsage) -> ModelSpend {
377 let quote = pricing.quote(Some(model));
378 let cost_usd = quote.cost.estimate_usd(
379 usage.input_tokens,
380 usage.output_tokens,
381 usage.cache_write_tokens,
382 usage.cache_read_tokens,
383 );
384 ModelSpend {
385 model: model.to_string(),
386 requests: usage.requests,
387 input_tokens: usage.input_tokens,
388 output_tokens: usage.output_tokens,
389 cache_read_tokens: usage.cache_read_tokens,
390 cache_write_tokens: usage.cache_write_tokens,
391 reasoning_tokens: usage.reasoning_tokens,
392 cost_usd,
393 pricing_estimated: !matches!(quote.match_kind, PricingMatchKind::Exact),
394 }
395}
396
397fn usage_path() -> Option<std::path::PathBuf> {
398 crate::core::data_dir::lean_ctx_data_dir()
399 .ok()
400 .map(|d| d.join(PROXY_USAGE_FILE))
401}
402
403fn persist() {
405 let Some(path) = usage_path() else {
406 return;
407 };
408 let models = {
409 let map = store()
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 map.clone()
413 };
414 let cohorts = {
415 let map = cohort_store()
416 .lock()
417 .unwrap_or_else(std::sync::PoisonError::into_inner);
418 map.clone()
419 };
420 let payload = PersistedUsage {
421 ts: std::time::SystemTime::now()
422 .duration_since(std::time::UNIX_EPOCH)
423 .unwrap_or_default()
424 .as_secs(),
425 models,
426 cohorts,
427 };
428 let Ok(json) = serde_json::to_string(&payload) else {
429 return;
430 };
431 let tmp = path.with_extension("json.tmp");
432 if std::fs::write(&tmp, json).is_ok() {
433 let _ = std::fs::rename(&tmp, &path);
434 }
435}
436
437pub fn load_persisted() -> Option<PersistedUsage> {
439 let path = usage_path()?;
440 let data = std::fs::read_to_string(path).ok()?;
441 serde_json::from_str(&data).ok()
442}
443
444pub fn persisted_snapshot() -> Vec<ModelSpend> {
446 load_persisted()
447 .map(|p| price_models(&p.models))
448 .unwrap_or_default()
449}
450
451pub fn persisted_dominant_model() -> Option<String> {
455 let persisted = load_persisted()?;
456 persisted
457 .models
458 .iter()
459 .filter(|(m, _)| m.as_str() != "unknown" && !m.trim().is_empty())
460 .max_by_key(|(_, u)| u.billable_tokens())
461 .filter(|(_, u)| u.billable_tokens() > 0)
462 .map(|(m, _)| m.clone())
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 fn usage(
470 model: &str,
471 input: u64,
472 output: u64,
473 cache_read: u64,
474 ) -> super::super::usage::RealUsage {
475 super::super::usage::RealUsage {
476 model: model.to_string(),
477 input_tokens: input,
478 output_tokens: output,
479 cache_read_tokens: cache_read,
480 ..Default::default()
481 }
482 }
483
484 #[test]
487 fn counterfactual_pair_recorded_only_when_probe_answered() {
488 let slot = super::super::counterfactual::CounterfactualSlot::new();
489 slot.set(5_000);
490 let with_probe = super::super::usage::RealUsage {
491 wire: Some(Box::new(super::super::usage::WireContext {
492 counterfactual: Some(slot),
493 ..Default::default()
494 })),
495 cache_write_tokens: 200,
496 ..usage("claude-sonnet-4.5", 1_000, 0, 300)
497 };
498 let mut acc = ModelUsage::default();
499 acc.add(&with_probe);
500 assert_eq!(acc.counterfactual_requests, 1);
501 assert_eq!(acc.counterfactual_input_tokens, 5_000);
502 assert_eq!(acc.counterfactual_billed_tokens, 1_000 + 300 + 200);
504
505 let empty = super::super::usage::RealUsage {
508 wire: Some(Box::new(super::super::usage::WireContext {
509 counterfactual: Some(super::super::counterfactual::CounterfactualSlot::new()),
510 ..Default::default()
511 })),
512 ..usage("claude-sonnet-4.5", 1_000, 0, 0)
513 };
514 acc.add(&empty);
515 assert_eq!(acc.requests, 2);
516 assert_eq!(acc.counterfactual_requests, 1, "empty slot adds no pair");
517
518 acc.add(&usage("claude-sonnet-4.5", 10, 0, 0));
520 assert_eq!(acc.counterfactual_requests, 1);
521 }
522
523 #[test]
525 fn verified_of_aggregates_and_reports_signed_savings() {
526 let mut map = HashMap::new();
527 assert!(verified_of(&map).is_none(), "no coverage → None, not zeros");
528
529 map.insert(
530 "claude-sonnet-4.5".to_string(),
531 ModelUsage {
532 counterfactual_requests: 2,
533 counterfactual_input_tokens: 10_000,
534 counterfactual_billed_tokens: 6_000,
535 ..Default::default()
536 },
537 );
538 map.insert(
541 "claude-haiku-4.5".to_string(),
542 ModelUsage {
543 counterfactual_requests: 1,
544 counterfactual_input_tokens: 1_000,
545 counterfactual_billed_tokens: 1_400,
546 ..Default::default()
547 },
548 );
549 let v = verified_of(&map).expect("covered rows present");
550 assert_eq!(v.requests, 3);
551 assert_eq!(v.counterfactual_input_tokens, 11_000);
552 assert_eq!(v.billed_input_tokens, 7_400);
553 assert_eq!(v.verified_saved_tokens, 3_600);
554
555 map.get_mut("claude-sonnet-4.5")
556 .unwrap()
557 .counterfactual_input_tokens = 0;
558 map.get_mut("claude-sonnet-4.5")
559 .unwrap()
560 .counterfactual_billed_tokens = 0;
561 let negative = verified_of(&map).unwrap();
562 assert_eq!(
563 negative.verified_saved_tokens, -400,
564 "a net-negative verified saving is reported, never clamped"
565 );
566 }
567
568 #[test]
571 fn counterfactual_fields_roundtrip_and_default_for_legacy_files() {
572 let legacy = r#"{"ts":1,"models":{"m":{"requests":1,"input_tokens":10,"output_tokens":5,"cache_read_tokens":0,"cache_write_tokens":0,"reasoning_tokens":0}}}"#;
573 let p: PersistedUsage = serde_json::from_str(legacy).expect("legacy file loads");
574 assert_eq!(p.models["m"].counterfactual_requests, 0);
575
576 let mut p = PersistedUsage::default();
577 p.models.insert(
578 "m".into(),
579 ModelUsage {
580 counterfactual_requests: 4,
581 counterfactual_input_tokens: 9_999,
582 counterfactual_billed_tokens: 5_555,
583 ..Default::default()
584 },
585 );
586 let back: PersistedUsage =
587 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
588 assert_eq!(back.models["m"].counterfactual_input_tokens, 9_999);
589 assert_eq!(back.models["m"].counterfactual_billed_tokens, 5_555);
590 }
591
592 #[test]
593 fn prices_known_model_with_cache_split() {
594 let mut map = HashMap::new();
595 let mut acc = ModelUsage::default();
596 acc.add(&usage("claude-sonnet-4.5", 1_000_000, 1_000_000, 1_000_000));
597 map.insert("claude-sonnet-4.5".to_string(), acc);
598
599 let rows = price_models(&map);
600 assert_eq!(rows.len(), 1);
601 let row = &rows[0];
602 assert!(
604 (row.cost_usd - 18.30).abs() < 1e-6,
605 "cost was {}",
606 row.cost_usd
607 );
608 assert!(!row.pricing_estimated, "exact model match");
609 assert_eq!(row.requests, 1);
610 }
611
612 #[test]
613 fn unknown_model_prices_with_fallback_and_is_estimated() {
614 let mut map = HashMap::new();
615 let mut acc = ModelUsage::default();
616 acc.add(&usage("some-novel-model-xyz", 1_000_000, 0, 0));
617 map.insert("some-novel-model-xyz".to_string(), acc);
618
619 let rows = price_models(&map);
620 assert!(rows[0].pricing_estimated, "fallback pricing is estimated");
621 assert!(rows[0].cost_usd > 0.0);
622 }
623
624 #[test]
625 fn dominant_model_picks_highest_token_real_model() {
626 let mut models = HashMap::new();
627 models.insert("claude-haiku-4.5".to_string(), {
628 let mut u = ModelUsage::default();
629 u.add(&usage("claude-haiku-4.5", 100, 100, 0));
630 u
631 });
632 models.insert("claude-opus-4.5".to_string(), {
633 let mut u = ModelUsage::default();
634 u.add(&usage("claude-opus-4.5", 10_000, 10_000, 0));
635 u
636 });
637 models.insert("unknown".to_string(), {
638 let mut u = ModelUsage::default();
639 u.add(&usage("unknown", 999_999, 0, 0));
640 u
641 });
642 let dominant = models
643 .iter()
644 .filter(|(m, _)| m.as_str() != "unknown")
645 .max_by_key(|(_, u)| u.billable_tokens())
646 .map(|(m, _)| m.clone());
647 assert_eq!(dominant.as_deref(), Some("claude-opus-4.5"));
648 }
649
650 #[test]
651 fn empty_model_buckets_as_unknown() {
652 assert_eq!(normalize_key(" "), "unknown");
653 assert_eq!(normalize_key(""), "unknown");
654 assert_eq!(normalize_key("gpt-5.4"), "gpt-5.4");
655 }
656
657 #[test]
658 fn cohort_avg_output_is_mean_per_turn() {
659 let mut c = CohortUsage::default();
660 assert_eq!(c.avg_output(), None, "no observations → None");
661 c.add(&usage("m", 10, 100, 0));
662 c.add(&usage("m", 10, 50, 0));
663 assert_eq!(c.requests, 2);
664 assert_eq!(c.output_tokens, 150);
665 assert!((c.avg_output().unwrap() - 75.0).abs() < f64::EPSILON);
666 }
667
668 #[test]
669 fn persisted_usage_without_cohorts_field_loads() {
670 let json = r#"{"ts":1,"models":{"gpt-5.4":{"requests":1,"input_tokens":10,"output_tokens":5,"cache_read_tokens":0,"cache_write_tokens":0,"reasoning_tokens":0}}}"#;
673 let p: PersistedUsage = serde_json::from_str(json).expect("loads legacy file");
674 assert_eq!(p.models.len(), 1);
675 assert!(p.cohorts.is_empty());
676 }
677
678 #[test]
679 fn persisted_usage_roundtrips_cohorts() {
680 let mut p = PersistedUsage::default();
681 p.cohorts.insert(
682 "control".into(),
683 CohortUsage {
684 requests: 3,
685 input_tokens: 30,
686 output_tokens: 300,
687 sum_sq_output: 30_000,
688 },
689 );
690 let json = serde_json::to_string(&p).unwrap();
691 let back: PersistedUsage = serde_json::from_str(&json).unwrap();
692 assert_eq!(back.cohorts.get("control").unwrap().output_tokens, 300);
693 }
694}