1use std::collections::HashMap;
15use std::sync::{Mutex, OnceLock};
16
17use serde::{Deserialize, Serialize};
18
19use crate::core::gain::model_pricing::ModelPricing;
20use crate::core::ocla::{OclaRegistry, UsageSink};
21
22fn project_ocla_usage(u: &super::usage::RealUsage, sink: &dyn UsageSink) {
28 let Ok(Some(record)) = u.to_ocla_usage_record() else {
29 return;
30 };
31 let _ = sink.record_usage(record);
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
37pub struct ModelUsage {
38 pub requests: u64,
39 pub input_tokens: u64,
40 pub output_tokens: u64,
41 pub cache_read_tokens: u64,
42 pub cache_write_tokens: u64,
43 pub reasoning_tokens: u64,
44 #[serde(default)]
48 pub counterfactual_requests: u64,
49 #[serde(default)]
52 pub counterfactual_input_tokens: u64,
53 #[serde(default)]
56 pub counterfactual_billed_tokens: u64,
57 #[serde(default)]
62 pub measured: MeasuredSlice,
63}
64
65#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
67pub struct MeasuredSlice {
68 pub requests: u64,
69 pub input_tokens: u64,
70 pub output_tokens: u64,
71 pub cache_read_tokens: u64,
72 pub cache_write_tokens: u64,
73 pub cost_usd: f64,
75}
76
77impl MeasuredSlice {
78 fn merge(&mut self, other: &Self) {
79 self.requests += other.requests;
80 self.input_tokens += other.input_tokens;
81 self.output_tokens += other.output_tokens;
82 self.cache_read_tokens += other.cache_read_tokens;
83 self.cache_write_tokens += other.cache_write_tokens;
84 self.cost_usd += other.cost_usd;
85 }
86}
87
88impl ModelUsage {
89 fn add(&mut self, u: &super::usage::RealUsage) {
90 self.requests += 1;
91 self.input_tokens += u.input_tokens;
92 self.output_tokens += u.output_tokens;
93 self.cache_read_tokens += u.cache_read_tokens;
94 self.cache_write_tokens += u.cache_write_tokens;
95 self.reasoning_tokens += u.reasoning_tokens;
96 if let Some(cost) = u.provider_cost_usd {
97 self.measured.merge(&MeasuredSlice {
98 requests: 1,
99 input_tokens: u.input_tokens,
100 output_tokens: u.output_tokens,
101 cache_read_tokens: u.cache_read_tokens,
102 cache_write_tokens: u.cache_write_tokens,
103 cost_usd: cost,
104 });
105 }
106 if let Some(counted) = u
109 .wire
110 .as_deref()
111 .and_then(|w| w.counterfactual.as_ref())
112 .and_then(super::counterfactual::CounterfactualSlot::get)
113 {
114 self.counterfactual_requests += 1;
115 self.counterfactual_input_tokens += counted;
116 self.counterfactual_billed_tokens +=
117 u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
118 }
119 }
120
121 fn billable_tokens(&self) -> u64 {
122 self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
123 }
124}
125
126#[derive(Debug, Clone, Serialize, PartialEq)]
128pub struct ModelSpend {
129 pub model: String,
130 pub requests: u64,
131 pub input_tokens: u64,
132 pub output_tokens: u64,
133 pub cache_read_tokens: u64,
134 pub cache_write_tokens: u64,
135 pub reasoning_tokens: u64,
136 pub cost_usd: f64,
137 pub measured_cost_usd: f64,
140 pub measured_requests: u64,
142 pub pricing_estimated: bool,
145}
146
147#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
151pub struct CohortUsage {
152 pub requests: u64,
153 pub input_tokens: u64,
154 pub output_tokens: u64,
155 #[serde(default)]
159 pub sum_sq_output: u64,
160}
161
162impl CohortUsage {
163 fn add(&mut self, u: &super::usage::RealUsage) {
164 self.requests += 1;
165 self.input_tokens += u.input_tokens;
166 self.output_tokens += u.output_tokens;
167 self.sum_sq_output += u.output_tokens.saturating_mul(u.output_tokens);
168 }
169
170 #[must_use]
172 pub fn avg_output(&self) -> Option<f64> {
173 if self.requests == 0 {
174 None
175 } else {
176 #[allow(clippy::cast_precision_loss)]
177 Some(self.output_tokens as f64 / self.requests as f64)
178 }
179 }
180
181 #[must_use]
185 pub fn variance_output(&self) -> Option<f64> {
186 if self.requests < 2 {
187 return None;
188 }
189 #[allow(clippy::cast_precision_loss)]
190 let n = self.requests as f64;
191 #[allow(clippy::cast_precision_loss)]
192 let sum = self.output_tokens as f64;
193 #[allow(clippy::cast_precision_loss)]
194 let sum_sq = self.sum_sq_output as f64;
195 let var = (sum_sq - sum * sum / n) / (n - 1.0);
196 Some(var.max(0.0))
197 }
198}
199
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct PersistedUsage {
203 pub ts: u64,
204 pub models: HashMap<String, ModelUsage>,
205 #[serde(default)]
208 pub cohorts: HashMap<String, CohortUsage>,
209}
210
211const MAX_TRACKED_MODELS: usize = 256;
214
215const PROXY_USAGE_FILE: &str = "proxy_usage.json";
216
217fn store() -> &'static Mutex<HashMap<String, ModelUsage>> {
218 static STORE: OnceLock<Mutex<HashMap<String, ModelUsage>>> = OnceLock::new();
219 STORE.get_or_init(|| Mutex::new(HashMap::new()))
220}
221
222fn cohort_store() -> &'static Mutex<HashMap<String, CohortUsage>> {
223 static STORE: OnceLock<Mutex<HashMap<String, CohortUsage>>> = OnceLock::new();
224 STORE.get_or_init(|| Mutex::new(HashMap::new()))
225}
226
227pub fn resume_from_disk() {
231 let Some(persisted) = load_persisted() else {
232 return;
233 };
234 let mut map = store()
235 .lock()
236 .unwrap_or_else(std::sync::PoisonError::into_inner);
237 for (model, usage) in persisted.models {
238 let acc = map.entry(model).or_default();
239 acc.requests += usage.requests;
240 acc.input_tokens += usage.input_tokens;
241 acc.output_tokens += usage.output_tokens;
242 acc.cache_read_tokens += usage.cache_read_tokens;
243 acc.cache_write_tokens += usage.cache_write_tokens;
244 acc.reasoning_tokens += usage.reasoning_tokens;
245 acc.counterfactual_requests += usage.counterfactual_requests;
246 acc.counterfactual_input_tokens += usage.counterfactual_input_tokens;
247 acc.counterfactual_billed_tokens += usage.counterfactual_billed_tokens;
248 acc.measured.merge(&usage.measured);
249 }
250 drop(map);
251 let mut cohorts = cohort_store()
252 .lock()
253 .unwrap_or_else(std::sync::PoisonError::into_inner);
254 for (arm, usage) in persisted.cohorts {
255 let acc = cohorts.entry(arm).or_default();
256 acc.requests += usage.requests;
257 acc.input_tokens += usage.input_tokens;
258 acc.output_tokens += usage.output_tokens;
259 }
260}
261
262pub fn record(u: &super::usage::RealUsage) {
265 super::usage_sink::push(u);
268
269 {
271 let label = u.wire.as_ref().map_or("Unknown", |w| {
272 match w.provider.as_str() {
275 "Anthropic" => "Anthropic",
276 "OpenAI" | "ChatGPT" => "OpenAI",
277 "Gemini" => "Gemini",
278 "Bedrock" => "Bedrock",
279 "Azure" => "Azure",
280 _ => "Unknown",
281 }
282 });
283 let envelope = super::usage_parity::real_usage_to_envelope(u, label);
284 crate::core::context_kernel::envelope_bridge::record_proxy_envelope(&envelope);
285 }
286
287 project_ocla_usage(u, OclaRegistry::global().usage_sink.as_ref());
291
292 if let Some(wire) = u.wire.as_deref()
298 && (wire.person.is_some() || wire.project.is_some())
299 {
300 let baseline = crate::core::config::Config::load().proxy.baseline.clone();
301 #[allow(clippy::cast_precision_loss)]
302 let cost_usd = if wire.is_local {
303 let billable =
304 u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
305 baseline.effective_local_shadow_rate() / 1_000_000.0 * billable as f64
306 } else if let Some(measured) = u.provider_cost_usd {
307 measured
308 } else {
309 crate::core::gain::model_pricing::ModelPricing::load()
310 .quote(Some(&u.model))
311 .cost
312 .estimate_usd(
313 u.input_tokens,
314 u.output_tokens,
315 u.cache_write_tokens,
316 u.cache_read_tokens,
317 )
318 };
319 super::policy_gate::record_spend(wire.person.as_deref(), wire.project.as_deref(), cost_usd);
320 }
321
322 if let Some(wire) = u.wire.as_deref()
327 && let Some(routed_from) = wire.routed_from.as_deref()
328 {
329 crate::core::savings_ledger::record_routing_event(routed_from, &u.model, u.input_tokens);
330 }
331 if u.cache_read_tokens > 0 {
332 let cost = crate::core::gain::model_pricing::ModelPricing::load()
333 .quote(Some(&u.model))
334 .cost;
335 #[allow(clippy::cast_precision_loss)]
336 let discount_usd =
337 (cost.input_per_m - cost.cache_read_per_m) / 1_000_000.0 * u.cache_read_tokens as f64;
338 crate::core::savings_ledger::record_caching_event(
339 &u.model,
340 u.cache_read_tokens,
341 discount_usd,
342 );
343 }
344
345 let key = normalize_key(&u.model);
346 {
347 let mut map = store()
348 .lock()
349 .unwrap_or_else(std::sync::PoisonError::into_inner);
350 let key = if !map.contains_key(&key) && map.len() >= MAX_TRACKED_MODELS {
351 "unknown".to_string()
352 } else {
353 key
354 };
355 map.entry(key).or_default().add(u);
356 }
357 if let Some(arm) = u.cohort {
358 let mut cohorts = cohort_store()
359 .lock()
360 .unwrap_or_else(std::sync::PoisonError::into_inner);
361 cohorts.entry(arm.as_str().to_string()).or_default().add(u);
362 }
363 persist();
364}
365
366#[must_use]
368pub fn cohort_snapshot() -> HashMap<String, CohortUsage> {
369 cohort_store()
370 .lock()
371 .unwrap_or_else(std::sync::PoisonError::into_inner)
372 .clone()
373}
374
375#[must_use]
377pub fn persisted_cohorts() -> HashMap<String, CohortUsage> {
378 load_persisted().map(|p| p.cohorts).unwrap_or_default()
379}
380
381fn normalize_key(model: &str) -> String {
382 let m = model.trim();
383 if m.is_empty() {
384 "unknown".to_string()
385 } else {
386 m.to_string()
387 }
388}
389
390pub fn snapshot() -> Vec<ModelSpend> {
392 let map = store()
393 .lock()
394 .unwrap_or_else(std::sync::PoisonError::into_inner);
395 price_models(&map)
396}
397
398pub fn total_cost_usd() -> f64 {
400 snapshot().iter().map(|m| m.cost_usd).sum()
401}
402
403#[derive(Debug, Clone, Serialize, PartialEq)]
405pub struct VerifiedSavings {
406 pub requests: u64,
408 pub counterfactual_input_tokens: u64,
411 pub billed_input_tokens: u64,
414 pub verified_saved_tokens: i64,
417}
418
419pub fn verified_savings() -> Option<VerifiedSavings> {
424 let map = store()
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 verified_of(&map)
428}
429
430#[allow(clippy::cast_possible_wrap)]
432fn verified_of(map: &HashMap<String, ModelUsage>) -> Option<VerifiedSavings> {
433 let (mut requests, mut counterfactual, mut billed) = (0u64, 0u64, 0u64);
434 for usage in map.values() {
435 requests += usage.counterfactual_requests;
436 counterfactual += usage.counterfactual_input_tokens;
437 billed += usage.counterfactual_billed_tokens;
438 }
439 (requests > 0).then(|| VerifiedSavings {
440 requests,
441 counterfactual_input_tokens: counterfactual,
442 billed_input_tokens: billed,
443 verified_saved_tokens: counterfactual as i64 - billed as i64,
444 })
445}
446
447pub fn price_models(map: &HashMap<String, ModelUsage>) -> Vec<ModelSpend> {
450 let pricing = ModelPricing::load();
451 let mut rows: Vec<ModelSpend> = map
452 .iter()
453 .map(|(model, usage)| price_one(&pricing, model, usage))
454 .collect();
455 rows.sort_by(|a, b| {
456 b.cost_usd
457 .partial_cmp(&a.cost_usd)
458 .unwrap_or(std::cmp::Ordering::Equal)
459 });
460 rows
461}
462
463fn price_one(pricing: &ModelPricing, model: &str, usage: &ModelUsage) -> ModelSpend {
464 let quote = pricing.quote(Some(model));
465 let m = &usage.measured;
468 let derived_cost = quote.cost.estimate_usd(
469 usage.input_tokens.saturating_sub(m.input_tokens),
470 usage.output_tokens.saturating_sub(m.output_tokens),
471 usage
472 .cache_write_tokens
473 .saturating_sub(m.cache_write_tokens),
474 usage.cache_read_tokens.saturating_sub(m.cache_read_tokens),
475 );
476 let derived_requests = usage.requests.saturating_sub(m.requests);
477 ModelSpend {
478 model: model.to_string(),
479 requests: usage.requests,
480 input_tokens: usage.input_tokens,
481 output_tokens: usage.output_tokens,
482 cache_read_tokens: usage.cache_read_tokens,
483 cache_write_tokens: usage.cache_write_tokens,
484 reasoning_tokens: usage.reasoning_tokens,
485 cost_usd: m.cost_usd + derived_cost,
486 measured_cost_usd: m.cost_usd,
487 measured_requests: m.requests,
488 pricing_estimated: quote.match_kind.is_estimated() && derived_requests > 0,
490 }
491}
492
493fn usage_path() -> Option<std::path::PathBuf> {
494 crate::core::data_dir::lean_ctx_data_dir()
495 .ok()
496 .map(|d| d.join(PROXY_USAGE_FILE))
497}
498
499fn persist() {
501 let Some(path) = usage_path() else {
502 return;
503 };
504 let models = {
505 let map = store()
506 .lock()
507 .unwrap_or_else(std::sync::PoisonError::into_inner);
508 map.clone()
509 };
510 let cohorts = {
511 let map = cohort_store()
512 .lock()
513 .unwrap_or_else(std::sync::PoisonError::into_inner);
514 map.clone()
515 };
516 let payload = PersistedUsage {
517 ts: std::time::SystemTime::now()
518 .duration_since(std::time::UNIX_EPOCH)
519 .unwrap_or_default()
520 .as_secs(),
521 models,
522 cohorts,
523 };
524 let Ok(json) = serde_json::to_string(&payload) else {
525 return;
526 };
527 let tmp = path.with_extension("json.tmp");
528 if std::fs::write(&tmp, json).is_ok() {
529 let _ = std::fs::rename(&tmp, &path);
530 }
531}
532
533pub fn load_persisted() -> Option<PersistedUsage> {
535 let path = usage_path()?;
536 let data = std::fs::read_to_string(path).ok()?;
537 serde_json::from_str(&data).ok()
538}
539
540pub fn persisted_snapshot() -> Vec<ModelSpend> {
542 load_persisted()
543 .map(|p| price_models(&p.models))
544 .unwrap_or_default()
545}
546
547pub fn persisted_dominant_model() -> Option<String> {
551 let persisted = load_persisted()?;
552 persisted
553 .models
554 .iter()
555 .filter(|(m, _)| m.as_str() != "unknown" && !m.trim().is_empty())
556 .max_by_key(|(_, u)| u.billable_tokens())
557 .filter(|(_, u)| u.billable_tokens() > 0)
558 .map(|(m, _)| m.clone())
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 fn usage(
566 model: &str,
567 input: u64,
568 output: u64,
569 cache_read: u64,
570 ) -> super::super::usage::RealUsage {
571 super::super::usage::RealUsage {
572 model: model.to_string(),
573 input_tokens: input,
574 output_tokens: output,
575 cache_read_tokens: cache_read,
576 ..Default::default()
577 }
578 }
579
580 #[test]
583 fn counterfactual_pair_recorded_only_when_probe_answered() {
584 let slot = super::super::counterfactual::CounterfactualSlot::new();
585 slot.set(5_000);
586 let with_probe = super::super::usage::RealUsage {
587 wire: Some(Box::new(super::super::usage::WireContext {
588 counterfactual: Some(slot),
589 ..Default::default()
590 })),
591 cache_write_tokens: 200,
592 ..usage("claude-sonnet-4.5", 1_000, 0, 300)
593 };
594 let mut acc = ModelUsage::default();
595 acc.add(&with_probe);
596 assert_eq!(acc.counterfactual_requests, 1);
597 assert_eq!(acc.counterfactual_input_tokens, 5_000);
598 assert_eq!(acc.counterfactual_billed_tokens, 1_000 + 300 + 200);
600
601 let empty = super::super::usage::RealUsage {
604 wire: Some(Box::new(super::super::usage::WireContext {
605 counterfactual: Some(super::super::counterfactual::CounterfactualSlot::new()),
606 ..Default::default()
607 })),
608 ..usage("claude-sonnet-4.5", 1_000, 0, 0)
609 };
610 acc.add(&empty);
611 assert_eq!(acc.requests, 2);
612 assert_eq!(acc.counterfactual_requests, 1, "empty slot adds no pair");
613
614 acc.add(&usage("claude-sonnet-4.5", 10, 0, 0));
616 assert_eq!(acc.counterfactual_requests, 1);
617 }
618
619 #[test]
621 fn verified_of_aggregates_and_reports_signed_savings() {
622 let mut map = HashMap::new();
623 assert!(verified_of(&map).is_none(), "no coverage → None, not zeros");
624
625 map.insert(
626 "claude-sonnet-4.5".to_string(),
627 ModelUsage {
628 counterfactual_requests: 2,
629 counterfactual_input_tokens: 10_000,
630 counterfactual_billed_tokens: 6_000,
631 ..Default::default()
632 },
633 );
634 map.insert(
637 "claude-haiku-4.5".to_string(),
638 ModelUsage {
639 counterfactual_requests: 1,
640 counterfactual_input_tokens: 1_000,
641 counterfactual_billed_tokens: 1_400,
642 ..Default::default()
643 },
644 );
645 let v = verified_of(&map).expect("covered rows present");
646 assert_eq!(v.requests, 3);
647 assert_eq!(v.counterfactual_input_tokens, 11_000);
648 assert_eq!(v.billed_input_tokens, 7_400);
649 assert_eq!(v.verified_saved_tokens, 3_600);
650
651 map.get_mut("claude-sonnet-4.5")
652 .unwrap()
653 .counterfactual_input_tokens = 0;
654 map.get_mut("claude-sonnet-4.5")
655 .unwrap()
656 .counterfactual_billed_tokens = 0;
657 let negative = verified_of(&map).unwrap();
658 assert_eq!(
659 negative.verified_saved_tokens, -400,
660 "a net-negative verified saving is reported, never clamped"
661 );
662 }
663
664 #[test]
667 fn counterfactual_fields_roundtrip_and_default_for_legacy_files() {
668 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}}}"#;
669 let p: PersistedUsage = serde_json::from_str(legacy).expect("legacy file loads");
670 assert_eq!(p.models["m"].counterfactual_requests, 0);
671
672 let mut p = PersistedUsage::default();
673 p.models.insert(
674 "m".into(),
675 ModelUsage {
676 counterfactual_requests: 4,
677 counterfactual_input_tokens: 9_999,
678 counterfactual_billed_tokens: 5_555,
679 ..Default::default()
680 },
681 );
682 let back: PersistedUsage =
683 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
684 assert_eq!(back.models["m"].counterfactual_input_tokens, 9_999);
685 assert_eq!(back.models["m"].counterfactual_billed_tokens, 5_555);
686 }
687
688 #[test]
689 fn prices_known_model_with_cache_split() {
690 let mut map = HashMap::new();
691 let mut acc = ModelUsage::default();
692 acc.add(&usage("claude-sonnet-4.5", 1_000_000, 1_000_000, 1_000_000));
693 map.insert("claude-sonnet-4.5".to_string(), acc);
694
695 let rows = price_models(&map);
696 assert_eq!(rows.len(), 1);
697 let row = &rows[0];
698 assert!(
700 (row.cost_usd - 18.30).abs() < 1e-6,
701 "cost was {}",
702 row.cost_usd
703 );
704 assert!(!row.pricing_estimated, "exact model match");
705 assert_eq!(row.requests, 1);
706 }
707
708 #[test]
709 fn unknown_model_prices_with_fallback_and_is_estimated() {
710 let mut map = HashMap::new();
711 let mut acc = ModelUsage::default();
712 acc.add(&usage("some-novel-model-xyz", 1_000_000, 0, 0));
713 map.insert("some-novel-model-xyz".to_string(), acc);
714
715 let rows = price_models(&map);
716 assert!(rows[0].pricing_estimated, "fallback pricing is estimated");
717 assert!(rows[0].cost_usd > 0.0);
718 }
719
720 #[test]
725 fn measured_provider_cost_replaces_table_estimate() {
726 const MODEL: &str = "vendor/unlisted-model-20990101";
727 let mut acc = ModelUsage::default();
728 let mut measured = usage(MODEL, 431_600, 22_700, 126_700);
729 measured.provider_cost_usd = Some(0.05);
730 acc.add(&measured);
731
732 let mut map = HashMap::new();
733 map.insert(MODEL.to_string(), acc.clone());
734 let row = &price_models(&map)[0];
735 assert!(
736 (row.cost_usd - 0.05).abs() < 1e-12,
737 "the bill, not the table"
738 );
739 assert!((row.measured_cost_usd - 0.05).abs() < 1e-12);
740 assert_eq!(row.measured_requests, 1);
741 assert!(
742 !row.pricing_estimated,
743 "fully measured spend is not an estimate"
744 );
745
746 acc.add(&usage(MODEL, 10_000, 0, 0));
750 let mut map = HashMap::new();
751 map.insert(MODEL.to_string(), acc);
752 let row = &price_models(&map)[0];
753 assert!(row.cost_usd > 0.05, "unmeasured remainder adds table cost");
754 assert!(row.cost_usd < 0.2, "measured slice must not be re-priced");
755 assert!(
756 row.pricing_estimated,
757 "unmeasured remainder is heuristically priced"
758 );
759 }
760
761 #[test]
762 fn measured_slice_roundtrips_and_defaults_for_legacy_files() {
763 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}}}"#;
764 let p: PersistedUsage = serde_json::from_str(legacy).expect("legacy file loads");
765 assert_eq!(p.models["m"].measured, MeasuredSlice::default());
766
767 let mut p = PersistedUsage::default();
768 let mut acc = ModelUsage::default();
769 let mut m = usage("m", 100, 10, 0);
770 m.provider_cost_usd = Some(0.5);
771 acc.add(&m);
772 p.models.insert("m".into(), acc);
773 let back: PersistedUsage =
774 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
775 assert_eq!(back.models["m"].measured.requests, 1);
776 assert!((back.models["m"].measured.cost_usd - 0.5).abs() < 1e-12);
777 }
778
779 #[test]
780 fn dominant_model_picks_highest_token_real_model() {
781 let mut models = HashMap::new();
782 models.insert("claude-haiku-4.5".to_string(), {
783 let mut u = ModelUsage::default();
784 u.add(&usage("claude-haiku-4.5", 100, 100, 0));
785 u
786 });
787 models.insert("claude-opus-4.5".to_string(), {
788 let mut u = ModelUsage::default();
789 u.add(&usage("claude-opus-4.5", 10_000, 10_000, 0));
790 u
791 });
792 models.insert("unknown".to_string(), {
793 let mut u = ModelUsage::default();
794 u.add(&usage("unknown", 999_999, 0, 0));
795 u
796 });
797 let dominant = models
798 .iter()
799 .filter(|(m, _)| m.as_str() != "unknown")
800 .max_by_key(|(_, u)| u.billable_tokens())
801 .map(|(m, _)| m.clone());
802 assert_eq!(dominant.as_deref(), Some("claude-opus-4.5"));
803 }
804
805 #[test]
806 fn empty_model_buckets_as_unknown() {
807 assert_eq!(normalize_key(" "), "unknown");
808 assert_eq!(normalize_key(""), "unknown");
809 assert_eq!(normalize_key("gpt-5.4"), "gpt-5.4");
810 }
811
812 #[test]
813 fn cohort_avg_output_is_mean_per_turn() {
814 let mut c = CohortUsage::default();
815 assert_eq!(c.avg_output(), None, "no observations → None");
816 c.add(&usage("m", 10, 100, 0));
817 c.add(&usage("m", 10, 50, 0));
818 assert_eq!(c.requests, 2);
819 assert_eq!(c.output_tokens, 150);
820 assert!((c.avg_output().unwrap() - 75.0).abs() < f64::EPSILON);
821 }
822
823 #[test]
824 fn persisted_usage_without_cohorts_field_loads() {
825 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}}}"#;
828 let p: PersistedUsage = serde_json::from_str(json).expect("loads legacy file");
829 assert_eq!(p.models.len(), 1);
830 assert!(p.cohorts.is_empty());
831 }
832
833 #[test]
834 fn persisted_usage_roundtrips_cohorts() {
835 let mut p = PersistedUsage::default();
836 p.cohorts.insert(
837 "control".into(),
838 CohortUsage {
839 requests: 3,
840 input_tokens: 30,
841 output_tokens: 300,
842 sum_sq_output: 30_000,
843 },
844 );
845 let json = serde_json::to_string(&p).unwrap();
846 let back: PersistedUsage = serde_json::from_str(&json).unwrap();
847 assert_eq!(back.cohorts.get("control").unwrap().output_tokens, 300);
848 }
849
850 #[test]
851 fn managed_usage_projects_once_and_unmanaged_is_skipped() {
852 let sink = crate::core::ocla::builtin::usage_sink::BuiltinUsageSink::new();
853 let usage = super::super::usage::RealUsage {
854 model: "gpt-5".into(),
855 input_tokens: 100,
856 output_tokens: 40,
857 cache_read_tokens: 20,
858 cache_write_tokens: 5,
859 wire: Some(Box::new(super::super::usage::WireContext {
860 lineage: Some(crate::core::ocla::OclaRequestContext {
861 request_id: "request-1".into(),
862 session_id: "session-1".into(),
863 agent_id: "agent-1".into(),
864 content_ref: "blake3:content".into(),
865 tenant_id: None,
866 trace_id: "tr-unit".into(),
867 }),
868 ..Default::default()
869 })),
870 ..Default::default()
871 };
872
873 project_ocla_usage(&usage, &sink);
874 assert_eq!(sink.record_count(), 1);
875 assert_eq!(sink.total_input_tokens(), 125);
876 assert_eq!(sink.total_output_tokens(), 40);
877
878 project_ocla_usage(
879 &super::super::usage::RealUsage {
880 model: "unmanaged".into(),
881 input_tokens: 999,
882 ..Default::default()
883 },
884 &sink,
885 );
886 assert_eq!(sink.record_count(), 1);
887 }
888}