Skip to main content

llm_budget/
error.rs

1//! Typed error hierarchy for llm-budget. Every budget operation surfaces
2//! a named, matchable error variant, so nothing fails silently.
3
4use crate::model::ModelTier;
5
6/// Every error this crate returns.
7#[derive(Debug, thiserror::Error, Clone, PartialEq)]
8#[non_exhaustive]
9pub enum BudgetError {
10    /// The agent has exhausted its per-agent spend limit.
11    #[error("Agent '{agent_id}' budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
12    AgentExhausted {
13        /// Agent name.
14        agent_id: String,
15        /// Spent so far, in USD.
16        spent: f64,
17        /// The cap, in USD.
18        limit: f64,
19    },
20
21    /// A task-scoped budget envelope has been depleted.
22    #[error("Task '{task_id}' budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
23    TaskExhausted {
24        /// Task name.
25        task_id: String,
26        /// Spent so far, in USD.
27        spent: f64,
28        /// The cap, in USD.
29        limit: f64,
30    },
31
32    /// A session-scoped budget envelope has been depleted.
33    #[error("Session '{session_id}' budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
34    SessionExhausted {
35        /// Session name.
36        session_id: String,
37        /// Spent so far, in USD.
38        spent: f64,
39        /// The cap, in USD.
40        limit: f64,
41    },
42
43    /// The fleet-wide budget cap has been reached.
44    #[error("Fleet budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
45    FleetExhausted {
46        /// Spent so far, in USD.
47        spent: f64,
48        /// The cap, in USD.
49        limit: f64,
50    },
51
52    /// The kill switch was triggered; execution must halt.
53    #[error("Kill switch activated for agent '{agent_id}': {reason}")]
54    KillSwitchActivated {
55        /// Agent name.
56        agent_id: String,
57        /// Why the switch was flipped.
58        reason: String,
59    },
60
61    /// A model downgrade was required due to budget pressure.
62    #[error("Model downgraded from {from:?} to {to:?} due to budget pressure (remaining: ${remaining:.6})")]
63    ModelDowngraded {
64        /// Tier or agent moved from.
65        from: ModelTier,
66        /// Tier or agent moved to.
67        to: ModelTier,
68        /// Budget left, in USD.
69        remaining: f64,
70    },
71
72    /// No lower-cost model is available for downgrade.
73    #[error("No fallback model available from {from:?}; all tiers exhausted")]
74    NoFallbackAvailable {
75        /// Tier or agent moved from.
76        from: ModelTier,
77    },
78
79    /// Budget allocation failed: requested amount exceeds available fleet budget.
80    #[error("Cannot allocate ${requested:.6} to agent '{agent_id}': only ${available:.6} available in fleet")]
81    AllocationFailed {
82        /// Agent name.
83        agent_id: String,
84        /// Amount asked for, in USD.
85        requested: f64,
86        /// Amount actually available, in USD.
87        available: f64,
88    },
89
90    /// Reallocation failed because the source agent has insufficient budget.
91    #[error("Cannot reallocate ${amount:.6} from '{from}' to '{to}': source has ${available:.6}")]
92    ReallocationFailed {
93        /// Tier or agent moved from.
94        from: String,
95        /// Tier or agent moved to.
96        to: String,
97        /// Amount to move, in USD.
98        amount: f64,
99        /// Amount actually available, in USD.
100        available: f64,
101    },
102
103    /// Agent ID not found in the fleet registry.
104    #[error("Agent '{0}' not registered in fleet")]
105    UnknownAgent(String),
106
107    /// Persistence I/O failure.
108    #[error("Budget persistence error: {0}")]
109    PersistenceError(String),
110
111    /// Invalid configuration (e.g. negative limit).
112    #[error("Invalid budget configuration: {0}")]
113    InvalidConfig(String),
114
115    /// The model is not in the price table, so its cost cannot be computed.
116    #[error("No price known for model '{0}'")]
117    UnknownModel(String),
118
119    /// A tokens-per-minute or requests-per-minute limit would be crossed
120    /// (feature `ratelimit`).
121    #[error("Per-minute rate limit reached: retry in {retry_after_ms} ms")]
122    RateLimited {
123        /// How long to wait before the call can pass, in milliseconds.
124        retry_after_ms: u64,
125    },
126
127    /// The tokenizer could not be loaded or used (feature `tokens`).
128    #[error("Tokenizer error: {0}")]
129    Tokenizer(String),
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_agent_exhausted_display_contains_agent_id() {
138        let e = BudgetError::AgentExhausted {
139            agent_id: "agent-42".into(),
140            spent: 1.5,
141            limit: 1.0,
142        };
143        assert!(e.to_string().contains("agent-42"));
144        assert!(e.to_string().contains("1.5"));
145    }
146
147    #[test]
148    fn test_task_exhausted_display() {
149        let e = BudgetError::TaskExhausted {
150            task_id: "task-7".into(),
151            spent: 0.05,
152            limit: 0.04,
153        };
154        assert!(e.to_string().contains("task-7"));
155    }
156
157    #[test]
158    fn test_session_exhausted_display() {
159        let e = BudgetError::SessionExhausted {
160            session_id: "sess-1".into(),
161            spent: 2.0,
162            limit: 1.0,
163        };
164        assert!(e.to_string().contains("sess-1"));
165    }
166
167    #[test]
168    fn test_fleet_exhausted_display() {
169        let e = BudgetError::FleetExhausted { spent: 100.0, limit: 99.0 };
170        assert!(e.to_string().contains("Fleet"));
171    }
172
173    #[test]
174    fn test_kill_switch_display() {
175        let e = BudgetError::KillSwitchActivated {
176            agent_id: "a1".into(),
177            reason: "hard limit breach".into(),
178        };
179        assert!(e.to_string().contains("a1"));
180        assert!(e.to_string().contains("hard limit breach"));
181    }
182
183    #[test]
184    fn test_model_downgraded_display() {
185        let e = BudgetError::ModelDowngraded {
186            from: ModelTier::Premium,
187            to: ModelTier::Standard,
188            remaining: 0.01,
189        };
190        assert!(e.to_string().contains("downgraded"));
191    }
192
193    #[test]
194    fn test_no_fallback_display() {
195        let e = BudgetError::NoFallbackAvailable { from: ModelTier::Economy };
196        assert!(e.to_string().contains("Economy"));
197    }
198
199    #[test]
200    fn test_allocation_failed_display() {
201        let e = BudgetError::AllocationFailed {
202            agent_id: "a1".into(),
203            requested: 10.0,
204            available: 5.0,
205        };
206        assert!(e.to_string().contains("a1"));
207        assert!(e.to_string().contains("10"));
208    }
209
210    #[test]
211    fn test_reallocation_failed_display() {
212        let e = BudgetError::ReallocationFailed {
213            from: "a1".into(),
214            to: "a2".into(),
215            amount: 1.0,
216            available: 0.5,
217        };
218        assert!(e.to_string().contains("a1"));
219        assert!(e.to_string().contains("a2"));
220    }
221
222    #[test]
223    fn test_unknown_agent_display() {
224        let e = BudgetError::UnknownAgent("ghost-agent".into());
225        assert!(e.to_string().contains("ghost-agent"));
226    }
227
228    #[test]
229    fn test_persistence_error_display() {
230        let e = BudgetError::PersistenceError("disk full".into());
231        assert!(e.to_string().contains("disk full"));
232    }
233
234    #[test]
235    fn test_invalid_config_display() {
236        let e = BudgetError::InvalidConfig("limit must be positive".into());
237        assert!(e.to_string().contains("positive"));
238    }
239
240    #[test]
241    fn test_budget_error_is_clone() {
242        let e = BudgetError::UnknownAgent("x".into());
243        let _ = e.clone();
244    }
245
246    #[test]
247    fn test_budget_error_is_partial_eq() {
248        let a = BudgetError::UnknownAgent("x".into());
249        let b = BudgetError::UnknownAgent("x".into());
250        assert_eq!(a, b);
251    }
252}