Skip to main content

oxios_kernel/token_maxing/
quota_tracker.rs

1//! `QuotaTracker` — the per-provider availability decision layer (RFC-031 §4).
2//!
3//! Unifies three signals into one verdict per provider:
4//!
5//! 1. **Self-tracked counter (primary)** — [`ProviderBudget`] tracks tokens
6//!    oxios itself sent. The base signal. Works on ZAI/Minimax today with
7//!    zero endpoint work.
8//! 2. **Reactive override (safety net)** — when a real request 429s or
9//!    hits `QuotaExhausted` / `Transient` (which is what `classify.rs`
10//!    maps rate-limit/429 to), we mark the provider `CooledDown` until
11//!    `resets_at ?? now + window`. This is the drift failsafe: even if
12//!    the counter still shows headroom, a 429 always wins.
13//! 3. **Recalibration (accuracy upgrade)** — where a
14//!    [`crate::api::quota::QuotaFetcher`] exists, periodic recalibration
15//!    snaps the self-tracked counter to real provider state.
16//!
17//! ## Decision rule
18//!
19//! ```text
20//! availability(p):
21//!   if reactive_cooldown[p] active:    return CooledDown(cooldown[p].until)
22//!   if not eligible:                    return Ineligible
23//!   rem% = counter[p].remaining_percent # snapped to last recalibration
24//!   if rem% <= min_remaining_percent:   return Draining
25//!   return Available
26//! ```
27
28use 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/// Per-provider availability verdict (RFC-031 §4).
38#[derive(Debug, Clone, PartialEq, Serialize)]
39#[serde(tag = "state", rename_all = "snake_case")]
40pub enum Availability {
41    /// Provider is in the pool and has headroom. TokenMaxer may dispatch
42    /// to it.
43    Available {
44        /// Snapshot of the self-tracked counter (snapped to last
45        /// recalibration when one exists).
46        snapshot: ProviderSnapshot,
47        /// Per-provider floor (%). The TokenMaxer should stop draining
48        /// below this — `Available` is only returned while the
49        /// remaining% is **above** the floor.
50        min_remaining_percent: u8,
51    },
52    /// Provider is in the pool but the self-tracked counter is at or
53    /// below the configured floor. TokenMaxer may finish any in-flight
54    /// task on this provider, but should not start a new one.
55    Draining {
56        snapshot: ProviderSnapshot,
57        min_remaining_percent: u8,
58    },
59    /// Provider is in the pool but a real request just hit a 429 /
60    /// `QuotaExhausted` / `Transient`. TokenMaxer MUST NOT dispatch
61    /// to this provider until `until`.
62    CooledDown {
63        /// The expiry the TokenMaxer must wait for.
64        until: DateTime<Utc>,
65        /// The class that triggered the cooldown (for the report).
66        reason: FailureClass,
67        /// Optional snapshot if known.
68        snapshot: Option<ProviderSnapshot>,
69    },
70    /// Provider is not in `[token-maxing.providers]` (or its entry is
71    /// invalid). TokenMaxer MUST NOT touch it — this is the
72    /// metered-never guard.
73    Ineligible,
74}
75
76impl Availability {
77    /// Whether a task may be dispatched to this provider.
78    pub fn is_dispatchable(&self) -> bool {
79        matches!(self, Availability::Available { .. })
80    }
81}
82
83/// One recalibration event for the report.
84#[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    /// "ok" | "fetch-failed" | "no-fetcher"
91    pub outcome: String,
92}
93
94/// Reactive cooldown record. The 429 failsafe.
95#[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/// Snapshot of a provider's tracker state — used by the API to render
104/// the live status panel.
105#[derive(Debug, Clone, Serialize)]
106pub struct QuotaTrackerSnapshot {
107    pub provider: String,
108    pub availability: Availability,
109}
110
111/// The `QuotaTracker`.
112pub struct QuotaTracker {
113    /// Self-tracked counter (primary signal).
114    budget: ProviderBudget,
115    /// Reactive cooldowns (safety net). Keyed by provider id.
116    cooldowns: RwLock<HashMap<String, CooldownRecord>>,
117    /// Recalibration history (capped). Used by the report and the API.
118    recalibrations: RwLock<Vec<RecalibrationRecord>>,
119    /// Per-provider configuration — the only place "eligibility" lives.
120    config: RwLock<TokenMaxingConfig>,
121}
122
123impl QuotaTracker {
124    /// Build a tracker from the user's config. Providers not eligible
125    /// under the metered-never rule are dropped from the budget.
126    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    /// Reload the config (e.g. on `PUT /api/config`). Preserves
137    /// `tokens_used` for providers that remain in the config.
138    pub fn reload(&self, config: TokenMaxingConfig) {
139        self.budget.reload(&config);
140        *self.config.write() = config;
141    }
142
143    /// The verdict for a single provider.
144    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        // Reactive cooldown wins.
150        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        // Self-tracked snapshot.
160        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    /// All providers (eligible or not), in declared order. Used by the
182    /// orchestrator to build the live status panel and the report.
183    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    /// Reactive override — record a failure class for `provider` from a
204    /// real agent run.
205    ///
206    /// Cooldown policy (kept separate so a single transient hiccup
207    /// doesn't drain the pool for an hour):
208    ///
209    /// - `QuotaExhausted` → full reset-window cooldown. The provider's
210    ///   plan limit has been hit; waiting makes sense.
211    /// - `Transient` → **short** cooldown (60s by default). `classify.rs`
212    ///   lumps 429/rate-limit into the same bucket as 500/timeout/
213    ///   network blip. We don't want a single upstream hiccup to mark
214    ///   a provider `CooledDown` for the whole reset window — that
215    ///   defeats the drain-by-rotating-across-providers loop. A short
216    ///   cooldown lets the next retry succeed on the same provider
217    ///   once the blip clears, while still preventing hot-looping.
218    /// - Anything else (auth failure, model unavailable, etc.) → no
219    ///   cooldown. Those are recovery-coordinator concerns, not pool
220    ///   concerns.
221    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                // Provider's real quota is hit; wait the full reset window.
241                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            // Transient: short backoff so a 1-minute pause can ride out
250            // a 500/timeout/network blip, and a 429 retry still has
251            // room. Tunable but kept tight on purpose — if a provider
252            // needs the long wait, the QuotaExhausted arm will fire
253            // when its quota bucket really runs dry.
254            (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    /// Clear the cooldown for a provider (e.g. when a window is observed
269    /// to have actually reset).
270    pub fn clear_cooldown(&self, provider: &str) {
271        self.cooldowns.write().remove(provider);
272    }
273
274    /// Record a recalibration event and snap the budget to the new
275    /// state. Returns `true` if the snap was applied.
276    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 the provider says it has reset, clear any stale cooldown.
296            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            // Cap at 1024 entries to bound memory.
310            let len = log.len();
311            if len > 1024 {
312                log.drain(0..(len - 1024));
313            }
314        }
315        applied
316    }
317
318    /// Reserve tokens against the self-tracked counter. Used by the
319    /// TokenMaxer after dispatching each unit of work.
320    pub fn reserve(&self, provider: &str, tokens: u64) -> Result<(), super::budget::ReserveError> {
321        self.budget.reserve(provider, tokens)
322    }
323
324    /// Release reserved tokens. Used on task failure / retry.
325    pub fn release(&self, provider: &str, tokens: u64) {
326        self.budget.release(provider, tokens)
327    }
328
329    /// The full self-tracked state for a provider. Used by the report.
330    pub fn snapshot(&self, provider: &str) -> Option<ProviderSnapshot> {
331        self.budget.snapshot(provider)
332    }
333
334    /// Recalibration history.
335    pub fn recalibration_history(&self) -> Vec<RecalibrationRecord> {
336        self.recalibrations.read().clone()
337    }
338
339    /// Cooldown records. Used by the report.
340    pub fn cooldown_history(&self) -> Vec<CooldownRecord> {
341        self.cooldowns.read().values().cloned().collect()
342    }
343
344    /// Read-only access to the current config.
345    pub fn config(&self) -> TokenMaxingConfig {
346        self.config.read().clone()
347    }
348}
349
350/// Outcome of one recalibration attempt.
351#[derive(Debug, Clone, Copy)]
352pub enum RecalibrationOutcome {
353    /// Fetcher succeeded and produced a real `remaining_percent`.
354    Ok,
355    /// Fetcher exists but the request failed.
356    FetchFailed,
357    /// No fetcher is registered for this provider.
358    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(); // 5% remaining, below 10% floor
414        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        // Even though we have headroom, a 429 takes the verdict.
424        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        // Auth failure doesn't cool the provider.
438        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        // Tell the tracker the provider is already reset.
446        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        // Both should be Available (above their floors).
475        for s in &snaps {
476            assert!(matches!(s.availability, Availability::Available { .. }));
477        }
478    }
479}