brokecrew/src/budget.rs

248 lines
7.9 KiB
Rust

use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const MINUTE_LIMIT: usize = 20;
/// Estimates token count from text using standard heuristic (~4 characters per token).
pub fn estimate_tokens(text: &str) -> usize {
if text.is_empty() {
return 0;
}
text.len().div_ceil(4).max(1)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreditTier {
LowBalance, // < $10 lifetime purchased: 50 requests/day
Funded, // >= $10 lifetime purchased: 1,000 requests/day
}
impl CreditTier {
pub fn daily_limit(&self) -> usize {
match self {
CreditTier::LowBalance => 50,
CreditTier::Funded => 1_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BudgetStatus {
pub minute_requests: usize,
pub minute_limit: usize,
pub daily_requests: usize,
pub daily_limit: usize,
pub approaching_daily_limit: bool,
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum BudgetExceeded {
#[error("Rate limited: exceeded {0} requests in 1 minute. Please wait {1}s before retrying.")]
MinuteLimit(usize, u64),
#[error("Daily budget exhausted: reached {0} of {1} requests today.")]
DailyQuota(usize, usize),
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct StoredBudgetState {
daily_count: usize,
last_day_utc: String, // YYYY-MM-DD
recent_calls: VecDeque<DateTime<Utc>>,
}
pub struct BudgetTracker {
storage_path: Option<PathBuf>,
credit_tier: CreditTier,
daily_count: usize,
last_day_utc: String,
recent_calls: VecDeque<DateTime<Utc>>,
}
impl BudgetTracker {
pub fn new(storage_path: Option<PathBuf>, credit_tier: CreditTier) -> Self {
let mut tracker = Self {
storage_path,
credit_tier,
daily_count: 0,
last_day_utc: Utc::now().format("%Y-%m-%d").to_string(),
recent_calls: VecDeque::new(),
};
if let Some(ref path) = tracker.storage_path.clone() {
let _ = tracker.load_from_disk(path);
}
tracker.refresh_window();
tracker
}
fn today_utc() -> String {
Utc::now().format("%Y-%m-%d").to_string()
}
fn refresh_window(&mut self) {
let now = Utc::now();
let current_day = Self::today_utc();
// Day rollover check
if self.last_day_utc != current_day {
self.daily_count = 0;
self.last_day_utc = current_day;
}
// Sliding 60-second window eviction
let cutoff = now - Duration::seconds(60);
while let Some(&first) = self.recent_calls.front() {
if first < cutoff {
self.recent_calls.pop_front();
} else {
break;
}
}
}
/// Checks the current budget status against OpenRouter rate limits.
/// Returns `BudgetStatus` if permitted, or `BudgetExceeded` if limit reached.
pub fn check_budget(&mut self) -> Result<BudgetStatus, BudgetExceeded> {
self.refresh_window();
let daily_limit = self.credit_tier.daily_limit();
// Check daily ceiling
if self.daily_count >= daily_limit {
return Err(BudgetExceeded::DailyQuota(self.daily_count, daily_limit));
}
// Check 1-minute rolling ceiling
if self.recent_calls.len() >= MINUTE_LIMIT {
let oldest = self.recent_calls.front().copied().unwrap_or_else(Utc::now);
let elapsed = (Utc::now() - oldest).num_seconds().max(0);
let wait_secs = (60 - elapsed).max(1) as u64;
return Err(BudgetExceeded::MinuteLimit(MINUTE_LIMIT, wait_secs));
}
let approaching_daily_limit = self.daily_count >= (daily_limit * 80) / 100;
Ok(BudgetStatus {
minute_requests: self.recent_calls.len(),
minute_limit: MINUTE_LIMIT,
daily_requests: self.daily_count,
daily_limit,
approaching_daily_limit,
})
}
/// Records an outbound call and persists updated state to disk atomically.
pub fn record_call(&mut self, _is_paid: bool) -> Result<(), std::io::Error> {
self.refresh_window();
let now = Utc::now();
self.recent_calls.push_back(now);
self.daily_count += 1;
if let Some(ref path) = self.storage_path.clone() {
self.save_to_disk(path)?;
}
Ok(())
}
fn load_from_disk(&mut self, path: &Path) -> Result<(), std::io::Error> {
if !path.exists() {
return Ok(());
}
let data = fs::read_to_string(path)?;
if let Ok(state) = serde_json::from_str::<StoredBudgetState>(&data) {
self.daily_count = state.daily_count;
self.last_day_utc = state.last_day_utc;
self.recent_calls = state.recent_calls;
}
Ok(())
}
fn save_to_disk(&self, path: &Path) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let state = StoredBudgetState {
daily_count: self.daily_count,
last_day_utc: self.last_day_utc.clone(),
recent_calls: self.recent_calls.clone(),
};
let serialized = serde_json::to_string_pretty(&state).map_err(std::io::Error::other)?;
// Atomic write via tempfile in the same parent directory
let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut temp_file = tempfile::NamedTempFile::new_in(parent_dir)?;
temp_file.write_all(serialized.as_bytes())?;
temp_file.persist(path).map_err(|e| e.error)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_budget_minute_window_and_daily_limit() {
let mut tracker = BudgetTracker::new(None, CreditTier::LowBalance);
// Check budget initially
let status = tracker.check_budget().expect("initial check should pass");
assert_eq!(status.minute_requests, 0);
assert_eq!(status.minute_limit, 20);
assert_eq!(status.daily_requests, 0);
assert_eq!(status.daily_limit, 50);
assert!(!status.approaching_daily_limit);
// Record 19 calls
for _ in 0..19 {
tracker.record_call(false).unwrap();
}
let status = tracker.check_budget().expect("19 calls should pass");
assert_eq!(status.minute_requests, 19);
// 20th call
tracker.record_call(false).unwrap();
// 21st check should fail minute limit
let err = tracker.check_budget().unwrap_err();
match err {
BudgetExceeded::MinuteLimit(limit, _) => assert_eq!(limit, 20),
_ => panic!("Expected MinuteLimit error, got {:?}", err),
}
}
#[test]
fn test_budget_daily_ceiling_and_approaching_warning() {
let mut tracker = BudgetTracker::new(None, CreditTier::LowBalance);
tracker.daily_count = 40; // 80% of 50
let status = tracker.check_budget().unwrap();
assert!(status.approaching_daily_limit);
tracker.daily_count = 50;
let err = tracker.check_budget().unwrap_err();
assert_eq!(err, BudgetExceeded::DailyQuota(50, 50));
}
#[test]
fn test_budget_persistence() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("budget.json");
{
let mut tracker = BudgetTracker::new(Some(file_path.clone()), CreditTier::LowBalance);
tracker.record_call(false).unwrap();
tracker.record_call(false).unwrap();
}
// Reload in new tracker instance
let mut reloaded = BudgetTracker::new(Some(file_path), CreditTier::LowBalance);
let status = reloaded.check_budget().unwrap();
assert_eq!(status.daily_requests, 2);
assert_eq!(status.minute_requests, 2);
}
}