1use crate::core::budget::{
7 BudgetReservationError, BudgetStatus, UnifiedBudgetLimits, UnifiedBudgetReservation,
8};
9use std::sync::Arc;
10use tracing::{debug, warn};
11
12#[derive(Clone)]
17pub struct BudgetAwareRouter {
18 budget_limits: Arc<UnifiedBudgetLimits>,
20 log_warnings: bool,
22 warning_threshold: f64,
24}
25
26impl BudgetAwareRouter {
27 pub fn new(budget_limits: Arc<UnifiedBudgetLimits>) -> Self {
29 Self {
30 budget_limits,
31 log_warnings: true,
32 warning_threshold: 0.8,
33 }
34 }
35
36 pub fn with_warnings(mut self, log_warnings: bool) -> Self {
38 self.log_warnings = log_warnings;
39 self
40 }
41
42 pub fn with_warning_threshold(mut self, threshold: f64) -> Self {
44 self.warning_threshold = threshold.clamp(0.0, 1.0);
45 self
46 }
47
48 pub fn budget_limits(&self) -> &UnifiedBudgetLimits {
50 &self.budget_limits
51 }
52
53 pub fn filter_available_providers(&self, providers: Vec<String>) -> Vec<String> {
58 let available: Vec<String> = providers
59 .into_iter()
60 .filter(|provider| {
61 let status = self.budget_limits.providers.check_provider_budget(provider);
62 let is_available = status != BudgetStatus::Exceeded;
63
64 if !is_available {
65 debug!("Provider '{}' filtered out: budget exceeded", provider);
66 } else if self.log_warnings
67 && status == BudgetStatus::Warning
68 && let Some(usage) = self.budget_limits.providers.get_provider_usage(provider)
69 {
70 warn!(
71 "Provider '{}' approaching budget limit: ${:.2} / ${:.2} ({:.1}%)",
72 provider, usage.current_spend, usage.max_budget, usage.usage_percentage
73 );
74 }
75
76 is_available
77 })
78 .collect();
79
80 if available.is_empty() {
81 debug!("All providers have exceeded budget limits");
82 }
83
84 available
85 }
86
87 pub fn filter_available_models(&self, models: Vec<String>) -> Vec<String> {
89 models
90 .into_iter()
91 .filter(|model| {
92 let status = self.budget_limits.models.check_model_budget(model);
93 let is_available = status != BudgetStatus::Exceeded;
94
95 if !is_available {
96 debug!("Model '{}' filtered out: budget exceeded", model);
97 }
98
99 is_available
100 })
101 .collect()
102 }
103
104 pub fn is_provider_available(&self, provider: &str) -> bool {
106 self.budget_limits.is_provider_available(provider)
107 }
108
109 pub fn is_model_available(&self, model: &str) -> bool {
111 self.budget_limits.is_model_available(model)
112 }
113
114 pub fn can_make_request(
116 &self,
117 provider: &str,
118 model: &str,
119 estimated_cost: f64,
120 ) -> RequestBudgetCheck {
121 let provider_check = self.check_provider(provider, estimated_cost);
122 let model_check = self.check_model(model, estimated_cost);
123
124 RequestBudgetCheck {
125 allowed: provider_check.allowed && model_check.allowed,
126 provider_status: provider_check.status,
127 model_status: model_check.status,
128 provider_remaining: provider_check.remaining,
129 model_remaining: model_check.remaining,
130 reason: if !provider_check.allowed {
131 Some(format!("Provider '{}' has exceeded budget", provider))
132 } else if !model_check.allowed {
133 Some(format!("Model '{}' has exceeded budget", model))
134 } else {
135 None
136 },
137 }
138 }
139
140 pub fn reserve_request(
142 &self,
143 provider: &str,
144 model: &str,
145 estimated_cost: f64,
146 ) -> Result<UnifiedBudgetReservation, BudgetReservationError> {
147 self.budget_limits
148 .reserve_spend(provider, model, estimated_cost)
149 }
150
151 fn check_provider(&self, provider: &str, estimated_cost: f64) -> BudgetCheckResult {
153 let can_spend = self
154 .budget_limits
155 .providers
156 .can_provider_spend(provider, estimated_cost);
157 let status = self.budget_limits.providers.check_provider_budget(provider);
158 let remaining = self
159 .budget_limits
160 .providers
161 .get_provider_usage(provider)
162 .map(|u| u.remaining)
163 .unwrap_or(f64::INFINITY);
164
165 BudgetCheckResult {
166 allowed: can_spend,
167 status,
168 remaining,
169 }
170 }
171
172 fn check_model(&self, model: &str, estimated_cost: f64) -> BudgetCheckResult {
174 let can_spend = self
175 .budget_limits
176 .models
177 .can_model_spend(model, estimated_cost);
178 let status = self.budget_limits.models.check_model_budget(model);
179 let remaining = self
180 .budget_limits
181 .models
182 .get_model_usage(model)
183 .map(|u| u.remaining)
184 .unwrap_or(f64::INFINITY);
185
186 BudgetCheckResult {
187 allowed: can_spend,
188 status,
189 remaining,
190 }
191 }
192
193 pub fn record_spend(&self, provider: &str, model: &str, cost: f64) {
195 self.budget_limits.record_spend(provider, model, cost);
196 }
197
198 pub fn get_fallback_provider(&self, fallbacks: &[String]) -> Option<String> {
203 for provider in fallbacks {
204 if self.is_provider_available(provider) {
205 debug!("Using fallback provider: {}", provider);
206 return Some(provider.clone());
207 }
208 }
209 None
210 }
211
212 pub fn get_providers_by_remaining_budget(&self, providers: Vec<String>) -> Vec<String> {
214 let mut provider_budgets: Vec<(String, f64)> = providers
215 .into_iter()
216 .filter_map(|p| {
217 let remaining = self
218 .budget_limits
219 .providers
220 .get_provider_usage(&p)
221 .map(|u| u.remaining)
222 .unwrap_or(f64::INFINITY);
223
224 if self.is_provider_available(&p) {
226 Some((p, remaining))
227 } else {
228 None
229 }
230 })
231 .collect();
232
233 provider_budgets.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
235
236 provider_budgets.into_iter().map(|(p, _)| p).collect()
237 }
238}
239
240struct BudgetCheckResult {
242 allowed: bool,
243 status: BudgetStatus,
244 remaining: f64,
245}
246
247#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
249pub struct RequestBudgetCheck {
250 pub allowed: bool,
252 pub provider_status: BudgetStatus,
254 pub model_status: BudgetStatus,
256 pub provider_remaining: f64,
258 pub model_remaining: f64,
260 pub reason: Option<String>,
262}
263
264pub trait BudgetAwareRouting {
266 fn filter_by_budget(
268 &self,
269 providers: Vec<String>,
270 budget_router: &BudgetAwareRouter,
271 ) -> Vec<String>;
272}
273
274impl<T> BudgetAwareRouting for T {
275 fn filter_by_budget(
276 &self,
277 providers: Vec<String>,
278 budget_router: &BudgetAwareRouter,
279 ) -> Vec<String> {
280 budget_router.filter_available_providers(providers)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::core::budget::{ModelLimitConfig, ProviderLimitConfig, ResetPeriod};
288
289 fn create_test_router() -> BudgetAwareRouter {
290 let limits = Arc::new(UnifiedBudgetLimits::new());
291 BudgetAwareRouter::new(limits)
292 }
293
294 #[test]
295 fn test_budget_aware_router_creation() {
296 let router = create_test_router();
297 assert!(router.log_warnings);
298 assert!((router.warning_threshold - 0.8).abs() < f64::EPSILON);
299 }
300
301 #[test]
302 fn test_filter_available_providers_no_limits() {
303 let router = create_test_router();
304 let providers = vec!["openai".to_string(), "anthropic".to_string()];
305
306 let available = router.filter_available_providers(providers.clone());
307 assert_eq!(available, providers);
308 }
309
310 #[test]
311 fn test_filter_available_providers_with_exceeded() {
312 let limits = Arc::new(UnifiedBudgetLimits::new());
313 limits.providers.set_provider_limit(
314 "openai",
315 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
316 );
317 limits.providers.set_provider_limit(
318 "anthropic",
319 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
320 );
321
322 limits.providers.record_provider_spend("openai", 150.0);
324
325 let router = BudgetAwareRouter::new(limits);
326 let providers = vec!["openai".to_string(), "anthropic".to_string()];
327
328 let available = router.filter_available_providers(providers);
329 assert_eq!(available.len(), 1);
330 assert_eq!(available[0], "anthropic");
331 }
332
333 #[test]
334 fn test_is_provider_available() {
335 let limits = Arc::new(UnifiedBudgetLimits::new());
336 limits.providers.set_provider_limit(
337 "openai",
338 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
339 );
340
341 let router = BudgetAwareRouter::new(limits.clone());
342
343 assert!(router.is_provider_available("openai"));
344 assert!(router.is_provider_available("unknown")); limits.providers.record_provider_spend("openai", 150.0);
347 assert!(!router.is_provider_available("openai"));
348 }
349
350 #[test]
351 fn test_can_make_request() {
352 let limits = Arc::new(UnifiedBudgetLimits::new());
353 limits.providers.set_provider_limit(
354 "openai",
355 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
356 );
357 limits
358 .models
359 .set_model_limit("gpt-4", ModelLimitConfig::new(50.0, ResetPeriod::Monthly));
360
361 let router = BudgetAwareRouter::new(limits.clone());
362
363 let check = router.can_make_request("openai", "gpt-4", 10.0);
365 assert!(check.allowed);
366 assert!(check.reason.is_none());
367
368 limits.models.record_model_spend("gpt-4", 60.0);
370
371 let check = router.can_make_request("openai", "gpt-4", 10.0);
372 assert!(!check.allowed);
373 assert!(check.reason.is_some());
374 assert!(check.reason.unwrap().contains("gpt-4"));
375 }
376
377 #[test]
378 fn test_record_spend() {
379 let limits = Arc::new(UnifiedBudgetLimits::new());
380 limits.providers.set_provider_limit(
381 "openai",
382 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
383 );
384 limits
385 .models
386 .set_model_limit("gpt-4", ModelLimitConfig::new(100.0, ResetPeriod::Monthly));
387
388 let router = BudgetAwareRouter::new(limits.clone());
389 router.record_spend("openai", "gpt-4", 25.0);
390
391 let provider_usage = limits.providers.get_provider_usage("openai").unwrap();
392 let model_usage = limits.models.get_model_usage("gpt-4").unwrap();
393
394 assert_eq!(provider_usage.current_spend, 25.0);
395 assert_eq!(model_usage.current_spend, 25.0);
396 }
397
398 #[test]
399 fn test_reserve_request_settles_estimated_spend() {
400 let limits = Arc::new(UnifiedBudgetLimits::new());
401 limits.providers.set_provider_limit(
402 "openai",
403 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
404 );
405 limits
406 .models
407 .set_model_limit("gpt-4", ModelLimitConfig::new(100.0, ResetPeriod::Monthly));
408
409 let router = BudgetAwareRouter::new(limits.clone());
410 let reservation = router.reserve_request("openai", "gpt-4", 10.0).unwrap();
411
412 assert_eq!(
413 limits
414 .providers
415 .get_provider_usage("openai")
416 .unwrap()
417 .current_spend,
418 10.0
419 );
420 reservation.settle(4.0).unwrap();
421 assert_eq!(
422 limits
423 .providers
424 .get_provider_usage("openai")
425 .unwrap()
426 .current_spend,
427 4.0
428 );
429 assert_eq!(
430 limits
431 .models
432 .get_model_usage("gpt-4")
433 .unwrap()
434 .current_spend,
435 4.0
436 );
437 }
438
439 #[test]
440 fn test_get_fallback_provider() {
441 let limits = Arc::new(UnifiedBudgetLimits::new());
442 limits.providers.set_provider_limit(
443 "openai",
444 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
445 );
446 limits.providers.set_provider_limit(
447 "anthropic",
448 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
449 );
450 limits.providers.set_provider_limit(
451 "google",
452 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
453 );
454
455 limits.providers.record_provider_spend("openai", 150.0);
457 limits.providers.record_provider_spend("anthropic", 150.0);
458
459 let router = BudgetAwareRouter::new(limits);
460
461 let fallbacks = vec![
462 "openai".to_string(),
463 "anthropic".to_string(),
464 "google".to_string(),
465 ];
466
467 let fallback = router.get_fallback_provider(&fallbacks);
468 assert_eq!(fallback, Some("google".to_string()));
469 }
470
471 #[test]
472 fn test_get_providers_by_remaining_budget() {
473 let limits = Arc::new(UnifiedBudgetLimits::new());
474 limits.providers.set_provider_limit(
475 "openai",
476 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
477 );
478 limits.providers.set_provider_limit(
479 "anthropic",
480 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
481 );
482 limits.providers.set_provider_limit(
483 "google",
484 ProviderLimitConfig::new(100.0, ResetPeriod::Monthly),
485 );
486
487 limits.providers.record_provider_spend("openai", 80.0); limits.providers.record_provider_spend("anthropic", 30.0); limits.providers.record_provider_spend("google", 50.0); let router = BudgetAwareRouter::new(limits);
493
494 let providers = vec![
495 "openai".to_string(),
496 "anthropic".to_string(),
497 "google".to_string(),
498 ];
499
500 let sorted = router.get_providers_by_remaining_budget(providers);
501
502 assert_eq!(sorted[0], "anthropic"); assert_eq!(sorted[1], "google"); assert_eq!(sorted[2], "openai"); }
507
508 #[test]
509 fn test_filter_available_models() {
510 let limits = Arc::new(UnifiedBudgetLimits::new());
511 limits
512 .models
513 .set_model_limit("gpt-4", ModelLimitConfig::new(100.0, ResetPeriod::Monthly));
514 limits.models.set_model_limit(
515 "gpt-3.5-turbo",
516 ModelLimitConfig::new(100.0, ResetPeriod::Monthly),
517 );
518
519 limits.models.record_model_spend("gpt-4", 150.0);
521
522 let router = BudgetAwareRouter::new(limits);
523
524 let models = vec!["gpt-4".to_string(), "gpt-3.5-turbo".to_string()];
525 let available = router.filter_available_models(models);
526
527 assert_eq!(available.len(), 1);
528 assert_eq!(available[0], "gpt-3.5-turbo");
529 }
530}