1use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ModelCost {
11 pub model: String,
12 pub input_cost_per_1m: f64,
13 pub output_cost_per_1m: f64,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TokenUsage {
19 pub input_tokens: usize,
20 pub output_tokens: usize,
21 pub total_tokens: usize,
22}
23
24impl TokenUsage {
25 pub fn calculate_cost(&self, cost: &ModelCost) -> f64 {
26 let input_cost = (self.input_tokens as f64 / 1_000_000.0) * cost.input_cost_per_1m;
27 let output_cost = (self.output_tokens as f64 / 1_000_000.0) * cost.output_cost_per_1m;
28 input_cost + output_cost
29 }
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CostRecord {
35 pub id: String,
36 pub model: String,
37 pub input_tokens: usize,
38 pub output_tokens: usize,
39 pub cost_usd: f64,
40 #[serde(default = "default_pricing_known")]
42 pub pricing_known: bool,
43 pub timestamp: chrono::DateTime<chrono::Utc>,
44}
45
46fn default_pricing_known() -> bool {
47 true
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct ContextSnapshot {
55 pub system_chars: usize,
56 pub history_chars: usize,
57 pub tool_chars: usize,
58 pub estimated_input_tokens: usize,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct ContextSummary {
63 pub request_count: usize,
64 pub system_chars: usize,
65 pub history_chars: usize,
66 pub tool_chars: usize,
67 pub estimated_input_tokens: usize,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RateLimitStatus {
73 pub requests_limit: Option<usize>,
74 pub requests_remaining: Option<usize>,
75 pub input_tokens_limit: Option<usize>,
76 pub input_tokens_remaining: Option<usize>,
77 pub output_tokens_limit: Option<usize>,
78 pub output_tokens_remaining: Option<usize>,
79 pub tokens_reset: Option<String>,
80}
81
82pub struct CostTracker {
84 costs: Arc<RwLock<Vec<CostRecord>>>,
85 contexts: Arc<RwLock<ContextSummary>>,
86 models: Arc<RwLock<Vec<ModelCost>>>,
87 rate_limit_status: Arc<RwLock<Option<RateLimitStatus>>>,
88}
89
90impl CostTracker {
91 pub fn new() -> Self {
92 let models = vec![
93 ModelCost {
94 model: "claude-opus-4-6".to_string(),
95 input_cost_per_1m: 15.0,
96 output_cost_per_1m: 75.0,
97 },
98 ModelCost {
99 model: "claude-3-5-sonnet-20241022".to_string(),
100 input_cost_per_1m: 3.0,
101 output_cost_per_1m: 15.0,
102 },
103 ModelCost {
104 model: "gpt-4-turbo".to_string(),
105 input_cost_per_1m: 10.0,
106 output_cost_per_1m: 30.0,
107 },
108 ModelCost {
109 model: "gpt-4".to_string(),
110 input_cost_per_1m: 30.0,
111 output_cost_per_1m: 60.0,
112 },
113 ModelCost {
114 model: "gpt-3.5-turbo".to_string(),
115 input_cost_per_1m: 0.5,
116 output_cost_per_1m: 1.5,
117 },
118 ModelCost {
119 model: "gemini-2.0-flash".to_string(),
120 input_cost_per_1m: 0.075,
121 output_cost_per_1m: 0.3,
122 },
123 ];
124
125 Self {
126 costs: Arc::new(RwLock::new(Vec::new())),
127 contexts: Arc::new(RwLock::new(ContextSummary::default())),
128 models: Arc::new(RwLock::new(models)),
129 rate_limit_status: Arc::new(RwLock::new(None)),
130 }
131 }
132
133 pub async fn record(&self, model: &str, usage: TokenUsage) -> anyhow::Result<()> {
135 let models = self.models.read().await;
136 let model_cost = models.iter().find(|m| m.model == model).cloned();
137
138 let (cost_usd, pricing_known) = match model_cost {
139 Some(model_cost) => (usage.calculate_cost(&model_cost), true),
140 None => {
141 tracing::warn!(model, "recording usage without a configured model price");
142 (0.0, false)
143 }
144 };
145
146 let record = CostRecord {
147 id: uuid::Uuid::new_v4().to_string(),
148 model: model.to_string(),
149 input_tokens: usage.input_tokens,
150 output_tokens: usage.output_tokens,
151 cost_usd,
152 pricing_known,
153 timestamp: chrono::Utc::now(),
154 };
155
156 self.costs.write().await.push(record);
157 Ok(())
158 }
159
160 pub async fn record_context(&self, snapshot: ContextSnapshot) {
162 let mut summary = self.contexts.write().await;
163 summary.request_count += 1;
164 summary.system_chars += snapshot.system_chars;
165 summary.history_chars += snapshot.history_chars;
166 summary.tool_chars += snapshot.tool_chars;
167 summary.estimated_input_tokens += snapshot.estimated_input_tokens;
168 }
169
170 pub async fn context_summary(&self) -> ContextSummary {
172 self.contexts.read().await.clone()
173 }
174
175 pub async fn summary(&self) -> CostSummary {
177 let costs = self.costs.read().await;
178
179 let total_cost: f64 = costs.iter().map(|c| c.cost_usd).fold(0.0, |acc, c| acc + c);
182 let total_tokens: usize = costs.iter().map(|c| c.input_tokens + c.output_tokens).sum();
183
184 let mut by_model: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
185 let mut unpriced_models = std::collections::BTreeSet::new();
186 for cost in costs.iter() {
187 *by_model.entry(cost.model.clone()).or_insert(0.0) += cost.cost_usd;
188 if !cost.pricing_known {
189 unpriced_models.insert(cost.model.clone());
190 }
191 }
192
193 let context = self.context_summary().await;
194 CostSummary {
195 total_cost,
196 total_tokens,
197 by_model,
198 call_count: costs.len(),
199 unpriced_call_count: costs.iter().filter(|cost| !cost.pricing_known).count(),
200 unpriced_models: unpriced_models.into_iter().collect(),
201 pricing_complete: costs.iter().all(|cost| cost.pricing_known),
202 context,
203 }
204 }
205
206 pub async fn history(&self, days: usize) -> Vec<CostRecord> {
208 let costs = self.costs.read().await;
209 let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
210
211 costs
212 .iter()
213 .filter(|c| c.timestamp > cutoff)
214 .cloned()
215 .collect()
216 }
217
218 pub async fn update_rate_limits(&self, headers: &reqwest::header::HeaderMap) {
220 let parse_usize = |key| {
221 headers
222 .get(key)
223 .and_then(|v| v.to_str().ok())
224 .and_then(|s| s.parse().ok())
225 };
226
227 let parse_string = |key| {
228 headers
229 .get(key)
230 .and_then(|v| v.to_str().ok())
231 .map(|s| s.to_string())
232 };
233
234 let status = RateLimitStatus {
235 requests_limit: parse_usize("anthropic-ratelimit-requests-limit"),
236 requests_remaining: parse_usize("anthropic-ratelimit-requests-remaining"),
237 input_tokens_limit: parse_usize("anthropic-ratelimit-input-tokens-limit"),
238 input_tokens_remaining: parse_usize("anthropic-ratelimit-input-tokens-remaining"),
239 output_tokens_limit: parse_usize("anthropic-ratelimit-output-tokens-limit"),
240 output_tokens_remaining: parse_usize("anthropic-ratelimit-output-tokens-remaining"),
241 tokens_reset: parse_string("anthropic-ratelimit-tokens-reset"),
242 };
243
244 *self.rate_limit_status.write().await = Some(status);
245 }
246
247 pub async fn get_rate_limits(&self) -> Option<RateLimitStatus> {
249 self.rate_limit_status.read().await.clone()
250 }
251}
252
253impl Default for CostTracker {
254 fn default() -> Self {
255 Self::new()
256 }
257}
258
259#[derive(Debug, Serialize, Deserialize)]
260pub struct CostSummary {
261 pub total_cost: f64,
262 pub total_tokens: usize,
263 pub by_model: std::collections::HashMap<String, f64>,
264 pub call_count: usize,
265 #[serde(default)]
267 pub unpriced_call_count: usize,
268 #[serde(default)]
269 pub unpriced_models: Vec<String>,
270 #[serde(default = "default_pricing_complete")]
273 pub pricing_complete: bool,
274 pub context: ContextSummary,
275}
276
277fn default_pricing_complete() -> bool {
278 true
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_token_cost_calculation() {
287 let cost = ModelCost {
288 model: "test".to_string(),
289 input_cost_per_1m: 1.0,
290 output_cost_per_1m: 2.0,
291 };
292
293 let usage = TokenUsage {
294 input_tokens: 1_000_000,
295 output_tokens: 1_000_000,
296 total_tokens: 2_000_000,
297 };
298
299 let calculated = usage.calculate_cost(&cost);
300 assert_eq!(calculated, 3.0); }
302
303 #[tokio::test]
304 async fn test_cost_tracking() {
305 let tracker = CostTracker::new();
306
307 tracker
308 .record(
309 "claude-opus-4-6",
310 TokenUsage {
311 input_tokens: 100,
312 output_tokens: 50,
313 total_tokens: 150,
314 },
315 )
316 .await
317 .unwrap();
318
319 let summary = tracker.summary().await;
320 assert_eq!(summary.call_count, 1);
321 assert!(summary.total_cost > 0.0);
322 }
323
324 #[tokio::test]
325 async fn unknown_model_usage_is_reported_as_unpriced() {
326 let tracker = CostTracker::new();
327 tracker
328 .record(
329 "future-model",
330 TokenUsage {
331 input_tokens: 100,
332 output_tokens: 50,
333 total_tokens: 150,
334 },
335 )
336 .await
337 .unwrap();
338
339 let summary = tracker.summary().await;
340 assert_eq!(summary.unpriced_call_count, 1);
341 assert_eq!(summary.unpriced_models, vec!["future-model"]);
342 assert!(!summary.pricing_complete);
343 assert_eq!(summary.total_cost, 0.0);
344 }
345
346 #[tokio::test]
347 async fn an_empty_tracker_reports_a_positive_zero_cost() {
348 let summary = CostTracker::new().summary().await;
349 assert_eq!(summary.call_count, 0);
350 assert_eq!(summary.total_cost, 0.0);
351 assert!(!summary.total_cost.is_sign_negative());
352 assert_eq!(format!("{:.1}", summary.total_cost), "0.0");
353 }
354
355 #[tokio::test]
356 async fn test_update_rate_limits() {
357 let tracker = CostTracker::new();
358 let mut headers = reqwest::header::HeaderMap::new();
359
360 headers.insert(
361 "anthropic-ratelimit-requests-limit",
362 "1000".parse().unwrap(),
363 );
364 headers.insert(
365 "anthropic-ratelimit-requests-remaining",
366 "999".parse().unwrap(),
367 );
368 headers.insert(
369 "anthropic-ratelimit-input-tokens-limit",
370 "400000".parse().unwrap(),
371 );
372 headers.insert(
373 "anthropic-ratelimit-input-tokens-remaining",
374 "399000".parse().unwrap(),
375 );
376 headers.insert(
377 "anthropic-ratelimit-output-tokens-limit",
378 "100000".parse().unwrap(),
379 );
380 headers.insert(
381 "anthropic-ratelimit-output-tokens-remaining",
382 "99000".parse().unwrap(),
383 );
384 headers.insert(
385 "anthropic-ratelimit-tokens-reset",
386 "2023-11-20T12:00:00Z".parse().unwrap(),
387 );
388
389 tracker.update_rate_limits(&headers).await;
390
391 let status = tracker.get_rate_limits().await.unwrap();
392
393 assert_eq!(status.requests_limit, Some(1000));
394 assert_eq!(status.requests_remaining, Some(999));
395 assert_eq!(status.input_tokens_limit, Some(400000));
396 assert_eq!(status.input_tokens_remaining, Some(399000));
397 assert_eq!(status.output_tokens_limit, Some(100000));
398 assert_eq!(status.output_tokens_remaining, Some(99000));
399 assert_eq!(
400 status.tokens_reset,
401 Some("2023-11-20T12:00:00Z".to_string())
402 );
403 }
404
405 #[tokio::test]
406 async fn context_telemetry_aggregates_request_shape() {
407 let tracker = CostTracker::new();
408 tracker
409 .record_context(ContextSnapshot {
410 system_chars: 40,
411 history_chars: 80,
412 tool_chars: 20,
413 estimated_input_tokens: 35,
414 })
415 .await;
416 let summary = tracker.context_summary().await;
417 assert_eq!(summary.request_count, 1);
418 assert_eq!(summary.estimated_input_tokens, 35);
419 assert_eq!(summary.system_chars, 40);
420
421 tracker
422 .record_context(ContextSnapshot {
423 system_chars: 2,
424 history_chars: 3,
425 tool_chars: 4,
426 estimated_input_tokens: 5,
427 })
428 .await;
429 let summary = tracker.context_summary().await;
430 assert_eq!(summary.request_count, 2);
431 assert_eq!(summary.system_chars, 42);
432 assert_eq!(summary.estimated_input_tokens, 40);
433 }
434}