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
274impl ProviderSnapshot {
275 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#[derive(Debug, Clone, thiserror::Error)]
311pub enum ReserveError {
312 #[error("no budget configured for provider '{0}'")]
314 UnknownProvider(String),
315 #[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 remaining: u64,
326 },
327}
328
329impl ReserveError {
330 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}