use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use crate::{
run::{RunUsage, TurnOptions},
workspace::RunSpec,
};
#[derive(Clone)]
pub struct BudgetPool {
limit: u64,
spent: Arc<AtomicU64>,
}
impl BudgetPool {
pub fn new(limit: u64) -> Self {
Self {
limit,
spent: Arc::new(AtomicU64::new(0)),
}
}
pub const fn limit(&self) -> u64 {
self.limit
}
pub fn spent(&self) -> u64 {
self.spent.load(Ordering::SeqCst)
}
pub fn remaining(&self) -> u64 {
self.limit.saturating_sub(self.spent())
}
pub fn is_exhausted(&self) -> bool {
self.remaining() == 0
}
pub fn spec(&self, prompt: impl Into<String>) -> RunSpec {
RunSpec::new(prompt).with_budget(self.clone())
}
pub fn bounds(&self) -> TurnOptions {
TurnOptions::default().with_budget(self.clone())
}
pub fn record(&self, usage: RunUsage) -> u64 {
let tokens = usage.total_tokens();
let previous = self
.spent
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |spent| {
Some(spent.saturating_add(tokens))
})
.unwrap_or_else(|spent| spent);
previous.saturating_add(tokens)
}
pub(crate) fn counter(&self) -> Arc<AtomicU64> {
Arc::clone(&self.spent)
}
pub(crate) fn turn_bound(&self, per_turn: Option<u64>) -> u64 {
match per_turn {
Some(cap) => self.limit.min(self.spent().saturating_add(cap)),
None => self.limit,
}
}
}
impl std::fmt::Debug for BudgetPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BudgetPool")
.field("limit", &self.limit)
.field("spent", &self.spent())
.field("remaining", &self.remaining())
.finish()
}
}
impl PartialEq for BudgetPool {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.spent, &other.spent)
}
}
impl Eq for BudgetPool {}
#[cfg(test)]
mod tests {
use super::*;
fn spent(tokens: u64) -> RunUsage {
RunUsage {
input_tokens: tokens,
output_tokens: 0,
cache_read_tokens: 1_000,
cache_creation_tokens: 1_000,
}
}
#[test]
fn a_new_pool_holds_its_whole_allowance() {
let pool = BudgetPool::new(500_000);
assert_eq!(pool.limit(), 500_000);
assert_eq!(pool.spent(), 0);
assert_eq!(pool.remaining(), 500_000);
assert!(!pool.is_exhausted());
}
#[test]
fn recording_usage_draws_the_pool_down() {
let pool = BudgetPool::new(1_000);
assert_eq!(pool.record(spent(300)), 300, "the new total comes back");
assert_eq!(pool.remaining(), 700);
assert_eq!(
pool.spent(),
300,
"cache tokens are counted by neither mentra nor the pool"
);
}
#[test]
fn a_pool_that_is_overspent_reports_nothing_left_rather_than_wrapping() {
let pool = BudgetPool::new(1_000);
pool.record(spent(1_500));
assert_eq!(pool.remaining(), 0);
assert!(pool.is_exhausted());
assert_eq!(pool.spent(), 1_500, "the overshoot stays legible");
}
#[test]
fn recording_saturates_instead_of_rolling_over() {
let pool = BudgetPool::new(1_000);
pool.record(spent(u64::MAX));
assert_eq!(pool.record(spent(u64::MAX)), u64::MAX);
assert_eq!(pool.remaining(), 0, "still out, not suddenly flush");
}
#[test]
fn a_pool_is_exhausted_the_moment_its_limit_is_reached() {
let pool = BudgetPool::new(100);
pool.record(spent(100));
assert!(pool.is_exhausted());
assert_eq!(pool.remaining(), 0);
}
#[test]
fn a_pool_with_no_allowance_is_exhausted_from_the_start() {
assert!(BudgetPool::new(0).is_exhausted());
}
#[test]
fn a_clone_is_another_handle_on_one_allowance() {
let pool = BudgetPool::new(1_000);
let handle = pool.clone();
handle.record(spent(400));
assert_eq!(pool.spent(), 400, "one pool, seen through two handles");
assert_eq!(pool.remaining(), handle.remaining());
assert_eq!(pool, handle);
}
#[test]
fn two_pools_of_the_same_size_are_not_the_same_pool() {
let one = BudgetPool::new(1_000);
let two = BudgetPool::new(1_000);
one.record(spent(400));
assert_ne!(one, two, "equal figures are not one allowance");
assert_eq!(
two.remaining(),
1_000,
"and spending one does not spend two"
);
}
#[test]
fn a_bare_pool_bounds_a_turn_at_its_limit() {
let pool = BudgetPool::new(500_000);
pool.record(spent(200_000));
assert_eq!(pool.turn_bound(None), 500_000);
}
#[test]
fn a_per_turn_cap_is_measured_from_what_the_job_has_already_spent() {
let pool = BudgetPool::new(500_000);
pool.record(spent(200_000));
assert_eq!(pool.turn_bound(Some(50_000)), 250_000);
}
#[test]
fn a_per_turn_cap_can_only_tighten_the_pool_bound() {
let pool = BudgetPool::new(1_000);
pool.record(spent(900));
assert_eq!(pool.turn_bound(Some(10_000)), 1_000);
}
#[test]
fn a_pool_hands_out_specs_and_options_that_draw_on_it() {
let pool = BudgetPool::new(1_000);
let spec = pool.spec("review the diff");
assert_eq!(spec.prompt, "review the diff");
assert_eq!(spec.budget, Some(pool.clone()));
assert_eq!(pool.bounds().budget, Some(pool.clone()));
assert_eq!(spec.deadline, None);
assert_eq!(spec.token_budget, None);
assert!(pool.bounds().cancel.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_draws_and_records_lose_nothing() {
let pool = BudgetPool::new(100_000);
let writers = (0..32).map(|_| {
let pool = pool.clone();
tokio::spawn(async move {
for _ in 0..100 {
pool.record(spent(10));
assert!(pool.spent() <= 32_000);
}
})
});
for writer in writers.collect::<Vec<_>>() {
writer.await.expect("a writer finishes");
}
assert_eq!(pool.spent(), 32_000);
assert_eq!(pool.remaining(), 68_000);
}
#[test]
fn a_pool_can_be_shared_across_tasks() {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<BudgetPool>();
}
}