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;
20use crate::core::ocla::{OclaRegistry, UsageSink};
21
22/// Projects one managed, provider-measured turn into the OCLA usage sink.
23///
24/// This is deliberately one-way and best-effort: the existing meter remains
25/// authoritative for billing/ledger totals, while OCLA receives a validated
26/// payload-free projection only when complete lineage is present.
27fn 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/// Cumulative real token counts for one model. Cost is derived at read time so a
35/// pricing-table change re-values historical usage consistently.
36#[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    /// Requests whose free `count_tokens` probe answered (#701) — the rows the
45    /// verified-savings pair below covers. `serde(default)` keeps pre-#701
46    /// usage files loadable.
47    #[serde(default)]
48    pub counterfactual_requests: u64,
49    /// Provider-counted input tokens the covered requests would have billed
50    /// WITHOUT lean-ctx (sum of probe answers on the original bodies).
51    #[serde(default)]
52    pub counterfactual_input_tokens: u64,
53    /// Input-side tokens those same requests actually billed (input +
54    /// cache read + cache write) — same request, same moment, no confound.
55    #[serde(default)]
56    pub counterfactual_billed_tokens: u64,
57    /// The turns whose response carried the provider's own USD charge
58    /// (OpenRouter usage accounting, #1179). Their tokens are *inside* the
59    /// totals above; pricing subtracts this slice and books its measured USD
60    /// instead of a table estimate. `serde(default)` keeps older files loadable.
61    #[serde(default)]
62    pub measured: MeasuredSlice,
63}
64
65/// Token/cost sums of the turns that reported a measured provider charge.
66#[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    /// Sum of provider-reported USD for those turns — the bill, not a table.
74    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        // Verified-savings pair (#701): only when the probe answered by the
107        // time the billed usage arrived — both sides of the pair or neither.
108        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/// One model's measured, priced spend for `/status` and the dashboard.
127#[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    /// Share of `cost_usd` the provider itself reported (OpenRouter usage
138    /// accounting, #1179) — a bill, not a table estimate.
139    pub measured_cost_usd: f64,
140    /// Requests covered by that provider-reported cost.
141    pub measured_requests: u64,
142    /// True when part of the cost had to be priced from a heuristic/fallback
143    /// match — never set by measured or exactly-priced spend.
144    pub pricing_estimated: bool,
145}
146
147/// Cumulative output-savings cohort totals (#895 Track B). Keyed by arm name
148/// (`"control"` | `"treatment"`); the average output tokens per turn is
149/// `output_tokens / requests`. Only populated while a holdout is active.
150#[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    /// Sum of squared per-turn output tokens, enabling an online sample variance
156    /// (and therefore a confidence interval) without retaining every turn.
157    /// `#[serde(default)]` keeps pre-#895 files loadable.
158    #[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    /// Average output tokens per turn, or `None` with no observations.
171    #[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    /// Unbiased sample variance of per-turn output tokens, or `None` with < 2
182    /// observations. Computed from the running sum / sum-of-squares (clamped at
183    /// 0 to absorb floating-point error on near-constant samples).
184    #[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/// On-disk shape of the measured spend totals.
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct PersistedUsage {
203    pub ts: u64,
204    pub models: HashMap<String, ModelUsage>,
205    /// Output-savings cohort totals (#895). `#[serde(default)]` keeps older
206    /// `proxy_usage.json` files (written before the holdout existed) loadable.
207    #[serde(default)]
208    pub cohorts: HashMap<String, CohortUsage>,
209}
210
211/// Distinct-model bucket cap. `record` keys on the raw response model string, so
212/// overflow folds into "unknown" to keep the map bounded (real model names < ~50).
213const 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
227/// Seeds the in-memory totals from `proxy_usage.json`. Call once on proxy
228/// startup so measured spend is cumulative across restarts. Idempotent-ish: it
229/// merges the persisted totals into whatever is in memory (normally empty).
230pub 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
262/// Records one turn's measured usage against its model bucket (and its
263/// output-savings cohort, when tagged) and persists.
264pub fn record(u: &super::usage::RealUsage) {
265    // Gateway store subscription (enterprise#17): forward the full record to
266    // the installed sink (no-op locally). Never blocks the request path.
267    super::usage_sink::push(u);
268
269    // R33: Feed measured usage into kernel envelope pipeline.
270    {
271        let label = u.wire.as_ref().map_or("Unknown", |w| {
272            // provider_kind_from_label expects a static or owned string;
273            // the label lives long enough for the conversion call.
274            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    // OCLA projection: keep the canonical usage capability on the same single
288    // finalized-turn choke-point without feeding back into billing or ledger
289    // aggregation. Invalid/missing lineage stays explicitly unmanaged.
290    project_ocla_usage(u, OclaRegistry::global().usage_sink.as_ref());
291
292    // Budget windows (enterprise#25): book this turn's measured cost against
293    // the person/day and project/month accumulators the policy gate checks.
294    // The provider's own reported charge wins (#1179); local turns book the
295    // shadow rate — the same valuation the usage store applies — so
296    // local-only budgets stay meaningful.
297    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    // Mechanism attribution into the local savings ledger (enterprise#19).
323    // Routing: the gateway served a cheaper model than requested — value the
324    // rate delta on the measured input tokens. Caching: provider prompt-cache
325    // reads billed below the input rate. Both best-effort, never blocking.
326    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/// Live output-savings cohort totals (#895). Empty until a holdout runs.
367#[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/// Cross-process read of the persisted output-savings cohort totals.
376#[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
390/// Live per-model measured spend, priced and sorted by USD descending.
391pub 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
398/// Total measured spend across all models (live in-memory totals).
399pub fn total_cost_usd() -> f64 {
400    snapshot().iter().map(|m| m.cost_usd).sum()
401}
402
403/// Cross-model verified-savings totals (#701) for `/status` and the dashboard.
404#[derive(Debug, Clone, Serialize, PartialEq)]
405pub struct VerifiedSavings {
406    /// Requests covered by a successful `count_tokens` probe.
407    pub requests: u64,
408    /// Provider-counted input tokens those requests would have billed
409    /// without lean-ctx.
410    pub counterfactual_input_tokens: u64,
411    /// Input-side tokens (input + cache read + cache write) they actually
412    /// billed.
413    pub billed_input_tokens: u64,
414    /// `counterfactual - billed`; negative when stub overhead outweighed the
415    /// squeeze — reported honestly, never clamped.
416    pub verified_saved_tokens: i64,
417}
418
419/// Aggregated provider-verified savings across all models (#701), or `None`
420/// until at least one probe-covered request has been recorded. Unlike the
421/// `tokens_saved` estimate (bytes/4), both sides of this pair were counted by
422/// the provider on the same request — receipts, not estimates.
423pub 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/// Pure aggregation behind [`verified_savings`], shared with tests.
431#[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
447/// Prices a model usage map into sorted [`ModelSpend`] rows. Pure: shared by the
448/// in-memory snapshot and the cross-process [`persisted_snapshot`].
449pub 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    // Measured-first (#1179): turns whose response carried the provider's own
466    // USD charge book that exact amount; only the remainder is table-priced.
467    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        // Fully measured spend is never an estimate, whatever the table says.
489        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
499/// Atomically writes the current in-memory totals to disk.
500fn 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
533/// Cross-process read of the persisted measured spend (dashboard / CLI / ledger).
534pub 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
540/// Cross-process priced spend rows, read from disk.
541pub fn persisted_snapshot() -> Vec<ModelSpend> {
542    load_persisted()
543        .map(|p| price_models(&p.models))
544        .unwrap_or_default()
545}
546
547/// The model carrying the most measured tokens (excludes the "unknown" bucket).
548/// Used to value savings against the real dominant model when no explicit model
549/// is configured.
550pub 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    /// #701: the verified pair is recorded only when the probe answered — both
581    /// sides of the pair or neither, never a half row.
582    #[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        // billed side = input + cache read + cache write of the same turn.
599        assert_eq!(acc.counterfactual_billed_tokens, 1_000 + 300 + 200);
600
601        // Empty slot (probe failed / still in flight) → row degrades to the
602        // estimate: no pair recorded, normal usage still counted.
603        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        // No wire context at all (tests / non-forward paths) → no pair.
615        acc.add(&usage("claude-sonnet-4.5", 10, 0, 0));
616        assert_eq!(acc.counterfactual_requests, 1);
617    }
618
619    /// #701: cross-model aggregation and the honest signed difference.
620    #[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        // Stub overhead outweighed the squeeze on this model: billed MORE
635        // than the counterfactual. The total must subtract honestly.
636        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    /// #701: persisted usage files from before the feature load cleanly and
665    /// the new fields round-trip.
666    #[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        // input 3.00 + output 15.00 + cache_read 0.30 (per 1M) = 18.30.
699        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    /// #1179: turns carrying the provider's own charge book that USD; only the
721    /// remaining (unmeasured) turns are table-priced — and a fully measured
722    /// model is never flagged as estimated, even when the table has no entry.
723    /// (Model name chosen to never hit the embedded or live price tables.)
724    #[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        // A second, unmeasured turn on the same model: its tokens are priced
747        // from the table ON TOP of the measured USD, never double-counted
748        // (10k tokens at the $2.50/M blended fallback ≈ $0.025).
749        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        // proxy_usage.json written before #895 has no `cohorts` key; serde(default)
826        // must backfill an empty map so old files stay loadable.
827        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}