1use std::collections::HashMap;
15use std::sync::{Mutex, OnceLock};
16
17use serde::{Deserialize, Serialize};
18
19use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
20
21#[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#[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 pub pricing_estimated: bool,
61}
62
63#[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 #[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 #[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct PersistedUsage {
119 pub ts: u64,
120 pub models: HashMap<String, ModelUsage>,
121 #[serde(default)]
124 pub cohorts: HashMap<String, CohortUsage>,
125}
126
127const 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
143pub 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
174pub 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#[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#[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
222pub 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
230pub fn total_cost_usd() -> f64 {
232 snapshot().iter().map(|m| m.cost_usd).sum()
233}
234
235pub 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
278fn 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
312pub 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
319pub fn persisted_snapshot() -> Vec<ModelSpend> {
321 load_persisted()
322 .map(|p| price_models(&p.models))
323 .unwrap_or_default()
324}
325
326pub 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 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 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}