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
274/// Errors from [`ProviderBudget::reserve`].
275#[derive(Debug, Clone, thiserror::Error)]
276pub enum ReserveError {
277    /// Provider has no budget configured (ineligible).
278    #[error("no budget configured for provider '{0}'")]
279    UnknownProvider(String),
280    /// Requested tokens would push the counter past the plan limit.
281    #[error(
282        "token limit exceeded: requested {requested} but only {remaining} remaining (limit {limit})"
283    )]
284    Exceeded {
285        limit: u64,
286        used: u64,
287        requested: u64,
288        /// Convenience field โ€” equal to `limit - used` at error time.
289        /// thiserror can format it directly without a helper.
290        remaining: u64,
291    },
292}
293
294impl ReserveError {
295    /// Tokens still available in the current window. `0` for
296    /// `UnknownProvider`.
297    pub fn remaining(&self) -> u64 {
298        match self {
299            ReserveError::UnknownProvider(_) => 0,
300            ReserveError::Exceeded { remaining, .. } => *remaining,
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::super::config::TokenMaxingProviderConfig;
308    use super::*;
309
310    fn cfg_with(provider: &str, limit: u64, window: u64) -> TokenMaxingConfig {
311        TokenMaxingConfig {
312            enabled: true,
313            providers: vec![TokenMaxingProviderConfig {
314                provider: provider.into(),
315                billing_model: "subscription".into(),
316                token_limit: limit,
317                reset_window_secs: window,
318                min_remaining_percent: None,
319                models: vec![],
320            }],
321            default_min_remaining_percent: 5,
322            recalibration_interval_secs: 0,
323            parallel_providers: false,
324        }
325    }
326
327    #[test]
328    fn reserve_within_budget_succeeds() {
329        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
330        assert!(b.reserve("zai", 500).is_ok());
331        let s = b.snapshot("zai").unwrap();
332        assert_eq!(s.tokens_used, 500);
333        assert_eq!(s.tokens_remaining, 500);
334    }
335
336    #[test]
337    fn reserve_exceeds_budget() {
338        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
339        b.reserve("zai", 1000).unwrap();
340        let err = b.reserve("zai", 1).unwrap_err();
341        match err {
342            ReserveError::Exceeded {
343                limit,
344                used,
345                requested,
346                ..
347            } => {
348                assert_eq!(limit, 1000);
349                assert_eq!(used, 1000);
350                assert_eq!(requested, 1);
351            }
352            _ => panic!("wrong variant"),
353        }
354    }
355
356    #[test]
357    fn unknown_provider_rejected() {
358        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
359        assert!(matches!(
360            b.reserve("anthropic", 1).unwrap_err(),
361            ReserveError::UnknownProvider(_)
362        ));
363    }
364
365    #[test]
366    fn release_returns_tokens() {
367        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
368        b.reserve("zai", 800).unwrap();
369        b.release("zai", 300);
370        let s = b.snapshot("zai").unwrap();
371        assert_eq!(s.tokens_used, 500);
372    }
373
374    #[test]
375    fn recalibrate_snaps_counter() {
376        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
377        b.reserve("zai", 700).unwrap();
378        let r = b.recalibrate("zai", 850, None);
379        assert!(r);
380        let s = b.snapshot("zai").unwrap();
381        assert_eq!(s.tokens_used, 850);
382    }
383
384    #[test]
385    fn reload_preserves_existing_usage() {
386        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
387        b.reserve("zai", 400).unwrap();
388        b.reload(&cfg_with("zai", 1000, 3600));
389        let s = b.snapshot("zai").unwrap();
390        assert_eq!(s.tokens_used, 400);
391    }
392
393    #[test]
394    fn reload_drops_removed_providers() {
395        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
396        let new_cfg = TokenMaxingConfig {
397            enabled: true,
398            ..TokenMaxingConfig::default()
399        };
400        b.reload(&new_cfg);
401        assert!(b.snapshot("zai").is_none());
402    }
403
404    #[test]
405    fn eligibility_filter_blocks_metered() {
406        let mut cfg = cfg_with("openai", 1000, 3600);
407        cfg.providers[0].billing_model = "metered".into();
408        let b = ProviderBudget::from_config(&cfg);
409        assert!(b.snapshot("openai").is_none());
410        assert!(matches!(
411            b.reserve("openai", 1).unwrap_err(),
412            ReserveError::UnknownProvider(_)
413        ));
414    }
415
416    #[test]
417    fn remaining_percent_computed() {
418        let b = ProviderBudget::from_config(&cfg_with("zai", 1000, 3600));
419        b.reserve("zai", 250).unwrap();
420        let s = b.snapshot("zai").unwrap();
421        assert!((s.remaining_percent - 75.0).abs() < 1e-9);
422    }
423}