Skip to main content

hoosh/budget/
mod.rs

1//! Token budget management: per-agent and per-pool token accounting.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7/// A named token pool with a capacity limit.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct TokenPool {
10    /// Pool name (e.g. "default", "agent-123", "batch-jobs").
11    pub name: String,
12    /// Maximum tokens allowed in this pool.
13    pub capacity: u64,
14    /// Tokens consumed so far.
15    pub used: u64,
16    /// Tokens currently reserved (pending completion).
17    pub reserved: u64,
18}
19
20impl TokenPool {
21    /// Create a new pool with the given capacity.
22    pub fn new(name: impl Into<String>, capacity: u64) -> Self {
23        Self {
24            name: name.into(),
25            capacity,
26            used: 0,
27            reserved: 0,
28        }
29    }
30
31    /// Available tokens (capacity - used - reserved).
32    pub fn available(&self) -> u64 {
33        self.capacity
34            .saturating_sub(self.used.saturating_add(self.reserved))
35    }
36
37    /// Whether the pool can accommodate `tokens` more.
38    pub fn can_reserve(&self, tokens: u64) -> bool {
39        self.available() >= tokens
40    }
41
42    /// Reserve tokens (before inference).
43    pub fn reserve(&mut self, tokens: u64) -> bool {
44        if !self.can_reserve(tokens) {
45            return false;
46        }
47        self.reserved += tokens;
48        true
49    }
50
51    /// Commit reserved tokens as used (after inference completes).
52    pub fn commit(&mut self, reserved: u64, actual: u64) {
53        self.reserved = self.reserved.saturating_sub(reserved);
54        self.used = self.used.saturating_add(actual);
55    }
56
57    /// Release reserved tokens without using them (on failure/cancel).
58    pub fn release(&mut self, tokens: u64) {
59        self.reserved = self.reserved.saturating_sub(tokens);
60    }
61
62    /// Utilisation ratio (0.0–1.0).
63    pub fn utilization(&self) -> f64 {
64        if self.capacity == 0 {
65            return 0.0;
66        }
67        self.used as f64 / self.capacity as f64
68    }
69}
70
71/// Token budget manager: tracks multiple named pools.
72pub struct TokenBudget {
73    pools: HashMap<String, TokenPool>,
74}
75
76impl TokenBudget {
77    /// Create a new budget manager.
78    pub fn new() -> Self {
79        Self {
80            pools: HashMap::new(),
81        }
82    }
83
84    /// Add or replace a pool.
85    pub fn add_pool(&mut self, pool: TokenPool) {
86        self.pools.insert(pool.name.clone(), pool);
87    }
88
89    /// Get a pool by name.
90    pub fn get_pool(&self, name: &str) -> Option<&TokenPool> {
91        self.pools.get(name)
92    }
93
94    /// Get a mutable pool by name.
95    pub fn get_pool_mut(&mut self, name: &str) -> Option<&mut TokenPool> {
96        self.pools.get_mut(name)
97    }
98
99    /// All pools.
100    pub fn pools(&self) -> &HashMap<String, TokenPool> {
101        &self.pools
102    }
103
104    /// Check if a pool can accommodate a request.
105    pub fn check(&self, pool_name: &str, tokens: u64) -> bool {
106        self.pools
107            .get(pool_name)
108            .map(|p| p.can_reserve(tokens))
109            .unwrap_or(false)
110    }
111
112    /// Reserve tokens in a pool.
113    pub fn reserve(&mut self, pool_name: &str, tokens: u64) -> bool {
114        self.pools
115            .get_mut(pool_name)
116            .map(|p| p.reserve(tokens))
117            .unwrap_or(false)
118    }
119
120    /// Report actual usage and release the reservation.
121    pub fn report(&mut self, pool_name: &str, reserved: u64, actual: u64) {
122        if let Some(pool) = self.pools.get_mut(pool_name) {
123            pool.commit(reserved, actual);
124        }
125    }
126}
127
128impl Default for TokenBudget {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn pool_basic() {
140        let mut pool = TokenPool::new("test", 1000);
141        assert_eq!(pool.available(), 1000);
142        assert!(pool.reserve(400));
143        assert_eq!(pool.available(), 600);
144        pool.commit(400, 350);
145        assert_eq!(pool.used, 350);
146        assert_eq!(pool.reserved, 0);
147        assert_eq!(pool.available(), 650);
148    }
149
150    #[test]
151    fn pool_over_budget() {
152        let pool = TokenPool::new("test", 100);
153        assert!(!pool.can_reserve(200));
154    }
155
156    #[test]
157    fn pool_release() {
158        let mut pool = TokenPool::new("test", 1000);
159        pool.reserve(500);
160        pool.release(500);
161        assert_eq!(pool.reserved, 0);
162        assert_eq!(pool.available(), 1000);
163    }
164
165    #[test]
166    fn pool_utilization() {
167        let mut pool = TokenPool::new("test", 1000);
168        pool.used = 750;
169        assert!((pool.utilization() - 0.75).abs() < f64::EPSILON);
170    }
171
172    #[test]
173    fn budget_multi_pool() {
174        let mut budget = TokenBudget::new();
175        budget.add_pool(TokenPool::new("default", 10000));
176        budget.add_pool(TokenPool::new("agent-1", 5000));
177
178        assert!(budget.check("default", 8000));
179        assert!(!budget.check("agent-1", 8000));
180        assert!(!budget.check("nonexistent", 1));
181    }
182
183    #[test]
184    fn budget_reserve_report() {
185        let mut budget = TokenBudget::new();
186        budget.add_pool(TokenPool::new("pool", 1000));
187        assert!(budget.reserve("pool", 500));
188        budget.report("pool", 500, 420);
189        let pool = budget.get_pool("pool").unwrap();
190        assert_eq!(pool.used, 420);
191        assert_eq!(pool.reserved, 0);
192    }
193}