use std::collections::BTreeMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use once_cell::sync::Lazy;
static CALLS: AtomicU64 = AtomicU64::new(0);
static CACHE_HITS: AtomicU64 = AtomicU64::new(0);
static PROMPT_TOKENS: AtomicU64 = AtomicU64::new(0);
static COMPLETION_TOKENS: AtomicU64 = AtomicU64::new(0);
static NETWORK_TIME_MS: AtomicU64 = AtomicU64::new(0);
static CALLS_WITHOUT_USAGE: AtomicU64 = AtomicU64::new(0);
static BY_CATEGORY: Lazy<Mutex<BTreeMap<String, CategoryUsage>>> = Lazy::new(|| Mutex::new(BTreeMap::new()));
static MODEL: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
#[derive(Debug, Clone, Default)]
pub struct CategoryUsage {
pub calls: u64,
pub cache_hits: u64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub network_time_ms: u64,
}
pub fn note_model(model: &str) {
if model.trim().is_empty() {
return;
}
if let Ok(mut m) = MODEL.lock()
&& m.is_none()
{
*m = Some(model.to_string());
}
}
pub fn model_name() -> Option<String> {
MODEL.lock().ok().and_then(|m| m.clone())
}
pub fn record(
category: &str,
prompt_tokens: u64,
completion_tokens: u64,
elapsed_ms: u64,
from_cache: bool,
tokens_reported: bool,
) {
CALLS.fetch_add(1, Ordering::Relaxed);
PROMPT_TOKENS.fetch_add(prompt_tokens, Ordering::Relaxed);
COMPLETION_TOKENS.fetch_add(completion_tokens, Ordering::Relaxed);
if from_cache {
CACHE_HITS.fetch_add(1, Ordering::Relaxed);
} else {
NETWORK_TIME_MS.fetch_add(elapsed_ms, Ordering::Relaxed);
}
if !tokens_reported {
CALLS_WITHOUT_USAGE.fetch_add(1, Ordering::Relaxed);
}
if let Ok(mut map) = BY_CATEGORY.lock() {
let e = map.entry(category.to_string()).or_default();
e.calls += 1;
e.prompt_tokens += prompt_tokens;
e.completion_tokens += completion_tokens;
if from_cache {
e.cache_hits += 1;
} else {
e.network_time_ms += elapsed_ms;
}
}
}
pub fn categories() -> Vec<(String, CategoryUsage)> {
let mut v: Vec<(String, CategoryUsage)> = BY_CATEGORY
.lock()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
v.sort_by(|a, b| {
let (ta, tb) = (
a.1.prompt_tokens + a.1.completion_tokens,
b.1.prompt_tokens + b.1.completion_tokens,
);
tb.cmp(&ta).then_with(|| a.0.cmp(&b.0))
});
v
}
pub fn breakdown_lines() -> Vec<String> {
categories()
.into_iter()
.filter(|(_, u)| u.calls > 0)
.map(|(name, u)| format_category_line(&name, &u))
.collect()
}
fn format_category_line(name: &str, u: &CategoryUsage) -> String {
let cache = if u.cache_hits > 0 {
format!(", {} from cache", u.cache_hits)
} else {
String::new()
};
format!(
"AI tokens — {}: {} request(s){}, input {} tokens, output {} tokens",
name,
u.calls,
cache,
format_count(u.prompt_tokens),
format_count(u.completion_tokens),
)
}
#[derive(Debug, Clone, Copy)]
pub struct UsageSnapshot {
pub calls: u64,
pub cache_hits: u64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub network_time_s: f64,
pub calls_without_usage: u64,
}
pub fn snapshot() -> UsageSnapshot {
UsageSnapshot {
calls: CALLS.load(Ordering::Relaxed),
cache_hits: CACHE_HITS.load(Ordering::Relaxed),
prompt_tokens: PROMPT_TOKENS.load(Ordering::Relaxed),
completion_tokens: COMPLETION_TOKENS.load(Ordering::Relaxed),
network_time_s: NETWORK_TIME_MS.load(Ordering::Relaxed) as f64 / 1000.0,
calls_without_usage: CALLS_WITHOUT_USAGE.load(Ordering::Relaxed),
}
}
pub fn format_count(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.3}M ({})", n as f64 / 1_000_000.0, n)
} else if n >= 1_000 {
format!("{:.1}k ({})", n as f64 / 1_000.0, n)
} else {
n.to_string()
}
}
pub fn summary_line() -> Option<String> {
let s = snapshot();
if s.calls == 0 {
return None;
}
let cache = if s.cache_hits > 0 {
format!(" ({} served from cache)", s.cache_hits)
} else {
String::new()
};
let model = model_name().map(|m| format!(" using {}", m)).unwrap_or_default();
if s.prompt_tokens == 0 && s.completion_tokens == 0 {
return Some(format!(
"AI usage: {} LLM call(s){} in {:.1}s{} — token usage not reported by the provider.",
s.calls, cache, s.network_time_s, model
));
}
let partial = if s.calls_without_usage > 0 {
format!(" ({} call(s) without token data)", s.calls_without_usage)
} else {
String::new()
};
Some(format!(
"AI usage: {} LLM call(s){} in {:.1}s{} — input {} tokens, output {} tokens (incl. reasoning){}",
s.calls,
cache,
s.network_time_s,
model,
format_count(s.prompt_tokens),
format_count(s.completion_tokens),
partial,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_count_buckets() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(999), "999");
assert_eq!(format_count(12_345), "12.3k (12345)");
assert_eq!(format_count(1_245_000), "1.245M (1245000)");
assert_eq!(format_count(2_500_000), "2.500M (2500000)");
}
#[test]
fn format_category_line_shape() {
let u = CategoryUsage {
calls: 12,
cache_hits: 0,
prompt_tokens: 40_079,
completion_tokens: 4_609,
network_time_ms: 0,
};
let line = format_category_line("SEO analysis", &u);
assert!(line.contains("SEO analysis"));
assert!(line.contains("12 request(s)"));
assert!(line.contains("input 40.1k"));
assert!(line.contains("output 4.6k"));
assert!(!line.contains("from cache"));
}
#[test]
fn format_category_line_with_cache() {
let u = CategoryUsage {
calls: 5,
cache_hits: 2,
prompt_tokens: 100,
completion_tokens: 50,
network_time_ms: 0,
};
let line = format_category_line("Custom check", &u);
assert!(line.contains("2 from cache"));
}
}