1use std::collections::HashMap;
5use std::sync::Arc;
6
7use parking_lot::Mutex;
8
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12#[error(
13 "daily budget exhausted: spent {spent_cents:.2} / {budget_cents:.2} cents \
14 (raise or disable via `[cost] max_daily_cents` in config.toml; 0 = unlimited)"
15)]
16pub struct BudgetExhausted {
17 pub spent_cents: f64,
18 pub budget_cents: f64,
19}
20
21#[derive(Debug, Clone, Default)]
23pub struct ProviderUsage {
24 pub input_tokens: u64,
25 pub cache_read_tokens: u64,
26 pub cache_write_tokens: u64,
27 pub output_tokens: u64,
28 pub cost_cents: f64,
29 pub request_count: u64,
30 pub model: String,
32}
33
34#[derive(Debug, Clone)]
35pub struct ModelPricing {
36 pub prompt_cents_per_1k: f64,
37 pub completion_cents_per_1k: f64,
38 pub cache_read_cents_per_1k: f64,
40 pub cache_write_cents_per_1k: f64,
42}
43
44struct CostState {
45 spent_cents: f64,
46 day: u32,
47 providers: HashMap<String, ProviderUsage>,
48 successful_tasks: u64,
49}
50
51pub struct CostTracker {
52 pricing: HashMap<String, ModelPricing>,
53 state: Arc<Mutex<CostState>>,
54 max_daily_cents: f64,
55 enabled: bool,
56}
57
58fn current_day() -> u32 {
59 use std::time::{SystemTime, UNIX_EPOCH};
60 let secs = SystemTime::now()
61 .duration_since(UNIX_EPOCH)
62 .unwrap_or_default()
63 .as_secs();
64 u32::try_from(secs / 86_400).unwrap_or(0)
66}
67
68fn claude_pricing(prompt: f64, completion: f64) -> ModelPricing {
69 ModelPricing {
70 prompt_cents_per_1k: prompt,
71 completion_cents_per_1k: completion,
72 cache_read_cents_per_1k: prompt * 0.1,
74 cache_write_cents_per_1k: prompt * 1.25,
75 }
76}
77
78fn openai_pricing(prompt: f64, completion: f64) -> ModelPricing {
79 ModelPricing {
80 prompt_cents_per_1k: prompt,
81 completion_cents_per_1k: completion,
82 cache_read_cents_per_1k: prompt * 0.5,
84 cache_write_cents_per_1k: 0.0,
85 }
86}
87
88fn default_pricing() -> HashMap<String, ModelPricing> {
89 let mut m = HashMap::new();
90 m.insert("claude-sonnet-4-20250514".into(), claude_pricing(0.3, 1.5));
92 m.insert("claude-opus-4-20250514".into(), claude_pricing(1.5, 7.5));
93 m.insert("claude-opus-4-1-20250805".into(), claude_pricing(1.5, 7.5));
95 m.insert("claude-haiku-4-5-20251001".into(), claude_pricing(0.1, 0.5));
97 m.insert(
98 "claude-sonnet-4-5-20250929".into(),
99 claude_pricing(0.3, 1.5),
100 );
101 m.insert("claude-opus-4-5-20251101".into(), claude_pricing(0.5, 2.5));
102 m.insert("claude-sonnet-4-6".into(), claude_pricing(0.3, 1.5));
104 m.insert("claude-opus-4-6".into(), claude_pricing(0.5, 2.5));
105 m.insert("claude-sonnet-5".into(), claude_pricing(0.3, 1.5));
107 m.insert("claude-opus-4-8".into(), claude_pricing(0.5, 2.5));
108 m.insert("gpt-4o".into(), openai_pricing(0.25, 1.0));
110 m.insert("gpt-4o-mini".into(), openai_pricing(0.015, 0.06));
111 m.insert("gpt-5".into(), openai_pricing(0.125, 1.0));
113 m.insert("gpt-5-mini".into(), openai_pricing(0.025, 0.2));
115 m
116}
117
118fn reset_if_new_day(state: &mut CostState) {
119 let today = current_day();
120 if state.day != today {
121 state.spent_cents = 0.0;
122 state.day = today;
123 state.providers.clear();
124 state.successful_tasks = 0;
125 }
126}
127
128impl CostTracker {
129 #[must_use]
130 pub fn new(enabled: bool, max_daily_cents: f64) -> Self {
131 Self {
132 pricing: default_pricing(),
133 state: Arc::new(Mutex::new(CostState {
134 spent_cents: 0.0,
135 day: current_day(),
136 providers: HashMap::new(),
137 successful_tasks: 0,
138 })),
139 max_daily_cents,
140 enabled,
141 }
142 }
143
144 #[must_use]
145 pub fn with_pricing(mut self, model: &str, pricing: ModelPricing) -> Self {
146 self.pricing.insert(model.to_owned(), pricing);
147 self
148 }
149
150 #[must_use]
159 pub fn price_of(
160 &self,
161 model: &str,
162 input_tokens: u64,
163 cache_read_tokens: u64,
164 cache_write_tokens: u64,
165 output_tokens: u64,
166 ) -> f64 {
167 let pricing = self.pricing.get(model).cloned().unwrap_or(ModelPricing {
168 prompt_cents_per_1k: 0.0,
169 completion_cents_per_1k: 0.0,
170 cache_read_cents_per_1k: 0.0,
171 cache_write_cents_per_1k: 0.0,
172 });
173 #[allow(clippy::cast_precision_loss)]
174 {
175 pricing.prompt_cents_per_1k * (input_tokens as f64) / 1000.0
176 + pricing.completion_cents_per_1k * (output_tokens as f64) / 1000.0
177 + pricing.cache_read_cents_per_1k * (cache_read_tokens as f64) / 1000.0
178 + pricing.cache_write_cents_per_1k * (cache_write_tokens as f64) / 1000.0
179 }
180 }
181
182 #[allow(clippy::too_many_arguments)] pub fn record_usage(
193 &self,
194 provider_name: &str,
195 provider_kind: &str,
196 model: &str,
197 input_tokens: u64,
198 cache_read_tokens: u64,
199 cache_write_tokens: u64,
200 output_tokens: u64,
201 ) {
202 if !self.enabled {
203 return;
204 }
205 if !self.pricing.contains_key(model) {
206 let is_local = matches!(provider_kind, "ollama" | "candle" | "local");
207 if is_local {
208 tracing::debug!(model, "local model; cost recorded as zero");
209 } else {
210 tracing::warn!(
211 model,
212 "model not found in pricing table; cost recorded as zero"
213 );
214 }
215 }
216 let cost = self.price_of(
217 model,
218 input_tokens,
219 cache_read_tokens,
220 cache_write_tokens,
221 output_tokens,
222 );
223
224 let mut state = self.state.lock();
225 reset_if_new_day(&mut state);
226 state.spent_cents += cost;
227
228 let entry = state.providers.entry(provider_name.to_owned()).or_default();
229 entry.input_tokens += input_tokens;
230 entry.cache_read_tokens += cache_read_tokens;
231 entry.cache_write_tokens += cache_write_tokens;
232 entry.output_tokens += output_tokens;
233 entry.cost_cents += cost;
234 entry.request_count += 1;
235 model.clone_into(&mut entry.model);
236 }
237
238 pub fn check_budget(&self) -> Result<(), BudgetExhausted> {
242 if !self.enabled {
243 return Ok(());
244 }
245 let mut state = self.state.lock();
246 reset_if_new_day(&mut state);
247 if self.max_daily_cents > 0.0 && state.spent_cents >= self.max_daily_cents {
248 return Err(BudgetExhausted {
249 spent_cents: state.spent_cents,
250 budget_cents: self.max_daily_cents,
251 });
252 }
253 Ok(())
254 }
255
256 #[must_use]
258 pub fn max_daily_cents(&self) -> f64 {
259 self.max_daily_cents
260 }
261
262 #[must_use]
263 pub fn current_spend(&self) -> f64 {
264 let state = self.state.lock();
265 state.spent_cents
266 }
267
268 pub fn record_successful_task(&self) {
272 if !self.enabled {
273 return;
274 }
275 let mut state = self.state.lock();
276 reset_if_new_day(&mut state);
277 state.successful_tasks += 1;
278 }
279
280 #[must_use]
282 pub fn cps(&self) -> Option<f64> {
283 let state = self.state.lock();
284 if state.successful_tasks == 0 {
285 return None;
286 }
287 #[allow(clippy::cast_precision_loss)]
288 Some(state.spent_cents / state.successful_tasks as f64)
289 }
290
291 #[must_use]
293 pub fn successful_tasks(&self) -> u64 {
294 self.state.lock().successful_tasks
295 }
296
297 #[must_use]
299 pub fn provider_breakdown(&self) -> Vec<(String, ProviderUsage)> {
300 let state = self.state.lock();
301 let mut breakdown: Vec<(String, ProviderUsage)> = state
302 .providers
303 .iter()
304 .map(|(k, v)| (k.clone(), v.clone()))
305 .collect();
306 breakdown.sort_by(|a, b| {
307 b.1.cost_cents
308 .partial_cmp(&a.1.cost_cents)
309 .unwrap_or(std::cmp::Ordering::Equal)
310 });
311 breakdown
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn record(tracker: &CostTracker, provider: &str, model: &str, input: u64, output: u64) {
320 tracker.record_usage(provider, "cloud", model, input, 0, 0, output);
321 }
322
323 #[test]
324 fn cost_tracker_records_usage_and_calculates_cost() {
325 let tracker = CostTracker::new(true, 1000.0);
326 record(&tracker, "openai", "gpt-4o", 1000, 1000);
327 let spend = tracker.current_spend();
329 assert!((spend - 1.25).abs() < 0.001);
330 }
331
332 #[test]
333 fn check_budget_passes_when_under_limit() {
334 let tracker = CostTracker::new(true, 100.0);
335 record(&tracker, "openai", "gpt-4o-mini", 100, 100);
336 assert!(tracker.check_budget().is_ok());
337 }
338
339 #[test]
340 fn check_budget_fails_when_over_limit() {
341 let tracker = CostTracker::new(true, 0.01);
342 record(&tracker, "claude", "claude-opus-4-20250514", 10000, 10000);
343 assert!(tracker.check_budget().is_err());
344 }
345
346 #[test]
347 fn daily_reset_clears_spending() {
348 let tracker = CostTracker::new(true, 100.0);
349 record(&tracker, "openai", "gpt-4o", 1000, 1000);
350 assert!(tracker.current_spend() > 0.0);
351 {
353 let mut state = tracker.state.lock();
354 state.day = 0; }
356 assert!(tracker.check_budget().is_ok());
358 assert!((tracker.current_spend() - 0.0).abs() < 0.001);
359 }
360
361 #[test]
362 fn daily_reset_clears_provider_breakdown() {
363 let tracker = CostTracker::new(true, 100.0);
364 record(&tracker, "openai", "gpt-4o", 1000, 1000);
365 assert!(!tracker.provider_breakdown().is_empty());
366 {
368 let mut state = tracker.state.lock();
369 state.day = 0;
370 }
371 assert!(tracker.check_budget().is_ok());
372 assert!(tracker.provider_breakdown().is_empty());
373 }
374
375 #[test]
376 fn ollama_zero_cost() {
377 let tracker = CostTracker::new(true, 100.0);
378 record(&tracker, "ollama", "llama3:8b", 10000, 10000);
379 assert!((tracker.current_spend() - 0.0).abs() < 0.001);
380 }
381
382 #[test]
383 fn ollama_unknown_model_no_warn_no_panic() {
384 let tracker = CostTracker::new(true, 100.0);
386 tracker.record_usage(
387 "local",
388 "ollama",
389 "totally-unknown-ollama-model",
390 5000,
391 0,
392 0,
393 5000,
394 );
395 assert!((tracker.current_spend() - 0.0).abs() < 0.001);
396 }
397
398 #[test]
399 fn cloud_unknown_model_still_records_zero_cost() {
400 let tracker = CostTracker::new(true, 100.0);
402 tracker.record_usage(
403 "openai",
404 "cloud",
405 "totally-unknown-cloud-model",
406 5000,
407 0,
408 0,
409 5000,
410 );
411 assert!((tracker.current_spend() - 0.0).abs() < 0.001);
412 }
413
414 #[test]
415 fn unknown_model_zero_cost() {
416 let tracker = CostTracker::new(true, 100.0);
417 record(&tracker, "unknown", "totally-unknown-model", 5000, 5000);
418 assert!((tracker.current_spend() - 0.0).abs() < 0.001);
419 }
420
421 #[test]
426 fn price_of_matches_record_usage_for_known_model() {
427 let tracker = CostTracker::new(true, 1000.0);
428 let expected = tracker.price_of("gpt-4o", 1000, 0, 0, 1000);
429 record(&tracker, "openai", "gpt-4o", 1000, 1000);
430 assert!(
431 (tracker.current_spend() - expected).abs() < 1e-9,
432 "price_of={expected} current_spend={}",
433 tracker.current_spend()
434 );
435 }
436
437 #[test]
438 fn price_of_zero_for_unpriced_model_matches_record_usage_fallback() {
439 let tracker = CostTracker::new(true, 1000.0);
440 let price = tracker.price_of("totally-unknown-model", 5000, 0, 0, 5000);
441 assert!(
442 (price - 0.0).abs() < 1e-9,
443 "unpriced model must price at zero"
444 );
445
446 record(&tracker, "unknown", "totally-unknown-model", 5000, 5000);
447 assert!((tracker.current_spend() - price).abs() < 1e-9);
448 }
449
450 #[test]
451 fn price_of_includes_cache_tokens() {
452 let tracker = CostTracker::new(true, 1000.0);
453 let price = tracker.price_of("claude-opus-4-6", 0, 0, 1000, 0);
456 assert!((price - 0.625).abs() < 0.001);
457 }
458
459 #[test]
460 fn known_claude_model_has_nonzero_cost() {
461 let tracker = CostTracker::new(true, 1000.0);
462 record(&tracker, "claude", "claude-haiku-4-5-20251001", 1000, 1000);
463 assert!(tracker.current_spend() > 0.0);
464 }
465
466 #[test]
467 fn gpt5_pricing_is_correct() {
468 let tracker = CostTracker::new(true, 1000.0);
469 record(&tracker, "openai", "gpt-5", 1000, 1000);
470 let spend = tracker.current_spend();
472 assert!((spend - 1.125).abs() < 0.001);
473 }
474
475 #[test]
476 fn gpt5_mini_pricing_is_correct() {
477 let tracker = CostTracker::new(true, 1000.0);
478 record(&tracker, "openai", "gpt-5-mini", 1000, 1000);
479 let spend = tracker.current_spend();
481 assert!((spend - 0.225).abs() < 0.001);
482 }
483
484 #[test]
485 fn disabled_tracker_always_passes() {
486 let tracker = CostTracker::new(false, 0.0);
487 record(
488 &tracker,
489 "claude",
490 "claude-opus-4-20250514",
491 1_000_000,
492 1_000_000,
493 );
494 assert!(tracker.check_budget().is_ok());
495 assert!((tracker.current_spend() - 0.0).abs() < 0.001);
496 }
497
498 #[test]
499 fn check_budget_unlimited_when_max_daily_cents_is_zero() {
500 let tracker = CostTracker::new(true, 0.0);
501 record(
502 &tracker,
503 "claude",
504 "claude-opus-4-20250514",
505 100_000,
506 100_000,
507 );
508 assert!(tracker.check_budget().is_ok());
509 }
510
511 #[test]
512 fn per_provider_accumulation() {
513 let tracker = CostTracker::new(true, 1000.0);
514 record(&tracker, "claude", "claude-haiku-4-5-20251001", 1000, 500);
515 record(&tracker, "openai", "gpt-4o", 2000, 1000);
516 record(&tracker, "claude", "claude-haiku-4-5-20251001", 500, 200);
517
518 let breakdown = tracker.provider_breakdown();
519 assert_eq!(breakdown.len(), 2);
520
521 let claude = breakdown.iter().find(|(n, _)| n == "claude").unwrap();
522 assert_eq!(claude.1.request_count, 2);
523 assert_eq!(claude.1.input_tokens, 1500);
524 assert_eq!(claude.1.output_tokens, 700);
525
526 let openai = breakdown.iter().find(|(n, _)| n == "openai").unwrap();
527 assert_eq!(openai.1.request_count, 1);
528 assert_eq!(openai.1.input_tokens, 2000);
529 }
530
531 #[test]
532 fn provider_breakdown_sorted_by_cost_desc() {
533 let tracker = CostTracker::new(true, 1000.0);
534 record(&tracker, "cheap", "gpt-4o-mini", 100, 100);
536 record(&tracker, "expensive", "claude-opus-4-20250514", 10000, 5000);
537
538 let breakdown = tracker.provider_breakdown();
539 assert_eq!(breakdown[0].0, "expensive");
540 }
541
542 #[test]
543 fn cache_tokens_included_in_cost() {
544 let tracker = CostTracker::new(true, 1000.0);
545 tracker.record_usage(
548 "claude",
549 "cloud",
550 "claude-haiku-4-5-20251001",
551 0,
552 1000,
553 0,
554 0,
555 );
556 let spend = tracker.current_spend();
557 assert!(spend > 0.0, "cache read should contribute to cost");
558 }
559
560 #[test]
561 fn cache_write_cost_included_in_total() {
562 let tracker = CostTracker::new(true, 1000.0);
563 tracker.record_usage("claude-provider", "cloud", "claude-opus-4-6", 0, 0, 1000, 0);
567 let cost = tracker.current_spend();
568 assert!((cost - 0.625).abs() < 0.001);
569 }
570
571 #[test]
572 fn claude_5_generation_pricing_matches_retained_4_6_rows() {
573 let sonnet_5 = CostTracker::new(true, 1000.0);
576 sonnet_5.record_usage(
577 "claude-provider",
578 "cloud",
579 "claude-sonnet-5",
580 1000,
581 0,
582 0,
583 1000,
584 );
585 let sonnet_4_6 = CostTracker::new(true, 1000.0);
586 sonnet_4_6.record_usage(
587 "claude-provider",
588 "cloud",
589 "claude-sonnet-4-6",
590 1000,
591 0,
592 0,
593 1000,
594 );
595 assert!((sonnet_5.current_spend() - sonnet_4_6.current_spend()).abs() < f64::EPSILON);
596 assert!(
597 sonnet_5.current_spend() > 0.0,
598 "must not fall back to zero-cost"
599 );
600
601 let opus_4_8 = CostTracker::new(true, 1000.0);
602 opus_4_8.record_usage(
603 "claude-provider",
604 "cloud",
605 "claude-opus-4-8",
606 1000,
607 0,
608 0,
609 1000,
610 );
611 let opus_4_6 = CostTracker::new(true, 1000.0);
612 opus_4_6.record_usage(
613 "claude-provider",
614 "cloud",
615 "claude-opus-4-6",
616 1000,
617 0,
618 0,
619 1000,
620 );
621 assert!((opus_4_8.current_spend() - opus_4_6.current_spend()).abs() < f64::EPSILON);
622 assert!(
623 opus_4_8.current_spend() > 0.0,
624 "must not fall back to zero-cost"
625 );
626 }
627
628 #[test]
629 fn provider_breakdown_empty_when_disabled() {
630 let tracker = CostTracker::new(false, 100.0);
631 tracker.record_usage(
632 "claude",
633 "cloud",
634 "claude-haiku-4-5-20251001",
635 1000,
636 0,
637 0,
638 1000,
639 );
640 assert!(tracker.provider_breakdown().is_empty());
641 }
642
643 #[test]
644 fn cps_none_when_no_tasks() {
645 let tracker = CostTracker::new(true, 100.0);
646 assert!(tracker.cps().is_none());
647 assert_eq!(tracker.successful_tasks(), 0);
648 }
649
650 #[test]
651 fn cps_calculated_correctly() {
652 let tracker = CostTracker::new(true, 100.0);
653 record(&tracker, "openai", "gpt-4o", 1000, 1000);
655 tracker.record_successful_task();
656 tracker.record_successful_task();
657 assert_eq!(tracker.successful_tasks(), 2);
658 let cps = tracker.cps().expect("cps should be Some after tasks");
659 assert!((cps - 0.625).abs() < 0.001, "cps={cps}");
661 }
662
663 #[test]
664 fn cps_resets_on_new_day() {
665 let tracker = CostTracker::new(true, 100.0);
666 record(&tracker, "openai", "gpt-4o", 1000, 1000);
667 tracker.record_successful_task();
668 assert_eq!(tracker.successful_tasks(), 1);
669 {
671 let mut state = tracker.state.lock();
672 state.day = 0;
673 }
674 assert!(tracker.check_budget().is_ok());
676 assert_eq!(tracker.successful_tasks(), 0);
677 assert!(tracker.cps().is_none());
678 }
679}