1use crate::model::ModelTier;
5
6#[derive(Debug, thiserror::Error, Clone, PartialEq)]
8#[non_exhaustive]
9pub enum BudgetError {
10 #[error("Agent '{agent_id}' budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
12 AgentExhausted {
13 agent_id: String,
15 spent: f64,
17 limit: f64,
19 },
20
21 #[error("Task '{task_id}' budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
23 TaskExhausted {
24 task_id: String,
26 spent: f64,
28 limit: f64,
30 },
31
32 #[error("Session '{session_id}' budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
34 SessionExhausted {
35 session_id: String,
37 spent: f64,
39 limit: f64,
41 },
42
43 #[error("Fleet budget exhausted: spent ${spent:.6}, limit ${limit:.6}")]
45 FleetExhausted {
46 spent: f64,
48 limit: f64,
50 },
51
52 #[error("Kill switch activated for agent '{agent_id}': {reason}")]
54 KillSwitchActivated {
55 agent_id: String,
57 reason: String,
59 },
60
61 #[error("Model downgraded from {from:?} to {to:?} due to budget pressure (remaining: ${remaining:.6})")]
63 ModelDowngraded {
64 from: ModelTier,
66 to: ModelTier,
68 remaining: f64,
70 },
71
72 #[error("No fallback model available from {from:?}; all tiers exhausted")]
74 NoFallbackAvailable {
75 from: ModelTier,
77 },
78
79 #[error("Cannot allocate ${requested:.6} to agent '{agent_id}': only ${available:.6} available in fleet")]
81 AllocationFailed {
82 agent_id: String,
84 requested: f64,
86 available: f64,
88 },
89
90 #[error("Cannot reallocate ${amount:.6} from '{from}' to '{to}': source has ${available:.6}")]
92 ReallocationFailed {
93 from: String,
95 to: String,
97 amount: f64,
99 available: f64,
101 },
102
103 #[error("Agent '{0}' not registered in fleet")]
105 UnknownAgent(String),
106
107 #[error("Budget persistence error: {0}")]
109 PersistenceError(String),
110
111 #[error("Invalid budget configuration: {0}")]
113 InvalidConfig(String),
114
115 #[error("No price known for model '{0}'")]
117 UnknownModel(String),
118
119 #[error("Per-minute rate limit reached: retry in {retry_after_ms} ms")]
122 RateLimited {
123 retry_after_ms: u64,
125 },
126
127 #[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}