//! Cost enforcement guardrails for the agent. //! //! Tracks LLM spending and action rates, enforcing configurable limits //! to prevent runaway agents from burning through API credits. Especially //! important for daemon/heartbeat modes where the agent acts autonomously. use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use rust_decimal::Decimal; use rust_decimal_macros::dec; use tokio::sync::Mutex; use crate::llm::costs; /// Configuration for cost guardrails. #[derive(Debug, Clone, Default)] pub struct CostGuardConfig { /// Maximum spend per day in cents (e.g. 10000 = $100). None = unlimited. pub max_cost_per_day_cents: Option, /// Maximum LLM calls per hour. None = unlimited. pub max_actions_per_hour: Option, } /// Error returned when a cost limit is exceeded. #[derive(Debug, Clone)] pub enum CostLimitExceeded { /// Daily spending cap reached. DailyBudget { spent_cents: u64, limit_cents: u64 }, /// Hourly action rate limit reached. HourlyRate { actions: u64, limit: u64 }, } impl std::fmt::Display for CostLimitExceeded { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::DailyBudget { spent_cents, limit_cents, } => write!( f, "Daily cost limit exceeded: spent ${:.2} of ${:.2} allowed", *spent_cents as f64 / 100.0, *limit_cents as f64 / 100.0 ), Self::HourlyRate { actions, limit } => write!( f, "Hourly action limit exceeded: {} actions of {} allowed per hour", actions, limit ), } } } /// Per-model token usage counters. #[derive(Debug, Clone, Default)] pub struct ModelTokens { pub input_tokens: u64, pub output_tokens: u64, pub cost: Decimal, } /// Tracks costs and action rates, enforcing configurable limits. /// /// Thread-safe; designed to be shared via `Arc`. pub struct CostGuard { config: CostGuardConfig, /// Running cost total for the current day (in USD, not cents). daily_cost: Mutex, /// Sliding window of action timestamps for rate limiting. action_window: Mutex>, /// Flag set when daily budget is exceeded to short-circuit checks. budget_exceeded: AtomicBool, /// Per-model token usage since startup. model_tokens: Mutex>, } struct DailyCost { total: Decimal, /// Day boundary (midnight UTC) for resetting the counter. reset_date: chrono::NaiveDate, } impl CostGuard { pub fn new(config: CostGuardConfig) -> Self { Self { config, daily_cost: Mutex::new(DailyCost { total: Decimal::ZERO, reset_date: chrono::Utc::now().date_naive(), }), action_window: Mutex::new(VecDeque::new()), budget_exceeded: AtomicBool::new(false), model_tokens: Mutex::new(HashMap::new()), } } /// Check whether the next action is allowed under the configured limits. /// /// Call this BEFORE making an LLM call. Does NOT record the action yet, /// call `record_action` after the action completes. pub async fn check_allowed(&self) -> Result<(), CostLimitExceeded> { // Fast path: if budget already blown, skip the lock if self.budget_exceeded.load(Ordering::Relaxed) { let daily = self.daily_cost.lock().await; let spent_cents = to_cents(daily.total); return Err(CostLimitExceeded::DailyBudget { spent_cents, limit_cents: self.config.max_cost_per_day_cents.unwrap_or(0), }); } // Check daily budget if let Some(limit_cents) = self.config.max_cost_per_day_cents { let daily = self.daily_cost.lock().await; let spent_cents = to_cents(daily.total); if spent_cents >= limit_cents { self.budget_exceeded.store(true, Ordering::Relaxed); return Err(CostLimitExceeded::DailyBudget { spent_cents, limit_cents, }); } } // Check hourly rate if let Some(limit) = self.config.max_actions_per_hour { let mut window = self.action_window.lock().await; // checked_sub avoids panic when system uptime < 1 hour (Windows) if let Some(cutoff) = Instant::now().checked_sub(std::time::Duration::from_secs(3600)) { // Drain expired entries while window.front().is_some_and(|t| *t < cutoff) { window.pop_front(); } } let count = window.len() as u64; if count >= limit { return Err(CostLimitExceeded::HourlyRate { actions: count, limit, }); } } Ok(()) } /// Record a completed LLM action: its token costs and the action timestamp. /// /// Call this AFTER an LLM call completes so that costs are tracked. /// - `cache_read_input_tokens`: tokens served from cache. /// - `cache_creation_input_tokens`: tokens written to cache. /// - `cache_read_discount`: divisor for cache-read cost (e.g. 10 for Anthropic 90% off, 2 for OpenAI 50% off). /// - `cache_write_multiplier`: cost multiplier for cache writes (1.25 for 5m, 2.0 for 1h). /// /// When `cost_per_token` is `Some`, those rates are used directly (provider- /// sourced pricing). When `None`, falls back to the static `costs::model_cost` /// lookup table, then `costs::default_cost`. #[allow(clippy::too_many_arguments)] pub async fn record_llm_call( &self, model: &str, input_tokens: u32, output_tokens: u32, cache_read_input_tokens: u32, cache_creation_input_tokens: u32, cache_read_discount: Decimal, cache_write_multiplier: Decimal, cost_per_token: Option<(Decimal, Decimal)>, ) -> Decimal { let (input_rate, output_rate) = cost_per_token .unwrap_or_else(|| costs::model_cost(model).unwrap_or_else(costs::default_cost)); // Cached read tokens cost input_rate / cache_read_discount (provider-specific). // Cached write tokens cost write_multiplier × input_rate (e.g. 1.25× for 5m, 2× for 1h). // Uncached tokens = total input - cache reads - cache writes. let cached_total = cache_read_input_tokens.saturating_add(cache_creation_input_tokens); let uncached_input = input_tokens.saturating_sub(cached_total); let effective_discount = if cache_read_discount.is_zero() { Decimal::ONE } else { cache_read_discount }; let cache_read_cost = input_rate * Decimal::from(cache_read_input_tokens) / effective_discount; let cache_write_cost = input_rate * Decimal::from(cache_creation_input_tokens) * cache_write_multiplier; let cost = input_rate * Decimal::from(uncached_input) + cache_read_cost + cache_write_cost + output_rate * Decimal::from(output_tokens); // Update daily cost (reset if new day) { let mut daily = self.daily_cost.lock().await; let today = chrono::Utc::now().date_naive(); if today != daily.reset_date { daily.total = Decimal::ZERO; daily.reset_date = today; self.budget_exceeded.store(false, Ordering::Relaxed); tracing::info!("Cost guard: daily counter reset for {}", today); } daily.total += cost; // Check if we just crossed the threshold if let Some(limit_cents) = self.config.max_cost_per_day_cents { let spent_cents = to_cents(daily.total); if spent_cents >= limit_cents { self.budget_exceeded.store(true, Ordering::Relaxed); tracing::warn!( "Daily cost limit reached: ${:.2} of ${:.2}", daily.total, Decimal::from(limit_cents) / dec!(100) ); } // Warn at 80% threshold let warn_threshold = limit_cents * 80 / 100; if spent_cents >= warn_threshold && spent_cents < limit_cents { tracing::warn!( "Approaching daily cost limit: ${:.2} of ${:.2} ({}%)", daily.total, Decimal::from(limit_cents) / dec!(100), spent_cents * 100 / limit_cents ); } } } // Record action in sliding window { let mut window = self.action_window.lock().await; window.push_back(Instant::now()); } // Track per-model token usage { let mut tokens = self.model_tokens.lock().await; let entry = tokens.entry(model.to_string()).or_default(); entry.input_tokens += u64::from(input_tokens); entry.output_tokens += u64::from(output_tokens); entry.cost += cost; } cost } /// Current daily spend in USD (as Decimal). pub async fn daily_spend(&self) -> Decimal { let daily = self.daily_cost.lock().await; let today = chrono::Utc::now().date_naive(); if today != daily.reset_date { Decimal::ZERO } else { daily.total } } /// Number of actions in the current hourly window. pub async fn actions_this_hour(&self) -> u64 { let mut window = self.action_window.lock().await; // checked_sub avoids panic when system uptime < 1 hour (Windows) if let Some(cutoff) = Instant::now().checked_sub(std::time::Duration::from_secs(3600)) { while window.front().is_some_and(|t| *t < cutoff) { window.pop_front(); } } window.len() as u64 } /// Per-model token usage since startup. pub async fn model_usage(&self) -> HashMap { self.model_tokens.lock().await.clone() } } /// Convert a Decimal USD amount to whole cents (truncated). fn to_cents(usd: Decimal) -> u64 { let cents = (usd * dec!(100)).trunc(); cents.to_string().parse::().unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_unlimited_allows_everything() { let guard = CostGuard::new(CostGuardConfig::default()); // No limits set, should always be allowed assert!(guard.check_allowed().await.is_ok()); // Record a big call, still allowed guard .record_llm_call( "gpt-4o", 100_000, 100_000, 0, 0, Decimal::ONE, Decimal::ONE, None, ) .await; assert!(guard.check_allowed().await.is_ok()); } #[tokio::test] async fn test_daily_budget_enforcement() { let guard = CostGuard::new(CostGuardConfig { max_cost_per_day_cents: Some(1), // $0.01 limit max_actions_per_hour: None, }); // First call allowed assert!(guard.check_allowed().await.is_ok()); // Record a call that costs more than $0.01 // gpt-4o: input=$0.0000025/tok, output=$0.00001/tok // 10000 input + 10000 output = $0.025 + $0.10 = $0.125 guard .record_llm_call( "gpt-4o", 10_000, 10_000, 0, 0, Decimal::ONE, Decimal::ONE, None, ) .await; // Now should be blocked let result = guard.check_allowed().await; assert!(result.is_err()); match result.unwrap_err() { CostLimitExceeded::DailyBudget { limit_cents, .. } => { assert_eq!(limit_cents, 1); } other => panic!("Expected DailyBudget, got {:?}", other), } } #[tokio::test] async fn test_hourly_rate_enforcement() { let guard = CostGuard::new(CostGuardConfig { max_cost_per_day_cents: None, max_actions_per_hour: Some(3), }); // First 3 actions allowed for _ in 0..3 { assert!(guard.check_allowed().await.is_ok()); guard .record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; } // 4th should be blocked let result = guard.check_allowed().await; assert!(result.is_err()); match result.unwrap_err() { CostLimitExceeded::HourlyRate { actions, limit } => { assert_eq!(actions, 3); assert_eq!(limit, 3); } other => panic!("Expected HourlyRate, got {:?}", other), } } #[tokio::test] async fn test_daily_spend_tracking() { let guard = CostGuard::new(CostGuardConfig::default()); assert_eq!(guard.daily_spend().await, Decimal::ZERO); let cost = guard .record_llm_call("gpt-4o", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; assert!(cost > Decimal::ZERO); assert_eq!(guard.daily_spend().await, cost); } #[tokio::test] async fn test_actions_this_hour() { let guard = CostGuard::new(CostGuardConfig::default()); assert_eq!(guard.actions_this_hour().await, 0); guard .record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; guard .record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; assert_eq!(guard.actions_this_hour().await, 2); } #[test] fn test_to_cents() { assert_eq!(to_cents(dec!(1.50)), 150); assert_eq!(to_cents(dec!(0.01)), 1); assert_eq!(to_cents(Decimal::ZERO), 0); } #[test] fn test_cost_limit_display() { let budget = CostLimitExceeded::DailyBudget { spent_cents: 1050, limit_cents: 1000, }; assert!(budget.to_string().contains("$10.50")); assert!(budget.to_string().contains("$10.00")); let rate = CostLimitExceeded::HourlyRate { actions: 101, limit: 100, }; assert!(rate.to_string().contains("101 actions")); assert!(rate.to_string().contains("100 allowed")); } #[tokio::test] async fn test_model_usage_per_model_tracking() { let guard = CostGuard::new(CostGuardConfig::default()); // Initially empty assert!(guard.model_usage().await.is_empty()); // Record calls for two different models guard .record_llm_call("gpt-4o", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; guard .record_llm_call("gpt-4o", 2000, 1000, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; guard .record_llm_call( "claude-3-5-sonnet-20241022", 500, 200, 0, 0, Decimal::ONE, Decimal::ONE, None, ) .await; let usage = guard.model_usage().await; assert_eq!(usage.len(), 2); let gpt = usage.get("gpt-4o").expect("gpt-4o should be tracked"); assert_eq!(gpt.input_tokens, 3000); assert_eq!(gpt.output_tokens, 1500); assert!(gpt.cost > Decimal::ZERO); let claude = usage .get("claude-3-5-sonnet-20241022") .expect("claude should be tracked"); assert_eq!(claude.input_tokens, 500); assert_eq!(claude.output_tokens, 200); assert!(claude.cost > Decimal::ZERO); // Costs should differ since models have different pricing assert_ne!(gpt.cost, claude.cost); } #[tokio::test] async fn test_cache_discount_reduces_cost() { let guard = CostGuard::new(CostGuardConfig::default()); // Full price: 1000 input + 500 output, no cache let full_cost = guard .record_llm_call( "claude-opus-4-6", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None, ) .await; let guard2 = CostGuard::new(CostGuardConfig::default()); // Same tokens but all input cached (90% discount on input) let cached_cost = guard2 .record_llm_call( "claude-opus-4-6", 1000, 500, 1000, 0, dec!(10), Decimal::ONE, None, ) .await; // Cached cost must be strictly less than full cost assert!( cached_cost < full_cost, "cached_cost ({}) should be less than full_cost ({})", cached_cost, full_cost ); // The difference should be exactly 90% of the input cost let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap(); let expected_savings = input_rate * Decimal::from(1000u32) * dec!(9) / dec!(10); let actual_savings = full_cost - cached_cost; assert_eq!( actual_savings, expected_savings, "savings should be 90% of input cost for fully-cached request" ); } #[tokio::test] async fn test_cache_write_surcharge_increases_cost() { let guard = CostGuard::new(CostGuardConfig::default()); // Full price: 1000 input + 500 output, no cache activity let full_cost = guard .record_llm_call( "claude-opus-4-6", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None, ) .await; let guard2 = CostGuard::new(CostGuardConfig::default()); // Same tokens, but all input tokens are cache writes (1.25x surcharge for 5m TTL) let short_multiplier = Decimal::new(125, 2); // 1.25 let write_cost = guard2 .record_llm_call( "claude-opus-4-6", 1000, 500, 0, 1000, Decimal::ONE, short_multiplier, None, ) .await; // Write cost must be strictly greater than full cost assert!( write_cost > full_cost, "write_cost ({}) should be greater than full_cost ({})", write_cost, full_cost ); // The difference should be exactly 25% of the input cost let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap(); let expected_surcharge = input_rate * Decimal::from(1000u32) * dec!(0.25); let actual_surcharge = write_cost - full_cost; assert_eq!( actual_surcharge, expected_surcharge, "surcharge should be 25% of input cost for 5m cache writes" ); } #[tokio::test] async fn test_cache_write_surcharge_long_ttl() { let guard = CostGuard::new(CostGuardConfig::default()); // Full price: 1000 input + 500 output let full_cost = guard .record_llm_call( "claude-opus-4-6", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None, ) .await; let guard2 = CostGuard::new(CostGuardConfig::default()); // All input tokens are cache writes with 2.0x multiplier (1h TTL) let long_multiplier = Decimal::TWO; let write_cost = guard2 .record_llm_call( "claude-opus-4-6", 1000, 500, 0, 1000, Decimal::ONE, long_multiplier, None, ) .await; // Write cost > full cost assert!(write_cost > full_cost); // Surcharge should be 100% of input cost (2.0x - 1.0x = 1.0x) let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap(); let expected_surcharge = input_rate * Decimal::from(1000u32); let actual_surcharge = write_cost - full_cost; assert_eq!( actual_surcharge, expected_surcharge, "surcharge should be 100% of input cost for 1h cache writes" ); } /// Regression test for #657: Instant::now() - Duration panics on Windows /// when system uptime is less than the subtracted duration. #[tokio::test] async fn test_checked_sub_no_panic_on_fresh_guard() { // A fresh CostGuard with rate limits should not panic even if // checked_sub returns None (simulating short uptime). let guard = CostGuard::new(CostGuardConfig { max_cost_per_day_cents: None, max_actions_per_hour: Some(100), }); // These must not panic regardless of system uptime assert!(guard.check_allowed().await.is_ok()); assert_eq!(guard.actions_this_hour().await, 0); // Record some actions and verify again guard .record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None) .await; assert!(guard.check_allowed().await.is_ok()); assert_eq!(guard.actions_this_hour().await, 1); } /// Verify that checked_sub itself behaves as expected for the pattern we use. #[test] fn test_instant_checked_sub_returns_none_for_overflow() { // Duration::MAX will always exceed uptime, so checked_sub must return None let result = Instant::now().checked_sub(std::time::Duration::MAX); assert!(result.is_none()); } }