Skip to main content

litellm_rs/core/router/
budget_routing.rs

1//! Budget-aware routing
2//!
3//! This module provides budget-aware routing capabilities that filter out
4//! providers and models that have exceeded their budget limits.
5
6use crate::core::budget::{
7    BudgetReservationError, BudgetStatus, UnifiedBudgetLimits, UnifiedBudgetReservation,
8};
9use std::sync::Arc;
10use tracing::{debug, warn};
11
12/// Budget-aware router wrapper
13///
14/// Wraps routing decisions with budget checking to skip providers
15/// that have exceeded their budget limits.
16#[derive(Clone)]
17pub struct BudgetAwareRouter {
18    /// Budget limits manager
19    budget_limits: Arc<UnifiedBudgetLimits>,
20    /// Whether to log warnings when approaching limits
21    log_warnings: bool,
22    /// Warning threshold percentage (0.0 to 1.0)
23    warning_threshold: f64,
24}
25
26impl BudgetAwareRouter {
27    /// Create a new budget-aware router
28    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    /// Set whether to log warnings
37    pub fn with_warnings(mut self, log_warnings: bool) -> Self {
38        self.log_warnings = log_warnings;
39        self
40    }
41
42    /// Set warning threshold
43    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    /// Get the budget limits manager
49    pub fn budget_limits(&self) -> &UnifiedBudgetLimits {
50        &self.budget_limits
51    }
52
53    /// Filter providers based on budget availability
54    ///
55    /// Returns providers that have not exceeded their budget limits.
56    /// Providers without configured budgets are always included.
57    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    /// Filter models based on budget availability
88    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    /// Check if a specific provider is available
105    pub fn is_provider_available(&self, provider: &str) -> bool {
106        self.budget_limits.is_provider_available(provider)
107    }
108
109    /// Check if a specific model is available
110    pub fn is_model_available(&self, model: &str) -> bool {
111        self.budget_limits.is_model_available(model)
112    }
113
114    /// Check if a request can be made with the given provider and model
115    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    /// Reserve estimated spend for a request before upstream work starts.
141    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    /// Check provider budget
152    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    /// Check model budget
173    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    /// Record spend after a request completes
194    pub fn record_spend(&self, provider: &str, model: &str, cost: f64) {
195        self.budget_limits.record_spend(provider, model, cost);
196    }
197
198    /// Get a fallback provider when the primary is over budget
199    ///
200    /// Returns the first available provider from the fallback list,
201    /// or None if all providers have exceeded their budgets.
202    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    /// Get providers sorted by remaining budget (highest first)
213    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                // Only include providers that aren't exceeded
225                if self.is_provider_available(&p) {
226                    Some((p, remaining))
227                } else {
228                    None
229                }
230            })
231            .collect();
232
233        // Sort by remaining budget (highest first)
234        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
240/// Internal budget check result
241struct BudgetCheckResult {
242    allowed: bool,
243    status: BudgetStatus,
244    remaining: f64,
245}
246
247/// Result of checking if a request is within budget
248#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
249pub struct RequestBudgetCheck {
250    /// Whether the request is allowed
251    pub allowed: bool,
252    /// Provider budget status
253    pub provider_status: BudgetStatus,
254    /// Model budget status
255    pub model_status: BudgetStatus,
256    /// Remaining provider budget
257    pub provider_remaining: f64,
258    /// Remaining model budget
259    pub model_remaining: f64,
260    /// Reason if not allowed
261    pub reason: Option<String>,
262}
263
264/// Extension trait for adding budget awareness to routers
265pub trait BudgetAwareRouting {
266    /// Filter providers based on budget availability
267    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        // Exceed openai budget
323        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")); // No limit = available
345
346        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        // Should allow
364        let check = router.can_make_request("openai", "gpt-4", 10.0);
365        assert!(check.allowed);
366        assert!(check.reason.is_none());
367
368        // Exceed model budget
369        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        // Exceed openai and anthropic
456        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        // Different spend amounts
488        limits.providers.record_provider_spend("openai", 80.0); // 20 remaining
489        limits.providers.record_provider_spend("anthropic", 30.0); // 70 remaining
490        limits.providers.record_provider_spend("google", 50.0); // 50 remaining
491
492        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        // Should be sorted by remaining budget (highest first)
503        assert_eq!(sorted[0], "anthropic"); // 70 remaining
504        assert_eq!(sorted[1], "google"); // 50 remaining
505        assert_eq!(sorted[2], "openai"); // 20 remaining
506    }
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        // Exceed gpt-4 budget
520        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}