Skip to main content

oxios_kernel/token_maxing/
quota_tracker.rs

1//! `QuotaTracker` — the per-provider availability decision layer
2//! (RFC-031 v2 §4).
3//!
4//! Unifies three signals into one verdict per provider:
5//!
6//! 1. **Live quota snapshot (v2 primary)** — when a
7//!    [`crate::api::quota::QuotaFetcher`] exists for the provider,
8//!    its most recent [`super::live_quota::QuotaSnapshot`] is the
9//!    authoritative signal for both **eligibility** and
10//!    `remaining_percent`. Auto-discovers providers that the user
11//!    has not added to `[[token-maxing.providers]]`.
12//! 2. **Self-tracked counter (fallback)** — [`ProviderBudget`]
13//!    tracks tokens oxios itself sent. Used when no live fetcher
14//!    exists, or as a consistency check between polls.
15//! 3. **Reactive cooldown (safety net)** — when a real request 429s
16//!    or hits `QuotaExhausted` / `Transient`, we mark the provider
17//!    `CooledDown` until `resets_at ?? now + window`. Drift failsafe.
18//!
19//! ## Decision rule (v2)
20//!
21//! ```text
22//! availability(p):
23//!   if reactive_cooldown[p] active:    return CooledDown(...)
24//!   if live_snapshot(p) exists:
25//!     if plan_type == Metered:         return Ineligible  // metered-never
26//!     if plan_type == Subscription && usable signal:
27//!       rem% = live.remaining_percent
28//!       if rem% <= min_remaining_percent: return Draining
29//!       return Available
30//!   // v1 fallback: config gate + self-tracked counter.
31//!   if !cfg.is_eligible(p):            return Ineligible
32//!   rem% = counter[p].remaining_percent
33//!   ...
34//! ```
35
36use 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/// Per-provider availability verdict (RFC-031 §4).
47#[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
95/// The `QuotaTracker`.
96pub struct QuotaTracker {
97    budget: ProviderBudget,
98    cooldowns: RwLock<HashMap<String, CooldownRecord>>,
99    recalibrations: RwLock<Vec<RecalibrationRecord>>,
100    config: RwLock<TokenMaxingConfig>,
101    /// v2: live quota snapshots from `QuotaFetcher`s, keyed by
102    /// provider. A `Subscription` snapshot makes the provider
103    /// eligible for token maxing regardless of `[[token-maxing.providers]]`.
104    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    /// Store the most recent live `QuotaSnapshot` for `provider`.
125    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    /// `Some(true)` = subscription, `Some(false)` = metered (or
140    /// unknown), `None` = no live data.
141    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    /// Build a `ProviderSnapshot` from a live `QuotaSnapshot` so the
149    /// existing `Available`/`Draining` variant payload shape is
150    /// preserved.
151    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    /// The verdict for a single provider (v2 — live snapshot first).
178    pub fn availability(&self, provider: &str) -> Availability {
179        // Reactive cooldown wins (drift failsafe).
180        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        // v2: live snapshot is the primary signal.
191        if let Some(snap) = self.live_snapshot(provider) {
192            // metered-never guard: live Metered response excludes
193            // even if the user marked it `subscription` in TOML.
194            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        // v1 fallback: config gate + self-tracked counter.
216        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    /// All providers (eligible or not). v2 includes config entries,
242    /// self-tracked budget providers, AND auto-discovered live
243    /// providers.
244    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    /// Reactive override — record a failure class.
275    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    /// v2: takes `token_limit: Option<u64>` from the live response
323    /// (replaces the v1 config lookup). When `None`, falls back to
324    /// `[[token-maxing.providers]]` config.
325    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    /// v2: a live Subscription snapshot makes a provider eligible
529    /// even with no `[[token-maxing.providers]]` entry — this is
530    /// the user's actual bug fix.
531    #[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        // No config entry for zai — v1 would return Ineligible.
541        assert!(matches!(t.availability("zai"), Availability::Ineligible));
542        // Cache a live Subscription snapshot. v2: zai becomes Available.
543        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    /// v2: a live Metered snapshot returns Ineligible (metered-never
565    /// guard). Even if the user marked it `subscription` in TOML.
566    #[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}