use chrono::Datelike;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
OpenAi,
Anthropic,
Cohere,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Model {
Gpt4o,
Gpt4,
Gpt35Turbo,
Claude3Opus,
Claude3Sonnet,
Claude3Haiku,
CommandRPlus,
CommandR,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BudgetKind {
Daily,
Monthly,
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BudgetWindow {
#[default]
Daily,
Duration(std::time::Duration),
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum BudgetError {
#[error("ancestor {ancestor_id:?} budget exhausted ({kind:?})")]
AncestorBudgetExhausted {
ancestor_id: [u8; 16],
kind: BudgetKind,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum BudgetStatus {
WithinBudget { spent_usd: f64, remaining_usd: f64 },
ThresholdAlert { pct: u8 },
LimitExceeded,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BudgetState {
#[serde(with = "rust_decimal::serde::str")]
pub spent_usd: rust_decimal::Decimal,
pub date: chrono::NaiveDate,
#[serde(default)]
pub month: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monthly_spent_usd: Option<rust_decimal::Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_reset_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl BudgetState {
fn month_tag(date: chrono::NaiveDate) -> u32 {
date.year() as u32 * 100 + date.month()
}
pub fn new_today() -> Self {
let date = chrono::Utc::now().date_naive();
Self {
spent_usd: rust_decimal::Decimal::ZERO,
date,
month: Self::month_tag(date),
monthly_spent_usd: None,
last_reset_at: None,
}
}
pub fn new_for_date(date: chrono::NaiveDate) -> Self {
Self {
spent_usd: rust_decimal::Decimal::ZERO,
date,
month: Self::month_tag(date),
monthly_spent_usd: None,
last_reset_at: None,
}
}
pub fn maybe_reset(&mut self, today: chrono::NaiveDate) {
let current_month = Self::month_tag(today);
if current_month != self.month {
self.monthly_spent_usd = self.monthly_spent_usd.map(|_| rust_decimal::Decimal::ZERO);
self.month = current_month;
}
if self.date < today {
self.spent_usd = rust_decimal::Decimal::ZERO;
self.date = today;
}
}
pub fn maybe_reset_window(&mut self, now: chrono::DateTime<chrono::Utc>, window: BudgetWindow, tz: chrono_tz::Tz) {
match window {
BudgetWindow::Daily => {
self.maybe_reset(now.with_timezone(&tz).date_naive());
}
BudgetWindow::Duration(interval) => {
let today = now.with_timezone(&tz).date_naive();
let current_month = Self::month_tag(today);
if current_month != self.month {
self.monthly_spent_usd = self.monthly_spent_usd.map(|_| rust_decimal::Decimal::ZERO);
self.month = current_month;
}
match self.last_reset_at {
None => {
self.last_reset_at = Some(now);
}
Some(anchor) => {
let elapsed = now
.signed_duration_since(anchor)
.to_std()
.unwrap_or(std::time::Duration::ZERO);
if elapsed >= interval {
self.spent_usd = rust_decimal::Decimal::ZERO;
self.last_reset_at = Some(now);
self.date = today;
}
}
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SubtreeSpend {
pub tokens: u64,
pub usd: rust_decimal::Decimal,
pub agents_counted: usize,
}
#[derive(Debug, Clone)]
pub struct BudgetAlert {
pub agent_id: aa_core::AgentId,
pub team_id: Option<String>,
pub threshold_pct: u8,
pub spent_usd: f64,
pub limit_usd: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_variants_are_distinct() {
assert_eq!(Provider::OpenAi, Provider::OpenAi);
assert_ne!(Provider::OpenAi, Provider::Anthropic);
assert_ne!(Provider::OpenAi, Provider::Cohere);
assert_ne!(Provider::Anthropic, Provider::Cohere);
}
#[test]
fn model_variants_are_distinct() {
assert_eq!(Model::Gpt4o, Model::Gpt4o);
assert_ne!(Model::Gpt4o, Model::Gpt4);
assert_ne!(Model::Claude3Opus, Model::Claude3Haiku);
assert_ne!(Model::CommandRPlus, Model::CommandR);
}
#[test]
fn budget_status_within_budget_holds_values() {
let s = BudgetStatus::WithinBudget {
spent_usd: 5.0,
remaining_usd: 45.0,
};
match s {
BudgetStatus::WithinBudget {
spent_usd,
remaining_usd,
} => {
assert!((spent_usd - 5.0).abs() < f64::EPSILON);
assert!((remaining_usd - 45.0).abs() < f64::EPSILON);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn budget_status_threshold_alert_holds_pct() {
let s = BudgetStatus::ThresholdAlert { pct: 80 };
assert_eq!(s, BudgetStatus::ThresholdAlert { pct: 80 });
assert_ne!(s, BudgetStatus::ThresholdAlert { pct: 95 });
}
#[test]
fn budget_state_new_today_has_zero_spend() {
use chrono::Utc;
use rust_decimal::Decimal;
let state = BudgetState::new_today();
assert_eq!(state.spent_usd, Decimal::ZERO);
assert_eq!(state.date, Utc::now().date_naive());
}
#[test]
fn budget_state_maybe_reset_clears_old_date() {
use chrono::Utc;
use rust_decimal::Decimal;
let yesterday = Utc::now().date_naive() - chrono::Duration::days(1);
let mut state = BudgetState {
spent_usd: Decimal::new(500, 2), date: yesterday,
month: BudgetState::month_tag(yesterday),
monthly_spent_usd: None,
last_reset_at: None,
};
state.maybe_reset(Utc::now().date_naive());
assert_eq!(state.spent_usd, Decimal::ZERO);
assert_eq!(state.date, Utc::now().date_naive());
}
#[test]
fn budget_state_maybe_reset_same_day_is_noop() {
use chrono::Utc;
use rust_decimal::Decimal;
let amount = Decimal::new(500, 2); let today = Utc::now().date_naive();
let mut state = BudgetState {
spent_usd: amount,
date: today,
month: BudgetState::month_tag(today),
monthly_spent_usd: None,
last_reset_at: None,
};
state.maybe_reset(Utc::now().date_naive());
assert_eq!(state.spent_usd, amount);
}
#[test]
fn budget_state_maybe_reset_uses_injected_date() {
use chrono::NaiveDate;
use rust_decimal::Decimal;
let jan1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let mut state = BudgetState {
spent_usd: Decimal::new(500, 2), date: jan1,
month: BudgetState::month_tag(jan1),
monthly_spent_usd: None,
last_reset_at: None,
};
let injected_today = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap();
state.maybe_reset(injected_today);
assert_eq!(state.spent_usd, Decimal::ZERO);
assert_eq!(state.date, injected_today);
}
#[test]
fn monthly_reset_clears_monthly_spend_on_month_change() {
use chrono::NaiveDate;
use rust_decimal::Decimal;
let jan31 = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let mut state = BudgetState {
spent_usd: Decimal::new(500, 2),
date: jan31,
month: BudgetState::month_tag(jan31),
monthly_spent_usd: Some(Decimal::new(10000, 2)), last_reset_at: None,
};
let feb1 = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
state.maybe_reset(feb1);
assert_eq!(state.spent_usd, Decimal::ZERO);
assert_eq!(state.monthly_spent_usd, Some(Decimal::ZERO));
assert_eq!(state.month, 202402);
assert_eq!(state.date, feb1);
}
#[test]
fn monthly_no_reset_within_same_month() {
use chrono::NaiveDate;
use rust_decimal::Decimal;
let jan1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let monthly = Decimal::new(5000, 2); let mut state = BudgetState {
spent_usd: Decimal::new(500, 2),
date: jan1,
month: BudgetState::month_tag(jan1),
monthly_spent_usd: Some(monthly),
last_reset_at: None,
};
let jan2 = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap();
state.maybe_reset(jan2);
assert_eq!(state.spent_usd, Decimal::ZERO);
assert_eq!(state.monthly_spent_usd, Some(monthly));
assert_eq!(state.month, 202401);
}
#[test]
fn monthly_none_stays_none_on_month_change() {
use chrono::NaiveDate;
use rust_decimal::Decimal;
let dec31 = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let mut state = BudgetState {
spent_usd: Decimal::new(100, 2),
date: dec31,
month: BudgetState::month_tag(dec31),
monthly_spent_usd: None,
last_reset_at: None,
};
let jan1 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
state.maybe_reset(jan1);
assert!(state.monthly_spent_usd.is_none());
assert_eq!(state.month, 202501);
}
#[test]
fn month_tag_computes_correctly() {
use chrono::NaiveDate;
assert_eq!(
BudgetState::month_tag(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
202401
);
assert_eq!(
BudgetState::month_tag(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()),
202412
);
assert_eq!(
BudgetState::month_tag(NaiveDate::from_ymd_opt(2026, 4, 29).unwrap()),
202604
);
}
#[test]
fn budget_alert_stores_fields() {
use aa_core::AgentId;
let id = AgentId::from_bytes([1u8; 16]);
let alert = BudgetAlert {
agent_id: id,
team_id: None,
threshold_pct: 80,
spent_usd: 8.0,
limit_usd: 10.0,
};
assert_eq!(alert.threshold_pct, 80);
assert!((alert.spent_usd - 8.0).abs() < f64::EPSILON);
}
#[test]
fn maybe_reset_window_daily_matches_maybe_reset() {
use chrono::Utc;
use rust_decimal::Decimal;
let yesterday = Utc::now().date_naive() - chrono::Duration::days(1);
let mut a = BudgetState::new_for_date(yesterday);
a.spent_usd = Decimal::new(500, 2);
let mut b = a.clone();
a.maybe_reset(Utc::now().date_naive());
b.maybe_reset_window(Utc::now(), BudgetWindow::Daily, chrono_tz::UTC);
assert_eq!(a.spent_usd, b.spent_usd);
assert_eq!(a.date, b.date);
}
#[test]
fn maybe_reset_window_duration_seeds_anchor_without_zeroing() {
use chrono::Utc;
use rust_decimal::Decimal;
let mut state = BudgetState::new_today();
state.spent_usd = Decimal::new(500, 2);
let before = state.spent_usd;
let now = Utc::now();
state.maybe_reset_window(
now,
BudgetWindow::Duration(std::time::Duration::from_secs(60)),
chrono_tz::UTC,
);
assert_eq!(state.spent_usd, before);
assert_eq!(state.last_reset_at, Some(now));
}
#[test]
fn maybe_reset_window_duration_keeps_spend_inside_interval() {
use chrono::Utc;
use rust_decimal::Decimal;
let now = Utc::now();
let mut state = BudgetState::new_today();
state.spent_usd = Decimal::new(250, 2);
state.last_reset_at = Some(now);
state.maybe_reset_window(
now + chrono::Duration::milliseconds(100),
BudgetWindow::Duration(std::time::Duration::from_secs(5)),
chrono_tz::UTC,
);
assert_eq!(state.spent_usd, Decimal::new(250, 2));
assert_eq!(state.last_reset_at, Some(now));
}
#[test]
fn maybe_reset_window_duration_zeroes_after_interval() {
use chrono::Utc;
use rust_decimal::Decimal;
let now = Utc::now();
let mut state = BudgetState::new_today();
state.spent_usd = Decimal::new(250, 2);
state.last_reset_at = Some(now);
let later = now + chrono::Duration::milliseconds(200);
state.maybe_reset_window(
later,
BudgetWindow::Duration(std::time::Duration::from_millis(100)),
chrono_tz::UTC,
);
assert_eq!(state.spent_usd, Decimal::ZERO);
assert_eq!(state.last_reset_at, Some(later));
}
#[test]
fn budget_window_default_is_daily() {
assert_eq!(BudgetWindow::default(), BudgetWindow::Daily);
}
}