Skip to main content

lean_ctx/core/
proxy_usage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! On-disk record of the proxy's measured provider spend (`proxy_usage.json`).
3//!
4//! The proxy's usage meter (`proxy::usage_meter`) aggregates the real
5//! provider usage of every finalized turn into these per-model sums and
6//! persists them; the dashboard, CLI, stats and the savings ledger (which run
7//! in *other* processes) read them back through [`load_persisted`] to learn
8//! the user's real provider bill.
9
10use std::collections::{HashMap, VecDeque};
11
12use serde::{Deserialize, Serialize};
13
14/// Where provider usage was observed, not an assertion of answer quality or
15/// successful generation. Terminal usage also accompanies failed responses.
16#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "snake_case")]
18pub enum UsageBoundary {
19    BodyEnd,
20    ProviderTerminalUsage,
21    StreamEnd,
22    StreamError,
23}
24
25/// Payload-free local measurement. Unknown cost stays unknown; neither HTTP
26/// status nor receipt of usage supplies the private runtime's quality label.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(deny_unknown_fields)]
29pub struct ResponseObservation {
30    pub schema_version: u8,
31    pub http_status: u16,
32    /// Monotonic elapsed time from upstream dispatch (including retries) to
33    /// the indicated usage boundary, not necessarily full response latency.
34    pub usage_observed_ms: u64,
35    pub boundary: UsageBoundary,
36    pub provider_cost_usd: Option<f64>,
37}
38
39/// Cumulative real token counts for one model. Cost is derived at read time so a
40/// pricing-table change re-values historical usage consistently.
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
42pub struct ModelUsage {
43    pub requests: u64,
44    pub input_tokens: u64,
45    pub output_tokens: u64,
46    pub cache_read_tokens: u64,
47    pub cache_write_tokens: u64,
48    pub reasoning_tokens: u64,
49    /// Requests whose free `count_tokens` probe answered (#701) — the rows the
50    /// verified-savings pair below covers. `serde(default)` keeps pre-#701
51    /// usage files loadable.
52    #[serde(default)]
53    pub counterfactual_requests: u64,
54    /// Provider-counted input tokens the covered requests would have billed
55    /// WITHOUT lean-ctx (sum of probe answers on the original bodies).
56    #[serde(default)]
57    pub counterfactual_input_tokens: u64,
58    /// Input-side tokens those same requests actually billed (input +
59    /// cache read + cache write) — same request, same moment, no confound.
60    #[serde(default)]
61    pub counterfactual_billed_tokens: u64,
62    /// The turns whose response carried the provider's own USD charge
63    /// (OpenRouter usage accounting, #1179). Their tokens are *inside* the
64    /// totals above; pricing subtracts this slice and books its measured USD
65    /// instead of a table estimate. `serde(default)` keeps older files loadable.
66    #[serde(default)]
67    pub measured: MeasuredSlice,
68    /// Recent factual response measurements, not quality/training labels.
69    /// This local diagnostic history must not be pooled into private ranking:
70    /// it has no verified actor/endpoint scope or user-accepted outcome yet.
71    #[serde(default)]
72    pub response_observations: VecDeque<ResponseObservation>,
73}
74
75/// Token/cost sums of the turns that reported a measured provider charge.
76#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
77pub struct MeasuredSlice {
78    pub requests: u64,
79    pub input_tokens: u64,
80    pub output_tokens: u64,
81    pub cache_read_tokens: u64,
82    pub cache_write_tokens: u64,
83    /// Sum of provider-reported USD for those turns — the bill, not a table.
84    pub cost_usd: f64,
85}
86
87impl MeasuredSlice {
88    pub fn merge(&mut self, other: &Self) {
89        self.requests += other.requests;
90        self.input_tokens += other.input_tokens;
91        self.output_tokens += other.output_tokens;
92        self.cache_read_tokens += other.cache_read_tokens;
93        self.cache_write_tokens += other.cache_write_tokens;
94        self.cost_usd += other.cost_usd;
95    }
96}
97
98impl ModelUsage {
99    #[must_use]
100    pub fn billable_tokens(&self) -> u64 {
101        self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
102    }
103
104    pub fn add_observation(&mut self, observation: ResponseObservation) {
105        const MAX_RESPONSE_OBSERVATIONS: usize = 32;
106        self.response_observations.push_back(observation);
107        while self.response_observations.len() > MAX_RESPONSE_OBSERVATIONS {
108            self.response_observations.pop_front();
109        }
110    }
111}
112
113/// Cumulative holdout cohort totals. Keyed by arm name: `"control"` |
114/// `"treatment"` for output savings (#895 Track B), `"compression:control"` |
115/// `"compression:treatment"` for input compression (#1905). The average tokens
116/// per turn are `*_tokens / requests`. Only populated while a holdout is active.
117#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
118pub struct CohortUsage {
119    pub requests: u64,
120    pub input_tokens: u64,
121    pub output_tokens: u64,
122    /// Sum of squared per-turn output tokens, enabling an online sample variance
123    /// (and therefore a confidence interval) without retaining every turn.
124    /// `#[serde(default)]` keeps pre-#895 files loadable.
125    #[serde(default)]
126    pub sum_sq_output: u64,
127    /// Whole prompt per turn — billed input plus cache reads and writes (#1905).
128    /// Compression shrinks the prompt wherever it is billed, so the
129    /// input-compression comparison uses this, not `input_tokens` alone.
130    /// `#[serde(default)]` keeps earlier files loadable.
131    #[serde(default)]
132    pub prompt_tokens: u64,
133    /// Sum of squared per-turn `prompt_tokens`, for its confidence interval.
134    #[serde(default)]
135    pub sum_sq_prompt: u64,
136}
137
138impl CohortUsage {
139    /// Adds another cohort's running totals — every field, so the sums of
140    /// squares (and with them the variance / CI) survive a proxy restart.
141    pub fn merge(&mut self, other: &Self) {
142        self.requests += other.requests;
143        self.input_tokens += other.input_tokens;
144        self.output_tokens += other.output_tokens;
145        self.sum_sq_output = self.sum_sq_output.saturating_add(other.sum_sq_output);
146        self.prompt_tokens += other.prompt_tokens;
147        self.sum_sq_prompt = self.sum_sq_prompt.saturating_add(other.sum_sq_prompt);
148    }
149
150    /// Average output tokens per turn, or `None` with no observations.
151    #[must_use]
152    pub fn avg_output(&self) -> Option<f64> {
153        if self.requests == 0 {
154            None
155        } else {
156            #[allow(clippy::cast_precision_loss)]
157            Some(self.output_tokens as f64 / self.requests as f64)
158        }
159    }
160
161    /// Unbiased sample variance of per-turn output tokens, or `None` with < 2
162    /// observations. Computed from the running sum / sum-of-squares (clamped at
163    /// 0 to absorb floating-point error on near-constant samples).
164    #[must_use]
165    pub fn variance_output(&self) -> Option<f64> {
166        if self.requests < 2 {
167            return None;
168        }
169        #[allow(clippy::cast_precision_loss)]
170        let n = self.requests as f64;
171        #[allow(clippy::cast_precision_loss)]
172        let sum = self.output_tokens as f64;
173        #[allow(clippy::cast_precision_loss)]
174        let sum_sq = self.sum_sq_output as f64;
175        let var = (sum_sq - sum * sum / n) / (n - 1.0);
176        Some(var.max(0.0))
177    }
178}
179
180/// On-disk shape of the measured spend totals.
181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182pub struct PersistedUsage {
183    pub ts: u64,
184    pub models: HashMap<String, ModelUsage>,
185    /// Output-savings cohort totals (#895). `#[serde(default)]` keeps older
186    /// `proxy_usage.json` files (written before the holdout existed) loadable.
187    #[serde(default)]
188    pub cohorts: HashMap<String, CohortUsage>,
189    /// The foreground model of the most recent finalized turn and when it was
190    /// seen (unix seconds), so other processes can attribute work to the model
191    /// in use now rather than the all-time largest bucket.
192    #[serde(default)]
193    pub last_model: Option<LastModel>,
194    /// The same totals per project key and model (the folder the agent named
195    /// in each request). Turns that named none stay in `models` only, which
196    /// remains the device total.
197    #[serde(default)]
198    pub projects: HashMap<String, HashMap<String, ModelUsage>>,
199    /// The same totals per app session (the `x-leanctx-session-id` header a
200    /// launching app sends, see `core::activity::SESSION_HEADER`); only the
201    /// most recently used sessions are kept. Attribution only.
202    #[serde(default)]
203    pub sessions: HashMap<String, SessionUsage>,
204}
205
206/// One app session's totals per model and when the proxy last saw it.
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct SessionUsage {
209    /// Unix seconds of the session's newest turn.
210    pub last_seen: u64,
211    pub models: HashMap<String, ModelUsage>,
212}
213
214/// The model the proxy saw last, with the time it saw it.
215#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
216pub struct LastModel {
217    pub model: String,
218    pub at: u64,
219}
220
221const PROXY_USAGE_FILE: &str = "proxy_usage.json";
222
223/// Where the measured spend totals live in the data directory.
224#[must_use]
225pub fn usage_path() -> Option<std::path::PathBuf> {
226    crate::core::data_dir::lean_ctx_data_dir()
227        .ok()
228        .map(|d| d.join(PROXY_USAGE_FILE))
229}
230
231/// Cross-process read of the persisted measured spend (dashboard / CLI / ledger).
232#[must_use]
233pub fn load_persisted() -> Option<PersistedUsage> {
234    let path = usage_path()?;
235    let data = std::fs::read_to_string(path).ok()?;
236    serde_json::from_str(&data).ok()
237}
238
239/// The model carrying the most measured tokens (excludes the "unknown" bucket).
240/// Used to value savings against the real dominant model when no explicit model
241/// is configured.
242#[must_use]
243pub fn persisted_dominant_model() -> Option<String> {
244    let persisted = load_persisted()?;
245    persisted
246        .models
247        .iter()
248        .filter(|(m, _)| m.as_str() != "unknown" && !m.trim().is_empty())
249        .max_by_key(|(_, u)| u.billable_tokens())
250        .filter(|(_, u)| u.billable_tokens() > 0)
251        .map(|(m, _)| m.clone())
252}