use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use clawft_types::config::CostBudgetConfig;
use clawft_types::error::ClawftError;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BudgetUsage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub usd: f64,
#[serde(default)]
pub iterations: u32,
#[serde(default)]
pub circuit_open: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tripped_dimension: Option<String>,
}
impl BudgetUsage {
pub fn total_tokens(&self) -> u64 {
self.input_tokens.saturating_add(self.output_tokens)
}
pub fn add_call(&mut self, input_tokens: u32, output_tokens: u32, usd_cost: f64) {
self.input_tokens = self.input_tokens.saturating_add(input_tokens as u64);
self.output_tokens = self.output_tokens.saturating_add(output_tokens as u64);
let new = self.usd + usd_cost.max(0.0);
self.usd = if new.is_finite() { new } else { f64::MAX };
self.iterations = self.iterations.saturating_add(1);
}
}
pub trait BudgetStore: Send + Sync + 'static {
fn load(&self, conv_id: &str) -> BudgetUsage;
fn save(&self, conv_id: &str, usage: &BudgetUsage) -> Result<(), String>;
}
#[derive(Debug, Default)]
pub struct InMemoryBudgetStore {
inner: Mutex<HashMap<String, BudgetUsage>>,
}
impl InMemoryBudgetStore {
pub fn new() -> Self {
Self::default()
}
#[allow(dead_code)]
pub fn snapshot(&self) -> HashMap<String, BudgetUsage> {
self.inner.lock().map(|g| g.clone()).unwrap_or_default()
}
}
impl BudgetStore for InMemoryBudgetStore {
fn load(&self, conv_id: &str) -> BudgetUsage {
self.inner
.lock()
.ok()
.and_then(|g| g.get(conv_id).cloned())
.unwrap_or_default()
}
fn save(&self, conv_id: &str, usage: &BudgetUsage) -> Result<(), String> {
let mut guard = self
.inner
.lock()
.map_err(|_| "InMemoryBudgetStore mutex poisoned".to_string())?;
guard.insert(conv_id.to_string(), usage.clone());
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BudgetDecision {
Allowed,
Tripped {
dimension: String,
limit: f64,
used: f64,
},
}
impl BudgetDecision {
pub fn into_error(self, conv_id: &str) -> Option<ClawftError> {
match self {
BudgetDecision::Allowed => None,
BudgetDecision::Tripped {
dimension,
limit,
used,
} => Some(ClawftError::ConversationBudgetExceeded {
conv_id: conv_id.to_string(),
dimension,
limit,
used,
}),
}
}
}
pub struct ConversationBudget {
config: CostBudgetConfig,
overrides: Mutex<HashMap<String, CostBudgetConfig>>,
store: Arc<dyn BudgetStore>,
}
impl ConversationBudget {
pub fn new(config: CostBudgetConfig, store: Arc<dyn BudgetStore>) -> Self {
Self {
config,
overrides: Mutex::new(HashMap::new()),
store,
}
}
pub fn in_memory(config: CostBudgetConfig) -> Self {
Self::new(config, Arc::new(InMemoryBudgetStore::new()))
}
pub fn config(&self) -> &CostBudgetConfig {
&self.config
}
pub fn config_for(&self, conv_id: &str) -> CostBudgetConfig {
self.overrides
.lock()
.ok()
.and_then(|g| g.get(conv_id).cloned())
.unwrap_or_else(|| self.config.clone())
}
pub fn set_conv_config(&self, conv_id: &str, cfg: CostBudgetConfig) -> CostBudgetConfig {
let clamped = CostBudgetConfig {
max_tokens_per_conv: cfg
.max_tokens_per_conv
.min(self.config.max_tokens_per_conv),
max_usd_per_conv: cfg.max_usd_per_conv.min(self.config.max_usd_per_conv),
max_iterations_per_conv: cfg
.max_iterations_per_conv
.min(self.config.max_iterations_per_conv),
};
if let Ok(mut guard) = self.overrides.lock() {
guard.insert(conv_id.to_string(), clamped.clone());
}
clamped
}
pub fn clear_conv_config(&self, conv_id: &str) {
if let Ok(mut guard) = self.overrides.lock() {
guard.remove(conv_id);
}
}
pub fn usage(&self, conv_id: &str) -> BudgetUsage {
self.store.load(conv_id)
}
pub fn check_can_call(&self, conv_id: &str) -> BudgetDecision {
let cfg = self.config_for(conv_id);
let u = self.store.load(conv_id);
if u.circuit_open {
let dim = u
.tripped_dimension
.clone()
.unwrap_or_else(|| "unknown".into());
let (limit, used) = match dim.as_str() {
"tokens" => (cfg.max_tokens_per_conv as f64, u.total_tokens() as f64),
"usd" => (cfg.max_usd_per_conv, u.usd),
"iterations" => (cfg.max_iterations_per_conv as f64, u.iterations as f64),
_ => (0.0, 0.0),
};
return BudgetDecision::Tripped {
dimension: dim,
limit,
used,
};
}
Self::check_usage_against(&cfg, &u)
}
pub fn record_call(
&self,
conv_id: &str,
input_tokens: u32,
output_tokens: u32,
usd_cost: f64,
) -> Result<BudgetUsage, String> {
let mut u = self.store.load(conv_id);
u.add_call(input_tokens, output_tokens, usd_cost);
self.store.save(conv_id, &u)?;
Ok(u)
}
pub fn check_after_call(&self, conv_id: &str, usage: &BudgetUsage) -> BudgetDecision {
let cfg = self.config_for(conv_id);
Self::check_usage_against(&cfg, usage)
}
fn check_usage_against(cfg: &CostBudgetConfig, usage: &BudgetUsage) -> BudgetDecision {
if usage.total_tokens() >= cfg.max_tokens_per_conv {
return BudgetDecision::Tripped {
dimension: "tokens".into(),
limit: cfg.max_tokens_per_conv as f64,
used: usage.total_tokens() as f64,
};
}
if usage.usd >= cfg.max_usd_per_conv {
return BudgetDecision::Tripped {
dimension: "usd".into(),
limit: cfg.max_usd_per_conv,
used: usage.usd,
};
}
if usage.iterations >= cfg.max_iterations_per_conv {
return BudgetDecision::Tripped {
dimension: "iterations".into(),
limit: cfg.max_iterations_per_conv as f64,
used: usage.iterations as f64,
};
}
BudgetDecision::Allowed
}
pub fn mark_open(&self, conv_id: &str, dimension: &str) -> Result<(), String> {
let mut u = self.store.load(conv_id);
u.circuit_open = true;
u.tripped_dimension = Some(dimension.to_string());
self.store.save(conv_id, &u)
}
pub fn reset(&self, conv_id: &str) -> Result<BudgetUsage, String> {
let prev = self.store.load(conv_id);
let cleared = BudgetUsage::default();
self.store.save(conv_id, &cleared)?;
self.clear_conv_config(conv_id);
Ok(prev)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(tokens: u64, usd: f64, iters: u32) -> CostBudgetConfig {
CostBudgetConfig {
max_tokens_per_conv: tokens,
max_usd_per_conv: usd,
max_iterations_per_conv: iters,
}
}
#[test]
fn defaults_match_spec() {
let c = CostBudgetConfig::default();
assert_eq!(c.max_tokens_per_conv, 200_000);
assert!((c.max_usd_per_conv - 1.00).abs() < 1e-9);
assert_eq!(c.max_iterations_per_conv, 30);
}
#[test]
fn fresh_conv_is_allowed() {
let b = ConversationBudget::in_memory(cfg(1_000, 1.0, 5));
assert_eq!(b.check_can_call("c1"), BudgetDecision::Allowed);
}
#[test]
fn token_cap_trips() {
let b = ConversationBudget::in_memory(cfg(100, 10.0, 100));
b.record_call("c1", 60, 0, 0.0).unwrap();
let u = b.record_call("c1", 60, 0, 0.0).unwrap();
let dec = b.check_after_call("c1", &u);
assert!(
matches!(dec, BudgetDecision::Tripped { ref dimension, .. } if dimension == "tokens")
);
b.mark_open("c1", "tokens").unwrap();
assert!(matches!(
b.check_can_call("c1"),
BudgetDecision::Tripped { ref dimension, .. } if dimension == "tokens"
));
}
#[test]
fn usd_cap_trips() {
let b = ConversationBudget::in_memory(cfg(10_000, 0.50, 100));
b.record_call("c1", 10, 10, 0.30).unwrap();
let u = b.record_call("c1", 10, 10, 0.30).unwrap();
let dec = b.check_after_call("c1", &u);
match dec {
BudgetDecision::Tripped {
dimension,
used,
limit,
} => {
assert_eq!(dimension, "usd");
assert!(used >= 0.50);
assert!((limit - 0.50).abs() < 1e-9);
}
_ => panic!("expected USD trip, got {:?}", dec),
}
}
#[test]
fn per_conv_override_trips_tighter_cap() {
let b = ConversationBudget::in_memory(cfg(1_000, 10.0, 100));
let effective = b.set_conv_config(
"child",
CostBudgetConfig {
max_tokens_per_conv: 50,
max_usd_per_conv: 10.0,
max_iterations_per_conv: 100,
},
);
assert_eq!(effective.max_tokens_per_conv, 50);
b.record_call("child", 30, 0, 0.0).unwrap();
let u = b.record_call("child", 30, 0, 0.0).unwrap(); let dec = b.check_after_call("child", &u);
assert!(
matches!(
dec,
BudgetDecision::Tripped {
ref dimension,
limit,
..
} if dimension == "tokens" && (limit - 50.0).abs() < 1e-9
),
"got {:?}",
dec
);
b.record_call("parent", 60, 0, 0.0).unwrap();
assert_eq!(b.check_can_call("parent"), BudgetDecision::Allowed);
}
#[test]
fn per_conv_override_clamped_to_base() {
let b = ConversationBudget::in_memory(cfg(100, 1.0, 10));
let effective = b.set_conv_config(
"c1",
CostBudgetConfig {
max_tokens_per_conv: 9_999,
max_usd_per_conv: 99.0,
max_iterations_per_conv: 999,
},
);
assert_eq!(effective.max_tokens_per_conv, 100);
assert!((effective.max_usd_per_conv - 1.0).abs() < 1e-9);
assert_eq!(effective.max_iterations_per_conv, 10);
assert_eq!(b.config_for("c1").max_tokens_per_conv, 100);
}
#[test]
fn reset_clears_override() {
let b = ConversationBudget::in_memory(cfg(1_000, 10.0, 100));
b.set_conv_config(
"c1",
CostBudgetConfig {
max_tokens_per_conv: 10,
max_usd_per_conv: 10.0,
max_iterations_per_conv: 100,
},
);
assert_eq!(b.config_for("c1").max_tokens_per_conv, 10);
b.reset("c1").unwrap();
assert_eq!(b.config_for("c1").max_tokens_per_conv, 1_000);
}
#[test]
fn iteration_cap_trips() {
let b = ConversationBudget::in_memory(cfg(10_000, 100.0, 3));
for _ in 0..3 {
let u = b.record_call("c1", 1, 1, 0.0).unwrap();
let _ = u;
}
let dec = b.check_can_call("c1");
match dec {
BudgetDecision::Tripped {
dimension,
used,
limit,
} => {
assert_eq!(dimension, "iterations");
assert_eq!(used, 3.0);
assert_eq!(limit, 3.0);
}
_ => panic!("expected iterations trip, got {:?}", dec),
}
}
#[test]
fn reset_clears_circuit() {
let b = ConversationBudget::in_memory(cfg(100, 0.10, 5));
b.record_call("c1", 60, 60, 0.0).unwrap();
b.mark_open("c1", "tokens").unwrap();
assert!(matches!(
b.check_can_call("c1"),
BudgetDecision::Tripped { .. }
));
let prev = b.reset("c1").unwrap();
assert!(prev.circuit_open);
assert_eq!(prev.input_tokens, 60);
assert_eq!(b.check_can_call("c1"), BudgetDecision::Allowed);
assert_eq!(b.usage("c1"), BudgetUsage::default());
}
#[test]
fn into_error_carries_typed_fields() {
let dec = BudgetDecision::Tripped {
dimension: "tokens".into(),
limit: 200_000.0,
used: 250_000.0,
};
let err = dec.into_error("conv-abc").unwrap();
let msg = err.to_string();
assert!(msg.contains("conv-abc"), "{}", msg);
assert!(msg.contains("tokens"), "{}", msg);
assert!(msg.contains("circuit_open"), "{}", msg);
match err {
ClawftError::ConversationBudgetExceeded {
conv_id,
dimension,
limit,
used,
} => {
assert_eq!(conv_id, "conv-abc");
assert_eq!(dimension, "tokens");
assert_eq!(limit, 200_000.0);
assert_eq!(used, 250_000.0);
}
other => panic!("wrong error variant: {:?}", other),
}
}
#[test]
fn allowed_into_error_is_none() {
assert!(BudgetDecision::Allowed.into_error("c1").is_none());
}
#[test]
fn isolated_per_conv() {
let b = ConversationBudget::in_memory(cfg(100, 1.0, 5));
b.record_call("a", 60, 60, 0.0).unwrap();
b.mark_open("a", "tokens").unwrap();
assert!(matches!(
b.check_can_call("a"),
BudgetDecision::Tripped { .. }
));
assert_eq!(b.check_can_call("b"), BudgetDecision::Allowed);
}
#[test]
fn store_round_trip_persists_across_handles() {
let store: Arc<dyn BudgetStore> = Arc::new(InMemoryBudgetStore::new());
{
let b1 = ConversationBudget::new(cfg(100, 1.0, 5), Arc::clone(&store));
b1.record_call("c1", 60, 60, 0.0).unwrap();
b1.mark_open("c1", "tokens").unwrap();
}
let b2 = ConversationBudget::new(cfg(100, 1.0, 5), Arc::clone(&store));
let u = b2.usage("c1");
assert!(u.circuit_open);
assert_eq!(u.input_tokens, 60);
assert!(matches!(
b2.check_can_call("c1"),
BudgetDecision::Tripped { .. }
));
}
}