Skip to main content

lean_ctx/proxy/
usage_meter.rs

1//! Measured per-model spend meter.
2//!
3//! [`record`] aggregates the real provider usage extracted by [`super::usage`]
4//! into per-model token sums, prices them with the shared
5//! [`ModelPricing`] table, and
6//! persists the totals to `proxy_usage.json` so the dashboard, CLI and the
7//! savings ledger (which run in *other* processes) can read the user's real
8//! provider bill.
9//!
10//! Unlike [`super::metrics`] (which resets per proxy lifetime), this meter is a
11//! lifetime-cumulative spend counter: [`resume_from_disk`] seeds the in-memory
12//! totals on proxy startup so a restart never zeroes the user's measured spend.
13
14use std::collections::HashMap;
15use std::sync::{Mutex, OnceLock};
16
17use serde::{Deserialize, Serialize};
18
19use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
20
21/// Cumulative real token counts for one model. Cost is derived at read time so a
22/// pricing-table change re-values historical usage consistently.
23#[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    /// Requests whose free `count_tokens` probe answered (#701) — the rows the
32    /// verified-savings pair below covers. `serde(default)` keeps pre-#701
33    /// usage files loadable.
34    #[serde(default)]
35    pub counterfactual_requests: u64,
36    /// Provider-counted input tokens the covered requests would have billed
37    /// WITHOUT lean-ctx (sum of probe answers on the original bodies).
38    #[serde(default)]
39    pub counterfactual_input_tokens: u64,
40    /// Input-side tokens those same requests actually billed (input +
41    /// cache read + cache write) — same request, same moment, no confound.
42    #[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        // Verified-savings pair (#701): only when the probe answered by the
55        // time the billed usage arrived — both sides of the pair or neither.
56        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/// One model's measured, priced spend for `/status` and the dashboard.
75#[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    /// True when pricing came from a heuristic/fallback match, not an exact one.
86    pub pricing_estimated: bool,
87}
88
89/// Cumulative output-savings cohort totals (#895 Track B). Keyed by arm name
90/// (`"control"` | `"treatment"`); the average output tokens per turn is
91/// `output_tokens / requests`. Only populated while a holdout is active.
92#[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    /// Sum of squared per-turn output tokens, enabling an online sample variance
98    /// (and therefore a confidence interval) without retaining every turn.
99    /// `#[serde(default)]` keeps pre-#895 files loadable.
100    #[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    /// Average output tokens per turn, or `None` with no observations.
113    #[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    /// Unbiased sample variance of per-turn output tokens, or `None` with < 2
124    /// observations. Computed from the running sum / sum-of-squares (clamped at
125    /// 0 to absorb floating-point error on near-constant samples).
126    #[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/// On-disk shape of the measured spend totals.
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct PersistedUsage {
145    pub ts: u64,
146    pub models: HashMap<String, ModelUsage>,
147    /// Output-savings cohort totals (#895). `#[serde(default)]` keeps older
148    /// `proxy_usage.json` files (written before the holdout existed) loadable.
149    #[serde(default)]
150    pub cohorts: HashMap<String, CohortUsage>,
151}
152
153/// Distinct-model bucket cap. `record` keys on the raw response model string, so
154/// overflow folds into "unknown" to keep the map bounded (real model names < ~50).
155const 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
169/// Seeds the in-memory totals from `proxy_usage.json`. Call once on proxy
170/// startup so measured spend is cumulative across restarts. Idempotent-ish: it
171/// merges the persisted totals into whatever is in memory (normally empty).
172pub 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
203/// Records one turn's measured usage against its model bucket (and its
204/// output-savings cohort, when tagged) and persists.
205pub fn record(u: &super::usage::RealUsage) {
206    // Gateway store subscription (enterprise#17): forward the full record to
207    // the installed sink (no-op locally). Never blocks the request path.
208    super::usage_sink::push(u);
209
210    // Budget windows (enterprise#25): book this turn's measured cost against
211    // the person/day and project/month accumulators the policy gate checks.
212    // Local turns book the shadow rate — the same valuation the usage store
213    // applies — so local-only budgets stay meaningful.
214    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    // Mechanism attribution into the local savings ledger (enterprise#19).
236    // Routing: the gateway served a cheaper model than requested — value the
237    // rate delta on the measured input tokens. Caching: provider prompt-cache
238    // reads billed below the input rate. Both best-effort, never blocking.
239    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/// Live output-savings cohort totals (#895). Empty until a holdout runs.
280#[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/// Cross-process read of the persisted output-savings cohort totals.
289#[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
303/// Live per-model measured spend, priced and sorted by USD descending.
304pub 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
311/// Total measured spend across all models (live in-memory totals).
312pub fn total_cost_usd() -> f64 {
313    snapshot().iter().map(|m| m.cost_usd).sum()
314}
315
316/// Cross-model verified-savings totals (#701) for `/status` and the dashboard.
317#[derive(Debug, Clone, Serialize, PartialEq)]
318pub struct VerifiedSavings {
319    /// Requests covered by a successful `count_tokens` probe.
320    pub requests: u64,
321    /// Provider-counted input tokens those requests would have billed
322    /// without lean-ctx.
323    pub counterfactual_input_tokens: u64,
324    /// Input-side tokens (input + cache read + cache write) they actually
325    /// billed.
326    pub billed_input_tokens: u64,
327    /// `counterfactual - billed`; negative when stub overhead outweighed the
328    /// squeeze — reported honestly, never clamped.
329    pub verified_saved_tokens: i64,
330}
331
332/// Aggregated provider-verified savings across all models (#701), or `None`
333/// until at least one probe-covered request has been recorded. Unlike the
334/// `tokens_saved` estimate (bytes/4), both sides of this pair were counted by
335/// the provider on the same request — receipts, not estimates.
336pub 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/// Pure aggregation behind [`verified_savings`], shared with tests.
344#[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
360/// Prices a model usage map into sorted [`ModelSpend`] rows. Pure: shared by the
361/// in-memory snapshot and the cross-process [`persisted_snapshot`].
362pub 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
403/// Atomically writes the current in-memory totals to disk.
404fn 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
437/// Cross-process read of the persisted measured spend (dashboard / CLI / ledger).
438pub 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
444/// Cross-process priced spend rows, read from disk.
445pub fn persisted_snapshot() -> Vec<ModelSpend> {
446    load_persisted()
447        .map(|p| price_models(&p.models))
448        .unwrap_or_default()
449}
450
451/// The model carrying the most measured tokens (excludes the "unknown" bucket).
452/// Used to value savings against the real dominant model when no explicit model
453/// is configured.
454pub 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    /// #701: the verified pair is recorded only when the probe answered — both
485    /// sides of the pair or neither, never a half row.
486    #[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        // billed side = input + cache read + cache write of the same turn.
503        assert_eq!(acc.counterfactual_billed_tokens, 1_000 + 300 + 200);
504
505        // Empty slot (probe failed / still in flight) → row degrades to the
506        // estimate: no pair recorded, normal usage still counted.
507        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        // No wire context at all (tests / non-forward paths) → no pair.
519        acc.add(&usage("claude-sonnet-4.5", 10, 0, 0));
520        assert_eq!(acc.counterfactual_requests, 1);
521    }
522
523    /// #701: cross-model aggregation and the honest signed difference.
524    #[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        // Stub overhead outweighed the squeeze on this model: billed MORE
539        // than the counterfactual. The total must subtract honestly.
540        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    /// #701: persisted usage files from before the feature load cleanly and
569    /// the new fields round-trip.
570    #[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        // input 3.00 + output 15.00 + cache_read 0.30 (per 1M) = 18.30.
603        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        // proxy_usage.json written before #895 has no `cohorts` key; serde(default)
671        // must backfill an empty map so old files stay loadable.
672        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}