1use std::collections::HashMap;
16use std::sync::{Mutex, OnceLock};
17
18use serde::Serialize;
19
20use lean_ctx::core::gain::model_pricing::ModelPricing;
21use lean_ctx::core::ocla::{OclaRegistry, UsageSink};
22pub use lean_ctx::core::proxy_usage::{CohortUsage, load_persisted};
23use lean_ctx::core::proxy_usage::{
24 LastModel, MeasuredSlice, ModelUsage, PersistedUsage, SessionUsage, usage_path,
25};
26
27fn project_ocla_usage(u: &super::usage::RealUsage, sink: &dyn UsageSink) {
33 use futures::FutureExt;
34 let Ok(Some(record)) = u.to_ocla_usage_record() else {
35 return;
36 };
37 let _ = sink.record_usage(record).now_or_never();
38}
39
40trait AddTurn {
43 fn add(&mut self, u: &super::usage::RealUsage);
44}
45
46impl AddTurn for ModelUsage {
47 fn add(&mut self, u: &super::usage::RealUsage) {
48 if let Some(observation) = &u.response_observation {
49 self.add_observation(observation.clone());
50 }
51 self.requests += 1;
52 self.input_tokens += u.input_tokens;
53 self.output_tokens += u.output_tokens;
54 self.cache_read_tokens += u.cache_read_tokens;
55 self.cache_write_tokens += u.cache_write_tokens;
56 self.reasoning_tokens += u.reasoning_tokens;
57 if let Some(cost) = u.provider_cost_usd {
58 self.measured.merge(&MeasuredSlice {
59 requests: 1,
60 input_tokens: u.input_tokens,
61 output_tokens: u.output_tokens,
62 cache_read_tokens: u.cache_read_tokens,
63 cache_write_tokens: u.cache_write_tokens,
64 cost_usd: cost,
65 });
66 }
67 if let Some(counted) = u
70 .wire
71 .as_deref()
72 .and_then(|w| w.counterfactual.as_ref())
73 .and_then(super::counterfactual::CounterfactualSlot::get)
74 {
75 self.counterfactual_requests += 1;
76 self.counterfactual_input_tokens += counted;
77 self.counterfactual_billed_tokens +=
78 u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
79 }
80 }
81}
82
83impl AddTurn for CohortUsage {
84 fn add(&mut self, u: &super::usage::RealUsage) {
85 self.requests += 1;
86 self.input_tokens += u.input_tokens;
87 self.output_tokens += u.output_tokens;
88 self.sum_sq_output += u.output_tokens.saturating_mul(u.output_tokens);
89 let prompt = u
90 .input_tokens
91 .saturating_add(u.cache_read_tokens)
92 .saturating_add(u.cache_write_tokens);
93 self.prompt_tokens += prompt;
94 self.sum_sq_prompt += prompt.saturating_mul(prompt);
95 }
96}
97
98#[derive(Debug, Clone, Serialize, PartialEq)]
100pub struct ModelSpend {
101 pub model: String,
102 pub requests: u64,
103 pub input_tokens: u64,
104 pub output_tokens: u64,
105 pub cache_read_tokens: u64,
106 pub cache_write_tokens: u64,
107 pub reasoning_tokens: u64,
108 pub cost_usd: f64,
109 pub measured_cost_usd: f64,
112 pub measured_requests: u64,
114 pub pricing_estimated: bool,
117}
118
119#[must_use]
123pub fn compression_cohort_key(arm: super::holdout::Arm) -> String {
124 format!("compression:{}", arm.as_str())
125}
126
127fn last_model_store() -> &'static Mutex<Option<LastModel>> {
128 static STORE: OnceLock<Mutex<Option<LastModel>>> = OnceLock::new();
129 STORE.get_or_init(|| Mutex::new(None))
130}
131
132const MAX_TRACKED_MODELS: usize = 256;
135
136fn store() -> &'static Mutex<HashMap<String, ModelUsage>> {
137 static STORE: OnceLock<Mutex<HashMap<String, ModelUsage>>> = OnceLock::new();
138 STORE.get_or_init(|| Mutex::new(HashMap::new()))
139}
140
141const MAX_TRACKED_PROJECTS: usize = 512;
143
144fn project_store() -> &'static Mutex<HashMap<String, HashMap<String, ModelUsage>>> {
145 static STORE: OnceLock<Mutex<HashMap<String, HashMap<String, ModelUsage>>>> = OnceLock::new();
146 STORE.get_or_init(|| Mutex::new(HashMap::new()))
147}
148
149fn cohort_store() -> &'static Mutex<HashMap<String, CohortUsage>> {
150 static STORE: OnceLock<Mutex<HashMap<String, CohortUsage>>> = OnceLock::new();
151 STORE.get_or_init(|| Mutex::new(HashMap::new()))
152}
153
154const MAX_TRACKED_SESSIONS: usize = 256;
156
157fn session_store() -> &'static Mutex<HashMap<String, SessionUsage>> {
158 static STORE: OnceLock<Mutex<HashMap<String, SessionUsage>>> = OnceLock::new();
159 STORE.get_or_init(|| Mutex::new(HashMap::new()))
160}
161
162fn add_session_turn(
166 sessions: &mut HashMap<String, SessionUsage>,
167 session: &str,
168 key: &str,
169 u: &super::usage::RealUsage,
170 now: u64,
171) {
172 if !sessions.contains_key(session) && sessions.len() >= MAX_TRACKED_SESSIONS {
173 let oldest = sessions
174 .iter()
175 .min_by(|a, b| a.1.last_seen.cmp(&b.1.last_seen).then_with(|| a.0.cmp(b.0)))
176 .map(|(id, _)| id.clone());
177 if let Some(oldest) = oldest {
178 sessions.remove(&oldest);
179 }
180 }
181 let entry = sessions.entry(session.to_owned()).or_default();
182 entry.last_seen = entry.last_seen.max(now);
183 entry.models.entry(key.to_owned()).or_default().add(u);
184}
185
186pub fn resume_from_disk() {
190 let Some(persisted) = load_persisted() else {
191 return;
192 };
193 {
194 let mut last = last_model_store()
195 .lock()
196 .unwrap_or_else(std::sync::PoisonError::into_inner);
197 if last.is_none() {
198 last.clone_from(&persisted.last_model);
199 }
200 }
201 let mut map = store()
202 .lock()
203 .unwrap_or_else(std::sync::PoisonError::into_inner);
204 merge_models(&mut map, persisted.models);
205 drop(map);
206 let mut projects = project_store()
207 .lock()
208 .unwrap_or_else(std::sync::PoisonError::into_inner);
209 for (project, models) in persisted.projects {
210 merge_models(projects.entry(project).or_default(), models);
211 }
212 drop(projects);
213 let mut sessions = session_store()
214 .lock()
215 .unwrap_or_else(std::sync::PoisonError::into_inner);
216 for (session, usage) in persisted.sessions {
217 let entry = sessions.entry(session).or_default();
218 entry.last_seen = entry.last_seen.max(usage.last_seen);
219 merge_models(&mut entry.models, usage.models);
220 }
221 drop(sessions);
222 let mut cohorts = cohort_store()
223 .lock()
224 .unwrap_or_else(std::sync::PoisonError::into_inner);
225 for (arm, usage) in persisted.cohorts {
226 cohorts.entry(arm).or_default().merge(&usage);
227 }
228}
229
230fn merge_models(map: &mut HashMap<String, ModelUsage>, models: HashMap<String, ModelUsage>) {
231 for (model, usage) in models {
232 let acc = map.entry(model).or_default();
233 acc.requests += usage.requests;
234 acc.input_tokens += usage.input_tokens;
235 acc.output_tokens += usage.output_tokens;
236 acc.cache_read_tokens += usage.cache_read_tokens;
237 acc.cache_write_tokens += usage.cache_write_tokens;
238 acc.reasoning_tokens += usage.reasoning_tokens;
239 acc.counterfactual_requests += usage.counterfactual_requests;
240 acc.counterfactual_input_tokens += usage.counterfactual_input_tokens;
241 acc.counterfactual_billed_tokens += usage.counterfactual_billed_tokens;
242 acc.measured.merge(&usage.measured);
243 for observation in usage.response_observations {
244 acc.add_observation(observation);
245 }
246 }
247}
248
249pub fn record(u: &super::usage::RealUsage) {
252 super::usage_sink::push(u);
255
256 {
258 let label = u.wire.as_ref().map_or("Unknown", |w| {
259 match w.provider.as_str() {
262 "Anthropic" => "Anthropic",
263 "OpenAI" | "ChatGPT" => "OpenAI",
264 "Gemini" => "Gemini",
265 "Bedrock" => "Bedrock",
266 "Azure" => "Azure",
267 _ => "Unknown",
268 }
269 });
270 let envelope = super::usage_parity::real_usage_to_envelope(u, label);
271 lean_ctx::core::context_kernel::envelope_bridge::record_proxy_envelope(&envelope);
272 }
273
274 project_ocla_usage(u, OclaRegistry::global().usage_sink.as_ref());
278
279 #[cfg(feature = "enterprise")]
285 if let Some(wire) = u.wire.as_deref()
286 && (wire.person.is_some() || wire.project.is_some())
287 {
288 let baseline = lean_ctx::core::config::Config::load()
289 .proxy
290 .baseline
291 .clone();
292 #[allow(clippy::cast_precision_loss)]
293 let cost_usd = if wire.is_local {
294 let billable =
295 u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
296 baseline.effective_local_shadow_rate() / 1_000_000.0 * billable as f64
297 } else if let Some(measured) = u.provider_cost_usd {
298 measured
299 } else {
300 lean_ctx::core::gain::model_pricing::ModelPricing::load()
301 .quote(Some(&u.model))
302 .cost
303 .estimate_usd(
304 u.input_tokens,
305 u.output_tokens,
306 u.cache_write_tokens,
307 u.cache_read_tokens,
308 )
309 };
310 super::policy_gate::record_spend(wire.person.as_deref(), wire.project.as_deref(), cost_usd);
311 }
312
313 let pricing = ModelPricing::load();
316 super::cache_share::record_turn(u, &pricing);
317
318 if u.cache_read_tokens > 0 {
322 let cost = pricing.quote(Some(&u.model)).cost;
323 #[allow(clippy::cast_precision_loss)]
324 let discount_usd =
325 (cost.input_per_m - cost.cache_read_per_m) / 1_000_000.0 * u.cache_read_tokens as f64;
326 lean_ctx::core::savings_ledger::record_caching_event(
327 &u.model,
328 u.cache_read_tokens,
329 discount_usd,
330 u.wire.as_deref().and_then(|w| w.project_key.as_deref()),
331 );
332 }
333
334 let model = u.model.trim();
337 if !model.is_empty()
338 && model != "unknown"
339 && !lean_ctx::core::model_registry::is_background_model(model)
340 {
341 *last_model_store()
342 .lock()
343 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(LastModel {
344 model: model.to_string(),
345 at: std::time::SystemTime::now()
346 .duration_since(std::time::UNIX_EPOCH)
347 .unwrap_or_default()
348 .as_secs(),
349 });
350 }
351
352 let key = normalize_key(&u.model);
353 {
354 let mut map = store()
355 .lock()
356 .unwrap_or_else(std::sync::PoisonError::into_inner);
357 let key = if !map.contains_key(&key) && map.len() >= MAX_TRACKED_MODELS {
358 "unknown".to_string()
359 } else {
360 key
361 };
362 map.entry(key.clone()).or_default().add(u);
363 drop(map);
364 if let Some(project) = u.wire.as_deref().and_then(|w| w.project_key.as_deref()) {
365 let mut projects = project_store()
366 .lock()
367 .unwrap_or_else(std::sync::PoisonError::into_inner);
368 if projects.contains_key(project) || projects.len() < MAX_TRACKED_PROJECTS {
369 projects
370 .entry(project.to_string())
371 .or_default()
372 .entry(key.clone())
373 .or_default()
374 .add(u);
375 }
376 }
377 if let Some(session) = u.wire.as_deref().and_then(|w| w.app_session.as_deref()) {
378 let now = std::time::SystemTime::now()
379 .duration_since(std::time::UNIX_EPOCH)
380 .unwrap_or_default()
381 .as_secs();
382 let mut sessions = session_store()
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 add_session_turn(&mut sessions, session, &key, u, now);
386 }
387 }
388 if u.cohort.output.is_some() || u.cohort.compression.is_some() {
389 let mut cohorts = cohort_store()
390 .lock()
391 .unwrap_or_else(std::sync::PoisonError::into_inner);
392 if let Some(arm) = u.cohort.output {
393 cohorts.entry(arm.as_str().to_string()).or_default().add(u);
394 }
395 if let Some(arm) = u.cohort.compression {
396 cohorts
397 .entry(compression_cohort_key(arm))
398 .or_default()
399 .add(u);
400 }
401 }
402 persist();
403}
404
405#[must_use]
407pub fn cohort_snapshot() -> HashMap<String, CohortUsage> {
408 cohort_store()
409 .lock()
410 .unwrap_or_else(std::sync::PoisonError::into_inner)
411 .clone()
412}
413
414#[must_use]
416pub fn persisted_cohorts() -> HashMap<String, CohortUsage> {
417 load_persisted().map(|p| p.cohorts).unwrap_or_default()
418}
419
420fn normalize_key(model: &str) -> String {
421 let m = model.trim();
422 if m.is_empty() {
423 "unknown".to_string()
424 } else {
425 m.to_string()
426 }
427}
428
429pub fn snapshot() -> Vec<ModelSpend> {
431 let map = store()
432 .lock()
433 .unwrap_or_else(std::sync::PoisonError::into_inner);
434 price_models(&map)
435}
436
437pub fn total_cost_usd() -> f64 {
439 snapshot().iter().map(|m| m.cost_usd).sum()
440}
441
442#[derive(Debug, Clone, Serialize, PartialEq)]
444pub struct VerifiedSavings {
445 pub requests: u64,
447 pub counterfactual_input_tokens: u64,
450 pub billed_input_tokens: u64,
453 pub verified_saved_tokens: i64,
456}
457
458pub fn verified_savings() -> Option<VerifiedSavings> {
463 let map = store()
464 .lock()
465 .unwrap_or_else(std::sync::PoisonError::into_inner);
466 verified_of(&map)
467}
468
469#[allow(clippy::cast_possible_wrap)]
471fn verified_of(map: &HashMap<String, ModelUsage>) -> Option<VerifiedSavings> {
472 let (mut requests, mut counterfactual, mut billed) = (0u64, 0u64, 0u64);
473 for usage in map.values() {
474 requests += usage.counterfactual_requests;
475 counterfactual += usage.counterfactual_input_tokens;
476 billed += usage.counterfactual_billed_tokens;
477 }
478 (requests > 0).then(|| VerifiedSavings {
479 requests,
480 counterfactual_input_tokens: counterfactual,
481 billed_input_tokens: billed,
482 verified_saved_tokens: counterfactual as i64 - billed as i64,
483 })
484}
485
486pub fn price_models(map: &HashMap<String, ModelUsage>) -> Vec<ModelSpend> {
489 let pricing = ModelPricing::load();
490 let mut rows: Vec<ModelSpend> = map
491 .iter()
492 .map(|(model, usage)| price_one(&pricing, model, usage))
493 .collect();
494 rows.sort_by(|a, b| {
495 b.cost_usd
496 .partial_cmp(&a.cost_usd)
497 .unwrap_or(std::cmp::Ordering::Equal)
498 });
499 rows
500}
501
502fn price_one(pricing: &ModelPricing, model: &str, usage: &ModelUsage) -> ModelSpend {
503 let quote = pricing.quote(Some(model));
504 let m = &usage.measured;
507 let derived_cost = quote.cost.estimate_usd(
508 usage.input_tokens.saturating_sub(m.input_tokens),
509 usage.output_tokens.saturating_sub(m.output_tokens),
510 usage
511 .cache_write_tokens
512 .saturating_sub(m.cache_write_tokens),
513 usage.cache_read_tokens.saturating_sub(m.cache_read_tokens),
514 );
515 let derived_requests = usage.requests.saturating_sub(m.requests);
516 ModelSpend {
517 model: model.to_string(),
518 requests: usage.requests,
519 input_tokens: usage.input_tokens,
520 output_tokens: usage.output_tokens,
521 cache_read_tokens: usage.cache_read_tokens,
522 cache_write_tokens: usage.cache_write_tokens,
523 reasoning_tokens: usage.reasoning_tokens,
524 cost_usd: m.cost_usd + derived_cost,
525 measured_cost_usd: m.cost_usd,
526 measured_requests: m.requests,
527 pricing_estimated: quote.match_kind.is_estimated() && derived_requests > 0,
529 }
530}
531
532fn persist() {
534 let Some(path) = usage_path() else {
535 return;
536 };
537 let models = {
538 let map = store()
539 .lock()
540 .unwrap_or_else(std::sync::PoisonError::into_inner);
541 map.clone()
542 };
543 let cohorts = {
544 let map = cohort_store()
545 .lock()
546 .unwrap_or_else(std::sync::PoisonError::into_inner);
547 map.clone()
548 };
549 let last_model = last_model_store()
550 .lock()
551 .unwrap_or_else(std::sync::PoisonError::into_inner)
552 .clone();
553 let projects = project_store()
554 .lock()
555 .unwrap_or_else(std::sync::PoisonError::into_inner)
556 .clone();
557 let sessions = session_store()
558 .lock()
559 .unwrap_or_else(std::sync::PoisonError::into_inner)
560 .clone();
561 let payload = PersistedUsage {
562 ts: std::time::SystemTime::now()
563 .duration_since(std::time::UNIX_EPOCH)
564 .unwrap_or_default()
565 .as_secs(),
566 models,
567 cohorts,
568 last_model,
569 projects,
570 sessions,
571 };
572 let Ok(json) = serde_json::to_string(&payload) else {
573 return;
574 };
575 let tmp = path.with_extension("json.tmp");
576 if std::fs::write(&tmp, json).is_ok() {
577 let _ = std::fs::rename(&tmp, &path);
578 }
579}
580
581pub fn persisted_snapshot() -> Vec<ModelSpend> {
583 load_persisted()
584 .map(|p| price_models(&p.models))
585 .unwrap_or_default()
586}
587
588pub fn persisted_snapshot_where(keep: impl Fn(&str) -> bool) -> Vec<ModelSpend> {
590 let Some(persisted) = load_persisted() else {
591 return Vec::new();
592 };
593 let mut models: HashMap<String, ModelUsage> = HashMap::new();
594 for (project, usage) in persisted.projects {
595 if keep(&project) {
596 merge_models(&mut models, usage);
597 }
598 }
599 price_models(&models)
600}
601
602pub fn persisted_projects() -> Vec<(String, Vec<ModelSpend>)> {
604 let Some(persisted) = load_persisted() else {
605 return Vec::new();
606 };
607 let mut rows: Vec<(String, Vec<ModelSpend>)> = persisted
608 .projects
609 .iter()
610 .map(|(project, models)| (project.clone(), price_models(models)))
611 .collect();
612 rows.sort_by(|a, b| a.0.cmp(&b.0));
613 rows
614}
615
616pub fn persisted_session(session: &str) -> Option<Vec<ModelSpend>> {
619 load_persisted()?
620 .sessions
621 .get(session)
622 .map(|usage| price_models(&usage.models))
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn usage(
630 model: &str,
631 input: u64,
632 output: u64,
633 cache_read: u64,
634 ) -> super::super::usage::RealUsage {
635 super::super::usage::RealUsage {
636 model: model.to_string(),
637 input_tokens: input,
638 output_tokens: output,
639 cache_read_tokens: cache_read,
640 ..Default::default()
641 }
642 }
643
644 #[test]
648 fn app_sessions_are_bounded_and_the_least_recent_gives_way() {
649 let mut sessions = HashMap::new();
650 let turn = usage("claude-sonnet-4.5", 100, 10, 0);
651 for i in 0..MAX_TRACKED_SESSIONS as u64 {
652 add_session_turn(&mut sessions, &format!("s{i}"), "m", &turn, 1_000 + i);
653 }
654 add_session_turn(&mut sessions, "s0", "m", &turn, 9_000);
656 add_session_turn(&mut sessions, "new", "m", &turn, 9_001);
657 assert_eq!(sessions.len(), MAX_TRACKED_SESSIONS);
658 assert!(
659 !sessions.contains_key("s1"),
660 "the least recent session gives way"
661 );
662 assert_eq!(sessions["s0"].models["m"].requests, 2);
663 assert_eq!(sessions["s0"].last_seen, 9_000);
664 assert!(sessions.contains_key("new"));
665 }
666
667 #[test]
668 fn cohort_merge_keeps_every_running_total() {
669 let mut persisted = CohortUsage::default();
670 for (out, cached) in [(150, 900), (210, 1_200), (180, 0)] {
671 persisted.add(&usage("claude-sonnet-4.5", 1_000, out, cached));
672 }
673 let mut resumed = CohortUsage::default();
674 resumed.merge(&persisted);
675 assert_eq!(resumed, persisted, "every field survives the restart");
676 assert!(resumed.variance_output().is_some_and(|v| v > 0.0));
677
678 let mut live = CohortUsage::default();
680 live.add(&usage("claude-sonnet-4.5", 500, 100, 0));
681 let expected_sum_sq = live.sum_sq_output + persisted.sum_sq_output;
682 live.merge(&persisted);
683 assert_eq!(live.requests, 4);
684 assert_eq!(live.sum_sq_output, expected_sum_sq);
685 assert_eq!(live.prompt_tokens, 500 + persisted.prompt_tokens);
686 }
687
688 #[test]
692 fn spend_is_split_per_project_and_persisted() {
693 let _dir = lean_ctx::core::data_dir::isolated_data_dir();
694 let project = format!("{:016x}", std::process::id() as u64 * 7919 + 17);
695 let other = format!("{:016x}", std::process::id() as u64 * 7919 + 18);
696 let turn = |key: Option<&str>, input| super::super::usage::RealUsage {
697 wire: Some(Box::new(super::super::usage::WireContext {
698 project_key: key.map(str::to_string),
699 ..Default::default()
700 })),
701 ..usage("claude-sonnet-4.5", input, 10, 0)
702 };
703 record(&turn(Some(&project), 1_000));
704 record(&turn(Some(&project), 500));
705 record(&turn(Some(&other), 200));
706 record(&turn(None, 100));
707
708 let mine = persisted_snapshot_where(|key| key == project);
709 assert_eq!(mine.len(), 1);
710 assert_eq!(mine[0].requests, 2);
711 assert_eq!(mine[0].input_tokens, 1_500);
712 let persisted = load_persisted().expect("persisted");
713 let device = &persisted.models["claude-sonnet-4.5"];
714 let split: u64 = persisted_projects()
715 .iter()
716 .filter(|(key, _)| *key == project || *key == other)
717 .flat_map(|(_, models)| models.iter().map(|m| m.input_tokens))
718 .sum();
719 assert_eq!(split, 1_700);
720 assert!(
721 device.input_tokens >= 1_800,
722 "the device total keeps every turn, named or not"
723 );
724 }
725
726 #[test]
731 fn recording_a_turn_leaves_earlier_savings_ledger_entries_unchanged() {
732 use lean_ctx::core::savings_ledger::{self, event::SavingsEvent, store};
733 let _dir = lean_ctx::core::data_dir::isolated_data_dir();
734 let path = store::default_path().expect("ledger path");
735 let legacy: SavingsEvent = serde_json::from_value(serde_json::json!({
736 "ts": "2026-06-01T12:00:00+00:00",
737 "tool": "ctx_read",
738 "model_id": "claude-3.5-sonnet",
739 "tokenizer": "o200k_base",
740 "baseline_tokens": 1000,
741 "actual_tokens": 300,
742 "saved_tokens": 700,
743 "bounce_adjustment": 0,
744 "unit_price_per_m_usd": 3.0,
745 "saved_usd": 0.0021,
746 "repo_hash": "repo",
747 "agent_id": "local",
748 "prev_hash": "",
749 "entry_hash": ""
750 }))
751 .expect("legacy fixture");
752 store::append(&path, legacy).expect("append fixture");
753 record(&usage("claude-sonnet-4.5", 1_000, 50, 400));
754 let before = std::fs::read(&path).expect("ledger bytes");
755 let before_events = savings_ledger::all_events();
756 assert_eq!(before_events.len(), 2, "fixture plus one caching event");
757
758 record(&usage("claude-sonnet-4.5", 2_000, 80, 600));
759
760 let after = std::fs::read(&path).expect("ledger bytes");
761 assert!(after.starts_with(&before), "earlier entries are untouched");
762 let after_events = savings_ledger::all_events();
763 assert_eq!(after_events.len(), 3, "one turn appends one event");
764 for (old, new) in before_events.iter().zip(&after_events) {
765 assert_eq!(old.entry_hash, new.entry_hash);
766 assert_eq!(old.saved_tokens, new.saved_tokens);
767 assert_eq!(old.saved_usd.to_bits(), new.saved_usd.to_bits());
768 }
769 let verified = savings_ledger::verify();
770 assert!(verified.valid, "{verified:?}");
771 assert_eq!(verified.total, 3);
772 }
773
774 #[test]
777 fn counterfactual_pair_recorded_only_when_probe_answered() {
778 let slot = super::super::counterfactual::CounterfactualSlot::new();
779 slot.set(5_000);
780 let with_probe = super::super::usage::RealUsage {
781 wire: Some(Box::new(super::super::usage::WireContext {
782 counterfactual: Some(slot),
783 ..Default::default()
784 })),
785 cache_write_tokens: 200,
786 ..usage("claude-sonnet-4.5", 1_000, 0, 300)
787 };
788 let mut acc = ModelUsage::default();
789 acc.add(&with_probe);
790 assert_eq!(acc.counterfactual_requests, 1);
791 assert_eq!(acc.counterfactual_input_tokens, 5_000);
792 assert_eq!(acc.counterfactual_billed_tokens, 1_000 + 300 + 200);
794
795 let empty = super::super::usage::RealUsage {
798 wire: Some(Box::new(super::super::usage::WireContext {
799 counterfactual: Some(super::super::counterfactual::CounterfactualSlot::new()),
800 ..Default::default()
801 })),
802 ..usage("claude-sonnet-4.5", 1_000, 0, 0)
803 };
804 acc.add(&empty);
805 assert_eq!(acc.requests, 2);
806 assert_eq!(acc.counterfactual_requests, 1, "empty slot adds no pair");
807
808 acc.add(&usage("claude-sonnet-4.5", 10, 0, 0));
810 assert_eq!(acc.counterfactual_requests, 1);
811 }
812
813 #[test]
815 fn verified_of_aggregates_and_reports_signed_savings() {
816 let mut map = HashMap::new();
817 assert!(verified_of(&map).is_none(), "no coverage → None, not zeros");
818
819 map.insert(
820 "claude-sonnet-4.5".to_string(),
821 ModelUsage {
822 counterfactual_requests: 2,
823 counterfactual_input_tokens: 10_000,
824 counterfactual_billed_tokens: 6_000,
825 ..Default::default()
826 },
827 );
828 map.insert(
831 "claude-haiku-4.5".to_string(),
832 ModelUsage {
833 counterfactual_requests: 1,
834 counterfactual_input_tokens: 1_000,
835 counterfactual_billed_tokens: 1_400,
836 ..Default::default()
837 },
838 );
839 let v = verified_of(&map).expect("covered rows present");
840 assert_eq!(v.requests, 3);
841 assert_eq!(v.counterfactual_input_tokens, 11_000);
842 assert_eq!(v.billed_input_tokens, 7_400);
843 assert_eq!(v.verified_saved_tokens, 3_600);
844
845 map.get_mut("claude-sonnet-4.5")
846 .unwrap()
847 .counterfactual_input_tokens = 0;
848 map.get_mut("claude-sonnet-4.5")
849 .unwrap()
850 .counterfactual_billed_tokens = 0;
851 let negative = verified_of(&map).unwrap();
852 assert_eq!(
853 negative.verified_saved_tokens, -400,
854 "a net-negative verified saving is reported, never clamped"
855 );
856 }
857
858 #[test]
861 fn counterfactual_fields_roundtrip_and_default_for_legacy_files() {
862 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}}}"#;
863 let p: PersistedUsage = serde_json::from_str(legacy).expect("legacy file loads");
864 assert_eq!(p.models["m"].counterfactual_requests, 0);
865
866 let mut p = PersistedUsage::default();
867 p.models.insert(
868 "m".into(),
869 ModelUsage {
870 counterfactual_requests: 4,
871 counterfactual_input_tokens: 9_999,
872 counterfactual_billed_tokens: 5_555,
873 ..Default::default()
874 },
875 );
876 let back: PersistedUsage =
877 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
878 assert_eq!(back.models["m"].counterfactual_input_tokens, 9_999);
879 assert_eq!(back.models["m"].counterfactual_billed_tokens, 5_555);
880 }
881
882 #[test]
883 fn prices_known_model_with_cache_split() {
884 let mut map = HashMap::new();
885 let mut acc = ModelUsage::default();
886 acc.add(&usage("claude-sonnet-4.5", 1_000_000, 1_000_000, 1_000_000));
887 map.insert("claude-sonnet-4.5".to_string(), acc);
888
889 let rows = price_models(&map);
890 assert_eq!(rows.len(), 1);
891 let row = &rows[0];
892 assert!(
894 (row.cost_usd - 18.30).abs() < 1e-6,
895 "cost was {}",
896 row.cost_usd
897 );
898 assert!(!row.pricing_estimated, "exact model match");
899 assert_eq!(row.requests, 1);
900 }
901
902 #[test]
903 fn unknown_model_prices_with_fallback_and_is_estimated() {
904 let mut map = HashMap::new();
905 let mut acc = ModelUsage::default();
906 acc.add(&usage("some-novel-model-xyz", 1_000_000, 0, 0));
907 map.insert("some-novel-model-xyz".to_string(), acc);
908
909 let rows = price_models(&map);
910 assert!(rows[0].pricing_estimated, "fallback pricing is estimated");
911 assert!(rows[0].cost_usd > 0.0);
912 }
913
914 #[test]
919 fn measured_provider_cost_replaces_table_estimate() {
920 const MODEL: &str = "vendor/unlisted-model-20990101";
921 let mut acc = ModelUsage::default();
922 let mut measured = usage(MODEL, 431_600, 22_700, 126_700);
923 measured.provider_cost_usd = Some(0.05);
924 acc.add(&measured);
925
926 let mut map = HashMap::new();
927 map.insert(MODEL.to_string(), acc.clone());
928 let row = &price_models(&map)[0];
929 assert!(
930 (row.cost_usd - 0.05).abs() < 1e-12,
931 "the bill, not the table"
932 );
933 assert!((row.measured_cost_usd - 0.05).abs() < 1e-12);
934 assert_eq!(row.measured_requests, 1);
935 assert!(
936 !row.pricing_estimated,
937 "fully measured spend is not an estimate"
938 );
939
940 acc.add(&usage(MODEL, 10_000, 0, 0));
944 let mut map = HashMap::new();
945 map.insert(MODEL.to_string(), acc);
946 let row = &price_models(&map)[0];
947 assert!(row.cost_usd > 0.05, "unmeasured remainder adds table cost");
948 assert!(row.cost_usd < 0.2, "measured slice must not be re-priced");
949 assert!(
950 row.pricing_estimated,
951 "unmeasured remainder is heuristically priced"
952 );
953 }
954
955 #[test]
956 fn measured_slice_roundtrips_and_defaults_for_legacy_files() {
957 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}}}"#;
958 let p: PersistedUsage = serde_json::from_str(legacy).expect("legacy file loads");
959 assert_eq!(p.models["m"].measured, MeasuredSlice::default());
960
961 let mut p = PersistedUsage::default();
962 let mut acc = ModelUsage::default();
963 let mut m = usage("m", 100, 10, 0);
964 m.provider_cost_usd = Some(0.5);
965 acc.add(&m);
966 p.models.insert("m".into(), acc);
967 let back: PersistedUsage =
968 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
969 assert_eq!(back.models["m"].measured.requests, 1);
970 assert!((back.models["m"].measured.cost_usd - 0.5).abs() < 1e-12);
971 }
972
973 #[test]
974 fn dominant_model_picks_highest_token_real_model() {
975 let mut models = HashMap::new();
976 models.insert("claude-haiku-4.5".to_string(), {
977 let mut u = ModelUsage::default();
978 u.add(&usage("claude-haiku-4.5", 100, 100, 0));
979 u
980 });
981 models.insert("claude-opus-4.5".to_string(), {
982 let mut u = ModelUsage::default();
983 u.add(&usage("claude-opus-4.5", 10_000, 10_000, 0));
984 u
985 });
986 models.insert("unknown".to_string(), {
987 let mut u = ModelUsage::default();
988 u.add(&usage("unknown", 999_999, 0, 0));
989 u
990 });
991 let dominant = models
992 .iter()
993 .filter(|(m, _)| m.as_str() != "unknown")
994 .max_by_key(|(_, u)| u.billable_tokens())
995 .map(|(m, _)| m.clone());
996 assert_eq!(dominant.as_deref(), Some("claude-opus-4.5"));
997 }
998
999 #[test]
1000 fn empty_model_buckets_as_unknown() {
1001 assert_eq!(normalize_key(" "), "unknown");
1002 assert_eq!(normalize_key(""), "unknown");
1003 assert_eq!(normalize_key("gpt-5.4"), "gpt-5.4");
1004 }
1005
1006 #[test]
1007 fn cohort_avg_output_is_mean_per_turn() {
1008 let mut c = CohortUsage::default();
1009 assert_eq!(c.avg_output(), None, "no observations → None");
1010 c.add(&usage("m", 10, 100, 0));
1011 c.add(&usage("m", 10, 50, 0));
1012 assert_eq!(c.requests, 2);
1013 assert_eq!(c.output_tokens, 150);
1014 assert!((c.avg_output().unwrap() - 75.0).abs() < f64::EPSILON);
1015 }
1016
1017 #[test]
1018 fn persisted_usage_without_cohorts_field_loads() {
1019 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}}}"#;
1022 let p: PersistedUsage = serde_json::from_str(json).expect("loads legacy file");
1023 assert_eq!(p.models.len(), 1);
1024 assert!(p.cohorts.is_empty());
1025 }
1026
1027 #[test]
1028 fn persisted_usage_roundtrips_cohorts() {
1029 let mut p = PersistedUsage::default();
1030 p.cohorts.insert(
1031 "control".into(),
1032 CohortUsage {
1033 requests: 3,
1034 input_tokens: 30,
1035 output_tokens: 300,
1036 sum_sq_output: 30_000,
1037 ..CohortUsage::default()
1038 },
1039 );
1040 let json = serde_json::to_string(&p).unwrap();
1041 let back: PersistedUsage = serde_json::from_str(&json).unwrap();
1042 assert_eq!(back.cohorts.get("control").unwrap().output_tokens, 300);
1043 }
1044
1045 #[test]
1046 fn managed_usage_projects_once_and_unmanaged_is_skipped() {
1047 let sink = lean_ctx::core::ocla::builtin::usage_sink::BuiltinUsageSink::new();
1048 let usage = super::super::usage::RealUsage {
1049 model: "gpt-5".into(),
1050 input_tokens: 100,
1051 output_tokens: 40,
1052 cache_read_tokens: 20,
1053 cache_write_tokens: 5,
1054 wire: Some(Box::new(super::super::usage::WireContext {
1055 lineage: Some(lean_ctx::core::ocla::OclaRequestContext {
1056 request_id: "request-1".into(),
1057 session_id: "session-1".into(),
1058 agent_id: "agent-1".into(),
1059 content_ref: "blake3:content".into(),
1060 tenant_id: None,
1061 trace_id: "tr-unit".into(),
1062 task_id: None,
1063 parent_task_id: None,
1064 }),
1065 ..Default::default()
1066 })),
1067 ..Default::default()
1068 };
1069
1070 project_ocla_usage(&usage, &sink);
1071 assert_eq!(sink.record_count(), 1);
1072 assert_eq!(sink.total_input_tokens(), 125);
1073 assert_eq!(sink.total_output_tokens(), 40);
1074
1075 project_ocla_usage(
1076 &super::super::usage::RealUsage {
1077 model: "unmanaged".into(),
1078 input_tokens: 999,
1079 ..Default::default()
1080 },
1081 &sink,
1082 );
1083 assert_eq!(sink.record_count(), 1);
1084 }
1085
1086 struct SpyUsage(std::sync::Arc<std::sync::atomic::AtomicUsize>);
1088
1089 impl lean_ctx::core::ocla::traits::OclaService for SpyUsage {
1090 fn capability(&self) -> lean_ctx::core::ocla::types::OclaCapability {
1091 lean_ctx::core::ocla::types::OclaCapability::available(
1092 lean_ctx::core::ocla::types::OclaCapabilityKind::UsageSink,
1093 )
1094 }
1095 }
1096
1097 #[async_trait::async_trait]
1098 impl UsageSink for SpyUsage {
1099 async fn record_usage(
1100 &self,
1101 _usage: lean_ctx::core::ocla::types::UsageRecord,
1102 ) -> lean_ctx::core::ocla::OclaResult<()> {
1103 self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1104 Ok(())
1105 }
1106 }
1107
1108 #[test]
1109 fn proxy_request_projects_measured_usage_to_usage_spy() {
1110 let _dir = lean_ctx::core::data_dir::isolated_data_dir();
1111 let usage_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1112 let mut registry = OclaRegistry::with_builtins();
1113 registry.usage_sink = std::sync::Arc::new(SpyUsage(usage_calls.clone()));
1114 let _guard = lean_ctx::core::ocla::registry::with_test_registry(registry);
1115 let usage = super::super::usage::RealUsage {
1116 model: "test-model".into(),
1117 input_tokens: 11,
1118 output_tokens: 7,
1119 wire: Some(Box::new(super::super::usage::WireContext {
1120 lineage: Some(lean_ctx::core::ocla::OclaRequestContext {
1121 request_id: "request-1".into(),
1122 session_id: "session-1".into(),
1123 agent_id: "agent-1".into(),
1124 content_ref: "file:test.rs".into(),
1125 tenant_id: None,
1126 trace_id: "tr-unit".into(),
1127 task_id: None,
1128 parent_task_id: None,
1129 }),
1130 ..Default::default()
1131 })),
1132 ..Default::default()
1133 };
1134
1135 record(&usage);
1136
1137 assert_eq!(usage_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
1138 }
1139}