Skip to main content

batuta/serve/
circuit_breaker.rs

1//! Cost Circuit Breaker
2//!
3//! Implements Toyota Way "Muda Elimination" (Waste Prevention).
4//!
5//! Prevents runaway API costs by tracking usage and enforcing daily budgets.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::RwLock;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13// ============================================================================
14// SERVE-CBR-001: Cost Model
15// ============================================================================
16
17/// Token pricing for a backend (per 1M tokens)
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
19pub struct TokenPricing {
20    /// Input token cost per 1M tokens (USD)
21    pub input_per_million: f64,
22    /// Output token cost per 1M tokens (USD)
23    pub output_per_million: f64,
24}
25
26impl TokenPricing {
27    /// Create new pricing
28    #[must_use]
29    pub const fn new(input_per_million: f64, output_per_million: f64) -> Self {
30        Self { input_per_million, output_per_million }
31    }
32
33    /// Calculate cost for given token counts
34    #[must_use]
35    pub fn calculate(&self, input_tokens: u64, output_tokens: u64) -> f64 {
36        let input_cost = (input_tokens as f64 / 1_000_000.0) * self.input_per_million;
37        let output_cost = (output_tokens as f64 / 1_000_000.0) * self.output_per_million;
38        input_cost + output_cost
39    }
40
41    /// Known pricing for common models (approximate, as of late 2024)
42    #[must_use]
43    pub fn for_model(model: &str) -> Self {
44        const MODEL_PRICING: &[(&[&str], f64, f64)] = &[
45            (&["gpt-4o"], 2.50, 10.00),
46            (&["gpt-4-turbo", "gpt-4"], 10.00, 30.00),
47            (&["gpt-3.5"], 0.50, 1.50),
48            (&["claude-3-opus"], 15.00, 75.00),
49            (&["claude-3-sonnet", "claude-3.5"], 3.00, 15.00),
50            (&["claude-3-haiku"], 0.25, 1.25),
51            (&["llama", "mistral"], 0.20, 0.20),
52        ];
53        let lower = model.to_lowercase();
54        MODEL_PRICING
55            .iter()
56            .find(|(patterns, _, _)| patterns.iter().any(|p| lower.contains(p)))
57            .map(|(_, input, output)| Self::new(*input, *output))
58            .unwrap_or_else(|| Self::new(1.00, 2.00))
59    }
60}
61
62impl Default for TokenPricing {
63    fn default() -> Self {
64        Self::new(1.00, 2.00)
65    }
66}
67
68// ============================================================================
69// SERVE-CBR-002: Usage Tracking
70// ============================================================================
71
72/// Usage record for a single request
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct UsageRecord {
75    pub timestamp: u64,
76    pub backend: String,
77    pub model: String,
78    pub input_tokens: u64,
79    pub output_tokens: u64,
80    pub cost_usd: f64,
81}
82
83/// Daily usage summary
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct DailyUsage {
86    /// Date as YYYY-MM-DD
87    pub date: String,
88    /// Total input tokens
89    pub total_input_tokens: u64,
90    /// Total output tokens
91    pub total_output_tokens: u64,
92    /// Total cost in USD
93    pub total_cost_usd: f64,
94    /// Request count
95    pub request_count: u64,
96    /// Usage by model
97    pub by_model: HashMap<String, f64>,
98}
99
100impl DailyUsage {
101    /// Create for today
102    #[must_use]
103    pub fn today() -> Self {
104        Self { date: Self::current_date(), ..Default::default() }
105    }
106
107    /// Get current date string
108    #[must_use]
109    pub fn current_date() -> String {
110        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
111        // Simple date calculation (not timezone aware)
112        let days = now / 86400;
113        let year = 1970 + (days / 365); // Approximate
114        let day_of_year = days % 365;
115        let month = day_of_year / 30 + 1;
116        let day = day_of_year % 30 + 1;
117        format!("{}-{:02}-{:02}", year, month.min(12), day.min(31))
118    }
119
120    /// Add a usage record
121    pub fn add(&mut self, record: &UsageRecord) {
122        self.total_input_tokens += record.input_tokens;
123        self.total_output_tokens += record.output_tokens;
124        self.total_cost_usd += record.cost_usd;
125        self.request_count += 1;
126        *self.by_model.entry(record.model.clone()).or_insert(0.0) += record.cost_usd;
127    }
128}
129
130// ============================================================================
131// SERVE-CBR-003: Circuit Breaker
132// ============================================================================
133
134/// Circuit breaker state
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
136pub enum CircuitState {
137    /// Circuit is closed, requests allowed
138    #[default]
139    Closed,
140    /// Circuit is open, requests blocked
141    Open,
142    /// Half-open, testing if budget allows
143    HalfOpen,
144}
145
146/// Cost circuit breaker configuration
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CircuitBreakerConfig {
149    /// Daily budget in USD
150    pub daily_budget_usd: f64,
151    /// Warning threshold (percentage of budget)
152    pub warning_threshold: f64,
153    /// Per-request cost limit (USD)
154    pub max_request_cost_usd: f64,
155    /// Auto-reset after cooldown period
156    pub cooldown_seconds: u64,
157}
158
159impl Default for CircuitBreakerConfig {
160    fn default() -> Self {
161        Self {
162            daily_budget_usd: 10.0,    // $10/day default
163            warning_threshold: 0.8,    // Warn at 80%
164            max_request_cost_usd: 1.0, // Max $1 per request
165            cooldown_seconds: 3600,    // 1 hour cooldown
166        }
167    }
168}
169
170impl CircuitBreakerConfig {
171    /// Create with specific daily budget
172    #[must_use]
173    pub fn with_budget(daily_budget_usd: f64) -> Self {
174        Self { daily_budget_usd, ..Default::default() }
175    }
176}
177
178/// Cost circuit breaker
179///
180/// Thread-safe circuit breaker that tracks API costs and prevents overspending.
181pub struct CostCircuitBreaker {
182    config: CircuitBreakerConfig,
183    /// Accumulated cost in millicents (for atomic operations)
184    accumulated_millicents: AtomicU64,
185    /// Current date for reset tracking
186    current_date: RwLock<String>,
187    /// Circuit state
188    state: RwLock<CircuitState>,
189    /// Time when circuit was opened
190    opened_at: RwLock<Option<u64>>,
191}
192
193impl CostCircuitBreaker {
194    /// Create a new circuit breaker
195    #[must_use]
196    pub fn new(config: CircuitBreakerConfig) -> Self {
197        Self {
198            config,
199            accumulated_millicents: AtomicU64::new(0),
200            current_date: RwLock::new(DailyUsage::current_date()),
201            state: RwLock::new(CircuitState::Closed),
202            opened_at: RwLock::new(None),
203        }
204    }
205
206    /// Create with default config
207    #[must_use]
208    pub fn with_defaults() -> Self {
209        Self::new(CircuitBreakerConfig::default())
210    }
211
212    // Lock accessor helpers — single source of truth for lock patterns
213    fn read_state(&self) -> CircuitState {
214        *self.state.read().expect("circuit breaker state lock poisoned")
215    }
216
217    fn write_state(&self, new_state: CircuitState) {
218        *self.state.write().expect("circuit breaker state lock poisoned") = new_state;
219    }
220
221    fn read_opened_at(&self) -> Option<u64> {
222        *self.opened_at.read().expect("circuit breaker opened_at lock poisoned")
223    }
224
225    fn write_opened_at(&self, timestamp: Option<u64>) {
226        *self.opened_at.write().expect("circuit breaker opened_at lock poisoned") = timestamp;
227    }
228
229    fn read_current_date(&self) -> String {
230        self.current_date.read().expect("circuit breaker current_date lock poisoned").clone()
231    }
232
233    fn write_current_date(&self, date: String) {
234        *self.current_date.write().expect("circuit breaker current_date lock poisoned") = date;
235    }
236
237    /// Check if a request with estimated cost is allowed
238    pub fn check(&self, estimated_cost_usd: f64) -> Result<(), CircuitBreakerError> {
239        // Reset if new day
240        self.maybe_reset_daily();
241
242        // Check per-request limit
243        if estimated_cost_usd > self.config.max_request_cost_usd {
244            return Err(CircuitBreakerError::RequestTooExpensive {
245                estimated: estimated_cost_usd,
246                limit: self.config.max_request_cost_usd,
247            });
248        }
249
250        // Check circuit state
251        match self.read_state() {
252            CircuitState::Open => {
253                // Check if cooldown has passed
254                if self.cooldown_elapsed() {
255                    self.write_state(CircuitState::HalfOpen);
256                } else {
257                    return Err(CircuitBreakerError::BudgetExceeded {
258                        spent: self.accumulated_usd(),
259                        budget: self.config.daily_budget_usd,
260                    });
261                }
262            }
263            CircuitState::HalfOpen | CircuitState::Closed => {}
264        }
265
266        // Check if adding this cost would exceed budget
267        let current = self.accumulated_usd();
268        if current + estimated_cost_usd > self.config.daily_budget_usd {
269            self.write_state(CircuitState::Open);
270            self.write_opened_at(Some(Self::current_timestamp()));
271            return Err(CircuitBreakerError::BudgetExceeded {
272                spent: current,
273                budget: self.config.daily_budget_usd,
274            });
275        }
276
277        Ok(())
278    }
279
280    /// Record actual cost after request completes
281    pub fn record(&self, actual_cost_usd: f64) {
282        let millicents = (actual_cost_usd * 100_000.0) as u64;
283        self.accumulated_millicents.fetch_add(millicents, Ordering::SeqCst);
284
285        // Check if we've hit the budget
286        if self.accumulated_usd() >= self.config.daily_budget_usd {
287            self.write_state(CircuitState::Open);
288            self.write_opened_at(Some(Self::current_timestamp()));
289        }
290    }
291
292    /// Get current accumulated cost in USD
293    #[must_use]
294    pub fn accumulated_usd(&self) -> f64 {
295        self.accumulated_millicents.load(Ordering::SeqCst) as f64 / 100_000.0
296    }
297
298    /// Get remaining budget
299    #[must_use]
300    pub fn remaining_usd(&self) -> f64 {
301        (self.config.daily_budget_usd - self.accumulated_usd()).max(0.0)
302    }
303
304    /// Get budget utilization percentage
305    #[must_use]
306    pub fn utilization(&self) -> f64 {
307        if self.config.daily_budget_usd == 0.0 {
308            return if self.accumulated_usd() > 0.0 { 1.0 } else { 0.0 };
309        }
310        self.accumulated_usd() / self.config.daily_budget_usd
311    }
312
313    /// Check if at warning threshold
314    #[must_use]
315    pub fn is_warning(&self) -> bool {
316        self.utilization() >= self.config.warning_threshold
317    }
318
319    /// Get current state
320    #[must_use]
321    pub fn state(&self) -> CircuitState {
322        self.read_state()
323    }
324
325    /// Force reset (for testing or manual override)
326    pub fn reset(&self) {
327        self.accumulated_millicents.store(0, Ordering::SeqCst);
328        self.write_state(CircuitState::Closed);
329        self.write_opened_at(None);
330        self.write_current_date(DailyUsage::current_date());
331    }
332
333    fn maybe_reset_daily(&self) {
334        let today = DailyUsage::current_date();
335        let current = self.read_current_date();
336        if current != today {
337            drop(current);
338            self.reset();
339        }
340    }
341
342    fn cooldown_elapsed(&self) -> bool {
343        if let Some(opened) = self.read_opened_at() {
344            let now = Self::current_timestamp();
345            now - opened >= self.config.cooldown_seconds
346        } else {
347            true
348        }
349    }
350
351    fn current_timestamp() -> u64 {
352        SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs()
353    }
354}
355
356impl Default for CostCircuitBreaker {
357    fn default() -> Self {
358        Self::with_defaults()
359    }
360}
361
362/// Circuit breaker errors
363#[derive(Debug, Clone, PartialEq)]
364pub enum CircuitBreakerError {
365    /// Daily budget exceeded
366    BudgetExceeded { spent: f64, budget: f64 },
367    /// Single request too expensive
368    RequestTooExpensive { estimated: f64, limit: f64 },
369}
370
371impl std::fmt::Display for CircuitBreakerError {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        match self {
374            Self::BudgetExceeded { spent, budget } => {
375                write!(f, "Daily budget exceeded: ${:.2} spent of ${:.2} budget", spent, budget)
376            }
377            Self::RequestTooExpensive { estimated, limit } => {
378                write!(f, "Request too expensive: ${:.2} estimated, ${:.2} limit", estimated, limit)
379            }
380        }
381    }
382}
383
384impl std::error::Error for CircuitBreakerError {}
385
386// ============================================================================
387// Tests
388// ============================================================================
389
390#[cfg(test)]
391#[allow(non_snake_case)]
392mod tests {
393    use super::*;
394
395    // ========================================================================
396    // SERVE-CBR-001: Token Pricing Tests
397    // ========================================================================
398
399    #[test]
400    fn test_SERVE_CBR_001_pricing_calculate() {
401        let pricing = TokenPricing::new(1.0, 2.0); // $1/M input, $2/M output
402        let cost = pricing.calculate(1_000_000, 500_000);
403        assert!((cost - 2.0).abs() < 0.001); // $1 input + $1 output
404    }
405
406    #[test]
407    fn test_SERVE_CBR_001_pricing_small_amounts() {
408        let pricing = TokenPricing::new(10.0, 30.0); // GPT-4 pricing
409        let cost = pricing.calculate(1000, 500);
410        // 1000 input = $0.01, 500 output = $0.015
411        assert!((cost - 0.025).abs() < 0.001);
412    }
413
414    #[test]
415    fn test_SERVE_CBR_001_pricing_for_model_gpt4() {
416        let pricing = TokenPricing::for_model("gpt-4-turbo");
417        assert_eq!(pricing.input_per_million, 10.0);
418        assert_eq!(pricing.output_per_million, 30.0);
419    }
420
421    #[test]
422    fn test_SERVE_CBR_001_pricing_for_model_claude() {
423        let pricing = TokenPricing::for_model("claude-3-sonnet");
424        assert_eq!(pricing.input_per_million, 3.0);
425        assert_eq!(pricing.output_per_million, 15.0);
426    }
427
428    #[test]
429    fn test_SERVE_CBR_001_pricing_for_model_llama() {
430        let pricing = TokenPricing::for_model("llama-3.1-70b");
431        assert_eq!(pricing.input_per_million, 0.20);
432    }
433
434    #[test]
435    fn test_SERVE_CBR_001_pricing_default() {
436        let pricing = TokenPricing::default();
437        assert_eq!(pricing.input_per_million, 1.0);
438        assert_eq!(pricing.output_per_million, 2.0);
439    }
440
441    // ========================================================================
442    // SERVE-CBR-002: Daily Usage Tests
443    // ========================================================================
444
445    #[test]
446    fn test_SERVE_CBR_002_daily_usage_add() {
447        let mut usage = DailyUsage::today();
448        let record = UsageRecord {
449            timestamp: 0,
450            backend: "openai".to_string(),
451            model: "gpt-4".to_string(),
452            input_tokens: 1000,
453            output_tokens: 500,
454            cost_usd: 0.025,
455        };
456        usage.add(&record);
457        assert_eq!(usage.total_input_tokens, 1000);
458        assert_eq!(usage.total_output_tokens, 500);
459        assert!((usage.total_cost_usd - 0.025).abs() < 0.001);
460        assert_eq!(usage.request_count, 1);
461    }
462
463    #[test]
464    fn test_SERVE_CBR_002_daily_usage_by_model() {
465        let mut usage = DailyUsage::today();
466        usage.add(&UsageRecord {
467            timestamp: 0,
468            backend: "openai".to_string(),
469            model: "gpt-4".to_string(),
470            input_tokens: 1000,
471            output_tokens: 500,
472            cost_usd: 1.0,
473        });
474        usage.add(&UsageRecord {
475            timestamp: 0,
476            backend: "openai".to_string(),
477            model: "gpt-3.5".to_string(),
478            input_tokens: 1000,
479            output_tokens: 500,
480            cost_usd: 0.1,
481        });
482        assert_eq!(usage.by_model.get("gpt-4"), Some(&1.0));
483        assert_eq!(usage.by_model.get("gpt-3.5"), Some(&0.1));
484    }
485
486    // ========================================================================
487    // SERVE-CBR-003: Circuit Breaker Basic Tests
488    // ========================================================================
489
490    #[test]
491    fn test_SERVE_CBR_003_default_config() {
492        let config = CircuitBreakerConfig::default();
493        assert_eq!(config.daily_budget_usd, 10.0);
494        assert_eq!(config.warning_threshold, 0.8);
495    }
496
497    #[test]
498    fn test_SERVE_CBR_003_check_allows_under_budget() {
499        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(10.0));
500        assert!(cb.check(1.0).is_ok());
501    }
502
503    #[test]
504    fn test_SERVE_CBR_003_check_blocks_over_budget() {
505        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(1.0));
506        cb.record(0.9);
507        let result = cb.check(0.2);
508        assert!(result.is_err());
509    }
510
511    #[test]
512    fn test_SERVE_CBR_003_record_accumulates() {
513        let cb = CostCircuitBreaker::with_defaults();
514        cb.record(1.0);
515        cb.record(2.0);
516        assert!((cb.accumulated_usd() - 3.0).abs() < 0.001);
517    }
518
519    // ========================================================================
520    // SERVE-CBR-004: Circuit Breaker State Tests
521    // ========================================================================
522
523    #[test]
524    fn test_SERVE_CBR_004_initial_state_closed() {
525        let cb = CostCircuitBreaker::with_defaults();
526        assert_eq!(cb.state(), CircuitState::Closed);
527    }
528
529    #[test]
530    fn test_SERVE_CBR_004_opens_on_budget_exceed() {
531        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(1.0));
532        cb.record(1.0);
533        assert_eq!(cb.state(), CircuitState::Open);
534    }
535
536    #[test]
537    fn test_SERVE_CBR_004_reset_closes_circuit() {
538        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(1.0));
539        cb.record(1.0);
540        assert_eq!(cb.state(), CircuitState::Open);
541        cb.reset();
542        assert_eq!(cb.state(), CircuitState::Closed);
543        assert!((cb.accumulated_usd()).abs() < 0.001);
544    }
545
546    // ========================================================================
547    // SERVE-CBR-005: Request Limit Tests
548    // ========================================================================
549
550    #[test]
551    fn test_SERVE_CBR_005_rejects_expensive_request() {
552        let config = CircuitBreakerConfig { max_request_cost_usd: 0.5, ..Default::default() };
553        let cb = CostCircuitBreaker::new(config);
554        let result = cb.check(1.0);
555        assert!(matches!(result, Err(CircuitBreakerError::RequestTooExpensive { .. })));
556    }
557
558    #[test]
559    fn test_SERVE_CBR_005_allows_cheap_request() {
560        let config = CircuitBreakerConfig { max_request_cost_usd: 1.0, ..Default::default() };
561        let cb = CostCircuitBreaker::new(config);
562        assert!(cb.check(0.5).is_ok());
563    }
564
565    // ========================================================================
566    // SERVE-CBR-006: Utilization Tests
567    // ========================================================================
568
569    #[test]
570    fn test_SERVE_CBR_006_utilization_percentage() {
571        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(10.0));
572        cb.record(5.0);
573        assert!((cb.utilization() - 0.5).abs() < 0.001);
574    }
575
576    #[test]
577    fn test_SERVE_CBR_006_remaining_budget() {
578        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(10.0));
579        cb.record(3.0);
580        assert!((cb.remaining_usd() - 7.0).abs() < 0.001);
581    }
582
583    #[test]
584    fn test_SERVE_CBR_006_warning_threshold() {
585        let config = CircuitBreakerConfig {
586            daily_budget_usd: 10.0,
587            warning_threshold: 0.8,
588            ..Default::default()
589        };
590        let cb = CostCircuitBreaker::new(config);
591        cb.record(7.0);
592        assert!(!cb.is_warning());
593        cb.record(1.0);
594        assert!(cb.is_warning());
595    }
596
597    // ========================================================================
598    // SERVE-CBR-007: Error Display Tests
599    // ========================================================================
600
601    #[test]
602    fn test_SERVE_CBR_007_budget_exceeded_display() {
603        let err = CircuitBreakerError::BudgetExceeded { spent: 10.5, budget: 10.0 };
604        let msg = err.to_string();
605        assert!(msg.contains("10.50"));
606        assert!(msg.contains("10.00"));
607        assert!(msg.contains("exceeded"));
608    }
609
610    #[test]
611    fn test_SERVE_CBR_007_request_expensive_display() {
612        let err = CircuitBreakerError::RequestTooExpensive { estimated: 5.0, limit: 1.0 };
613        let msg = err.to_string();
614        assert!(msg.contains("5.00"));
615        assert!(msg.contains("1.00"));
616        assert!(msg.contains("expensive"));
617    }
618
619    // ========================================================================
620    // SERVE-CBR-008: Cooldown and Open-State Tests
621    // ========================================================================
622
623    #[test]
624    fn test_SERVE_CBR_008_open_state_blocks_during_cooldown() {
625        let config = CircuitBreakerConfig {
626            daily_budget_usd: 1.0,
627            max_request_cost_usd: 5.0,
628            cooldown_seconds: 3600, // 1 hour cooldown
629            ..Default::default()
630        };
631        let cb = CostCircuitBreaker::new(config);
632
633        // Spend entire budget to open the circuit
634        cb.record(1.0);
635        assert_eq!(cb.state(), CircuitState::Open);
636
637        // Now check should fail because circuit is open and cooldown hasn't elapsed
638        let result = cb.check(0.01);
639        assert!(result.is_err());
640        assert!(matches!(result, Err(CircuitBreakerError::BudgetExceeded { .. })));
641    }
642
643    #[test]
644    fn test_SERVE_CBR_008_cooldown_elapsed_with_no_opened_at() {
645        // Test cooldown_elapsed when opened_at is None (returns true)
646        let cb = CostCircuitBreaker::with_defaults();
647        // State is Closed, opened_at is None
648        assert!(cb.cooldown_elapsed());
649    }
650
651    #[test]
652    fn test_SERVE_CBR_008_cooldown_elapsed_recently_opened() {
653        let config = CircuitBreakerConfig {
654            daily_budget_usd: 1.0,
655            max_request_cost_usd: 5.0,
656            cooldown_seconds: 3600,
657            ..Default::default()
658        };
659        let cb = CostCircuitBreaker::new(config);
660
661        // Record enough to open the circuit
662        cb.record(1.0);
663        assert_eq!(cb.state(), CircuitState::Open);
664
665        // Cooldown should NOT have elapsed (just opened)
666        assert!(!cb.cooldown_elapsed());
667    }
668
669    #[test]
670    fn test_SERVE_CBR_008_cooldown_elapsed_with_zero_cooldown() {
671        let config = CircuitBreakerConfig {
672            daily_budget_usd: 1.0,
673            max_request_cost_usd: 5.0,
674            cooldown_seconds: 0, // Zero cooldown
675            ..Default::default()
676        };
677        let cb = CostCircuitBreaker::new(config);
678
679        // Open the circuit
680        cb.record(1.0);
681        assert_eq!(cb.state(), CircuitState::Open);
682
683        // With zero cooldown, elapsed should be true immediately
684        assert!(cb.cooldown_elapsed());
685    }
686
687    #[test]
688    fn test_SERVE_CBR_008_half_open_after_cooldown() {
689        let config = CircuitBreakerConfig {
690            daily_budget_usd: 10.0,
691            max_request_cost_usd: 5.0,
692            cooldown_seconds: 0, // Zero cooldown so it immediately transitions
693            ..Default::default()
694        };
695        let cb = CostCircuitBreaker::new(config);
696
697        // Spend entire budget
698        cb.record(10.0);
699        assert_eq!(cb.state(), CircuitState::Open);
700
701        // With zero cooldown, check should transition to HalfOpen
702        // But then it checks budget and re-opens because budget is still exceeded
703        let result = cb.check(0.5);
704        assert!(result.is_err());
705    }
706
707    #[test]
708    fn test_SERVE_CBR_008_check_transitions_open_to_halfopen_then_allows() {
709        let config = CircuitBreakerConfig {
710            daily_budget_usd: 10.0,
711            max_request_cost_usd: 5.0,
712            cooldown_seconds: 0, // Zero cooldown for instant transition
713            ..Default::default()
714        };
715        let cb = CostCircuitBreaker::new(config);
716
717        // Spend some (but not all) budget, then manually open circuit
718        cb.record(5.0);
719        cb.write_state(CircuitState::Open);
720        cb.write_opened_at(Some(CostCircuitBreaker::current_timestamp()));
721
722        // With zero cooldown, check should transition Open -> HalfOpen,
723        // then allow the request since we still have budget
724        let result = cb.check(1.0);
725        assert!(result.is_ok());
726    }
727
728    // ========================================================================
729    // SERVE-CBR-009: Budget Crossing in check()
730    // ========================================================================
731
732    #[test]
733    fn test_SERVE_CBR_009_check_opens_circuit_on_budget_cross() {
734        let config = CircuitBreakerConfig {
735            daily_budget_usd: 5.0,
736            max_request_cost_usd: 10.0,
737            ..Default::default()
738        };
739        let cb = CostCircuitBreaker::new(config);
740
741        // Record 4.5 (under budget)
742        cb.record(4.5);
743        assert_eq!(cb.state(), CircuitState::Closed);
744
745        // Try to add 1.0 which would exceed budget
746        let result = cb.check(1.0);
747        assert!(result.is_err());
748        assert_eq!(cb.state(), CircuitState::Open);
749    }
750
751    #[test]
752    fn test_SERVE_CBR_009_check_budget_just_under_allows() {
753        let config = CircuitBreakerConfig {
754            daily_budget_usd: 5.0,
755            max_request_cost_usd: 10.0,
756            ..Default::default()
757        };
758        let cb = CostCircuitBreaker::new(config);
759
760        cb.record(4.0);
761        // 4.0 + 0.5 = 4.5, under 5.0 budget
762        let result = cb.check(0.5);
763        assert!(result.is_ok());
764    }
765
766    // ========================================================================
767    // SERVE-CBR-010: read/write accessor helpers
768    // ========================================================================
769
770    #[test]
771    fn test_SERVE_CBR_010_read_opened_at_none_initially() {
772        let cb = CostCircuitBreaker::with_defaults();
773        assert_eq!(cb.read_opened_at(), None);
774    }
775
776    #[test]
777    fn test_SERVE_CBR_010_write_and_read_opened_at() {
778        let cb = CostCircuitBreaker::with_defaults();
779        let ts = CostCircuitBreaker::current_timestamp();
780        cb.write_opened_at(Some(ts));
781        assert_eq!(cb.read_opened_at(), Some(ts));
782    }
783
784    #[test]
785    fn test_SERVE_CBR_010_write_opened_at_clears() {
786        let cb = CostCircuitBreaker::with_defaults();
787        cb.write_opened_at(Some(12345));
788        cb.write_opened_at(None);
789        assert_eq!(cb.read_opened_at(), None);
790    }
791
792    // ========================================================================
793    // SERVE-CBR-011: Error trait impl
794    // ========================================================================
795
796    #[test]
797    fn test_SERVE_CBR_011_error_trait_budget_exceeded() {
798        let err: Box<dyn std::error::Error> =
799            Box::new(CircuitBreakerError::BudgetExceeded { spent: 10.0, budget: 5.0 });
800        assert!(err.to_string().contains("exceeded"));
801    }
802
803    #[test]
804    fn test_SERVE_CBR_011_error_trait_request_expensive() {
805        let err: Box<dyn std::error::Error> =
806            Box::new(CircuitBreakerError::RequestTooExpensive { estimated: 3.0, limit: 1.0 });
807        assert!(err.to_string().contains("expensive"));
808    }
809
810    // ========================================================================
811    // SERVE-CBR-012: Additional model pricing coverage
812    // ========================================================================
813
814    #[test]
815    fn test_SERVE_CBR_012_pricing_gpt4o() {
816        let pricing = TokenPricing::for_model("gpt-4o-mini");
817        assert_eq!(pricing.input_per_million, 2.50);
818        assert_eq!(pricing.output_per_million, 10.00);
819    }
820
821    #[test]
822    fn test_SERVE_CBR_012_pricing_gpt35() {
823        let pricing = TokenPricing::for_model("gpt-3.5-turbo");
824        assert_eq!(pricing.input_per_million, 0.50);
825    }
826
827    #[test]
828    fn test_SERVE_CBR_012_pricing_claude_opus() {
829        let pricing = TokenPricing::for_model("claude-3-opus-20240229");
830        assert_eq!(pricing.input_per_million, 15.00);
831        assert_eq!(pricing.output_per_million, 75.00);
832    }
833
834    #[test]
835    fn test_SERVE_CBR_012_pricing_claude_haiku() {
836        let pricing = TokenPricing::for_model("claude-3-haiku-20240307");
837        assert_eq!(pricing.input_per_million, 0.25);
838        assert_eq!(pricing.output_per_million, 1.25);
839    }
840
841    #[test]
842    fn test_SERVE_CBR_012_pricing_claude_35() {
843        let pricing = TokenPricing::for_model("claude-3.5-sonnet");
844        assert_eq!(pricing.input_per_million, 3.00);
845    }
846
847    #[test]
848    fn test_SERVE_CBR_012_pricing_mistral() {
849        let pricing = TokenPricing::for_model("mistral-7b");
850        assert_eq!(pricing.input_per_million, 0.20);
851    }
852
853    #[test]
854    fn test_SERVE_CBR_012_pricing_unknown_model() {
855        let pricing = TokenPricing::for_model("totally-unknown-model");
856        assert_eq!(pricing.input_per_million, 1.00);
857        assert_eq!(pricing.output_per_million, 2.00);
858    }
859
860    // ========================================================================
861    // SERVE-CBR-013: DailyUsage current_date
862    // ========================================================================
863
864    #[test]
865    fn test_SERVE_CBR_013_current_date_format() {
866        let date = DailyUsage::current_date();
867        // Should be in YYYY-MM-DD format
868        assert_eq!(date.len(), 10);
869        assert_eq!(&date[4..5], "-");
870        assert_eq!(&date[7..8], "-");
871    }
872
873    #[test]
874    fn test_SERVE_CBR_013_today_has_current_date() {
875        let usage = DailyUsage::today();
876        let expected = DailyUsage::current_date();
877        assert_eq!(usage.date, expected);
878        assert_eq!(usage.total_input_tokens, 0);
879        assert_eq!(usage.total_cost_usd, 0.0);
880    }
881
882    // ========================================================================
883    // SERVE-CBR-014: remaining_usd edge cases
884    // ========================================================================
885
886    #[test]
887    fn test_SERVE_CBR_014_remaining_usd_clamped_to_zero() {
888        let cb = CostCircuitBreaker::new(CircuitBreakerConfig::with_budget(1.0));
889        cb.record(2.0); // Over budget
890        assert!((cb.remaining_usd()).abs() < 0.001); // Should be 0, not negative
891    }
892}