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}