1use crate::store::{Spend, StateError, StateStore};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(deny_unknown_fields)]
17pub struct BudgetSpec {
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub max_tokens: Option<u64>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub max_payout_usd_micros: Option<u64>,
24 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26 pub max_tool_calls: BTreeMap<String, u64>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub max_total_tool_calls: Option<u64>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub enum BudgetDecision {
35 Allowed {
37 remaining: u64,
39 },
40 Refused {
42 limit: String,
44 cap: u64,
46 },
47}
48
49impl BudgetDecision {
50 pub fn is_allowed(&self) -> bool {
52 matches!(self, Self::Allowed { .. })
53 }
54}
55
56pub struct ActionBudget<'a> {
58 store: &'a dyn StateStore,
59 session: &'a str,
60 spec: &'a BudgetSpec,
61}
62
63impl<'a> ActionBudget<'a> {
64 pub fn new(store: &'a dyn StateStore, session: &'a str, spec: &'a BudgetSpec) -> Self {
66 Self { store, session, spec }
67 }
68
69 fn key(&self, dim: &str) -> String {
70 format!("{}{dim}", Self::session_prefix(self.session))
71 }
72
73 pub fn session_prefix(session: &str) -> String {
78 let digest = av_core::digest::sha256_hex(session.as_bytes());
79 format!("budget:{{{}}}:", digest.get(..32).unwrap_or(&digest))
81 }
82
83 pub fn try_tool_call(&self, tool: &str, payout_usd_micros: u64) -> Result<BudgetDecision, StateError> {
91 let mut spends: Vec<Spend> = Vec::with_capacity(3);
95 let mut limit_names: Vec<String> = Vec::with_capacity(3);
96
97 if let Some(cap) = self.spec.max_total_tool_calls {
98 spends.push(Spend {
99 key: self.key("total_calls"),
100 amount: 1,
101 limit: cap,
102 });
103 limit_names.push("max_total_tool_calls".into());
104 }
105 if let Some(cap) = self.spec.max_tool_calls.get(tool).copied() {
106 spends.push(Spend {
107 key: self.key(&format!("tool:{tool}")),
108 amount: 1,
109 limit: cap,
110 });
111 limit_names.push(format!("max_tool_calls.{tool}"));
112 }
113 if payout_usd_micros > 0 {
114 match self.spec.max_payout_usd_micros {
115 Some(cap) => {
116 spends.push(Spend {
117 key: self.key("payout"),
118 amount: payout_usd_micros,
119 limit: cap,
120 });
121 limit_names.push("max_payout_usd_micros".into());
122 }
123 None => {
124 return Ok(BudgetDecision::Refused {
125 limit: "max_payout_usd_micros(unset)".into(),
126 cap: 0,
127 });
128 }
129 }
130 }
131
132 if let Some(index) = self.store.try_spend_many(&spends)? {
133 let spend = spends
134 .get(index)
135 .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
136 let limit = limit_names
137 .get(index)
138 .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
139 return Ok(BudgetDecision::Refused {
140 limit: limit.clone(),
141 cap: spend.limit,
142 });
143 }
144
145 let remaining = self.remaining_min(tool)?;
146 Ok(BudgetDecision::Allowed { remaining })
147 }
148
149 pub fn refund_tool_call(&self, tool: &str, payout_usd_micros: u64) {
160 if self.spec.max_total_tool_calls.is_some() {
161 self.store.refund(&self.key("total_calls"), 1);
162 }
163 if self.spec.max_tool_calls.contains_key(tool) {
164 self.store.refund(&self.key(&format!("tool:{tool}")), 1);
165 }
166 if payout_usd_micros > 0 && self.spec.max_payout_usd_micros.is_some() {
167 self.store.refund(&self.key("payout"), payout_usd_micros);
168 }
169 }
170
171 pub fn try_tokens(&self, tokens: u64) -> Result<BudgetDecision, StateError> {
173 match self.spec.max_tokens {
174 Some(cap) => {
175 let key = self.key("tokens");
176 if self.store.try_spend(&key, tokens, cap)? {
177 let used = self.store.get(&key)?;
178 Ok(BudgetDecision::Allowed {
179 remaining: cap.saturating_sub(used),
180 })
181 } else {
182 Ok(BudgetDecision::Refused {
183 limit: "max_tokens".into(),
184 cap,
185 })
186 }
187 }
188 None => Ok(BudgetDecision::Allowed { remaining: u64::MAX }),
189 }
190 }
191
192 fn remaining_min(&self, tool: &str) -> Result<u64, StateError> {
193 let mut min = u64::MAX;
194 if let Some(cap) = self.spec.max_total_tool_calls {
195 let used = self.store.get(&self.key("total_calls"))?;
196 min = min.min(cap.saturating_sub(used));
197 }
198 if let Some(cap) = self.spec.max_tool_calls.get(tool) {
199 let used = self.store.get(&self.key(&format!("tool:{tool}")))?;
200 min = min.min(cap.saturating_sub(used));
201 }
202 if let Some(cap) = self.spec.max_payout_usd_micros {
203 let used = self.store.get(&self.key("payout"))?;
204 min = min.min(cap.saturating_sub(used));
205 }
206 Ok(min)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
213
214 use super::*;
215 use crate::store::InMemoryStore;
216
217 fn spec() -> BudgetSpec {
218 BudgetSpec {
219 max_tokens: Some(1000),
220 max_payout_usd_micros: Some(50_000_000), max_tool_calls: BTreeMap::from([("db_write".to_owned(), 3)]), max_total_tool_calls: Some(10),
223 }
224 }
225
226 #[test]
227 fn db_write_capped_at_three() {
228 let store = InMemoryStore::new();
229 let s = spec();
230 let b = ActionBudget::new(&store, "sess", &s);
231 for _ in 0..3 {
232 assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
233 }
234 let refused = b.try_tool_call("db_write", 0).unwrap();
235 assert_eq!(
236 refused,
237 BudgetDecision::Refused {
238 limit: "max_tool_calls.db_write".into(),
239 cap: 3
240 }
241 );
242 assert!(b.try_tool_call("search", 0).unwrap().is_allowed());
244 }
245
246 #[test]
247 fn payout_capped_at_fifty_dollars() {
248 let store = InMemoryStore::new();
249 let s = spec();
250 let b = ActionBudget::new(&store, "sess", &s);
251 assert!(b.try_tool_call("payout", 49_000_000).unwrap().is_allowed());
252 let refused = b.try_tool_call("payout", 2_000_000).unwrap(); assert!(!refused.is_allowed());
254 assert!(b.try_tool_call("payout", 1_000_000).unwrap().is_allowed());
257 }
258
259 #[test]
260 fn payout_without_cap_fails_closed() {
261 let store = InMemoryStore::new();
262 let s = BudgetSpec::default();
263 let b = ActionBudget::new(&store, "sess", &s);
264 let d = b.try_tool_call("payout", 1).unwrap();
265 assert!(
266 !d.is_allowed(),
267 "uncapped payout must be refused, not silently allowed"
268 );
269 }
270
271 #[test]
272 fn refused_multi_dimension_spend_rolls_back() {
273 let store = InMemoryStore::new();
274 let s = spec();
275 let b = ActionBudget::new(&store, "sess", &s);
276 for _ in 0..3 {
278 assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
279 }
280 assert!(!b.try_tool_call("db_write", 0).unwrap().is_allowed());
282 for _ in 0..7 {
284 assert!(b.try_tool_call("other", 0).unwrap().is_allowed());
285 }
286 assert!(
287 !b.try_tool_call("other", 0).unwrap().is_allowed(),
288 "total cap must bind at 10"
289 );
290 }
291
292 #[test]
293 fn token_budget() {
294 let store = InMemoryStore::new();
295 let s = spec();
296 let b = ActionBudget::new(&store, "sess", &s);
297 assert!(b.try_tokens(900).unwrap().is_allowed());
298 assert!(b.try_tokens(100).unwrap().is_allowed()); assert!(!b.try_tokens(1).unwrap().is_allowed());
300 }
301
302 #[test]
303 fn sessions_are_isolated() {
304 let store = InMemoryStore::new();
305 let s = spec();
306 let a = ActionBudget::new(&store, "sess-a", &s);
307 let b = ActionBudget::new(&store, "sess-b", &s);
308 for _ in 0..3 {
309 assert!(a.try_tool_call("db_write", 0).unwrap().is_allowed());
310 }
311 assert!(!a.try_tool_call("db_write", 0).unwrap().is_allowed());
312 assert!(
313 b.try_tool_call("db_write", 0).unwrap().is_allowed(),
314 "session b unaffected"
315 );
316 }
317
318 #[test]
319 fn concurrent_multi_dimension_spends_commit_all_or_none() {
320 let store = std::sync::Arc::new(InMemoryStore::new());
321 let spec = std::sync::Arc::new(BudgetSpec {
322 max_tool_calls: BTreeMap::from([("db_write".to_owned(), 100)]),
323 max_total_tool_calls: Some(100),
324 ..BudgetSpec::default()
325 });
326 let mut handles = Vec::new();
327 for _ in 0..32 {
328 let store = std::sync::Arc::clone(&store);
329 let spec = std::sync::Arc::clone(&spec);
330 handles.push(std::thread::spawn(move || {
331 let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
332 (0..20)
333 .filter(|_| budget.try_tool_call("db_write", 0).unwrap().is_allowed())
334 .count()
335 }));
336 }
337 let allowed: usize = handles.into_iter().map(|handle| handle.join().unwrap()).sum();
338 assert_eq!(allowed, 100);
339 let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
340 assert_eq!(store.get(&budget.key("total_calls")).unwrap(), 100);
341 assert_eq!(store.get(&budget.key("tool:db_write")).unwrap(), 100);
342 }
343
344 #[test]
345 fn unlimited_spec_allows_everything() {
346 let store = InMemoryStore::new();
347 let s = BudgetSpec::default();
348 let b = ActionBudget::new(&store, "sess", &s);
349 for _ in 0..100 {
350 assert!(b.try_tool_call("anything", 0).unwrap().is_allowed());
351 }
352 assert!(b.try_tokens(u64::MAX / 4).unwrap().is_allowed());
353 }
354
355 #[test]
356 fn allowed_decision_reports_true_remaining_headroom() {
357 let store = InMemoryStore::new();
361 let s = BudgetSpec {
362 max_total_tool_calls: Some(10),
363 ..BudgetSpec::default()
364 };
365 let b = ActionBudget::new(&store, "sess-rem", &s);
366 for _ in 0..3 {
367 assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
368 }
369 match b.try_tool_call("t", 0).unwrap() {
370 BudgetDecision::Allowed { remaining } => assert_eq!(remaining, 6),
371 other => panic!("expected Allowed with real remaining, got {other:?}"),
372 }
373 }
374
375 #[test]
380 fn refund_tool_call_reverses_the_spend_exactly() {
381 let store = InMemoryStore::new();
382 let s = BudgetSpec {
383 max_total_tool_calls: Some(2),
384 max_tool_calls: BTreeMap::from([("db_write".to_owned(), 1u64)]),
385 max_payout_usd_micros: Some(1_000_000),
386 ..BudgetSpec::default()
387 };
388 let b = ActionBudget::new(&store, "sess-refund", &s);
389 assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
391 assert!(matches!(
393 b.try_tool_call("db_write", 100).unwrap(),
394 BudgetDecision::Refused { .. }
395 ));
396 b.refund_tool_call("db_write", 500_000);
398 assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
401 }
402
403 #[test]
408 fn refund_is_saturating() {
409 let store = InMemoryStore::new();
410 let s = BudgetSpec {
411 max_total_tool_calls: Some(10),
412 ..BudgetSpec::default()
413 };
414 let b = ActionBudget::new(&store, "sess-sat", &s);
415 assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
416 b.refund_tool_call("t", 0);
419 b.refund_tool_call("t", 0);
420 for _ in 0..10 {
422 assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
423 }
424 assert!(matches!(
425 b.try_tool_call("t", 0).unwrap(),
426 BudgetDecision::Refused { .. }
427 ));
428 }
429}