Skip to main content

oxios_kernel/token_maxing/
budget.rs

1//! Self-tracked per-provider budget (RFC-031 §4, Phase 1).
2//!
3//! Reuses the [`crate::budget::BudgetManager`] sliding-window+reset pattern
4//! but re-keyed from `AgentId` to provider id. The cap is the subscription
5//! plan allocation; the reset window is the plan's reset cadence.
6//!
7//! ## Why self-tracked
8//!
9//! ZAI / Minimax may not expose a `remaining_percent` / `resets_at` field on
10//! their usage APIs. The self-tracked counter is the **primary** mechanism
11//! precisely because it never depends on those fields. The
12//! [`crate::token_maxing::quota_tracker::QuotaTracker`] layers
13//! recalibration (where an endpoint exists) and reactive 429 cooldown
14//! on top.
15//!
16//! ## Drift
17//!
18//! The counter only sees tokens oxios itself sent. If the same API key is
19//! used by another app/instance, the counter under-counts. That's why
20//! `QuotaTracker` recalibrates from real provider state when available,
21//! and falls back to a 429 cooldown when a real request hits a hard limit.
22
23use chrono::{DateTime, Duration, Utc};
24use parking_lot::RwLock;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27
28use super::config::TokenMaxingConfig;
29
30/// Per-provider state.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProviderState {
33    /// Plan allocation per window.
34    pub token_limit: u64,
35    /// Window length (seconds).
36    pub window_secs: u64,
37    /// Tokens consumed in the current window.
38    pub tokens_used: u64,
39    /// When the current window started.
40    pub window_start: DateTime<Utc>,
41    /// When the window actually resets, if the provider has ever given us
42    /// a real `resets_at` (via recalibration). When `Some`, it overrides
43    /// the sliding-window reset.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub resets_at: Option<DateTime<Utc>>,
46}
47
48impl ProviderState {
49    fn new(token_limit: u64, window_secs: u64, now: DateTime<Utc>) -> Self {
50        Self {
51            token_limit,
52            window_secs,
53            tokens_used: 0,
54            window_start: now,
55            resets_at: None,
56        }
57    }
58
59    /// Whether the sliding window has expired.
60    fn window_expired(&self, now: DateTime<Utc>) -> bool {
61        now.signed_duration_since(self.window_start) >= Duration::seconds(self.window_secs as i64)
62    }
63
64    /// Reset the counter, honoring `resets_at` if present.
65    fn reset(&mut self, now: DateTime<Utc>) {
66        self.tokens_used = 0;
67        self.window_start = now;
68        if let Some(r) = self.resets_at
69            && r <= now
70        {
71            self.resets_at = None;
72        }
73    }
74}
75
76/// Per-provider budget manager.
77///
78/// One [`ProviderBudget`] per kernel. The map is keyed by provider id (the
79/// same string the engine uses, e.g. `"zai"`).
80pub struct ProviderBudget {
81    states: RwLock<HashMap<String, ProviderState>>,
82}
83
84impl Default for ProviderBudget {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl ProviderBudget {
91    /// Create an empty budget. Use [`Self::from_config`] to populate from
92    /// the user's `[token-maxing]` config.
93    pub fn new() -> Self {
94        Self {
95            states: RwLock::new(HashMap::new()),
96        }
97    }
98
99    /// Build from the user's config. Providers not eligible under the
100    /// metered-never rule are silently skipped — the caller should have
101    /// validated the config first.
102    pub fn from_config(config: &TokenMaxingConfig) -> Self {
103        let now = Utc::now();
104        let mut states = HashMap::new();
105        for p in &config.providers {
106            if !config.is_eligible(&p.provider) {
107                continue;
108            }
109            states.insert(
110                p.provider.clone(),
111                ProviderState::new(p.token_limit, p.reset_window_secs, now),
112            );
113        }
114        Self {
115            states: RwLock::new(states),
116        }
117    }
118
119    /// Reload the configured providers while preserving existing usage
120    /// counters. New providers start fresh at 0; removed providers are
121    /// dropped; existing providers keep their `tokens_used` and window.
122    pub fn reload(&self, config: &TokenMaxingConfig) {
123        let now = Utc::now();
124        let mut states = self.states.write();
125        let eligible_ids: Vec<String> = config
126            .providers
127            .iter()
128            .filter(|p| config.is_eligible(&p.provider))
129            .map(|p| p.provider.clone())
130            .collect();
131        // Drop providers that disappeared from config.
132        states.retain(|id, _| eligible_ids.contains(id));
133        // Insert new providers.
134        for p in &config.providers {
135            if !config.is_eligible(&p.provider) {
136                continue;
137            }
138            states
139                .entry(p.provider.clone())
140                .or_insert_with(|| ProviderState::new(p.token_limit, p.reset_window_secs, now));
141        }
142    }
143
144    /// Reserve `tokens` against `provider`'s budget.
145    ///
146    /// `Ok(())` if the reservation fits; `Err` otherwise. The reserve
147    /// here is the second line of defense — the primary gate is the
148    /// availability verdict before the agent is dispatched.
149    pub fn reserve(&self, provider: &str, tokens: u64) -> Result<(), ReserveError> {
150        let now = Utc::now();
151        let mut states = self.states.write();
152        let state = states
153            .get_mut(provider)
154            .ok_or_else(|| ReserveError::UnknownProvider(provider.to_string()))?;
155
156        if state.window_expired(now) {
157            state.reset(now);
158        }
159
160        if state.tokens_used + tokens > state.token_limit {
161            let remaining = state.token_limit.saturating_sub(state.tokens_used);
162            return Err(ReserveError::Exceeded {
163                limit: state.token_limit,
164                used: state.tokens_used,
165                requested: tokens,
166                remaining,
167            });
168        }
169        state.tokens_used += tokens;
170        Ok(())
171    }
172
173    /// Release `tokens` back. Used on error/retry to free headroom.
174    pub fn release(&self, provider: &str, tokens: u64) {
175        let mut states = self.states.write();
176        if let Some(state) = states.get_mut(provider) {
177            state.tokens_used = state.tokens_used.saturating_sub(tokens);
178        }
179    }
180
181    /// Read a snapshot of a provider's state, applying the reset-if-expired
182    /// rule for callers that need an up-to-the-moment view.
183    pub fn snapshot(&self, provider: &str) -> Option<ProviderSnapshot> {
184        let now = Utc::now();
185        let mut states = self.states.write();
186        let state = states.get_mut(provider)?;
187        if state.window_expired(now) {
188            state.reset(now);
189        }
190        Some(ProviderSnapshot::from(&*state))
191    }
192
193    /// All provider states, snapshotted. Used by the `QuotaTracker`.
194    pub fn snapshots(&self) -> Vec<(String, ProviderSnapshot)> {
195        let now = Utc::now();
196        let mut states = self.states.write();
197        states
198            .iter_mut()
199            .map(|(k, v)| {
200                if v.window_expired(now) {
201                    v.reset(now);
202                }
203                (k.clone(), ProviderSnapshot::from(&*v))
204            })
205            .collect()
206    }
207
208    /// Snap a state's `tokens_used` to a real value, then close the
209    /// window. Called by `QuotaTracker` when a fetcher returns
210    /// `remaining_percent` / `resets_at`.
211    ///
212    /// `used_after_calibration` is what the counter should show right
213    /// after the snap (i.e. `token_limit - remaining`).
214    pub fn recalibrate(
215        &self,
216        provider: &str,
217        used_after_calibration: u64,
218        resets_at: Option<DateTime<Utc>>,
219    ) -> bool {
220        let now = Utc::now();
221        let mut states = self.states.write();
222        if let Some(state) = states.get_mut(provider) {
223            state.tokens_used = used_after_calibration.min(state.token_limit);
224            state.window_start = now;
225            state.resets_at = resets_at;
226            true
227        } else {
228            false
229        }
230    }
231}
232#[derive(Debug, Clone, PartialEq, Serialize)]
233/// Read-only view of a [`ProviderState`] at a moment in time.
234pub struct ProviderSnapshot {
235    pub token_limit: u64,
236    pub window_secs: u64,
237    pub tokens_used: u64,
238    pub tokens_remaining: u64,
239    pub remaining_percent: f64,
240    pub window_start: DateTime<Utc>,
241    pub resets_at: Option<DateTime<Utc>>,
242    /// Seconds until the window resets. Honours `resets_at` when present.
243    pub window_remaining_secs: u64,
244}
245
246impl From<&ProviderState> for ProviderSnapshot {
247    fn from(s: &ProviderState) -> Self {
248        let tokens_remaining = s.token_limit.saturating_sub(s.tokens_used);
249        let remaining_percent = if s.token_limit == 0 {
250            0.0
251        } else {
252            (tokens_remaining as f64 / s.token_limit as f64) * 100.0
253        };
254        let window_remaining_secs = match s.resets_at {
255            Some(r) => (r - Utc::now()).num_seconds().max(0) as u64,
256            None => {
257                let elapsed = (Utc::now() - s.window_start).num_seconds().max(0) as u64;
258                s.window_secs.saturating_sub(elapsed)
259            }
260        };
261        Self {
262            token_limit: s.token_limit,
263            window_secs: s.window_secs,
264            tokens_used: s.tokens_used,
265            tokens_remaining,
266            remaining_percent,
267            window_start: s.window_start,
268            resets_at: s.resets_at,
269            window_remaining_secs,
270        }
271    }
272}
273
274impl ProviderSnapshot {
275    /// Build a snapshot directly from parts — used by the v2 live
276    /// quota path. `window_secs` defaults to 1h as a placeholder
277    /// when no live `resets_at` is known.
278    pub fn from_parts(
279        _provider: &str,
280        tokens_used: u64,
281        token_limit: u64,
282        resets_at: Option<DateTime<Utc>>,
283        _floor_percent: u8,
284    ) -> Self {
285        let tokens_remaining = token_limit.saturating_sub(tokens_used);
286        let remaining_percent = if token_limit == 0 {
287            0.0
288        } else {
289            (tokens_remaining as f64 / token_limit as f64) * 100.0
290        };
291        let now = Utc::now();
292        let window_remaining_secs = match resets_at {
293            Some(r) => (r - now).num_seconds().max(0) as u64,
294            None => 3600,
295        };
296        Self {
297            token_limit,
298            window_secs: 3600,
299            tokens_used,
300            tokens_remaining,
301            remaining_percent,
302            window_start: now,
303            resets_at,
304            window_remaining_secs,
305        }
306    }
307}
308
309/// Errors from [`ProviderBudget::reserve`].
310#[derive(Debug, Clone, thiserror::Error)]
311pub enum ReserveError {
312    /// Provider has no budget configured (ineligible).
313    #[error("no budget configured for provider '{0}'")]
314    UnknownProvider(String),
315    /// Requested tokens would push the counter past the plan limit.
316    #[error(
317        "token limit exceeded: requested {requested} but only {remaining} remaining (limit {limit})"
318    )]
319    Exceeded {
320        limit: u64,
321        used: u64,
322        requested: u64,
323        /// Convenience field — equal to `limit - used` at error time.
324        /// thiserror can format it directly without a helper.
325        remaining: u64,
326    },
327}
328
329impl ReserveError {
330    /// Tokens still available in the current window. `0` for
331    /// `UnknownProvider`.
332    pub fn remaining(&self) -> u64 {
333        match self {
334            ReserveError::UnknownProvider(_) => 0,
335            ReserveError::Exceeded { remaining, .. } => *remaining,
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::super::config::TokenMaxingProviderConfig;
343    use super::*;
344
345    fn cfg_with(provider: &str, limit: u64, window: u64) -> TokenMaxingConfig {
346        TokenMaxingConfig {
347            enabled: true,
348            providers: vec![TokenMaxingProviderConfig {
349                provider: provider.into(),
350                billing_model: "subscription".into(),
351                token_limit: limit,
352                reset_window_secs: window,
353                min_remaining_percent: None,
354                models: vec![],
355            }],
356            default_min_remaining_percent: 5,
357            recalibration_interval_secs: 0,
358            parallel_providers: false,
359        }
360    }
361
362    #[test]
363    fn reserve_within_budget_succeeds() {
364        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
365        assert!(b.reserve("zai", 500).is_ok());
366        let s = b.snapshot("zai").unwrap();
367        assert_eq!(s.tokens_used, 500);
368        assert_eq!(s.tokens_remaining, 500);
369    }
370
371    #[test]
372    fn reserve_exceeds_budget() {
373        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
374        b.reserve("zai", 1000).unwrap();
375        let err = b.reserve("zai", 1).unwrap_err();
376        match err {
377            ReserveError::Exceeded {
378                limit,
379                used,
380                requested,
381                ..
382            } => {
383                assert_eq!(limit, 1000);
384                assert_eq!(used, 1000);
385                assert_eq!(requested, 1);
386            }
387            _ => panic!("wrong variant"),
388        }
389    }
390
391    #[test]
392    fn unknown_provider_rejected() {
393        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
394        assert!(matches!(
395            b.reserve("anthropic", 1).unwrap_err(),
396            ReserveError::UnknownProvider(_)
397        ));
398    }
399
400    #[test]
401    fn release_returns_tokens() {
402        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
403        b.reserve("zai", 800).unwrap();
404        b.release("zai", 300);
405        let s = b.snapshot("zai").unwrap();
406        assert_eq!(s.tokens_used, 500);
407    }
408
409    #[test]
410    fn recalibrate_snaps_counter() {
411        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
412        b.reserve("zai", 700).unwrap();
413        let r = b.recalibrate("zai", 850, None);
414        assert!(r);
415        let s = b.snapshot("zai").unwrap();
416        assert_eq!(s.tokens_used, 850);
417    }
418
419    #[test]
420    fn reload_preserves_existing_usage() {
421        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
422        b.reserve("zai", 400).unwrap();
423        b.reload(&cfg_with("zai", 1000, 3600));
424        let s = b.snapshot("zai").unwrap();
425        assert_eq!(s.tokens_used, 400);
426    }
427
428    #[test]
429    fn reload_drops_removed_providers() {
430        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
431        let new_cfg = TokenMaxingConfig {
432            enabled: true,
433            ..TokenMaxingConfig::default()
434        };
435        b.reload(&new_cfg);
436        assert!(b.snapshot("zai").is_none());
437    }
438
439    #[test]
440    fn eligibility_filter_blocks_metered() {
441        let mut cfg = cfg_with("openai", 1000, 3600);
442        cfg.providers[0].billing_model = "metered".into();
443        let b = ProviderBudget::from_config(&cfg);
444        assert!(b.snapshot("openai").is_none());
445        assert!(matches!(
446            b.reserve("openai", 1).unwrap_err(),
447            ReserveError::UnknownProvider(_)
448        ));
449    }
450
451    #[test]
452    fn remaining_percent_computed() {
453        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
454        b.reserve("zai", 250).unwrap();
455        let s = b.snapshot("zai").unwrap();
456        assert!((s.remaining_percent - 75.0).abs() < 1e-9);
457    }
458}