1use super::types::{
2 AgentCostStats, BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod,
3};
4use crate::schema::CostConfig;
5use anyhow::{Context, Result};
6use chrono::{DateTime, Datelike, NaiveDate, Utc};
7use parking_lot::{Mutex, MutexGuard};
8use std::collections::HashMap;
9use std::fs::{self, File, OpenOptions};
10use std::io::{BufRead, BufReader, Write};
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, OnceLock};
13
14pub struct CostTracker {
16 config: CostConfig,
17 storage: Arc<Mutex<CostStorage>>,
18 session_id: String,
19 session_costs: Arc<Mutex<Vec<CostRecord>>>,
20}
21
22impl CostTracker {
23 pub fn new(config: CostConfig, workspace_dir: &Path) -> Result<Self> {
25 let storage_path = resolve_storage_path(workspace_dir)?;
26
27 let storage = CostStorage::new(&storage_path).with_context(|| {
28 format!(
29 "Failed to open cost storage at {}",
30 storage_path.display().to_string()
31 )
32 })?;
33
34 Ok(Self {
35 config,
36 storage: Arc::new(Mutex::new(storage)),
37 session_id: uuid::Uuid::new_v4().to_string(),
38 session_costs: Arc::new(Mutex::new(Vec::new())),
39 })
40 }
41
42 pub fn session_id(&self) -> &str {
44 &self.session_id
45 }
46
47 fn lock_storage(&self) -> MutexGuard<'_, CostStorage> {
48 self.storage.lock()
49 }
50
51 fn lock_session_costs(&self) -> MutexGuard<'_, Vec<CostRecord>> {
52 self.session_costs.lock()
53 }
54
55 pub fn check_budget(&self, estimated_cost_usd: f64) -> Result<BudgetCheck> {
57 if !self.config.enabled {
58 return Ok(BudgetCheck::Allowed);
59 }
60
61 if !estimated_cost_usd.is_finite() || estimated_cost_usd < 0.0 {
62 ::zeroclaw_log::record!(
63 WARN,
64 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
65 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
66 .with_attrs(::serde_json::json!({"estimated_cost_usd": estimated_cost_usd})),
67 "cost budget check rejected: estimated cost is not finite or is negative"
68 );
69 anyhow::bail!("Estimated cost must be a finite, non-negative value");
70 }
71
72 let mut storage = self.lock_storage();
73 let (daily_cost, monthly_cost) = storage.get_aggregated_costs()?;
74
75 let projected_daily = daily_cost + estimated_cost_usd;
77 if projected_daily > self.config.daily_limit_usd {
78 return Ok(BudgetCheck::Exceeded {
79 current_usd: daily_cost,
80 limit_usd: self.config.daily_limit_usd,
81 period: UsagePeriod::Day,
82 });
83 }
84
85 let projected_monthly = monthly_cost + estimated_cost_usd;
87 if projected_monthly > self.config.monthly_limit_usd {
88 return Ok(BudgetCheck::Exceeded {
89 current_usd: monthly_cost,
90 limit_usd: self.config.monthly_limit_usd,
91 period: UsagePeriod::Month,
92 });
93 }
94
95 let warn_threshold = f64::from(self.config.warn_at_percent.min(100)) / 100.0;
97 let daily_warn_threshold = self.config.daily_limit_usd * warn_threshold;
98 let monthly_warn_threshold = self.config.monthly_limit_usd * warn_threshold;
99
100 if projected_daily >= daily_warn_threshold {
101 return Ok(BudgetCheck::Warning {
102 current_usd: daily_cost,
103 limit_usd: self.config.daily_limit_usd,
104 period: UsagePeriod::Day,
105 });
106 }
107
108 if projected_monthly >= monthly_warn_threshold {
109 return Ok(BudgetCheck::Warning {
110 current_usd: monthly_cost,
111 limit_usd: self.config.monthly_limit_usd,
112 period: UsagePeriod::Month,
113 });
114 }
115
116 Ok(BudgetCheck::Allowed)
117 }
118
119 pub fn record_usage(&self, usage: TokenUsage) -> Result<()> {
121 self.record_usage_with_agent(usage, None)
122 }
123
124 pub fn record_usage_with_agent(
128 &self,
129 usage: TokenUsage,
130 agent_alias: Option<&str>,
131 ) -> Result<()> {
132 if !self.config.enabled {
133 return Ok(());
134 }
135
136 if !usage.cost_usd.is_finite() || usage.cost_usd < 0.0 {
137 ::zeroclaw_log::record!(
138 WARN,
139 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
140 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
141 .with_attrs(::serde_json::json!({"cost_usd": usage.cost_usd})),
142 "token usage record rejected: cost is not finite or is negative"
143 );
144 anyhow::bail!("Token usage cost must be a finite, non-negative value");
145 }
146
147 let effective_alias = if self.config.track_per_agent {
148 agent_alias.map(str::to_string)
149 } else {
150 None
151 };
152 let record = CostRecord::with_agent(&self.session_id, effective_alias, usage);
153
154 {
156 let mut storage = self.lock_storage();
157 storage.add_record(record.clone())?;
158 }
159
160 let mut session_costs = self.lock_session_costs();
162 session_costs.push(record);
163
164 Ok(())
165 }
166
167 pub fn get_summary(&self) -> Result<CostSummary> {
171 self.get_summary_filtered(None)
172 }
173
174 pub fn get_summary_in_bounds(
180 &self,
181 from: Option<DateTime<Utc>>,
182 to: Option<DateTime<Utc>>,
183 ) -> Result<CostSummary> {
184 let (daily_cost, monthly_cost, records) = {
185 let mut storage = self.lock_storage();
186 let (d, m) = storage.get_aggregated_costs()?;
187 let recs = storage.records_in_bounds(from, to)?;
188 (d, m, recs)
189 };
190 let total_cost: f64 = records.iter().map(|r| r.usage.cost_usd).sum();
191 let total_tokens: u64 = records.iter().map(|r| r.usage.total_tokens).sum();
192 let request_count = records.len();
193 let by_model = build_model_stats(records.iter());
194 let by_agent = if self.config.track_per_agent {
195 build_agent_stats(&records)
196 } else {
197 HashMap::new()
198 };
199 Ok(CostSummary {
200 session_cost_usd: total_cost,
201 daily_cost_usd: daily_cost,
202 monthly_cost_usd: monthly_cost,
203 total_tokens,
204 request_count,
205 by_model,
206 by_agent,
207 })
208 }
209
210 pub fn get_summary_for_agent(&self, agent_alias: &str) -> Result<CostSummary> {
215 self.get_summary_filtered(Some(agent_alias))
216 }
217
218 fn get_summary_filtered(&self, agent_filter: Option<&str>) -> Result<CostSummary> {
219 let (daily_cost, monthly_cost, daily_records) = {
220 let mut storage = self.lock_storage();
221 let (d, m) = storage.get_aggregated_costs()?;
222 (d, m, storage.daily_records()?)
228 };
229
230 let session_costs = self.lock_session_costs();
231 let matches_agent = |record: &CostRecord| match agent_filter {
232 Some(alias) => record.agent_alias.as_deref() == Some(alias),
233 None => true,
234 };
235
236 let scoped: Vec<&CostRecord> = session_costs.iter().filter(|r| matches_agent(r)).collect();
240 let session_cost: f64 = scoped.iter().map(|record| record.usage.cost_usd).sum();
241 let total_tokens: u64 = scoped.iter().map(|record| record.usage.total_tokens).sum();
242 let request_count = scoped.len();
243
244 let model_records: Vec<&CostRecord> =
246 daily_records.iter().filter(|r| matches_agent(r)).collect();
247 let by_model = build_model_stats(model_records.iter().copied());
248
249 let (daily_total, monthly_total, by_agent) = if let Some(alias) = agent_filter {
250 let mut daily_total = 0.0;
252 let mut monthly_total = 0.0;
253 let today = Utc::now().date_naive();
254 let now = Utc::now();
255 for record in &daily_records {
256 if record.agent_alias.as_deref() != Some(alias) {
257 continue;
258 }
259 let ts = record.usage.timestamp.naive_utc();
260 if ts.date() == today {
261 daily_total += record.usage.cost_usd;
262 }
263 if ts.year() == now.year() && ts.month() == now.month() {
264 monthly_total += record.usage.cost_usd;
265 }
266 }
267 (daily_total, monthly_total, HashMap::new())
268 } else if self.config.track_per_agent {
269 let by_agent = build_agent_stats(&daily_records);
270 (daily_cost, monthly_cost, by_agent)
271 } else {
272 (daily_cost, monthly_cost, HashMap::new())
273 };
274
275 Ok(CostSummary {
276 session_cost_usd: session_cost,
277 daily_cost_usd: daily_total,
278 monthly_cost_usd: monthly_total,
279 total_tokens,
280 request_count,
281 by_model,
282 by_agent,
283 })
284 }
285
286 pub fn get_daily_cost(&self, date: NaiveDate) -> Result<f64> {
288 let storage = self.lock_storage();
289 storage.get_cost_for_date(date)
290 }
291
292 pub fn get_monthly_cost(&self, year: i32, month: u32) -> Result<f64> {
294 let storage = self.lock_storage();
295 storage.get_cost_for_month(year, month)
296 }
297}
298
299static GLOBAL_COST_TRACKER: OnceLock<Option<Arc<CostTracker>>> = OnceLock::new();
304
305impl CostTracker {
306 pub fn get_or_init_global(config: CostConfig, workspace_dir: &Path) -> Option<Arc<Self>> {
311 GLOBAL_COST_TRACKER
312 .get_or_init(|| {
313 if !config.enabled {
314 return None;
315 }
316 match Self::new(config, workspace_dir) {
317 Ok(ct) => Some(Arc::new(ct)),
318 Err(e) => {
319 ::zeroclaw_log::record!(
320 WARN,
321 ::zeroclaw_log::Event::new(
322 module_path!(),
323 ::zeroclaw_log::Action::Note
324 )
325 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
326 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
327 "Failed to initialize global cost tracker"
328 );
329 None
330 }
331 }
332 })
333 .clone()
334 }
335}
336
337fn resolve_storage_path(workspace_dir: &Path) -> Result<PathBuf> {
338 let storage_path = workspace_dir.join("state").join("costs.jsonl");
339 let legacy_path = workspace_dir.join(".zeroclaw").join("costs.db");
340
341 if !storage_path.exists() && legacy_path.exists() {
342 if let Some(parent) = storage_path.parent() {
343 fs::create_dir_all(parent).with_context(|| {
344 format!(
345 "Failed to create directory {}",
346 parent.display().to_string()
347 )
348 })?;
349 }
350
351 if let Err(error) = fs::rename(&legacy_path, &storage_path) {
352 ::zeroclaw_log::record!(
353 WARN,
354 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
355 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
356 &format!(
357 "Failed to move legacy cost storage from {} to {}: {error}; falling back to copy",
358 legacy_path.display().to_string(),
359 storage_path.display().to_string()
360 )
361 );
362 fs::copy(&legacy_path, &storage_path).with_context(|| {
363 format!(
364 "Failed to copy legacy cost storage from {} to {}",
365 legacy_path.display().to_string(),
366 storage_path.display()
367 )
368 })?;
369 }
370 }
371
372 Ok(storage_path)
373}
374
375fn build_model_stats<'a, I>(records: I) -> HashMap<String, ModelStats>
376where
377 I: IntoIterator<Item = &'a CostRecord>,
378{
379 let mut by_model: HashMap<String, ModelStats> = HashMap::new();
380
381 for record in records {
382 let entry = by_model
383 .entry(record.usage.model.clone())
384 .or_insert_with(|| ModelStats {
385 model: record.usage.model.clone(),
386 cost_usd: 0.0,
387 total_tokens: 0,
388 input_tokens: 0,
389 output_tokens: 0,
390 cached_input_tokens: 0,
391 request_count: 0,
392 });
393
394 entry.cost_usd += record.usage.cost_usd;
395 entry.total_tokens += record.usage.total_tokens;
396 entry.input_tokens += record.usage.input_tokens;
397 entry.output_tokens += record.usage.output_tokens;
398 entry.cached_input_tokens += record.usage.cached_input_tokens;
399 entry.request_count += 1;
400 }
401
402 by_model
403}
404
405fn build_agent_stats(records: &[CostRecord]) -> HashMap<String, AgentCostStats> {
406 let mut by_agent: HashMap<String, AgentCostStats> = HashMap::new();
407
408 for record in records {
409 let Some(alias) = record.agent_alias.as_deref() else {
410 continue;
411 };
412 let entry = by_agent
413 .entry(alias.to_string())
414 .or_insert_with(|| AgentCostStats {
415 agent_alias: alias.to_string(),
416 cost_usd: 0.0,
417 total_tokens: 0,
418 input_tokens: 0,
419 output_tokens: 0,
420 cached_input_tokens: 0,
421 request_count: 0,
422 });
423
424 entry.cost_usd += record.usage.cost_usd;
425 entry.total_tokens += record.usage.total_tokens;
426 entry.input_tokens += record.usage.input_tokens;
427 entry.output_tokens += record.usage.output_tokens;
428 entry.cached_input_tokens += record.usage.cached_input_tokens;
429 entry.request_count += 1;
430 }
431
432 by_agent
433}
434
435struct CostStorage {
437 path: PathBuf,
438 daily_cost_usd: f64,
439 monthly_cost_usd: f64,
440 cached_day: NaiveDate,
441 cached_year: i32,
442 cached_month: u32,
443}
444
445impl CostStorage {
446 fn new(path: &Path) -> Result<Self> {
448 if let Some(parent) = path.parent() {
449 fs::create_dir_all(parent).with_context(|| {
450 format!(
451 "Failed to create directory {}",
452 parent.display().to_string()
453 )
454 })?;
455 }
456
457 let now = Utc::now();
458 let mut storage = Self {
459 path: path.to_path_buf(),
460 daily_cost_usd: 0.0,
461 monthly_cost_usd: 0.0,
462 cached_day: now.date_naive(),
463 cached_year: now.year(),
464 cached_month: now.month(),
465 };
466
467 storage.rebuild_aggregates(
468 storage.cached_day,
469 storage.cached_year,
470 storage.cached_month,
471 )?;
472
473 Ok(storage)
474 }
475
476 fn for_each_record<F>(&self, mut on_record: F) -> Result<()>
477 where
478 F: FnMut(CostRecord),
479 {
480 if !self.path.exists() {
481 return Ok(());
482 }
483
484 let file = File::open(&self.path).with_context(|| {
485 format!(
486 "Failed to read cost storage from {}",
487 self.path.display().to_string()
488 )
489 })?;
490 let reader = BufReader::new(file);
491
492 for (line_number, line) in reader.lines().enumerate() {
493 let raw_line = line.with_context(|| {
494 format!(
495 "Failed to read line {} from cost storage {}",
496 line_number + 1,
497 self.path.display()
498 )
499 })?;
500
501 let trimmed = raw_line.trim();
502 if trimmed.is_empty() {
503 continue;
504 }
505
506 match serde_json::from_str::<CostRecord>(trimmed) {
507 Ok(record) => on_record(record),
508 Err(error) => {
509 ::zeroclaw_log::record!(
510 WARN,
511 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
512 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
513 &format!(
514 "Skipping malformed cost record at {}:{}: {error}",
515 self.path.display().to_string(),
516 line_number + 1
517 )
518 );
519 }
520 }
521 }
522
523 Ok(())
524 }
525
526 fn rebuild_aggregates(&mut self, day: NaiveDate, year: i32, month: u32) -> Result<()> {
527 let mut daily_cost = 0.0;
528 let mut monthly_cost = 0.0;
529
530 self.for_each_record(|record| {
531 let timestamp = record.usage.timestamp.naive_utc();
532
533 if timestamp.date() == day {
534 daily_cost += record.usage.cost_usd;
535 }
536
537 if timestamp.year() == year && timestamp.month() == month {
538 monthly_cost += record.usage.cost_usd;
539 }
540 })?;
541
542 self.daily_cost_usd = daily_cost;
543 self.monthly_cost_usd = monthly_cost;
544 self.cached_day = day;
545 self.cached_year = year;
546 self.cached_month = month;
547
548 Ok(())
549 }
550
551 fn ensure_period_cache_current(&mut self) -> Result<()> {
552 let now = Utc::now();
553 let day = now.date_naive();
554 let year = now.year();
555 let month = now.month();
556
557 if day != self.cached_day || year != self.cached_year || month != self.cached_month {
558 self.rebuild_aggregates(day, year, month)?;
559 }
560
561 Ok(())
562 }
563
564 fn add_record(&mut self, record: CostRecord) -> Result<()> {
566 let mut file = OpenOptions::new()
567 .create(true)
568 .append(true)
569 .open(&self.path)
570 .with_context(|| {
571 format!(
572 "Failed to open cost storage at {}",
573 self.path.display().to_string()
574 )
575 })?;
576
577 writeln!(file, "{}", serde_json::to_string(&record)?).with_context(|| {
578 format!(
579 "Failed to write cost record to {}",
580 self.path.display().to_string()
581 )
582 })?;
583 file.sync_all().with_context(|| {
584 format!(
585 "Failed to sync cost storage at {}",
586 self.path.display().to_string()
587 )
588 })?;
589
590 self.ensure_period_cache_current()?;
591
592 let timestamp = record.usage.timestamp.naive_utc();
593 if timestamp.date() == self.cached_day {
594 self.daily_cost_usd += record.usage.cost_usd;
595 }
596 if timestamp.year() == self.cached_year && timestamp.month() == self.cached_month {
597 self.monthly_cost_usd += record.usage.cost_usd;
598 }
599
600 Ok(())
601 }
602
603 fn get_aggregated_costs(&mut self) -> Result<(f64, f64)> {
605 self.ensure_period_cache_current()?;
606 Ok((self.daily_cost_usd, self.monthly_cost_usd))
607 }
608
609 fn daily_records(&mut self) -> Result<Vec<CostRecord>> {
613 self.ensure_period_cache_current()?;
614 let year = self.cached_year;
615 let month = self.cached_month;
616 let mut out = Vec::new();
617 self.for_each_record(|record| {
618 let ts = record.usage.timestamp.naive_utc();
619 if ts.year() == year && ts.month() == month {
620 out.push(record);
621 }
622 })?;
623 Ok(out)
624 }
625
626 fn records_in_bounds(
627 &mut self,
628 from: Option<DateTime<Utc>>,
629 to: Option<DateTime<Utc>>,
630 ) -> Result<Vec<CostRecord>> {
631 let mut out = Vec::new();
632 self.for_each_record(|record| {
633 let ts = record.usage.timestamp;
634 if from.is_some_and(|f| ts < f) {
635 return;
636 }
637 if to.is_some_and(|t| ts >= t) {
638 return;
639 }
640 out.push(record);
641 })?;
642 Ok(out)
643 }
644
645 fn get_cost_for_date(&self, date: NaiveDate) -> Result<f64> {
647 let mut cost = 0.0;
648
649 self.for_each_record(|record| {
650 if record.usage.timestamp.naive_utc().date() == date {
651 cost += record.usage.cost_usd;
652 }
653 })?;
654
655 Ok(cost)
656 }
657
658 fn get_cost_for_month(&self, year: i32, month: u32) -> Result<f64> {
660 let mut cost = 0.0;
661
662 self.for_each_record(|record| {
663 let timestamp = record.usage.timestamp.naive_utc();
664 if timestamp.year() == year && timestamp.month() == month {
665 cost += record.usage.cost_usd;
666 }
667 })?;
668
669 Ok(cost)
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use tempfile::TempDir;
677
678 fn enabled_config() -> CostConfig {
679 CostConfig {
680 enabled: true,
681 ..Default::default()
682 }
683 }
684
685 #[test]
686 fn cost_tracker_initialization() {
687 let tmp = TempDir::new().unwrap();
688 let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
689 assert!(!tracker.session_id().is_empty());
690 }
691
692 #[test]
693 fn budget_check_when_disabled() {
694 let tmp = TempDir::new().unwrap();
695 let config = CostConfig {
696 enabled: false,
697 ..Default::default()
698 };
699
700 let tracker = CostTracker::new(config, tmp.path()).unwrap();
701 let check = tracker.check_budget(1000.0).unwrap();
702 assert!(matches!(check, BudgetCheck::Allowed));
703 }
704
705 #[test]
706 fn record_usage_and_get_summary() {
707 let tmp = TempDir::new().unwrap();
708 let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
709
710 let usage = TokenUsage::new("test/model", 1000, 500, 0, 1.0, 2.0, 0.0);
711 tracker.record_usage(usage).unwrap();
712
713 let summary = tracker.get_summary().unwrap();
714 assert_eq!(summary.request_count, 1);
715 assert!(summary.session_cost_usd > 0.0);
716 assert_eq!(summary.by_model.len(), 1);
717 }
718
719 #[test]
720 fn budget_exceeded_daily_limit() {
721 let tmp = TempDir::new().unwrap();
722 let config = CostConfig {
723 enabled: true,
724 daily_limit_usd: 0.01, ..Default::default()
726 };
727
728 let tracker = CostTracker::new(config, tmp.path()).unwrap();
729
730 let usage = TokenUsage::new("test/model", 10000, 5000, 0, 1.0, 2.0, 0.0); tracker.record_usage(usage).unwrap();
733
734 let check = tracker.check_budget(0.01).unwrap();
735 assert!(matches!(check, BudgetCheck::Exceeded { .. }));
736 }
737
738 #[test]
739 fn summary_by_model_is_daily_scoped() {
740 let tmp = TempDir::new().unwrap();
746 let storage_path = resolve_storage_path(tmp.path()).unwrap();
747 if let Some(parent) = storage_path.parent() {
748 fs::create_dir_all(parent).unwrap();
749 }
750
751 let prior_today = CostRecord::new(
752 "prior-session",
753 TokenUsage::new("prior/model", 500, 500, 0, 1.0, 1.0, 0.0),
754 );
755 let mut file = OpenOptions::new()
756 .create(true)
757 .append(true)
758 .open(storage_path)
759 .unwrap();
760 writeln!(file, "{}", serde_json::to_string(&prior_today).unwrap()).unwrap();
761 file.sync_all().unwrap();
762
763 let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
764 tracker
765 .record_usage(TokenUsage::new(
766 "session/model",
767 1000,
768 1000,
769 0,
770 1.0,
771 1.0,
772 0.0,
773 ))
774 .unwrap();
775
776 let summary = tracker.get_summary().unwrap();
777 assert_eq!(
778 summary.by_model.len(),
779 2,
780 "by_model must include every model that recorded today, \
781 regardless of which session wrote the record"
782 );
783 assert!(summary.by_model.contains_key("session/model"));
784 assert!(summary.by_model.contains_key("prior/model"));
785 }
786
787 #[test]
788 fn malformed_lines_are_ignored_while_loading() {
789 let tmp = TempDir::new().unwrap();
790 let storage_path = resolve_storage_path(tmp.path()).unwrap();
791 if let Some(parent) = storage_path.parent() {
792 fs::create_dir_all(parent).unwrap();
793 }
794
795 let valid_usage = TokenUsage::new("test/model", 1000, 0, 0, 1.0, 1.0, 0.0);
796 let valid_record = CostRecord::new("session-a", valid_usage.clone());
797
798 let mut file = OpenOptions::new()
799 .create(true)
800 .append(true)
801 .open(storage_path)
802 .unwrap();
803 writeln!(file, "{}", serde_json::to_string(&valid_record).unwrap()).unwrap();
804 writeln!(file, "not-a-json-line").unwrap();
805 writeln!(file).unwrap();
806 file.sync_all().unwrap();
807
808 let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
809 let today_cost = tracker.get_daily_cost(Utc::now().date_naive()).unwrap();
810 assert!((today_cost - valid_usage.cost_usd).abs() < f64::EPSILON);
811 }
812
813 #[test]
814 fn per_agent_aggregation_buckets_by_alias() {
815 let tmp = TempDir::new().unwrap();
816 let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
817
818 tracker
819 .record_usage_with_agent(
820 TokenUsage::new("test/model", 1_000, 1_000, 0, 1.0, 1.0, 0.0),
821 Some("scout"),
822 )
823 .unwrap();
824 tracker
825 .record_usage_with_agent(
826 TokenUsage::new("test/model", 2_000, 0, 0, 1.0, 1.0, 0.0),
827 Some("scout"),
828 )
829 .unwrap();
830 tracker
831 .record_usage_with_agent(
832 TokenUsage::new("test/model", 500, 500, 0, 1.0, 1.0, 0.0),
833 Some("scribe"),
834 )
835 .unwrap();
836
837 let summary = tracker.get_summary().unwrap();
838 assert_eq!(summary.by_agent.len(), 2);
839 let scout = summary.by_agent.get("scout").unwrap();
840 assert_eq!(scout.request_count, 2);
841 assert_eq!(scout.total_tokens, 4_000);
842 let scribe = summary.by_agent.get("scribe").unwrap();
843 assert_eq!(scribe.request_count, 1);
844 assert_eq!(scribe.total_tokens, 1_000);
845
846 let scoped = tracker.get_summary_for_agent("scout").unwrap();
847 assert_eq!(scoped.request_count, 2);
848 assert!(
849 scoped.by_agent.is_empty(),
850 "per-agent view doesn't re-bucket"
851 );
852 assert!(
853 (scoped.daily_cost_usd - scout.cost_usd).abs() < 1e-9,
854 "daily filtered to alias must match by_agent bucket"
855 );
856 }
857
858 #[test]
859 fn track_per_agent_disabled_strips_alias() {
860 let tmp = TempDir::new().unwrap();
861 let config = CostConfig {
862 enabled: true,
863 track_per_agent: false,
864 ..Default::default()
865 };
866 let tracker = CostTracker::new(config, tmp.path()).unwrap();
867
868 tracker
869 .record_usage_with_agent(
870 TokenUsage::new("test/model", 1_000, 1_000, 0, 1.0, 1.0, 0.0),
871 Some("scout"),
872 )
873 .unwrap();
874
875 let summary = tracker.get_summary().unwrap();
876 assert_eq!(summary.request_count, 1);
877 assert!(
878 summary.by_agent.is_empty(),
879 "track_per_agent=false must not surface per-agent rollups"
880 );
881 }
882
883 #[test]
884 fn invalid_budget_estimate_is_rejected() {
885 let tmp = TempDir::new().unwrap();
886 let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
887
888 let err = tracker.check_budget(f64::NAN).unwrap_err();
889 assert!(
890 err.to_string()
891 .contains("Estimated cost must be a finite, non-negative value")
892 );
893 }
894}