Skip to main content

lean_ctx_proxy/
usage_meter.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Measured per-model spend meter.
3//!
4//! [`record`] aggregates the real provider usage extracted by [`super::usage`]
5//! into per-model token sums, prices them with the shared
6//! `ModelPricing` table, and
7//! persists the totals to `proxy_usage.json` so the dashboard, CLI and the
8//! savings ledger (which run in *other* processes) can read the user's real
9//! provider bill.
10//!
11//! Unlike [`lean_ctx::core::proxy_metrics`] (which resets per proxy lifetime), this meter is a
12//! lifetime-cumulative spend counter: [`resume_from_disk`] seeds the in-memory
13//! totals on proxy startup so a restart never zeroes the user's measured spend.
14
15use 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
27/// Projects one managed, provider-measured turn into the OCLA usage sink.
28///
29/// This is deliberately one-way and best-effort: the existing meter remains
30/// authoritative for billing/ledger totals, while OCLA receives a validated
31/// payload-free projection only when complete lineage is present.
32fn 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
40/// Adds one finalized turn to a running total. The totals themselves are the
41/// on-disk record of [`lean_ctx::core::proxy_usage`]; only the meter sees a turn.
42trait 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        // Verified-savings pair (#701): only when the probe answered by the
68        // time the billed usage arrived — both sides of the pair or neither.
69        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/// One model's measured, priced spend for `/status` and the dashboard.
99#[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    /// Share of `cost_usd` the provider itself reported (OpenRouter usage
110    /// accounting, #1179) — a bill, not a table estimate.
111    pub measured_cost_usd: f64,
112    /// Requests covered by that provider-reported cost.
113    pub measured_requests: u64,
114    /// True when part of the cost had to be priced from a heuristic/fallback
115    /// match — never set by measured or exactly-priced spend.
116    pub pricing_estimated: bool,
117}
118
119/// Cohort-store key for an input-compression holdout arm (#1905). Prefixed so
120/// it never collides with the output-savings arms stored as `control` /
121/// `treatment`, which keeps existing files and readers unchanged.
122#[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
132/// Distinct-model bucket cap. `record` keys on the raw response model string, so
133/// overflow folds into "unknown" to keep the map bounded (real model names < ~50).
134const 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
141/// Distinct projects kept apart; turns of further projects stay in the device total.
142const 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
154/// App sessions kept apart; the least recently seen one gives way.
155const 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
162/// Book `u` (model bucket `key`) on app session `session` at `now`; when
163/// the map is full the least recently seen session (ties: smallest id) is
164/// dropped first.
165fn 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
186/// Seeds the in-memory totals from `proxy_usage.json`. Call once on proxy
187/// startup so measured spend is cumulative across restarts. Idempotent-ish: it
188/// merges the persisted totals into whatever is in memory (normally empty).
189pub 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
249/// Records one turn's measured usage against its model bucket (and its
250/// output-savings cohort, when tagged) and persists.
251pub fn record(u: &super::usage::RealUsage) {
252    // Gateway store subscription (enterprise#17): forward the full record to
253    // the installed sink (no-op locally). Never blocks the request path.
254    super::usage_sink::push(u);
255
256    // R33: Feed measured usage into kernel envelope pipeline.
257    {
258        let label = u.wire.as_ref().map_or("Unknown", |w| {
259            // provider_kind_from_label expects a static or owned string;
260            // the label lives long enough for the conversion call.
261            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    // OCLA projection: keep the canonical usage capability on the same single
275    // finalized-turn choke-point without feeding back into billing or ledger
276    // aggregation. Invalid/missing lineage stays explicitly unmanaged.
277    project_ocla_usage(u, OclaRegistry::global().usage_sink.as_ref());
278
279    // Budget windows (enterprise#25): book this turn's measured cost against
280    // the person/day and project/month accumulators the policy gate checks.
281    // The provider's own reported charge wins (#1179); local turns book the
282    // shadow rate — the same valuation the usage store applies — so
283    // local-only budgets stay meaningful.
284    #[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    // Cache-share guard (hc25): per provider+model cost log, rolling
314    // cache-read share and its alarm.
315    let pricing = ModelPricing::load();
316    super::cache_share::record_turn(u, &pricing);
317
318    // Mechanism attribution into the local savings ledger (enterprise#19):
319    // provider prompt-cache reads billed below the input rate. Best-effort,
320    // never blocking. A model swap is never booked as a LeanCTX saving.
321    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    // Remember the foreground model for attribution in other processes
335    // (savings ledger). Background models never count as "the model in use".
336    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/// Live output-savings cohort totals (#895). Empty until a holdout runs.
406#[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/// Cross-process read of the persisted output-savings cohort totals.
415#[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
429/// Live per-model measured spend, priced and sorted by USD descending.
430pub 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
437/// Total measured spend across all models (live in-memory totals).
438pub fn total_cost_usd() -> f64 {
439    snapshot().iter().map(|m| m.cost_usd).sum()
440}
441
442/// Cross-model verified-savings totals (#701) for `/status` and the dashboard.
443#[derive(Debug, Clone, Serialize, PartialEq)]
444pub struct VerifiedSavings {
445    /// Requests covered by a successful `count_tokens` probe.
446    pub requests: u64,
447    /// Provider-counted input tokens those requests would have billed
448    /// without lean-ctx.
449    pub counterfactual_input_tokens: u64,
450    /// Input-side tokens (input + cache read + cache write) they actually
451    /// billed.
452    pub billed_input_tokens: u64,
453    /// `counterfactual - billed`; negative when stub overhead outweighed the
454    /// squeeze — reported honestly, never clamped.
455    pub verified_saved_tokens: i64,
456}
457
458/// Aggregated provider-verified savings across all models (#701), or `None`
459/// until at least one probe-covered request has been recorded. Unlike the
460/// `tokens_saved` estimate (bytes/4), both sides of this pair were counted by
461/// the provider on the same request — receipts, not estimates.
462pub 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/// Pure aggregation behind [`verified_savings`], shared with tests.
470#[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
486/// Prices a model usage map into sorted [`ModelSpend`] rows. Pure: shared by the
487/// in-memory snapshot and the cross-process [`persisted_snapshot`].
488pub 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    // Measured-first (#1179): turns whose response carried the provider's own
505    // USD charge book that exact amount; only the remainder is table-priced.
506    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        // Fully measured spend is never an estimate, whatever the table says.
528        pricing_estimated: quote.match_kind.is_estimated() && derived_requests > 0,
529    }
530}
531
532/// Atomically writes the current in-memory totals to disk.
533fn 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
581/// Cross-process priced spend rows, read from disk.
582pub fn persisted_snapshot() -> Vec<ModelSpend> {
583    load_persisted()
584        .map(|p| price_models(&p.models))
585        .unwrap_or_default()
586}
587
588/// Priced spend rows of the projects whose key `keep` accepts, summed per model.
589pub 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
602/// Priced spend per project key (the device view's split).
603pub 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
616/// Priced spend of one app session, read from disk; `None` when the proxy
617/// saw no turn of it (or keeps it no longer).
618pub 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    /// #895: resuming from disk must carry the sums of squares and prompt
645    /// totals; dropping them zeroed the variance after every proxy restart and
646    /// turned the CI into a zero-width "measurement".
647    #[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        // s0 is touched again: it is no longer the least recently seen.
655        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        // Merging into live in-memory totals adds field by field.
679        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    /// A turn whose request named a project is booked to the device total and
689    /// to that project; one that named none to the device total only. Both
690    /// survive the file round trip.
691    #[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    /// 3.12.1: counting each proxied request once in the kernel evidence must
727    /// not rewrite what the savings ledger already holds. A pre-existing legacy
728    /// entry and the caching event of an earlier turn keep their exact bytes and
729    /// still verify; the next turn appends exactly one event.
730    #[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    /// #701: the verified pair is recorded only when the probe answered — both
775    /// sides of the pair or neither, never a half row.
776    #[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        // billed side = input + cache read + cache write of the same turn.
793        assert_eq!(acc.counterfactual_billed_tokens, 1_000 + 300 + 200);
794
795        // Empty slot (probe failed / still in flight) → row degrades to the
796        // estimate: no pair recorded, normal usage still counted.
797        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        // No wire context at all (tests / non-forward paths) → no pair.
809        acc.add(&usage("claude-sonnet-4.5", 10, 0, 0));
810        assert_eq!(acc.counterfactual_requests, 1);
811    }
812
813    /// #701: cross-model aggregation and the honest signed difference.
814    #[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        // Stub overhead outweighed the squeeze on this model: billed MORE
829        // than the counterfactual. The total must subtract honestly.
830        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    /// #701: persisted usage files from before the feature load cleanly and
859    /// the new fields round-trip.
860    #[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        // input 3.00 + output 15.00 + cache_read 0.30 (per 1M) = 18.30.
893        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    /// #1179: turns carrying the provider's own charge book that USD; only the
915    /// remaining (unmeasured) turns are table-priced — and a fully measured
916    /// model is never flagged as estimated, even when the table has no entry.
917    /// (Model name chosen to never hit the embedded or live price tables.)
918    #[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        // A second, unmeasured turn on the same model: its tokens are priced
941        // from the table ON TOP of the measured USD, never double-counted
942        // (10k tokens at the $2.50/M blended fallback ≈ $0.025).
943        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        // proxy_usage.json written before #895 has no `cohorts` key; serde(default)
1020        // must backfill an empty map so old files stay loadable.
1021        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    /// Counts the records the global registry's usage sink receives.
1087    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}