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}
32
33impl ModelUsage {
34    fn add(&mut self, u: &super::usage::RealUsage) {
35        self.requests += 1;
36        self.input_tokens += u.input_tokens;
37        self.output_tokens += u.output_tokens;
38        self.cache_read_tokens += u.cache_read_tokens;
39        self.cache_write_tokens += u.cache_write_tokens;
40        self.reasoning_tokens += u.reasoning_tokens;
41    }
42
43    fn billable_tokens(&self) -> u64 {
44        self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
45    }
46}
47
48/// One model's measured, priced spend for `/status` and the dashboard.
49#[derive(Debug, Clone, Serialize, PartialEq)]
50pub struct ModelSpend {
51    pub model: String,
52    pub requests: u64,
53    pub input_tokens: u64,
54    pub output_tokens: u64,
55    pub cache_read_tokens: u64,
56    pub cache_write_tokens: u64,
57    pub reasoning_tokens: u64,
58    pub cost_usd: f64,
59    /// True when pricing came from a heuristic/fallback match, not an exact one.
60    pub pricing_estimated: bool,
61}
62
63/// Cumulative output-savings cohort totals (#895 Track B). Keyed by arm name
64/// (`"control"` | `"treatment"`); the average output tokens per turn is
65/// `output_tokens / requests`. Only populated while a holdout is active.
66#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
67pub struct CohortUsage {
68    pub requests: u64,
69    pub input_tokens: u64,
70    pub output_tokens: u64,
71    /// Sum of squared per-turn output tokens, enabling an online sample variance
72    /// (and therefore a confidence interval) without retaining every turn.
73    /// `#[serde(default)]` keeps pre-#895 files loadable.
74    #[serde(default)]
75    pub sum_sq_output: u64,
76}
77
78impl CohortUsage {
79    fn add(&mut self, u: &super::usage::RealUsage) {
80        self.requests += 1;
81        self.input_tokens += u.input_tokens;
82        self.output_tokens += u.output_tokens;
83        self.sum_sq_output += u.output_tokens.saturating_mul(u.output_tokens);
84    }
85
86    /// Average output tokens per turn, or `None` with no observations.
87    #[must_use]
88    pub fn avg_output(&self) -> Option<f64> {
89        if self.requests == 0 {
90            None
91        } else {
92            #[allow(clippy::cast_precision_loss)]
93            Some(self.output_tokens as f64 / self.requests as f64)
94        }
95    }
96
97    /// Unbiased sample variance of per-turn output tokens, or `None` with < 2
98    /// observations. Computed from the running sum / sum-of-squares (clamped at
99    /// 0 to absorb floating-point error on near-constant samples).
100    #[must_use]
101    pub fn variance_output(&self) -> Option<f64> {
102        if self.requests < 2 {
103            return None;
104        }
105        #[allow(clippy::cast_precision_loss)]
106        let n = self.requests as f64;
107        #[allow(clippy::cast_precision_loss)]
108        let sum = self.output_tokens as f64;
109        #[allow(clippy::cast_precision_loss)]
110        let sum_sq = self.sum_sq_output as f64;
111        let var = (sum_sq - sum * sum / n) / (n - 1.0);
112        Some(var.max(0.0))
113    }
114}
115
116/// On-disk shape of the measured spend totals.
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct PersistedUsage {
119    pub ts: u64,
120    pub models: HashMap<String, ModelUsage>,
121    /// Output-savings cohort totals (#895). `#[serde(default)]` keeps older
122    /// `proxy_usage.json` files (written before the holdout existed) loadable.
123    #[serde(default)]
124    pub cohorts: HashMap<String, CohortUsage>,
125}
126
127/// Distinct-model bucket cap. `record` keys on the raw response model string, so
128/// overflow folds into "unknown" to keep the map bounded (real model names < ~50).
129const MAX_TRACKED_MODELS: usize = 256;
130
131const PROXY_USAGE_FILE: &str = "proxy_usage.json";
132
133fn store() -> &'static Mutex<HashMap<String, ModelUsage>> {
134    static STORE: OnceLock<Mutex<HashMap<String, ModelUsage>>> = OnceLock::new();
135    STORE.get_or_init(|| Mutex::new(HashMap::new()))
136}
137
138fn cohort_store() -> &'static Mutex<HashMap<String, CohortUsage>> {
139    static STORE: OnceLock<Mutex<HashMap<String, CohortUsage>>> = OnceLock::new();
140    STORE.get_or_init(|| Mutex::new(HashMap::new()))
141}
142
143/// Seeds the in-memory totals from `proxy_usage.json`. Call once on proxy
144/// startup so measured spend is cumulative across restarts. Idempotent-ish: it
145/// merges the persisted totals into whatever is in memory (normally empty).
146pub fn resume_from_disk() {
147    let Some(persisted) = load_persisted() else {
148        return;
149    };
150    let mut map = store()
151        .lock()
152        .unwrap_or_else(std::sync::PoisonError::into_inner);
153    for (model, usage) in persisted.models {
154        let acc = map.entry(model).or_default();
155        acc.requests += usage.requests;
156        acc.input_tokens += usage.input_tokens;
157        acc.output_tokens += usage.output_tokens;
158        acc.cache_read_tokens += usage.cache_read_tokens;
159        acc.cache_write_tokens += usage.cache_write_tokens;
160        acc.reasoning_tokens += usage.reasoning_tokens;
161    }
162    drop(map);
163    let mut cohorts = cohort_store()
164        .lock()
165        .unwrap_or_else(std::sync::PoisonError::into_inner);
166    for (arm, usage) in persisted.cohorts {
167        let acc = cohorts.entry(arm).or_default();
168        acc.requests += usage.requests;
169        acc.input_tokens += usage.input_tokens;
170        acc.output_tokens += usage.output_tokens;
171    }
172}
173
174/// Records one turn's measured usage against its model bucket (and its
175/// output-savings cohort, when tagged) and persists.
176pub fn record(u: &super::usage::RealUsage) {
177    let key = normalize_key(&u.model);
178    {
179        let mut map = store()
180            .lock()
181            .unwrap_or_else(std::sync::PoisonError::into_inner);
182        let key = if !map.contains_key(&key) && map.len() >= MAX_TRACKED_MODELS {
183            "unknown".to_string()
184        } else {
185            key
186        };
187        map.entry(key).or_default().add(u);
188    }
189    if let Some(arm) = u.cohort {
190        let mut cohorts = cohort_store()
191            .lock()
192            .unwrap_or_else(std::sync::PoisonError::into_inner);
193        cohorts.entry(arm.as_str().to_string()).or_default().add(u);
194    }
195    persist();
196}
197
198/// Live output-savings cohort totals (#895). Empty until a holdout runs.
199#[must_use]
200pub fn cohort_snapshot() -> HashMap<String, CohortUsage> {
201    cohort_store()
202        .lock()
203        .unwrap_or_else(std::sync::PoisonError::into_inner)
204        .clone()
205}
206
207/// Cross-process read of the persisted output-savings cohort totals.
208#[must_use]
209pub fn persisted_cohorts() -> HashMap<String, CohortUsage> {
210    load_persisted().map(|p| p.cohorts).unwrap_or_default()
211}
212
213fn normalize_key(model: &str) -> String {
214    let m = model.trim();
215    if m.is_empty() {
216        "unknown".to_string()
217    } else {
218        m.to_string()
219    }
220}
221
222/// Live per-model measured spend, priced and sorted by USD descending.
223pub fn snapshot() -> Vec<ModelSpend> {
224    let map = store()
225        .lock()
226        .unwrap_or_else(std::sync::PoisonError::into_inner);
227    price_models(&map)
228}
229
230/// Total measured spend across all models (live in-memory totals).
231pub fn total_cost_usd() -> f64 {
232    snapshot().iter().map(|m| m.cost_usd).sum()
233}
234
235/// Prices a model usage map into sorted [`ModelSpend`] rows. Pure: shared by the
236/// in-memory snapshot and the cross-process [`persisted_snapshot`].
237pub fn price_models(map: &HashMap<String, ModelUsage>) -> Vec<ModelSpend> {
238    let pricing = ModelPricing::load();
239    let mut rows: Vec<ModelSpend> = map
240        .iter()
241        .map(|(model, usage)| price_one(&pricing, model, usage))
242        .collect();
243    rows.sort_by(|a, b| {
244        b.cost_usd
245            .partial_cmp(&a.cost_usd)
246            .unwrap_or(std::cmp::Ordering::Equal)
247    });
248    rows
249}
250
251fn price_one(pricing: &ModelPricing, model: &str, usage: &ModelUsage) -> ModelSpend {
252    let quote = pricing.quote(Some(model));
253    let cost_usd = quote.cost.estimate_usd(
254        usage.input_tokens,
255        usage.output_tokens,
256        usage.cache_write_tokens,
257        usage.cache_read_tokens,
258    );
259    ModelSpend {
260        model: model.to_string(),
261        requests: usage.requests,
262        input_tokens: usage.input_tokens,
263        output_tokens: usage.output_tokens,
264        cache_read_tokens: usage.cache_read_tokens,
265        cache_write_tokens: usage.cache_write_tokens,
266        reasoning_tokens: usage.reasoning_tokens,
267        cost_usd,
268        pricing_estimated: !matches!(quote.match_kind, PricingMatchKind::Exact),
269    }
270}
271
272fn usage_path() -> Option<std::path::PathBuf> {
273    crate::core::data_dir::lean_ctx_data_dir()
274        .ok()
275        .map(|d| d.join(PROXY_USAGE_FILE))
276}
277
278/// Atomically writes the current in-memory totals to disk.
279fn persist() {
280    let Some(path) = usage_path() else {
281        return;
282    };
283    let models = {
284        let map = store()
285            .lock()
286            .unwrap_or_else(std::sync::PoisonError::into_inner);
287        map.clone()
288    };
289    let cohorts = {
290        let map = cohort_store()
291            .lock()
292            .unwrap_or_else(std::sync::PoisonError::into_inner);
293        map.clone()
294    };
295    let payload = PersistedUsage {
296        ts: std::time::SystemTime::now()
297            .duration_since(std::time::UNIX_EPOCH)
298            .unwrap_or_default()
299            .as_secs(),
300        models,
301        cohorts,
302    };
303    let Ok(json) = serde_json::to_string(&payload) else {
304        return;
305    };
306    let tmp = path.with_extension("json.tmp");
307    if std::fs::write(&tmp, json).is_ok() {
308        let _ = std::fs::rename(&tmp, &path);
309    }
310}
311
312/// Cross-process read of the persisted measured spend (dashboard / CLI / ledger).
313pub fn load_persisted() -> Option<PersistedUsage> {
314    let path = usage_path()?;
315    let data = std::fs::read_to_string(path).ok()?;
316    serde_json::from_str(&data).ok()
317}
318
319/// Cross-process priced spend rows, read from disk.
320pub fn persisted_snapshot() -> Vec<ModelSpend> {
321    load_persisted()
322        .map(|p| price_models(&p.models))
323        .unwrap_or_default()
324}
325
326/// The model carrying the most measured tokens (excludes the "unknown" bucket).
327/// Used to value savings against the real dominant model when no explicit model
328/// is configured.
329pub fn persisted_dominant_model() -> Option<String> {
330    let persisted = load_persisted()?;
331    persisted
332        .models
333        .iter()
334        .filter(|(m, _)| m.as_str() != "unknown" && !m.trim().is_empty())
335        .max_by_key(|(_, u)| u.billable_tokens())
336        .filter(|(_, u)| u.billable_tokens() > 0)
337        .map(|(m, _)| m.clone())
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn usage(
345        model: &str,
346        input: u64,
347        output: u64,
348        cache_read: u64,
349    ) -> super::super::usage::RealUsage {
350        super::super::usage::RealUsage {
351            model: model.to_string(),
352            input_tokens: input,
353            output_tokens: output,
354            cache_read_tokens: cache_read,
355            cache_write_tokens: 0,
356            reasoning_tokens: 0,
357            cohort: None,
358        }
359    }
360
361    #[test]
362    fn prices_known_model_with_cache_split() {
363        let mut map = HashMap::new();
364        let mut acc = ModelUsage::default();
365        acc.add(&usage("claude-sonnet-4.5", 1_000_000, 1_000_000, 1_000_000));
366        map.insert("claude-sonnet-4.5".to_string(), acc);
367
368        let rows = price_models(&map);
369        assert_eq!(rows.len(), 1);
370        let row = &rows[0];
371        // input 3.00 + output 15.00 + cache_read 0.30 (per 1M) = 18.30.
372        assert!(
373            (row.cost_usd - 18.30).abs() < 1e-6,
374            "cost was {}",
375            row.cost_usd
376        );
377        assert!(!row.pricing_estimated, "exact model match");
378        assert_eq!(row.requests, 1);
379    }
380
381    #[test]
382    fn unknown_model_prices_with_fallback_and_is_estimated() {
383        let mut map = HashMap::new();
384        let mut acc = ModelUsage::default();
385        acc.add(&usage("some-novel-model-xyz", 1_000_000, 0, 0));
386        map.insert("some-novel-model-xyz".to_string(), acc);
387
388        let rows = price_models(&map);
389        assert!(rows[0].pricing_estimated, "fallback pricing is estimated");
390        assert!(rows[0].cost_usd > 0.0);
391    }
392
393    #[test]
394    fn dominant_model_picks_highest_token_real_model() {
395        let mut models = HashMap::new();
396        models.insert("claude-haiku-4.5".to_string(), {
397            let mut u = ModelUsage::default();
398            u.add(&usage("claude-haiku-4.5", 100, 100, 0));
399            u
400        });
401        models.insert("claude-opus-4.5".to_string(), {
402            let mut u = ModelUsage::default();
403            u.add(&usage("claude-opus-4.5", 10_000, 10_000, 0));
404            u
405        });
406        models.insert("unknown".to_string(), {
407            let mut u = ModelUsage::default();
408            u.add(&usage("unknown", 999_999, 0, 0));
409            u
410        });
411        let dominant = models
412            .iter()
413            .filter(|(m, _)| m.as_str() != "unknown")
414            .max_by_key(|(_, u)| u.billable_tokens())
415            .map(|(m, _)| m.clone());
416        assert_eq!(dominant.as_deref(), Some("claude-opus-4.5"));
417    }
418
419    #[test]
420    fn empty_model_buckets_as_unknown() {
421        assert_eq!(normalize_key("  "), "unknown");
422        assert_eq!(normalize_key(""), "unknown");
423        assert_eq!(normalize_key("gpt-5.4"), "gpt-5.4");
424    }
425
426    #[test]
427    fn cohort_avg_output_is_mean_per_turn() {
428        let mut c = CohortUsage::default();
429        assert_eq!(c.avg_output(), None, "no observations → None");
430        c.add(&usage("m", 10, 100, 0));
431        c.add(&usage("m", 10, 50, 0));
432        assert_eq!(c.requests, 2);
433        assert_eq!(c.output_tokens, 150);
434        assert!((c.avg_output().unwrap() - 75.0).abs() < f64::EPSILON);
435    }
436
437    #[test]
438    fn persisted_usage_without_cohorts_field_loads() {
439        // proxy_usage.json written before #895 has no `cohorts` key; serde(default)
440        // must backfill an empty map so old files stay loadable.
441        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}}}"#;
442        let p: PersistedUsage = serde_json::from_str(json).expect("loads legacy file");
443        assert_eq!(p.models.len(), 1);
444        assert!(p.cohorts.is_empty());
445    }
446
447    #[test]
448    fn persisted_usage_roundtrips_cohorts() {
449        let mut p = PersistedUsage::default();
450        p.cohorts.insert(
451            "control".into(),
452            CohortUsage {
453                requests: 3,
454                input_tokens: 30,
455                output_tokens: 300,
456                sum_sq_output: 30_000,
457            },
458        );
459        let json = serde_json::to_string(&p).unwrap();
460        let back: PersistedUsage = serde_json::from_str(&json).unwrap();
461        assert_eq!(back.cohorts.get("control").unwrap().output_tokens, 300);
462    }
463}