1use std::sync::{Arc, Mutex};
47
48use crate::error::{Error, Result};
49
50type Callback = Arc<dyn Fn(u64) + Send + Sync>;
51
52#[derive(Default)]
53struct Config {
54 max_tokens: Option<u64>,
55 warn_at_tokens: Option<u64>,
56 on_warning: Option<Callback>,
57 on_exceeded: Option<Callback>,
58}
59
60#[derive(Default)]
61struct State {
62 total_tokens: u64,
63 turns_missing_usage: usize,
64 warned: bool,
65 exceeded: bool,
66}
67
68struct Inner {
69 config: Config,
70 state: Mutex<State>,
71}
72
73#[derive(Clone)]
79pub struct TokenBudget {
80 inner: Arc<Inner>,
81}
82
83impl TokenBudget {
84 #[must_use]
86 pub fn builder() -> TokenBudgetBuilder {
87 TokenBudgetBuilder::default()
88 }
89
90 pub fn record(&self, tokens: Option<u64>) {
100 let Some(tokens) = tokens else {
101 self.inner
102 .state
103 .lock()
104 .expect("budget mutex poisoned")
105 .turns_missing_usage += 1;
106 return;
107 };
108
109 let (warn_fired, exceeded_fired, total) = {
110 let mut state = self.inner.state.lock().expect("budget mutex poisoned");
111 state.total_tokens = state.total_tokens.saturating_add(tokens);
112
113 let warn_fired = match self.inner.config.warn_at_tokens {
114 Some(threshold) if !state.warned && state.total_tokens >= threshold => {
115 state.warned = true;
116 true
117 }
118 _ => false,
119 };
120
121 let exceeded_fired = match self.inner.config.max_tokens {
122 Some(threshold) if !state.exceeded && state.total_tokens >= threshold => {
123 state.exceeded = true;
124 true
125 }
126 _ => false,
127 };
128
129 (warn_fired, exceeded_fired, state.total_tokens)
130 };
131
132 if warn_fired && let Some(cb) = &self.inner.config.on_warning {
135 cb(total);
136 }
137 if exceeded_fired && let Some(cb) = &self.inner.config.on_exceeded {
138 cb(total);
139 }
140 }
141
142 pub fn check(&self) -> Result<()> {
146 let Some(max_tokens) = self.inner.config.max_tokens else {
147 return Ok(());
148 };
149 let total_tokens = self.total_tokens();
150 if total_tokens >= max_tokens {
151 Err(Error::TokenBudgetExceeded {
152 total_tokens,
153 max_tokens,
154 })
155 } else {
156 Ok(())
157 }
158 }
159
160 #[must_use]
162 pub fn total_tokens(&self) -> u64 {
163 self.inner
164 .state
165 .lock()
166 .expect("budget mutex poisoned")
167 .total_tokens
168 }
169
170 #[must_use]
175 pub fn turns_missing_usage(&self) -> usize {
176 self.inner
177 .state
178 .lock()
179 .expect("budget mutex poisoned")
180 .turns_missing_usage
181 }
182
183 #[must_use]
188 pub fn remaining_tokens(&self) -> Option<u64> {
189 self.inner
190 .config
191 .max_tokens
192 .map(|max| max.saturating_sub(self.total_tokens()))
193 }
194
195 #[must_use]
197 pub fn max_tokens(&self) -> Option<u64> {
198 self.inner.config.max_tokens
199 }
200
201 #[must_use]
203 pub fn warn_at_tokens(&self) -> Option<u64> {
204 self.inner.config.warn_at_tokens
205 }
206
207 pub fn reset(&self) {
209 *self.inner.state.lock().expect("budget mutex poisoned") = State::default();
210 }
211}
212
213impl std::fmt::Debug for TokenBudget {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("TokenBudget")
216 .field("total_tokens", &self.total_tokens())
217 .field("max_tokens", &self.max_tokens())
218 .field("warn_at_tokens", &self.warn_at_tokens())
219 .field("turns_missing_usage", &self.turns_missing_usage())
220 .finish()
221 }
222}
223
224#[derive(Default)]
226pub struct TokenBudgetBuilder {
227 config: Config,
228}
229
230impl TokenBudgetBuilder {
231 #[must_use]
233 pub fn max_tokens(mut self, max: u64) -> Self {
234 self.config.max_tokens = Some(max);
235 self
236 }
237
238 #[must_use]
240 pub fn warn_at_tokens(mut self, warn: u64) -> Self {
241 self.config.warn_at_tokens = Some(warn);
242 self
243 }
244
245 #[must_use]
247 pub fn on_warning<F>(mut self, f: F) -> Self
248 where
249 F: Fn(u64) + Send + Sync + 'static,
250 {
251 self.config.on_warning = Some(Arc::new(f));
252 self
253 }
254
255 #[must_use]
257 pub fn on_exceeded<F>(mut self, f: F) -> Self
258 where
259 F: Fn(u64) + Send + Sync + 'static,
260 {
261 self.config.on_exceeded = Some(Arc::new(f));
262 self
263 }
264
265 #[must_use]
267 pub fn build(self) -> TokenBudget {
268 TokenBudget {
269 inner: Arc::new(Inner {
270 config: self.config,
271 state: Mutex::new(State::default()),
272 }),
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use std::sync::atomic::{AtomicU64, Ordering};
280
281 use super::*;
282
283 #[test]
284 fn records_and_reports_a_running_total() {
285 let budget = TokenBudget::builder().max_tokens(1000).build();
286 budget.record(Some(400));
287 budget.record(Some(350));
288
289 assert_eq!(budget.total_tokens(), 750);
290 assert_eq!(budget.remaining_tokens(), Some(250));
291 assert!(budget.check().is_ok());
292 }
293
294 #[test]
295 fn check_fails_once_the_ceiling_is_reached() {
296 let budget = TokenBudget::builder().max_tokens(100).build();
297 budget.record(Some(100));
298
299 let err = budget.check().unwrap_err();
300 assert!(
301 matches!(
302 err,
303 Error::TokenBudgetExceeded {
304 total_tokens: 100,
305 max_tokens: 100
306 }
307 ),
308 "{err:?}"
309 );
310 }
311
312 #[test]
315 fn remaining_saturates_instead_of_wrapping() {
316 let budget = TokenBudget::builder().max_tokens(100).build();
317 budget.record(Some(250));
318
319 assert_eq!(budget.remaining_tokens(), Some(0));
320 assert_eq!(budget.total_tokens(), 250);
321 }
322
323 #[test]
324 fn no_ceiling_means_counting_only() {
325 let budget = TokenBudget::builder().build();
326 budget.record(Some(u64::MAX));
327
328 assert!(budget.check().is_ok());
329 assert_eq!(budget.remaining_tokens(), None);
330 }
331
332 #[test]
335 fn a_turn_without_usage_is_counted_not_ignored() {
336 let budget = TokenBudget::builder().max_tokens(100).build();
337 budget.record(None);
338 budget.record(Some(10));
339 budget.record(None);
340
341 assert_eq!(budget.total_tokens(), 10);
342 assert_eq!(budget.turns_missing_usage(), 2);
343 }
344
345 #[test]
346 fn callbacks_fire_once_each() {
347 let warnings = Arc::new(AtomicU64::new(0));
348 let exceeded = Arc::new(AtomicU64::new(0));
349 let w = Arc::clone(&warnings);
350 let e = Arc::clone(&exceeded);
351
352 let budget = TokenBudget::builder()
353 .warn_at_tokens(50)
354 .max_tokens(100)
355 .on_warning(move |_| {
356 w.fetch_add(1, Ordering::SeqCst);
357 })
358 .on_exceeded(move |_| {
359 e.fetch_add(1, Ordering::SeqCst);
360 })
361 .build();
362
363 for _ in 0..10 {
364 budget.record(Some(30));
365 }
366
367 assert_eq!(warnings.load(Ordering::SeqCst), 1);
368 assert_eq!(exceeded.load(Ordering::SeqCst), 1);
369 }
370
371 #[test]
374 fn a_callback_can_read_the_budget_it_belongs_to() {
375 let seen = Arc::new(Mutex::new(None));
376 let sink = Arc::clone(&seen);
377 let budget = TokenBudget::builder().max_tokens(10).build();
378 let handle = budget.clone();
379
380 let budget = TokenBudget::builder()
381 .max_tokens(10)
382 .on_exceeded(move |_| {
383 *sink.lock().unwrap() = Some(handle.total_tokens());
384 })
385 .build();
386
387 budget.record(Some(20));
388 assert!(seen.lock().unwrap().is_some());
389 }
390
391 #[test]
392 fn clones_share_one_total() {
393 let budget = TokenBudget::builder().max_tokens(100).build();
394 let other = budget.clone();
395
396 budget.record(Some(60));
397 other.record(Some(50));
398
399 assert_eq!(budget.total_tokens(), 110);
400 assert!(other.check().is_err());
401 }
402
403 #[test]
404 fn reset_clears_the_total_and_rearms_the_thresholds() {
405 let fired = Arc::new(AtomicU64::new(0));
406 let f = Arc::clone(&fired);
407 let budget = TokenBudget::builder()
408 .max_tokens(100)
409 .on_exceeded(move |_| {
410 f.fetch_add(1, Ordering::SeqCst);
411 })
412 .build();
413
414 budget.record(Some(150));
415 budget.record(None);
416 budget.reset();
417
418 assert_eq!(budget.total_tokens(), 0);
419 assert_eq!(budget.turns_missing_usage(), 0);
420 assert!(budget.check().is_ok());
421
422 budget.record(Some(150));
423 assert_eq!(fired.load(Ordering::SeqCst), 2, "threshold must re-arm");
424 }
425}