use std::num::NonZeroU64;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::error::Error;
use crate::llm::types::TokenUsage;
#[derive(Clone, Debug)]
pub struct Budget(Arc<BudgetInner>);
#[derive(Debug)]
struct BudgetInner {
total: Option<NonZeroU64>,
spent: AtomicU64,
}
impl Default for Budget {
fn default() -> Self {
Self::unbounded()
}
}
impl Budget {
pub fn unbounded() -> Self {
Self(Arc::new(BudgetInner {
total: None,
spent: AtomicU64::new(0),
}))
}
pub fn with_total(total: u64) -> Self {
Self(Arc::new(BudgetInner {
total: NonZeroU64::new(total),
spent: AtomicU64::new(0),
}))
}
pub fn total(&self) -> Option<u64> {
self.0.total.map(NonZeroU64::get)
}
pub fn spent(&self) -> u64 {
self.0.spent.load(Ordering::Relaxed)
}
pub fn remaining(&self) -> u64 {
match self.0.total {
None => u64::MAX,
Some(t) => t.get().saturating_sub(self.spent()),
}
}
pub(crate) fn check_admit(&self) -> Result<(), Error> {
if let Some(total) = self.0.total {
let spent = self.spent();
if spent >= total.get() {
return Err(Error::BudgetExceeded {
used: spent,
limit: total.get(),
});
}
}
Ok(())
}
pub(crate) fn record(&self, usage: &TokenUsage) {
self.0
.spent
.fetch_add(weighted_cost(usage), Ordering::AcqRel);
}
}
fn weighted_cost(usage: &TokenUsage) -> u64 {
let input = usage.input_tokens as u64;
let output = usage.output_tokens as u64;
let reasoning = usage.reasoning_tokens as u64;
let cache_write = usage.cache_creation_input_tokens as u64;
let cache_read = usage.cache_read_input_tokens as u64;
input + output + reasoning + (cache_write * 5 / 4) + (cache_read / 10)
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(input: u32, output: u32) -> TokenUsage {
TokenUsage {
input_tokens: input,
output_tokens: output,
..Default::default()
}
}
#[test]
fn unbounded_admits_and_reports_max_remaining() {
let b = Budget::unbounded();
assert_eq!(b.total(), None);
assert_eq!(b.remaining(), u64::MAX);
assert!(b.check_admit().is_ok());
b.record(&usage(1_000, 1_000));
assert!(b.check_admit().is_ok());
assert_eq!(b.remaining(), u64::MAX);
}
#[test]
fn with_total_reports_total_and_remaining() {
let b = Budget::with_total(100);
assert_eq!(b.total(), Some(100));
assert_eq!(b.spent(), 0);
assert_eq!(b.remaining(), 100);
}
#[test]
fn admit_ok_below_limit_err_at_or_above() {
let b = Budget::with_total(100);
assert!(b.check_admit().is_ok());
b.record(&usage(60, 0)); assert!(b.check_admit().is_ok());
b.record(&usage(40, 0)); match b.check_admit() {
Err(Error::BudgetExceeded { used, limit }) => {
assert_eq!(used, 100);
assert_eq!(limit, 100);
}
other => panic!("expected BudgetExceeded {{used:100, limit:100}}, got {other:?}"),
}
}
#[test]
fn record_accumulates_exactly() {
let b = Budget::with_total(1_000_000);
b.record(&usage(100, 50));
b.record(&usage(200, 80));
assert_eq!(b.spent(), 100 + 50 + 200 + 80);
assert_eq!(b.remaining(), 1_000_000 - 430);
}
#[test]
fn record_widens_u32_fields_without_overflow() {
let b = Budget::unbounded();
b.record(&usage(u32::MAX, u32::MAX));
assert_eq!(b.spent(), u32::MAX as u64 + u32::MAX as u64);
}
#[test]
fn weighted_cost_applies_cache_ratios() {
assert_eq!(weighted_cost(&usage(100, 50)), 150);
let cache_read = TokenUsage {
cache_read_input_tokens: 1_000,
..Default::default()
};
assert_eq!(weighted_cost(&cache_read), 100);
let cache_write = TokenUsage {
cache_creation_input_tokens: 400,
..Default::default()
};
assert_eq!(weighted_cost(&cache_write), 500);
let reasoning = TokenUsage {
reasoning_tokens: 42,
..Default::default()
};
assert_eq!(weighted_cost(&reasoning), 42);
}
#[test]
fn cache_read_heavy_usage_is_cheap() {
let heavy = TokenUsage {
cache_read_input_tokens: 10_000,
..Default::default()
};
assert_eq!(weighted_cost(&heavy), 1_000);
}
}