1use crate::store::{Refund, Spend, StateError, StateStore, TrySpendOutcome};
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
32impl BudgetSpec {
33 pub fn binds_anything(&self) -> bool {
40 self.max_tokens.is_some()
41 || self.max_payout_usd_micros.is_some()
42 || self.max_total_tool_calls.is_some()
43 || !self.max_tool_calls.is_empty()
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub enum BudgetDecision {
50 Allowed {
52 remaining: u64,
54 },
55 Refused {
57 limit: String,
59 cap: u64,
61 },
62}
63
64impl BudgetDecision {
65 pub fn is_allowed(&self) -> bool {
67 matches!(self, Self::Allowed { .. })
68 }
69}
70
71pub struct ActionBudget<'a> {
82 store: &'a dyn StateStore,
83 scope: Scope<'a>,
84 spec: &'a BudgetSpec,
85}
86
87#[derive(Debug, Clone, Copy)]
93enum Scope<'a> {
94 Session(&'a str),
95 Principal(&'a str),
96}
97
98impl<'a> ActionBudget<'a> {
99 pub fn new(store: &'a dyn StateStore, session: &'a str, spec: &'a BudgetSpec) -> Self {
101 Self {
102 store,
103 scope: Scope::Session(session),
104 spec,
105 }
106 }
107
108 pub fn for_principal(store: &'a dyn StateStore, principal: &'a str, spec: &'a BudgetSpec) -> Self {
115 Self {
116 store,
117 scope: Scope::Principal(principal),
118 spec,
119 }
120 }
121
122 fn key(&self, dim: &str) -> String {
123 let prefix = match self.scope {
124 Scope::Session(id) => Self::session_prefix(id),
125 Scope::Principal(id) => Self::principal_prefix(id),
126 };
127 format!("{prefix}{dim}")
128 }
129
130 pub fn session_prefix(session: &str) -> String {
135 let digest = av_core::digest::sha256_hex(session.as_bytes());
136 format!("budget:{{{}}}:", digest.get(..32).unwrap_or(&digest))
138 }
139
140 pub fn principal_prefix(principal: &str) -> String {
145 let digest = av_core::digest::sha256_hex(principal.as_bytes());
146 format!("budget:principal:{{{}}}:", digest.get(..32).unwrap_or(&digest))
147 }
148
149 pub fn try_tool_call(&self, tool: &str, payout_usd_micros: u64) -> Result<BudgetDecision, StateError> {
161 let mut spends: Vec<Spend> = Vec::with_capacity(3);
165 let mut limit_names: Vec<String> = Vec::with_capacity(3);
166
167 if let Some(cap) = self.spec.max_total_tool_calls {
168 spends.push(Spend {
169 key: self.key("total_calls"),
170 amount: 1,
171 limit: cap,
172 });
173 limit_names.push("max_total_tool_calls".into());
174 }
175 if let Some(cap) = self.spec.max_tool_calls.get(tool).copied() {
176 spends.push(Spend {
177 key: self.key(&format!("tool:{tool}")),
178 amount: 1,
179 limit: cap,
180 });
181 limit_names.push(format!("max_tool_calls.{tool}"));
182 }
183 if payout_usd_micros > 0 {
184 match self.spec.max_payout_usd_micros {
185 Some(cap) => {
186 spends.push(Spend {
187 key: self.key("payout"),
188 amount: payout_usd_micros,
189 limit: cap,
190 });
191 limit_names.push("max_payout_usd_micros".into());
192 }
193 None => {
194 return Ok(BudgetDecision::Refused {
195 limit: "max_payout_usd_micros(unset)".into(),
196 cap: 0,
197 });
198 }
199 }
200 }
201
202 match self.store.try_spend_many(&spends)? {
212 TrySpendOutcome::Refused { index } => {
213 let spend = spends
214 .get(index)
215 .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
216 let limit = limit_names
217 .get(index)
218 .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
219 Ok(BudgetDecision::Refused {
220 limit: limit.clone(),
221 cap: spend.limit,
222 })
223 }
224 TrySpendOutcome::Committed {
225 post_commit_min_remaining,
226 } => Ok(BudgetDecision::Allowed {
227 remaining: post_commit_min_remaining,
228 }),
229 }
230 }
231
232 pub fn refund_tool_call(&self, tool: &str, payout_usd_micros: u64) {
243 let mut refunds: Vec<Refund> = Vec::with_capacity(3);
254 if self.spec.max_total_tool_calls.is_some() {
255 refunds.push(Refund {
256 key: self.key("total_calls"),
257 amount: 1,
258 });
259 }
260 if self.spec.max_tool_calls.contains_key(tool) {
261 refunds.push(Refund {
262 key: self.key(&format!("tool:{tool}")),
263 amount: 1,
264 });
265 }
266 if payout_usd_micros > 0 && self.spec.max_payout_usd_micros.is_some() {
267 refunds.push(Refund {
268 key: self.key("payout"),
269 amount: payout_usd_micros,
270 });
271 }
272 if !refunds.is_empty() {
273 self.store.refund_many(&refunds);
274 }
275 }
276
277 pub fn refund_tokens(&self, tokens: u64) {
285 if tokens > 0 && self.spec.max_tokens.is_some() {
286 self.store.refund(&self.key("tokens"), tokens);
287 }
288 }
289
290 pub fn try_tokens(&self, tokens: u64) -> Result<BudgetDecision, StateError> {
292 match self.spec.max_tokens {
293 Some(cap) => {
294 match self.store.try_spend_many(&[Spend {
302 key: self.key("tokens"),
303 amount: tokens,
304 limit: cap,
305 }])? {
306 TrySpendOutcome::Committed {
307 post_commit_min_remaining,
308 } => Ok(BudgetDecision::Allowed {
309 remaining: post_commit_min_remaining,
310 }),
311 TrySpendOutcome::Refused { .. } => Ok(BudgetDecision::Refused {
312 limit: "max_tokens".into(),
313 cap,
314 }),
315 }
316 }
317 None => Ok(BudgetDecision::Allowed { remaining: u64::MAX }),
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
325
326 use super::*;
327 use crate::store::InMemoryStore;
328
329 fn spec() -> BudgetSpec {
330 BudgetSpec {
331 max_tokens: Some(1000),
332 max_payout_usd_micros: Some(50_000_000), max_tool_calls: BTreeMap::from([("db_write".to_owned(), 3)]), max_total_tool_calls: Some(10),
335 }
336 }
337
338 #[test]
347 fn ledger_prefixes_are_namespaced_and_cluster_hash_tagged() {
348 let session = ActionBudget::session_prefix("same-id");
349 let principal = ActionBudget::principal_prefix("same-id");
350 assert!(session.starts_with("budget:{"), "{session}");
351 assert!(principal.starts_with("budget:principal:{"), "{principal}");
352 for prefix in [&session, &principal] {
353 let open = prefix.find('{').unwrap();
354 let close = prefix.find('}').unwrap();
355 assert!(close > open + 1, "empty hash tag in {prefix}");
356 assert!(prefix.ends_with("}:"), "{prefix}");
357 }
358 assert!(!session.starts_with(&principal) && !principal.starts_with(&session));
362 }
363
364 #[test]
365 fn db_write_capped_at_three() {
366 let store = InMemoryStore::new();
367 let s = spec();
368 let b = ActionBudget::new(&store, "sess", &s);
369 for _ in 0..3 {
370 assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
371 }
372 let refused = b.try_tool_call("db_write", 0).unwrap();
373 assert_eq!(
374 refused,
375 BudgetDecision::Refused {
376 limit: "max_tool_calls.db_write".into(),
377 cap: 3
378 }
379 );
380 assert!(b.try_tool_call("search", 0).unwrap().is_allowed());
382 }
383
384 #[test]
385 fn payout_capped_at_fifty_dollars() {
386 let store = InMemoryStore::new();
387 let s = spec();
388 let b = ActionBudget::new(&store, "sess", &s);
389 assert!(b.try_tool_call("payout", 49_000_000).unwrap().is_allowed());
390 let refused = b.try_tool_call("payout", 2_000_000).unwrap(); assert!(!refused.is_allowed());
392 assert!(b.try_tool_call("payout", 1_000_000).unwrap().is_allowed());
395 }
396
397 #[test]
398 fn payout_without_cap_fails_closed() {
399 let store = InMemoryStore::new();
400 let s = BudgetSpec::default();
401 let b = ActionBudget::new(&store, "sess", &s);
402 let d = b.try_tool_call("payout", 1).unwrap();
403 assert!(
404 !d.is_allowed(),
405 "uncapped payout must be refused, not silently allowed"
406 );
407 }
408
409 #[test]
410 fn refused_multi_dimension_spend_rolls_back() {
411 let store = InMemoryStore::new();
412 let s = spec();
413 let b = ActionBudget::new(&store, "sess", &s);
414 for _ in 0..3 {
416 assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
417 }
418 assert!(!b.try_tool_call("db_write", 0).unwrap().is_allowed());
420 for _ in 0..7 {
422 assert!(b.try_tool_call("other", 0).unwrap().is_allowed());
423 }
424 assert!(
425 !b.try_tool_call("other", 0).unwrap().is_allowed(),
426 "total cap must bind at 10"
427 );
428 }
429
430 #[test]
431 fn token_budget() {
432 let store = InMemoryStore::new();
433 let s = spec();
434 let b = ActionBudget::new(&store, "sess", &s);
435 assert!(b.try_tokens(900).unwrap().is_allowed());
436 assert!(b.try_tokens(100).unwrap().is_allowed()); assert!(!b.try_tokens(1).unwrap().is_allowed());
438 }
439
440 #[test]
441 fn sessions_are_isolated() {
442 let store = InMemoryStore::new();
443 let s = spec();
444 let a = ActionBudget::new(&store, "sess-a", &s);
445 let b = ActionBudget::new(&store, "sess-b", &s);
446 for _ in 0..3 {
447 assert!(a.try_tool_call("db_write", 0).unwrap().is_allowed());
448 }
449 assert!(!a.try_tool_call("db_write", 0).unwrap().is_allowed());
450 assert!(
451 b.try_tool_call("db_write", 0).unwrap().is_allowed(),
452 "session b unaffected"
453 );
454 }
455
456 #[test]
457 fn concurrent_multi_dimension_spends_commit_all_or_none() {
458 let store = std::sync::Arc::new(InMemoryStore::new());
459 let spec = std::sync::Arc::new(BudgetSpec {
460 max_tool_calls: BTreeMap::from([("db_write".to_owned(), 100)]),
461 max_total_tool_calls: Some(100),
462 ..BudgetSpec::default()
463 });
464 let mut handles = Vec::new();
465 for _ in 0..32 {
466 let store = std::sync::Arc::clone(&store);
467 let spec = std::sync::Arc::clone(&spec);
468 handles.push(std::thread::spawn(move || {
469 let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
470 (0..20)
471 .filter(|_| budget.try_tool_call("db_write", 0).unwrap().is_allowed())
472 .count()
473 }));
474 }
475 let allowed: usize = handles.into_iter().map(|handle| handle.join().unwrap()).sum();
476 assert_eq!(allowed, 100);
477 let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
478 assert_eq!(store.get(&budget.key("total_calls")).unwrap(), 100);
479 assert_eq!(store.get(&budget.key("tool:db_write")).unwrap(), 100);
480 }
481
482 #[test]
483 fn unlimited_spec_allows_everything() {
484 let store = InMemoryStore::new();
485 let s = BudgetSpec::default();
486 let b = ActionBudget::new(&store, "sess", &s);
487 for _ in 0..100 {
488 assert!(b.try_tool_call("anything", 0).unwrap().is_allowed());
489 }
490 assert!(b.try_tokens(u64::MAX / 4).unwrap().is_allowed());
491 }
492
493 #[test]
494 fn allowed_decision_reports_true_remaining_headroom() {
495 let store = InMemoryStore::new();
499 let s = BudgetSpec {
500 max_total_tool_calls: Some(10),
501 ..BudgetSpec::default()
502 };
503 let b = ActionBudget::new(&store, "sess-rem", &s);
504 for _ in 0..3 {
505 assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
506 }
507 match b.try_tool_call("t", 0).unwrap() {
508 BudgetDecision::Allowed { remaining } => assert_eq!(remaining, 6),
509 other => panic!("expected Allowed with real remaining, got {other:?}"),
510 }
511 }
512
513 #[test]
518 fn refund_tool_call_reverses_the_spend_exactly() {
519 let store = InMemoryStore::new();
520 let s = BudgetSpec {
521 max_total_tool_calls: Some(2),
522 max_tool_calls: BTreeMap::from([("db_write".to_owned(), 1u64)]),
523 max_payout_usd_micros: Some(1_000_000),
524 ..BudgetSpec::default()
525 };
526 let b = ActionBudget::new(&store, "sess-refund", &s);
527 assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
529 assert!(matches!(
531 b.try_tool_call("db_write", 100).unwrap(),
532 BudgetDecision::Refused { .. }
533 ));
534 b.refund_tool_call("db_write", 500_000);
536 assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
539 }
540
541 #[test]
545 fn refund_tokens_reverses_the_spend_exactly() {
546 let store = InMemoryStore::new();
547 let s = BudgetSpec {
548 max_tokens: Some(100),
549 ..BudgetSpec::default()
550 };
551 let b = ActionBudget::new(&store, "sess-token-refund", &s);
552 assert!(b.try_tokens(80).unwrap().is_allowed());
553 assert!(matches!(
555 b.try_tokens(80).unwrap(),
556 BudgetDecision::Refused { .. }
557 ));
558 b.refund_tokens(80);
559 assert!(b.try_tokens(80).unwrap().is_allowed());
561 let uncapped_spec = BudgetSpec::default();
564 let uncapped = ActionBudget::new(&store, "sess-uncapped", &uncapped_spec);
565 uncapped.refund_tokens(50);
566 }
567
568 #[test]
573 fn refund_is_saturating() {
574 let store = InMemoryStore::new();
575 let s = BudgetSpec {
576 max_total_tool_calls: Some(10),
577 ..BudgetSpec::default()
578 };
579 let b = ActionBudget::new(&store, "sess-sat", &s);
580 assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
581 b.refund_tool_call("t", 0);
584 b.refund_tool_call("t", 0);
585 for _ in 0..10 {
587 assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
588 }
589 assert!(matches!(
590 b.try_tool_call("t", 0).unwrap(),
591 BudgetDecision::Refused { .. }
592 ));
593 }
594
595 #[test]
596 fn binds_anything_is_false_only_for_the_all_default_spec() {
597 assert!(!BudgetSpec::default().binds_anything());
598 assert!(BudgetSpec {
599 max_tokens: Some(1),
600 ..Default::default()
601 }
602 .binds_anything());
603 assert!(BudgetSpec {
604 max_payout_usd_micros: Some(1),
605 ..Default::default()
606 }
607 .binds_anything());
608 assert!(BudgetSpec {
609 max_total_tool_calls: Some(1),
610 ..Default::default()
611 }
612 .binds_anything());
613 let mut per_tool = BudgetSpec::default();
614 per_tool.max_tool_calls.insert("db_write".to_owned(), 1);
615 assert!(per_tool.binds_anything());
616 }
617}