1use chrono::{DateTime, Utc};
29use parking_lot::RwLock;
30use serde::Serialize;
31use std::collections::HashMap;
32
33use super::budget::{ProviderBudget, ProviderSnapshot};
34use super::config::TokenMaxingConfig;
35use crate::resilience::FailureClass;
36
37#[derive(Debug, Clone, PartialEq, Serialize)]
39#[serde(tag = "state", rename_all = "snake_case")]
40pub enum Availability {
41 Available {
44 snapshot: ProviderSnapshot,
47 min_remaining_percent: u8,
51 },
52 Draining {
56 snapshot: ProviderSnapshot,
57 min_remaining_percent: u8,
58 },
59 CooledDown {
63 until: DateTime<Utc>,
65 reason: FailureClass,
67 snapshot: Option<ProviderSnapshot>,
69 },
70 Ineligible,
74}
75
76impl Availability {
77 pub fn is_dispatchable(&self) -> bool {
79 matches!(self, Availability::Available { .. })
80 }
81}
82
83#[derive(Debug, Clone, Serialize)]
85pub struct RecalibrationRecord {
86 pub provider: String,
87 pub at: DateTime<Utc>,
88 pub remaining_percent: Option<f64>,
89 pub resets_at: Option<DateTime<Utc>>,
90 pub outcome: String,
92}
93
94#[derive(Debug, Clone, Serialize)]
96pub struct CooldownRecord {
97 pub provider: String,
98 pub since: DateTime<Utc>,
99 pub until: DateTime<Utc>,
100 pub reason: FailureClass,
101}
102
103#[derive(Debug, Clone, Serialize)]
106pub struct QuotaTrackerSnapshot {
107 pub provider: String,
108 pub availability: Availability,
109}
110
111pub struct QuotaTracker {
113 budget: ProviderBudget,
115 cooldowns: RwLock<HashMap<String, CooldownRecord>>,
117 recalibrations: RwLock<Vec<RecalibrationRecord>>,
119 config: RwLock<TokenMaxingConfig>,
121}
122
123impl QuotaTracker {
124 pub fn new(config: TokenMaxingConfig) -> Self {
127 let budget = ProviderBudget::from_config(&config);
128 Self {
129 budget,
130 cooldowns: RwLock::new(HashMap::new()),
131 recalibrations: RwLock::new(Vec::new()),
132 config: RwLock::new(config),
133 }
134 }
135
136 pub fn reload(&self, config: TokenMaxingConfig) {
139 self.budget.reload(&config);
140 *self.config.write() = config;
141 }
142
143 pub fn availability(&self, provider: &str) -> Availability {
145 let cfg = self.config.read();
146 if !cfg.is_eligible(provider) {
147 return Availability::Ineligible;
148 }
149 if let Some(cd) = self.cooldowns.read().get(provider).cloned()
151 && Utc::now() < cd.until
152 {
153 return Availability::CooledDown {
154 until: cd.until,
155 reason: cd.reason,
156 snapshot: self.budget.snapshot(provider),
157 };
158 }
159 let snapshot = match self.budget.snapshot(provider) {
161 Some(s) => s,
162 None => return Availability::Ineligible,
163 };
164 let floor = cfg
165 .get(provider)
166 .map(|p| p.min_remaining_percent(cfg.default_min_remaining_percent))
167 .unwrap_or(cfg.default_min_remaining_percent);
168 if snapshot.remaining_percent <= floor as f64 {
169 Availability::Draining {
170 snapshot,
171 min_remaining_percent: floor,
172 }
173 } else {
174 Availability::Available {
175 snapshot,
176 min_remaining_percent: floor,
177 }
178 }
179 }
180
181 pub fn snapshots(&self) -> Vec<QuotaTrackerSnapshot> {
184 let mut out = Vec::new();
185 let cfg = self.config.read();
186 for p in &cfg.providers {
187 out.push(QuotaTrackerSnapshot {
188 provider: p.provider.clone(),
189 availability: self.availability(&p.provider),
190 });
191 }
192 for (k, _) in self.budget.snapshots() {
193 if !cfg.providers.iter().any(|p| p.provider == k) {
194 out.push(QuotaTrackerSnapshot {
195 provider: k.clone(),
196 availability: self.availability(&k),
197 });
198 }
199 }
200 out
201 }
202
203 pub fn record_failure(
222 &self,
223 provider: &str,
224 class: FailureClass,
225 resets_at: Option<DateTime<Utc>>,
226 ) {
227 if !matches!(
228 class,
229 FailureClass::QuotaExhausted | FailureClass::Transient
230 ) {
231 return;
232 }
233 if !self.config.read().is_eligible(provider) {
234 return;
235 }
236 let now = Utc::now();
237 let until = match (class, resets_at) {
238 (FailureClass::QuotaExhausted, Some(r)) if r > now => r,
239 (FailureClass::QuotaExhausted, _) => {
240 let secs = self
242 .config
243 .read()
244 .get(provider)
245 .map(|p| p.reset_window_secs)
246 .unwrap_or(3600);
247 now + chrono::Duration::seconds(secs as i64)
248 }
249 (FailureClass::Transient, _) => now + chrono::Duration::seconds(60),
255 _ => unreachable!("matches! above already filtered"),
256 };
257 self.cooldowns.write().insert(
258 provider.to_string(),
259 CooldownRecord {
260 provider: provider.to_string(),
261 since: now,
262 until,
263 reason: class,
264 },
265 );
266 }
267
268 pub fn clear_cooldown(&self, provider: &str) {
271 self.cooldowns.write().remove(provider);
272 }
273
274 pub fn apply_recalibration(
277 &self,
278 provider: &str,
279 remaining_percent: Option<f64>,
280 resets_at: Option<DateTime<Utc>>,
281 outcome: RecalibrationOutcome,
282 ) -> bool {
283 let limit = match self.config.read().get(provider) {
284 Some(p) => p.token_limit,
285 None => return false,
286 };
287 let used = remaining_percent
288 .map(|pct| {
289 let remaining = (limit as f64 * pct.clamp(0.0, 100.0) / 100.0) as u64;
290 limit.saturating_sub(remaining)
291 })
292 .unwrap_or(0);
293 let applied = self.budget.recalibrate(provider, used, resets_at);
294 if applied {
295 if let Some(r) = resets_at
297 && r <= Utc::now()
298 {
299 self.clear_cooldown(provider);
300 }
301 let mut log = self.recalibrations.write();
302 log.push(RecalibrationRecord {
303 provider: provider.to_string(),
304 at: Utc::now(),
305 remaining_percent,
306 resets_at,
307 outcome: outcome.label().to_string(),
308 });
309 let len = log.len();
311 if len > 1024 {
312 log.drain(0..(len - 1024));
313 }
314 }
315 applied
316 }
317
318 pub fn reserve(&self, provider: &str, tokens: u64) -> Result<(), super::budget::ReserveError> {
321 self.budget.reserve(provider, tokens)
322 }
323
324 pub fn release(&self, provider: &str, tokens: u64) {
326 self.budget.release(provider, tokens)
327 }
328
329 pub fn snapshot(&self, provider: &str) -> Option<ProviderSnapshot> {
331 self.budget.snapshot(provider)
332 }
333
334 pub fn recalibration_history(&self) -> Vec<RecalibrationRecord> {
336 self.recalibrations.read().clone()
337 }
338
339 pub fn cooldown_history(&self) -> Vec<CooldownRecord> {
341 self.cooldowns.read().values().cloned().collect()
342 }
343
344 pub fn config(&self) -> TokenMaxingConfig {
346 self.config.read().clone()
347 }
348}
349
350#[derive(Debug, Clone, Copy)]
352pub enum RecalibrationOutcome {
353 Ok,
355 FetchFailed,
357 NoFetcher,
359}
360
361impl RecalibrationOutcome {
362 pub fn label(&self) -> &'static str {
363 match self {
364 RecalibrationOutcome::Ok => "ok",
365 RecalibrationOutcome::FetchFailed => "fetch-failed",
366 RecalibrationOutcome::NoFetcher => "no-fetcher",
367 }
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::super::config::TokenMaxingProviderConfig;
374 use super::*;
375 use crate::resilience::FailureClass;
376
377 fn cfg(p: &str, limit: u64, window: u64) -> TokenMaxingConfig {
378 TokenMaxingConfig {
379 enabled: true,
380 providers: vec![TokenMaxingProviderConfig {
381 provider: p.into(),
382 billing_model: "subscription".into(),
383 token_limit: limit,
384 reset_window_secs: window,
385 min_remaining_percent: Some(10),
386 models: vec![],
387 }],
388 default_min_remaining_percent: 5,
389 recalibration_interval_secs: 0,
390 parallel_providers: false,
391 }
392 }
393
394 #[test]
395 fn ineligible_provider_returns_ineligible() {
396 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
397 match t.availability("anthropic") {
398 Availability::Ineligible => {}
399 other => panic!("expected Ineligible, got {other:?}"),
400 }
401 }
402
403 #[test]
404 fn fresh_provider_is_available() {
405 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
406 let a = t.availability("zai");
407 assert!(a.is_dispatchable());
408 }
409
410 #[test]
411 fn draining_when_below_floor() {
412 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
413 t.reserve("zai", 950).unwrap(); match t.availability("zai") {
415 Availability::Draining { .. } => {}
416 other => panic!("expected Draining, got {other:?}"),
417 }
418 }
419
420 #[test]
421 fn reactive_cooldown_wins() {
422 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
423 t.record_failure("zai", FailureClass::Transient, None);
425 match t.availability("zai") {
426 Availability::CooledDown { reason, .. } => {
427 assert_eq!(reason, FailureClass::Transient);
428 }
429 other => panic!("expected CooledDown, got {other:?}"),
430 }
431 }
432
433 #[test]
434 fn reactive_non_quota_failure_ignored() {
435 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
436 t.record_failure("zai", FailureClass::AuthFailure, None);
437 assert!(t.availability("zai").is_dispatchable());
439 }
440
441 #[test]
442 fn recalibrate_snaps_counter_and_clears_stale_cooldown() {
443 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
444 t.reserve("zai", 200).unwrap();
445 let past = Utc::now() - chrono::Duration::seconds(1);
447 t.apply_recalibration("zai", Some(100.0), Some(past), RecalibrationOutcome::Ok);
448 let s = t.snapshot("zai").unwrap();
449 assert_eq!(s.tokens_used, 0);
450 }
451
452 #[test]
453 fn recalibrate_unknown_provider_returns_false() {
454 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
455 let ok = t.apply_recalibration("anthropic", Some(100.0), None, RecalibrationOutcome::Ok);
456 assert!(!ok);
457 }
458
459 #[test]
460 fn multiple_providers_snapshots() {
461 let mut c = cfg("zai", 1000, 3600);
462 c.providers.push(TokenMaxingProviderConfig {
463 provider: "minimax".into(),
464 billing_model: "subscription".into(),
465 token_limit: 1000,
466 reset_window_secs: 3600,
467 min_remaining_percent: Some(5),
468 models: vec![],
469 });
470 let t = QuotaTracker::new(c);
471 t.reserve("zai", 200).unwrap();
472 t.reserve("minimax", 800).unwrap();
473 let snaps = t.snapshots();
474 for s in &snaps {
476 assert!(matches!(s.availability, Availability::Available { .. }));
477 }
478 }
479}