1use chrono::{DateTime, Utc};
37use parking_lot::RwLock;
38use serde::Serialize;
39use std::collections::HashMap;
40
41use super::budget::{ProviderBudget, ProviderSnapshot};
42use super::config::TokenMaxingConfig;
43use super::live_quota::{PlanType, QuotaSnapshot};
44use crate::resilience::FailureClass;
45
46#[derive(Debug, Clone, PartialEq, Serialize)]
48#[serde(tag = "state", rename_all = "snake_case")]
49pub enum Availability {
50 Available {
51 snapshot: ProviderSnapshot,
52 min_remaining_percent: u8,
53 },
54 Draining {
55 snapshot: ProviderSnapshot,
56 min_remaining_percent: u8,
57 },
58 CooledDown {
59 until: DateTime<Utc>,
60 reason: FailureClass,
61 snapshot: Option<ProviderSnapshot>,
62 },
63 Ineligible,
64}
65
66impl Availability {
67 pub fn is_dispatchable(&self) -> bool {
68 matches!(self, Availability::Available { .. })
69 }
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct RecalibrationRecord {
74 pub provider: String,
75 pub at: DateTime<Utc>,
76 pub remaining_percent: Option<f64>,
77 pub resets_at: Option<DateTime<Utc>>,
78 pub outcome: String,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct CooldownRecord {
83 pub provider: String,
84 pub since: DateTime<Utc>,
85 pub until: DateTime<Utc>,
86 pub reason: FailureClass,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct QuotaTrackerSnapshot {
91 pub provider: String,
92 pub availability: Availability,
93}
94
95pub struct QuotaTracker {
97 budget: ProviderBudget,
98 cooldowns: RwLock<HashMap<String, CooldownRecord>>,
99 recalibrations: RwLock<Vec<RecalibrationRecord>>,
100 config: RwLock<TokenMaxingConfig>,
101 live_snapshots: RwLock<HashMap<String, QuotaSnapshot>>,
105}
106
107impl QuotaTracker {
108 pub fn new(config: TokenMaxingConfig) -> Self {
109 let budget = ProviderBudget::from_config(&config);
110 Self {
111 budget,
112 cooldowns: RwLock::new(HashMap::new()),
113 recalibrations: RwLock::new(Vec::new()),
114 config: RwLock::new(config),
115 live_snapshots: RwLock::new(HashMap::new()),
116 }
117 }
118
119 pub fn reload(&self, config: TokenMaxingConfig) {
120 self.budget.reload(&config);
121 *self.config.write() = config;
122 }
123
124 pub fn update_live_snapshot(&self, snapshot: QuotaSnapshot) {
126 self.live_snapshots
127 .write()
128 .insert(snapshot.provider.clone(), snapshot);
129 }
130
131 pub fn live_snapshot(&self, provider: &str) -> Option<QuotaSnapshot> {
132 self.live_snapshots.read().get(provider).cloned()
133 }
134
135 pub fn live_providers(&self) -> Vec<String> {
136 self.live_snapshots.read().keys().cloned().collect()
137 }
138
139 pub fn live_eligible(&self, provider: &str) -> Option<bool> {
142 self.live_snapshots
143 .read()
144 .get(provider)
145 .map(|s| matches!(s.plan_type, PlanType::Subscription))
146 }
147
148 fn snapshot_from_live(
152 live: &QuotaSnapshot,
153 rem_pct: Option<f64>,
154 resets_at: Option<DateTime<Utc>>,
155 floor: u8,
156 ) -> ProviderSnapshot {
157 let token_limit = live
158 .token_limit
159 .and_then(|l| if l > 0.0 { Some(l as u64) } else { None })
160 .or_else(|| {
161 live.rate_windows.iter().find_map(|w| {
162 w.limit
163 .and_then(|l| if l > 0.0 { Some(l as u64) } else { None })
164 })
165 })
166 .unwrap_or(0);
167 let remaining_percent = rem_pct.unwrap_or(100.0);
168 let tokens_used = if token_limit > 0 {
169 let remaining = (token_limit as f64 * remaining_percent / 100.0) as u64;
170 token_limit.saturating_sub(remaining)
171 } else {
172 0
173 };
174 ProviderSnapshot::from_parts(&live.provider, tokens_used, token_limit, resets_at, floor)
175 }
176
177 pub fn availability(&self, provider: &str) -> Availability {
179 if let Some(cd) = self.cooldowns.read().get(provider).cloned()
181 && Utc::now() < cd.until
182 {
183 return Availability::CooledDown {
184 until: cd.until,
185 reason: cd.reason,
186 snapshot: self.budget.snapshot(provider),
187 };
188 }
189
190 if let Some(snap) = self.live_snapshot(provider) {
192 if matches!(snap.plan_type, PlanType::Metered) {
195 return Availability::Ineligible;
196 }
197 if matches!(snap.plan_type, PlanType::Subscription) && snap.is_subscription_signal() {
198 let rem_pct = snap.best_remaining_percent();
199 let resets_at = snap.best_resets_at();
200 let floor = self.config.read().default_min_remaining_percent;
201 let s = Self::snapshot_from_live(&snap, rem_pct, resets_at, floor);
202 if rem_pct.unwrap_or(100.0) <= floor as f64 {
203 return Availability::Draining {
204 snapshot: s,
205 min_remaining_percent: floor,
206 };
207 }
208 return Availability::Available {
209 snapshot: s,
210 min_remaining_percent: floor,
211 };
212 }
213 }
214
215 let cfg = self.config.read();
217 if !cfg.is_eligible(provider) {
218 return Availability::Ineligible;
219 }
220 let snapshot = match self.budget.snapshot(provider) {
221 Some(s) => s,
222 None => return Availability::Ineligible,
223 };
224 let floor = cfg
225 .get(provider)
226 .map(|p| p.min_remaining_percent(cfg.default_min_remaining_percent))
227 .unwrap_or(cfg.default_min_remaining_percent);
228 if snapshot.remaining_percent <= floor as f64 {
229 Availability::Draining {
230 snapshot,
231 min_remaining_percent: floor,
232 }
233 } else {
234 Availability::Available {
235 snapshot,
236 min_remaining_percent: floor,
237 }
238 }
239 }
240
241 pub fn snapshots(&self) -> Vec<QuotaTrackerSnapshot> {
245 let mut out = Vec::new();
246 let cfg = self.config.read();
247 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
248 for p in &cfg.providers {
249 seen.insert(p.provider.clone());
250 out.push(QuotaTrackerSnapshot {
251 provider: p.provider.clone(),
252 availability: self.availability(&p.provider),
253 });
254 }
255 for (k, _) in self.budget.snapshots() {
256 if seen.insert(k.clone()) {
257 out.push(QuotaTrackerSnapshot {
258 provider: k.clone(),
259 availability: self.availability(&k),
260 });
261 }
262 }
263 for p in self.live_providers() {
264 if seen.insert(p.clone()) {
265 out.push(QuotaTrackerSnapshot {
266 provider: p.clone(),
267 availability: self.availability(&p),
268 });
269 }
270 }
271 out
272 }
273
274 pub fn record_failure(
276 &self,
277 provider: &str,
278 class: FailureClass,
279 resets_at: Option<DateTime<Utc>>,
280 ) {
281 if !matches!(
282 class,
283 FailureClass::QuotaExhausted | FailureClass::Transient
284 ) {
285 return;
286 }
287 if !self.config.read().is_eligible(provider)
288 && !matches!(self.live_eligible(provider), Some(true))
289 {
290 return;
291 }
292 let now = Utc::now();
293 let until = match (class, resets_at) {
294 (FailureClass::QuotaExhausted, Some(r)) if r > now => r,
295 (FailureClass::QuotaExhausted, _) => {
296 let secs = self
297 .config
298 .read()
299 .get(provider)
300 .map(|p| p.reset_window_secs)
301 .unwrap_or(3600);
302 now + chrono::Duration::seconds(secs as i64)
303 }
304 (FailureClass::Transient, _) => now + chrono::Duration::seconds(60),
305 _ => unreachable!("matches! above already filtered"),
306 };
307 self.cooldowns.write().insert(
308 provider.to_string(),
309 CooldownRecord {
310 provider: provider.to_string(),
311 since: now,
312 until,
313 reason: class,
314 },
315 );
316 }
317
318 pub fn clear_cooldown(&self, provider: &str) {
319 self.cooldowns.write().remove(provider);
320 }
321
322 pub fn apply_recalibration(
326 &self,
327 provider: &str,
328 remaining_percent: Option<f64>,
329 resets_at: Option<DateTime<Utc>>,
330 token_limit: Option<u64>,
331 outcome: RecalibrationOutcome,
332 ) -> bool {
333 let limit: u64 = match token_limit {
334 Some(l) if l > 0 => l,
335 _ => match self.config.read().get(provider) {
336 Some(p) => p.token_limit,
337 None => 0,
338 },
339 };
340 let used = if limit > 0 {
341 remaining_percent
342 .map(|pct| {
343 let remaining = (limit as f64 * pct.clamp(0.0, 100.0) / 100.0) as u64;
344 limit.saturating_sub(remaining)
345 })
346 .unwrap_or(0)
347 } else {
348 0
349 };
350 let applied = self.budget.recalibrate(provider, used, resets_at);
351 if applied
352 && let Some(r) = resets_at
353 && r <= Utc::now()
354 {
355 self.clear_cooldown(provider);
356 }
357 let mut log = self.recalibrations.write();
358 log.push(RecalibrationRecord {
359 provider: provider.to_string(),
360 at: Utc::now(),
361 remaining_percent,
362 resets_at,
363 outcome: outcome.label().to_string(),
364 });
365 let len = log.len();
366 if len > 1024 {
367 log.drain(0..(len - 1024));
368 }
369 applied
370 }
371
372 pub fn reserve(&self, provider: &str, tokens: u64) -> Result<(), super::budget::ReserveError> {
373 self.budget.reserve(provider, tokens)
374 }
375
376 pub fn release(&self, provider: &str, tokens: u64) {
377 self.budget.release(provider, tokens)
378 }
379
380 pub fn snapshot(&self, provider: &str) -> Option<ProviderSnapshot> {
381 self.budget.snapshot(provider)
382 }
383
384 pub fn recalibration_history(&self) -> Vec<RecalibrationRecord> {
385 self.recalibrations.read().clone()
386 }
387
388 pub fn cooldown_history(&self) -> Vec<CooldownRecord> {
389 self.cooldowns.read().values().cloned().collect()
390 }
391
392 pub fn config(&self) -> TokenMaxingConfig {
393 self.config.read().clone()
394 }
395}
396
397#[derive(Debug, Clone, Copy)]
398pub enum RecalibrationOutcome {
399 Ok,
400 FetchFailed,
401 NoFetcher,
402}
403
404impl RecalibrationOutcome {
405 pub fn label(&self) -> &'static str {
406 match self {
407 RecalibrationOutcome::Ok => "ok",
408 RecalibrationOutcome::FetchFailed => "fetch-failed",
409 RecalibrationOutcome::NoFetcher => "no-fetcher",
410 }
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::super::config::TokenMaxingProviderConfig;
417 use super::*;
418 use crate::resilience::FailureClass;
419
420 fn cfg(p: &str, limit: u64, window: u64) -> TokenMaxingConfig {
421 TokenMaxingConfig {
422 enabled: true,
423 providers: vec![TokenMaxingProviderConfig {
424 provider: p.into(),
425 billing_model: "subscription".into(),
426 token_limit: limit,
427 reset_window_secs: window,
428 min_remaining_percent: Some(10),
429 models: vec![],
430 }],
431 default_min_remaining_percent: 5,
432 recalibration_interval_secs: 0,
433 parallel_providers: false,
434 }
435 }
436
437 #[test]
438 fn ineligible_provider_returns_ineligible() {
439 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
440 match t.availability("anthropic") {
441 Availability::Ineligible => {}
442 other => panic!("expected Ineligible, got {other:?}"),
443 }
444 }
445
446 #[test]
447 fn fresh_provider_is_available() {
448 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
449 assert!(t.availability("zai").is_dispatchable());
450 }
451
452 #[test]
453 fn draining_when_below_floor() {
454 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
455 t.reserve("zai", 950).unwrap();
456 match t.availability("zai") {
457 Availability::Draining { .. } => {}
458 other => panic!("expected Draining, got {other:?}"),
459 }
460 }
461
462 #[test]
463 fn reactive_cooldown_wins() {
464 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
465 t.record_failure("zai", FailureClass::Transient, None);
466 match t.availability("zai") {
467 Availability::CooledDown { reason, .. } => {
468 assert_eq!(reason, FailureClass::Transient);
469 }
470 other => panic!("expected CooledDown, got {other:?}"),
471 }
472 }
473
474 #[test]
475 fn reactive_non_quota_failure_ignored() {
476 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
477 t.record_failure("zai", FailureClass::AuthFailure, None);
478 assert!(t.availability("zai").is_dispatchable());
479 }
480
481 #[test]
482 fn recalibrate_snaps_counter_and_clears_stale_cooldown() {
483 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
484 t.reserve("zai", 200).unwrap();
485 let past = Utc::now() - chrono::Duration::seconds(1);
486 t.apply_recalibration(
487 "zai",
488 Some(100.0),
489 Some(past),
490 None,
491 RecalibrationOutcome::Ok,
492 );
493 assert_eq!(t.snapshot("zai").unwrap().tokens_used, 0);
494 }
495
496 #[test]
497 fn recalibrate_unknown_provider_returns_false() {
498 let t = QuotaTracker::new(cfg("zai", 1000, 3600));
499 let ok = t.apply_recalibration(
500 "anthropic",
501 Some(100.0),
502 None,
503 None,
504 RecalibrationOutcome::Ok,
505 );
506 assert!(!ok);
507 }
508
509 #[test]
510 fn multiple_providers_snapshots() {
511 let mut c = cfg("zai", 1000, 3600);
512 c.providers.push(TokenMaxingProviderConfig {
513 provider: "minimax".into(),
514 billing_model: "subscription".into(),
515 token_limit: 1000,
516 reset_window_secs: 3600,
517 min_remaining_percent: Some(5),
518 models: vec![],
519 });
520 let t = QuotaTracker::new(c);
521 t.reserve("zai", 200).unwrap();
522 t.reserve("minimax", 800).unwrap();
523 for s in &t.snapshots() {
524 assert!(matches!(s.availability, Availability::Available { .. }));
525 }
526 }
527
528 #[test]
532 fn live_subscription_snapshot_auto_eligibility() {
533 let t = QuotaTracker::new(TokenMaxingConfig {
534 enabled: true,
535 providers: vec![],
536 default_min_remaining_percent: 5,
537 recalibration_interval_secs: 60,
538 parallel_providers: false,
539 });
540 assert!(matches!(t.availability("zai"), Availability::Ineligible));
542 t.update_live_snapshot(QuotaSnapshot {
544 provider: "zai".into(),
545 plan: Some("coding-plan".into()),
546 plan_type: PlanType::Subscription,
547 token_limit: Some(2_000_000.0),
548 rate_windows: vec![super::super::live_quota::RateWindow {
549 name: "5h".into(),
550 used: Some(500_000.0),
551 limit: Some(2_000_000.0),
552 remaining_percent: Some(75.0),
553 resets_at: None,
554 }],
555 fetched_at: Utc::now(),
556 error: None,
557 });
558 match t.availability("zai") {
559 Availability::Available { .. } => {}
560 other => panic!("expected Available from live snapshot, got {other:?}"),
561 }
562 }
563
564 #[test]
567 fn live_metered_snapshot_excludes_even_with_config() {
568 let t = QuotaTracker::new(cfg("openai", 1000, 3600));
569 t.update_live_snapshot(QuotaSnapshot {
570 provider: "openai".into(),
571 plan: None,
572 plan_type: PlanType::Metered,
573 token_limit: None,
574 rate_windows: vec![],
575 fetched_at: Utc::now(),
576 error: None,
577 });
578 assert!(matches!(t.availability("openai"), Availability::Ineligible));
579 }
580}