Skip to main content

codex_wrapper/
budget.rs

1//! Cumulative token budget tracking across turns.
2//!
3//! # Why tokens and not money
4//!
5//! `claude-wrapper`'s equivalent tracks USD, because that CLI reports a cost
6//! per turn. The codex CLI does not: a completed turn carries token counts and
7//! no monetary field (see the schema block in [`crate::types`]). Converting
8//! tokens to dollars needs a per-model price table the CLI does not provide,
9//! and a hardcoded one would go stale silently, which is the failure mode #73
10//! was filed about. So the ceiling here is denominated in what the CLI
11//! actually reports.
12//!
13//! # What a budget cannot see
14//!
15//! A tracker only counts what it is told, and two codex behaviours mean that
16//! is less than everything:
17//!
18//! - A `turn.completed` event without a `usage` object contributes nothing.
19//!   [`TokenBudget::turns_missing_usage`] counts those separately, so an
20//!   unmeasured turn is distinguishable from a genuinely cheap one.
21//! - A review reports usage as all zeros. A session of reviews never advances
22//!   the total at all.
23//!
24//! Treat a budget as a floor on consumption rather than an exact measure.
25//!
26//! # Example
27//!
28//! ```
29//! use codex_wrapper::TokenBudget;
30//!
31//! let budget = TokenBudget::builder()
32//!     .max_tokens(100_000)
33//!     .warn_at_tokens(80_000)
34//!     .on_warning(|total| eprintln!("at {total} tokens"))
35//!     .build();
36//!
37//! budget.record(Some(50_000));
38//! assert_eq!(budget.total_tokens(), 50_000);
39//! assert_eq!(budget.remaining_tokens(), Some(50_000));
40//! assert!(budget.check().is_ok());
41//!
42//! budget.record(Some(60_000));
43//! assert!(budget.check().is_err());
44//! ```
45
46use 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/// Cumulative token budget with threshold callbacks.
74///
75/// Cloning shares one running total, so a single budget can span several
76/// [`Session`](crate::Session)s. See the [module docs](crate::budget) for what
77/// a budget can and cannot see.
78#[derive(Clone)]
79pub struct TokenBudget {
80    inner: Arc<Inner>,
81}
82
83impl TokenBudget {
84    /// Start building a budget.
85    #[must_use]
86    pub fn builder() -> TokenBudgetBuilder {
87        TokenBudgetBuilder::default()
88    }
89
90    /// Add a turn's token usage to the running total.
91    ///
92    /// `None` records a turn the CLI reported no usage for. It does not move
93    /// the total, and is counted by
94    /// [`turns_missing_usage`](Self::turns_missing_usage) so the gap stays
95    /// visible rather than reading as zero consumption.
96    ///
97    /// Fires `on_warning` the first time the total reaches `warn_at_tokens`,
98    /// and `on_exceeded` the first time it reaches `max_tokens`.
99    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        // Fired outside the lock: a callback that touches this budget would
133        // otherwise deadlock on it.
134        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    /// `Err(Error::TokenBudgetExceeded)` once the total reaches `max_tokens`.
143    ///
144    /// `Ok(())` when no ceiling is set.
145    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    /// Tokens recorded so far.
161    #[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    /// Turns recorded with no usage reported.
171    ///
172    /// These consumed tokens the total does not include, so a non-zero count
173    /// means the total is a floor rather than a measure.
174    #[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    /// Tokens left before the ceiling, or `None` when there is no ceiling.
184    ///
185    /// Saturates at zero rather than going negative: a turn can overshoot,
186    /// since usage is only known once it has been spent.
187    #[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    /// The configured ceiling, if any.
196    #[must_use]
197    pub fn max_tokens(&self) -> Option<u64> {
198        self.inner.config.max_tokens
199    }
200
201    /// The configured warning threshold, if any.
202    #[must_use]
203    pub fn warn_at_tokens(&self) -> Option<u64> {
204        self.inner.config.warn_at_tokens
205    }
206
207    /// Clear the running total and re-arm both thresholds.
208    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/// Builder for [`TokenBudget`].
225#[derive(Default)]
226pub struct TokenBudgetBuilder {
227    config: Config,
228}
229
230impl TokenBudgetBuilder {
231    /// Stop at this many tokens. Without one, the budget only counts.
232    #[must_use]
233    pub fn max_tokens(mut self, max: u64) -> Self {
234        self.config.max_tokens = Some(max);
235        self
236    }
237
238    /// Fire `on_warning` once the total reaches this many tokens.
239    #[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    /// Called once, with the running total, when `warn_at_tokens` is reached.
246    #[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    /// Called once, with the running total, when `max_tokens` is reached.
256    #[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    /// Build the budget.
266    #[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    /// A turn can only be measured after it has run, so the total can land
313    /// past the ceiling. Remaining must floor at zero rather than wrap.
314    #[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    /// An unreported turn must not read as zero consumption, which would let
333    /// a session run indefinitely against a ceiling it never approaches.
334    #[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    /// A callback that reads the budget must not deadlock, which it would if
372    /// callbacks fired while the state lock was held.
373    #[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}