Skip to main content

zeroclaw_config/cost/
tracker.rs

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
14/// Cost tracker for API usage monitoring and budget enforcement.
15pub 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    /// Create a new cost tracker.
24    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    /// Get the session ID.
43    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    /// Check if a request is within budget.
56    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        // Check daily limit
76        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        // Check monthly limit
86        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        // Check warning thresholds
96        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    /// Record a usage event without per-agent attribution.
120    pub fn record_usage(&self, usage: TokenUsage) -> Result<()> {
121        self.record_usage_with_agent(usage, None)
122    }
123
124    /// Record a usage event attributed to a specific agent alias. When
125    /// `[cost].track_per_agent` is false the alias is dropped before
126    /// persistence.
127    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        // Persist first for durability guarantees.
155        {
156            let mut storage = self.lock_storage();
157            storage.add_record(record.clone())?;
158        }
159
160        // Then update in-memory session snapshot.
161        let mut session_costs = self.lock_session_costs();
162        session_costs.push(record);
163
164        Ok(())
165    }
166
167    /// Get the current cost summary. When `[cost].track_per_agent` is
168    /// enabled, the response includes a `by_agent` rollup over today's
169    /// records.
170    pub fn get_summary(&self) -> Result<CostSummary> {
171        self.get_summary_filtered(None)
172    }
173
174    /// Filter persisted records by `[from, to)` (either side `None` is
175    /// unbounded) and roll up by_model / by_agent / window totals.
176    /// Bounds come from the caller (the dashboard computes them in the
177    /// operator's local timezone); the tracker doesn't decide what
178    /// "today" means.
179    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    /// Get the current cost summary scoped to a single agent alias. The
211    /// session/day/month figures and `by_model` are filtered to records
212    /// attributed to that alias; `by_agent` is left empty since the
213    /// caller already chose the dimension.
214    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            // Always pull daily_records: per-model and per-agent rollups
223            // both want today's slice. The optional-skip optimisation tied
224            // to `track_per_agent` made the by-model rollup session-scoped,
225            // which surprised operators after a daemon restart and clashes
226            // with the daily totals in the same response.
227            (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        // Session view is kept on `CostSummary` for backward-compat with
237        // callers that still want it (CLI `cost` command, etc.), but the
238        // dashboard reads the daily-scoped rollups below.
239        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        // Daily-scoped per-model rollup. Filter by agent when scoped.
245        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            // Per-agent view: re-aggregate day/month from persisted records.
251            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    /// Get the daily cost for a specific date.
287    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    /// Get the monthly cost for a specific month.
293    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
299// ── Process-global singleton ────────────────────────────────────────
300// Both the gateway and the channels supervisor share a single CostTracker
301// so that budget enforcement is consistent across all paths.
302
303static GLOBAL_COST_TRACKER: OnceLock<Option<Arc<CostTracker>>> = OnceLock::new();
304
305impl CostTracker {
306    /// Return the process-global `CostTracker`, creating it on first call.
307    /// Subsequent calls (from gateway or channels, whichever starts second)
308    /// receive the same `Arc`.  Returns `None` when cost tracking is disabled
309    /// or initialisation fails.
310    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
435/// Persistent storage for cost records.
436struct 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    /// Create or open cost storage.
447    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    /// Add a new record.
565    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    /// Get aggregated costs for current day and month.
604    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    /// Snapshot every record whose timestamp falls within the current
610    /// calendar month. Used to build per-agent rollups without folding a
611    /// new aggregate table into the JSONL file.
612    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    /// Get cost for a specific date.
646    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    /// Get cost for a specific month.
659    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, // Very low limit
725            ..Default::default()
726        };
727
728        let tracker = CostTracker::new(config, tmp.path()).unwrap();
729
730        // Record a usage that exceeds the limit
731        let usage = TokenUsage::new("test/model", 10000, 5000, 0, 1.0, 2.0, 0.0); // ~0.02 USD
732        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        // by_model rollup pulls from today's persisted records so the
741        // dashboard's per-model breakdown survives daemon restarts (matches
742        // by_agent's behaviour). A record from another session that
743        // happened today still shows up; only ones outside the day fall
744        // off — exercised by the storage layer's get_aggregated_costs.
745        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}