use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::HashMap;
use super::budget::{ProviderBudget, ProviderSnapshot};
use super::config::TokenMaxingConfig;
use crate::resilience::FailureClass;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Availability {
Available {
snapshot: ProviderSnapshot,
min_remaining_percent: u8,
},
Draining {
snapshot: ProviderSnapshot,
min_remaining_percent: u8,
},
CooledDown {
until: DateTime<Utc>,
reason: FailureClass,
snapshot: Option<ProviderSnapshot>,
},
Ineligible,
}
impl Availability {
pub fn is_dispatchable(&self) -> bool {
matches!(self, Availability::Available { .. })
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RecalibrationRecord {
pub provider: String,
pub at: DateTime<Utc>,
pub remaining_percent: Option<f64>,
pub resets_at: Option<DateTime<Utc>>,
pub outcome: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CooldownRecord {
pub provider: String,
pub since: DateTime<Utc>,
pub until: DateTime<Utc>,
pub reason: FailureClass,
}
#[derive(Debug, Clone, Serialize)]
pub struct QuotaTrackerSnapshot {
pub provider: String,
pub availability: Availability,
}
pub struct QuotaTracker {
budget: ProviderBudget,
cooldowns: RwLock<HashMap<String, CooldownRecord>>,
recalibrations: RwLock<Vec<RecalibrationRecord>>,
config: RwLock<TokenMaxingConfig>,
}
impl QuotaTracker {
pub fn new(config: TokenMaxingConfig) -> Self {
let budget = ProviderBudget::from_config(&config);
Self {
budget,
cooldowns: RwLock::new(HashMap::new()),
recalibrations: RwLock::new(Vec::new()),
config: RwLock::new(config),
}
}
pub fn reload(&self, config: TokenMaxingConfig) {
self.budget.reload(&config);
*self.config.write() = config;
}
pub fn availability(&self, provider: &str) -> Availability {
let cfg = self.config.read();
if !cfg.is_eligible(provider) {
return Availability::Ineligible;
}
if let Some(cd) = self.cooldowns.read().get(provider).cloned()
&& Utc::now() < cd.until
{
return Availability::CooledDown {
until: cd.until,
reason: cd.reason,
snapshot: self.budget.snapshot(provider),
};
}
let snapshot = match self.budget.snapshot(provider) {
Some(s) => s,
None => return Availability::Ineligible,
};
let floor = cfg
.get(provider)
.map(|p| p.min_remaining_percent(cfg.default_min_remaining_percent))
.unwrap_or(cfg.default_min_remaining_percent);
if snapshot.remaining_percent <= floor as f64 {
Availability::Draining {
snapshot,
min_remaining_percent: floor,
}
} else {
Availability::Available {
snapshot,
min_remaining_percent: floor,
}
}
}
pub fn snapshots(&self) -> Vec<QuotaTrackerSnapshot> {
let mut out = Vec::new();
let cfg = self.config.read();
for p in &cfg.providers {
out.push(QuotaTrackerSnapshot {
provider: p.provider.clone(),
availability: self.availability(&p.provider),
});
}
for (k, _) in self.budget.snapshots() {
if !cfg.providers.iter().any(|p| p.provider == k) {
out.push(QuotaTrackerSnapshot {
provider: k.clone(),
availability: self.availability(&k),
});
}
}
out
}
pub fn record_failure(
&self,
provider: &str,
class: FailureClass,
resets_at: Option<DateTime<Utc>>,
) {
if !matches!(
class,
FailureClass::QuotaExhausted | FailureClass::Transient
) {
return;
}
if !self.config.read().is_eligible(provider) {
return;
}
let now = Utc::now();
let until = match (class, resets_at) {
(FailureClass::QuotaExhausted, Some(r)) if r > now => r,
(FailureClass::QuotaExhausted, _) => {
let secs = self
.config
.read()
.get(provider)
.map(|p| p.reset_window_secs)
.unwrap_or(3600);
now + chrono::Duration::seconds(secs as i64)
}
(FailureClass::Transient, _) => now + chrono::Duration::seconds(60),
_ => unreachable!("matches! above already filtered"),
};
self.cooldowns.write().insert(
provider.to_string(),
CooldownRecord {
provider: provider.to_string(),
since: now,
until,
reason: class,
},
);
}
pub fn clear_cooldown(&self, provider: &str) {
self.cooldowns.write().remove(provider);
}
pub fn apply_recalibration(
&self,
provider: &str,
remaining_percent: Option<f64>,
resets_at: Option<DateTime<Utc>>,
outcome: RecalibrationOutcome,
) -> bool {
let limit = match self.config.read().get(provider) {
Some(p) => p.token_limit,
None => return false,
};
let used = remaining_percent
.map(|pct| {
let remaining = (limit as f64 * pct.clamp(0.0, 100.0) / 100.0) as u64;
limit.saturating_sub(remaining)
})
.unwrap_or(0);
let applied = self.budget.recalibrate(provider, used, resets_at);
if applied {
if let Some(r) = resets_at
&& r <= Utc::now()
{
self.clear_cooldown(provider);
}
let mut log = self.recalibrations.write();
log.push(RecalibrationRecord {
provider: provider.to_string(),
at: Utc::now(),
remaining_percent,
resets_at,
outcome: outcome.label().to_string(),
});
let len = log.len();
if len > 1024 {
log.drain(0..(len - 1024));
}
}
applied
}
pub fn reserve(&self, provider: &str, tokens: u64) -> Result<(), super::budget::ReserveError> {
self.budget.reserve(provider, tokens)
}
pub fn release(&self, provider: &str, tokens: u64) {
self.budget.release(provider, tokens)
}
pub fn snapshot(&self, provider: &str) -> Option<ProviderSnapshot> {
self.budget.snapshot(provider)
}
pub fn recalibration_history(&self) -> Vec<RecalibrationRecord> {
self.recalibrations.read().clone()
}
pub fn cooldown_history(&self) -> Vec<CooldownRecord> {
self.cooldowns.read().values().cloned().collect()
}
pub fn config(&self) -> TokenMaxingConfig {
self.config.read().clone()
}
}
#[derive(Debug, Clone, Copy)]
pub enum RecalibrationOutcome {
Ok,
FetchFailed,
NoFetcher,
}
impl RecalibrationOutcome {
pub fn label(&self) -> &'static str {
match self {
RecalibrationOutcome::Ok => "ok",
RecalibrationOutcome::FetchFailed => "fetch-failed",
RecalibrationOutcome::NoFetcher => "no-fetcher",
}
}
}
#[cfg(test)]
mod tests {
use super::super::config::TokenMaxingProviderConfig;
use super::*;
use crate::resilience::FailureClass;
fn cfg(p: &str, limit: u64, window: u64) -> TokenMaxingConfig {
TokenMaxingConfig {
enabled: true,
providers: vec![TokenMaxingProviderConfig {
provider: p.into(),
billing_model: "subscription".into(),
token_limit: limit,
reset_window_secs: window,
min_remaining_percent: Some(10),
models: vec![],
}],
default_min_remaining_percent: 5,
recalibration_interval_secs: 0,
parallel_providers: false,
}
}
#[test]
fn ineligible_provider_returns_ineligible() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
match t.availability("anthropic") {
Availability::Ineligible => {}
other => panic!("expected Ineligible, got {other:?}"),
}
}
#[test]
fn fresh_provider_is_available() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
let a = t.availability("zai");
assert!(a.is_dispatchable());
}
#[test]
fn draining_when_below_floor() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
t.reserve("zai", 950).unwrap(); match t.availability("zai") {
Availability::Draining { .. } => {}
other => panic!("expected Draining, got {other:?}"),
}
}
#[test]
fn reactive_cooldown_wins() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
t.record_failure("zai", FailureClass::Transient, None);
match t.availability("zai") {
Availability::CooledDown { reason, .. } => {
assert_eq!(reason, FailureClass::Transient);
}
other => panic!("expected CooledDown, got {other:?}"),
}
}
#[test]
fn reactive_non_quota_failure_ignored() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
t.record_failure("zai", FailureClass::AuthFailure, None);
assert!(t.availability("zai").is_dispatchable());
}
#[test]
fn recalibrate_snaps_counter_and_clears_stale_cooldown() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
t.reserve("zai", 200).unwrap();
let past = Utc::now() - chrono::Duration::seconds(1);
t.apply_recalibration("zai", Some(100.0), Some(past), RecalibrationOutcome::Ok);
let s = t.snapshot("zai").unwrap();
assert_eq!(s.tokens_used, 0);
}
#[test]
fn recalibrate_unknown_provider_returns_false() {
let t = QuotaTracker::new(cfg("zai", 1000, 3600));
let ok = t.apply_recalibration("anthropic", Some(100.0), None, RecalibrationOutcome::Ok);
assert!(!ok);
}
#[test]
fn multiple_providers_snapshots() {
let mut c = cfg("zai", 1000, 3600);
c.providers.push(TokenMaxingProviderConfig {
provider: "minimax".into(),
billing_model: "subscription".into(),
token_limit: 1000,
reset_window_secs: 3600,
min_remaining_percent: Some(5),
models: vec![],
});
let t = QuotaTracker::new(c);
t.reserve("zai", 200).unwrap();
t.reserve("minimax", 800).unwrap();
let snaps = t.snapshots();
for s in &snaps {
assert!(matches!(s.availability, Availability::Available { .. }));
}
}
}