1#![doc = include_str!("../README.md")]
9
10mod log_scraper;
11mod provider_api;
12mod savings;
13
14pub use log_scraper::{scrape_claude_logs, TokenCounts};
15
16use anyhow::Result;
17use cortex_rs_core::{Db, CortexConfig};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21pub struct StatsEngine {
22 config: CortexConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct UsageSummary {
27 pub days: u32,
28 pub by_model: Vec<ModelUsage>,
29 pub total_cost_usd: f64,
30 pub routing_savings_usd: f64,
31 pub source: StatsSource,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ModelUsage {
36 pub model: String,
37 pub input_tokens: u64,
38 pub output_tokens: u64,
39 #[serde(default)]
40 pub cache_creation_input_tokens: u64,
41 #[serde(default)]
42 pub cache_read_input_tokens: u64,
43 pub cost_usd: f64,
44 pub pct: f64,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub enum StatsSource {
49 ProviderApi,
51 LocalLogScraper,
53 Unavailable,
55}
56
57impl StatsEngine {
58 pub fn new(config: CortexConfig) -> Self {
59 StatsEngine { config }
60 }
61
62 pub async fn fetch_summary(&self, days: u32) -> Result<UsageSummary> {
63 let mut api_models: Vec<ModelUsage> = Vec::new();
66 let mut used_api = false;
67
68 if let Some(key) = self.admin_key("anthropic") {
69 match provider_api::fetch_anthropic(&key, days).await {
70 Ok(s) => {
71 api_models.extend(s.by_model);
72 used_api = true;
73 }
74 Err(e) => tracing::warn!("Anthropic API failed: {}", e),
75 }
76 }
77 if let Some(key) = self.admin_key("openai") {
78 match provider_api::fetch_openai(&key, days).await {
79 Ok(s) => {
80 api_models.extend(s.by_model);
81 used_api = true;
82 }
83 Err(e) => tracing::warn!("OpenAI API failed: {}", e),
84 }
85 }
86
87 if used_api {
88 for m in &mut api_models {
91 if m.cost_usd == 0.0 && (m.input_tokens > 0 || m.output_tokens > 0) {
92 m.cost_usd = self.model_cost(
93 &m.model,
94 TokenCounts {
95 input: m.input_tokens,
96 output: m.output_tokens,
97 cache_creation_input: m.cache_creation_input_tokens,
98 cache_read_input: m.cache_read_input_tokens,
99 },
100 );
101 }
102 }
103 let total = api_models.iter().map(|m| m.cost_usd).sum::<f64>();
104 for m in &mut api_models {
105 m.pct = if total > 0.0 { m.cost_usd / total * 100.0 } else { 0.0 };
106 }
107 api_models.sort_by(|a, b| {
108 b.cost_usd.partial_cmp(&a.cost_usd).unwrap_or(std::cmp::Ordering::Equal)
109 });
110 let routing_savings_usd = self.compute_savings(days).unwrap_or(0.0);
111 return Ok(UsageSummary {
112 days,
113 by_model: api_models,
114 total_cost_usd: total,
115 routing_savings_usd,
116 source: StatsSource::ProviderApi,
117 });
118 }
119
120 let raw = scrape_claude_logs(days)?;
122 if raw.is_empty() {
123 return Ok(UsageSummary {
124 days,
125 by_model: vec![],
126 total_cost_usd: 0.0,
127 routing_savings_usd: 0.0,
128 source: StatsSource::Unavailable,
129 });
130 }
131
132 let by_model = self.cost_by_model(raw);
133 let total = by_model.iter().map(|m| m.cost_usd).sum::<f64>();
134 let routing_savings_usd = self.compute_savings(days).unwrap_or(0.0);
135
136 Ok(UsageSummary {
137 days,
138 by_model,
139 total_cost_usd: total,
140 routing_savings_usd,
141 source: StatsSource::LocalLogScraper,
142 })
143 }
144
145 fn admin_key(&self, provider: &str) -> Option<String> {
146 let cfg = match provider {
147 "anthropic" => self.config.providers.anthropic.as_ref()?,
148 "openai" => self.config.providers.openai.as_ref()?,
149 _ => return None,
150 };
151 let k = cfg.admin_key.clone()?;
152 if k.is_empty() { None } else { Some(k) }
153 }
154
155 fn compute_savings(&self, days: u32) -> Result<f64> {
158 let db_path = self.config.daemon.db_path();
159 if !db_path.exists() {
160 return Ok(0.0);
161 }
162 let db = Db::open(&db_path)?;
163 savings::compute(&db.conn, &self.config, days)
164 }
165
166 fn cost_by_model(&self, raw: HashMap<String, TokenCounts>) -> Vec<ModelUsage> {
167 let total_cost: f64 = raw
168 .iter()
169 .map(|(model, counts)| self.model_cost(model, *counts))
170 .sum();
171
172 let mut usage: Vec<ModelUsage> = raw
173 .into_iter()
174 .map(|(model, counts)| {
175 let cost = self.model_cost(&model, counts);
176 ModelUsage {
177 model,
178 input_tokens: counts.input,
179 output_tokens: counts.output,
180 cache_creation_input_tokens: counts.cache_creation_input,
181 cache_read_input_tokens: counts.cache_read_input,
182 cost_usd: cost,
183 pct: if total_cost > 0.0 { cost / total_cost * 100.0 } else { 0.0 },
184 }
185 })
186 .collect();
187
188 usage.sort_by(|a, b| {
189 b.cost_usd.partial_cmp(&a.cost_usd).unwrap_or(std::cmp::Ordering::Equal)
190 });
191 usage
192 }
193
194 fn model_cost(&self, model: &str, c: TokenCounts) -> f64 {
195 if let Some(pricing) = self.config.pricing.get(model) {
196 let per = |tokens: u64, rate: f64| (tokens as f64 / 1_000_000.0) * rate;
197 per(c.input, pricing.input)
198 + per(c.output, pricing.output)
199 + per(c.cache_creation_input, pricing.cache_creation_or_default())
200 + per(c.cache_read_input, pricing.cache_read_or_default())
201 } else {
202 let per = |tokens: u64, rate: f64| (tokens as f64 / 1_000_000.0) * rate;
204 per(c.input, 3.0) + per(c.output, 15.0) + per(c.cache_read_input, 0.30)
205 }
206 }
207}