oxios_kernel/token_maxing/
budget.rs1use chrono::{DateTime, Duration, Utc};
24use parking_lot::RwLock;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27
28use super::config::TokenMaxingConfig;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProviderState {
33 pub token_limit: u64,
35 pub window_secs: u64,
37 pub tokens_used: u64,
39 pub window_start: DateTime<Utc>,
41 #[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 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 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
76pub 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 pub fn new() -> Self {
94 Self {
95 states: RwLock::new(HashMap::new()),
96 }
97 }
98
99 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 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 states.retain(|id, _| eligible_ids.contains(id));
133 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 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 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 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 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 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)]
233pub 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 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#[derive(Debug, Clone, thiserror::Error)]
276pub enum ReserveError {
277 #[error("no budget configured for provider '{0}'")]
279 UnknownProvider(String),
280 #[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 remaining: u64,
291 },
292}
293
294impl ReserveError {
295 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}