dwara_core/ai/budget.rs
1//! AI token budgets (DW-078): tokens/min and cost/day per consumer or
2//! per policy-shared team.
3//!
4//! A budget is a LIMIT OF TOTALS, not a rate: it caps how many
5//! provider-reported tokens (or how much priced cost) one budget
6//! holder may consume inside a window. It composes with — never
7//! replaces — the request-count limiter (DW-017) and the request
8//! quotas (DW-033): those count REQUESTS, this counts TOKENS.
9//!
10//! # Check-then-spend (the locked no-estimation decision)
11//!
12//! The gateway never estimates a request's cost before the provider
13//! answers, so enforcement is two-phase:
14//!
15//! 1. **Pre-check** (before any provider contact): a holder whose
16//! window is ALREADY exhausted is rejected with 429
17//! `ai_budget_exceeded` and a `Retry-After` to the window boundary.
18//! 2. **Spend** (after usage is known): the provider-REPORTED usage is
19//! added to the window — non-streaming from the translated
20//! response, streaming from the accumulated DW-077 usage events.
21//!
22//! The contract is therefore: reject when the window is exhausted,
23//! spend what the provider reports. Overrun within one request is
24//! bounded by that one request's usage — a holder at the limit can
25//! always complete one more request, and the next pre-check rejects.
26//!
27//! # Mid-stream cutoff
28//!
29//! While a stream is being forwarded, the provider's usage report is
30//! spent as it GROWS — each reported token exactly once — and checked
31//! against the window after every batch. When a dialect reports usage
32//! EARLY (Anthropic carries input tokens in `message_start`), a
33//! crossing is detected mid-stream: forwarding stops, the client
34//! receives the documented `ai_budget_exceeded` SSE event and the
35//! terminator, and the provider body is dropped AT ONCE — the upstream
36//! cancel propagates immediately, so even a stalled client cannot keep
37//! the provider generating. Dialects that
38//! only report usage at the END (OpenAI without include_usage would
39//! be one; the gateway forces include_usage, whose chunk is terminal)
40//! cannot cut off mid-stream: their usage is spent at stream end and
41//! enforced by the next pre-check. Documented, honest boundary.
42//!
43//! # Windows and keys
44//!
45//! `tokens_per_min` is a fixed 60-second window aligned to the epoch
46//! minute (deterministic in tests; `Retry-After` counts to the next
47//! boundary). `cost_per_day_micros` is a UTC calendar day — the same
48//! window shape as the DW-033 quotas. Cost is integer MICRO-USD (no
49//! floating-point money). The budget KEY is the consumer name
50//! (`scope: consumer`) or, for a shared team budget, the POLICY name
51//! (`scope: policy`) — every consumer attaching that policy spends
52//! from one ledger entry.
53//!
54//! # Pricing seam (DW-079)
55//!
56//! Cost enforcement is wired end to end but reads prices through
57//! [`cost_micros`], which returns 0 until the pricing tables land in
58//! DW-079 — enforced-but-inert, the honest seam.
59//!
60//! # Lifetime
61//!
62//! The ledger (windows) lives on the dataplane and SURVIVES config
63//! reloads (a reload must not reset a 60-second window or a daily
64//! spend); only the limits resolve per generation. A reload PRUNES the
65//! keys the new generation can no longer derive (a removed or renamed
66//! holder's windows) — carried-forward state is bounded by the config,
67//! not the deployment's history. Unbudgeted consumers (no policy with
68//! a `token_budget` binds them) are unlimited — fail-open, the DW-017
69//! default posture. An ANONYMOUS request (a route without
70//! `auth_required`) cannot bind a consumer-scoped budget — the walk
71//! falls through to the next candidate, so a less-specific
72//! policy-scoped (team) budget still binds rather than silently
73//! unlimiting the request.
74
75use crate::ai::types::Usage;
76use crate::config::{TokenBudget, TokenBudgetScope};
77use std::collections::{HashMap, HashSet};
78use std::sync::atomic::{AtomicBool, Ordering};
79use std::sync::{Arc, Mutex};
80
81/// Seconds in a minute (the token window's whole length).
82const SECS_PER_MIN: u64 = 60;
83/// Seconds in a day (the cost window's whole length, UTC-aligned).
84const SECS_PER_DAY: u64 = 86_400;
85
86/// One ledger window's spent totals (reset when the window rolls).
87#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
88struct Spent {
89 tokens: u64,
90 cost_micros: u64,
91}
92
93/// Which window a ledger entry tracks.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95enum WindowKind {
96 Minute,
97 Day,
98}
99
100/// The shared spend ledger: one entry per (budget key, window kind).
101/// A single mutex guards the map — AI request rates are orders of
102/// magnitude below the GCRA path's sharding needs, and the critical
103/// section is two integer adds (the DW-033 store pays an fsync per
104/// request; this pays none).
105#[derive(Debug, Default)]
106pub struct BudgetLedger {
107 windows: Mutex<HashMap<(String, WindowKind), (u64, Spent)>>,
108}
109
110impl BudgetLedger {
111 /// The current spend of one window (0 when untouched).
112 fn spent(&self, key: &str, kind: WindowKind, window_index: u64) -> Spent {
113 let windows = self.windows.lock().unwrap_or_else(|p| p.into_inner());
114 windows
115 .get(&(key.to_string(), kind))
116 .filter(|(idx, _)| *idx == window_index)
117 .map(|(_, s)| *s)
118 .unwrap_or_default()
119 }
120
121 /// Add `tokens` to a window (rolling it forward if the window
122 /// advanced). Returns the NEW total and whether it crossed
123 /// `limit` (the spend is recorded either way — honest accounting).
124 fn spend_tokens(
125 &self,
126 key: &str,
127 window_index: u64,
128 tokens: u64,
129 limit: Option<u64>,
130 ) -> (u64, bool) {
131 let mut windows = self.windows.lock().unwrap_or_else(|p| p.into_inner());
132 let entry = windows
133 .entry((key.to_string(), WindowKind::Minute))
134 .or_insert((window_index, Spent::default()));
135 if entry.0 != window_index {
136 *entry = (window_index, Spent::default());
137 }
138 entry.1.tokens = entry.1.tokens.saturating_add(tokens);
139 let crossed = limit.is_some_and(|l| entry.1.tokens > l);
140 (entry.1.tokens, crossed)
141 }
142
143 /// Add `cost_micros` to the day window (same roll semantics).
144 fn spend_cost(&self, key: &str, window_index: u64, cost_micros: u64) {
145 let mut windows = self.windows.lock().unwrap_or_else(|p| p.into_inner());
146 let entry = windows
147 .entry((key.to_string(), WindowKind::Day))
148 .or_insert((window_index, Spent::default()));
149 if entry.0 != window_index {
150 *entry = (window_index, Spent::default());
151 }
152 entry.1.cost_micros = entry.1.cost_micros.saturating_add(cost_micros);
153 }
154
155 /// Drop every key not in `keep` (reload pruning): keys the new
156 /// generation can no longer resolve must not accumulate forever.
157 /// An in-flight request still holding a guard over a pruned key
158 /// re-creates its entry on its next spend — orphaned, and pruned
159 /// again by the following reload.
160 fn retain(&self, keep: &HashSet<String>) {
161 let mut windows = self.windows.lock().unwrap_or_else(|p| p.into_inner());
162 windows.retain(|(key, _), _| keep.contains(key));
163 }
164}
165
166/// The per-generation budget rules plus the reload-surviving ledger.
167#[derive(Debug, Default)]
168pub struct AiBudgetEngine {
169 /// Policy name -> its token budget (config-declared).
170 budgets: HashMap<String, TokenBudget>,
171 /// Per-consumer token budgets (DW-113): consumer name -> its
172 /// direct token budget. Checked FIRST in resolve (before the
173 /// policy chain) as the most-specific budget. A consumer with no
174 /// entry here falls through to the policy chain.
175 consumer_budgets: HashMap<String, TokenBudget>,
176 /// Shared spend ledger — carried across generations.
177 ledger: Arc<BudgetLedger>,
178 /// Whether the anonymous-fall-through warning already fired this
179 /// generation (once per compile, not per request).
180 warned_anonymous: AtomicBool,
181}
182
183impl AiBudgetEngine {
184 /// Compile from the gateway config; the ledger starts empty.
185 pub fn compile(gateway: &crate::config::Gateway) -> Self {
186 Self::compile_with_ledger(gateway, Arc::new(BudgetLedger::default()))
187 }
188
189 /// Compile with a PREVIOUS generation's ledger (reload: rules
190 /// change, spend survives — except for holders the new generation
191 /// can no longer derive, whose keys are pruned so carried state
192 /// stays config-bounded).
193 pub fn compile_with_ledger(
194 gateway: &crate::config::Gateway,
195 ledger: Arc<BudgetLedger>,
196 ) -> Self {
197 let budgets = gateway
198 .policies
199 .iter()
200 .filter_map(|p| p.token_budget.as_ref().map(|b| (p.name.clone(), *b)))
201 .collect();
202 // DW-113: per-consumer token budgets. A consumer with a
203 // `token_budget` set gets its own budget, checked before the
204 // policy chain (the most-specific budget).
205 let consumer_budgets = gateway
206 .consumers
207 .iter()
208 .filter_map(|c| c.token_budget.map(|b| (c.name.clone(), b)))
209 .collect();
210 ledger.retain(&derivable_keys(gateway, &budgets, &consumer_budgets));
211 AiBudgetEngine {
212 budgets,
213 consumer_budgets,
214 ledger,
215 warned_anonymous: AtomicBool::new(false),
216 }
217 }
218
219 /// Whether any policy or consumer declares a budget (cheap
220 /// dataplane skip).
221 pub fn is_empty(&self) -> bool {
222 self.budgets.is_empty() && self.consumer_budgets.is_empty()
223 }
224
225 /// The shared ledger (carried into the next generation).
226 pub fn ledger(&self) -> Arc<BudgetLedger> {
227 Arc::clone(&self.ledger)
228 }
229
230 /// Resolve the binding budget for one request: the FIRST policy
231 /// carrying a `token_budget` in the frozen precedence chain
232 /// consumer > route > service > listener > global (a budget is a
233 /// limit-of-totals, so the most specific level governs — unlike
234 /// AND-composed rate rules). The ledger key is the consumer name
235 /// or, for a team budget (`scope: policy`), the policy name.
236 ///
237 /// An ANONYMOUS request has no per-consumer window, so a
238 /// consumer-scoped budget CANNOT bind it: that candidate is
239 /// skipped and the walk CONTINUES — aborting here would silently
240 /// unlimit the request even when a less-specific policy-scoped
241 /// (team) budget later in the chain should govern it.
242 pub fn resolve(
243 &self,
244 consumer: Option<&str>,
245 consumer_policies: &[String],
246 route_policies: &[String],
247 service_policies: &[String],
248 listener_policies: &[String],
249 global_policies: &[String],
250 ) -> Option<BudgetGuard> {
251 // DW-113: per-consumer token budget — the MOST specific
252 // budget, checked before the policy chain. A consumer with a
253 // direct `token_budget` binds it (consumer-scoped by
254 // definition); an anonymous caller cannot bind one (no
255 // consumer name).
256 if let Some(consumer_name) = consumer {
257 if let Some(budget) = self.consumer_budgets.get(consumer_name) {
258 return Some(BudgetGuard {
259 ledger: Arc::clone(&self.ledger),
260 key: consumer_name.to_string(),
261 tokens_per_min: budget.tokens_per_min,
262 cost_per_day_micros: budget.cost_per_day_micros,
263 scope: TokenBudgetScope::Consumer,
264 });
265 }
266 }
267 let levels = [
268 consumer_policies,
269 route_policies,
270 service_policies,
271 listener_policies,
272 global_policies,
273 ];
274 for level in levels {
275 for name in level {
276 if let Some(budget) = self.budgets.get(name) {
277 let key = match budget.scope {
278 TokenBudgetScope::Consumer => match consumer {
279 Some(c) => c.to_string(),
280 None => {
281 // Once per generation: reachable only
282 // when an anonymous caller hit a route
283 // whose budget chain starts with a
284 // consumer-scoped policy — a config
285 // smell worth naming, not a rejection.
286 if !self.warned_anonymous.swap(true, Ordering::Relaxed) {
287 tracing::warn!(
288 code = "ai_budget_anonymous_skipped",
289 policy = %name,
290 "anonymous request cannot bind a \
291 consumer-scoped token budget; \
292 continuing to the next candidate"
293 );
294 }
295 continue;
296 }
297 },
298 TokenBudgetScope::Policy => name.clone(),
299 };
300 return Some(BudgetGuard {
301 ledger: Arc::clone(&self.ledger),
302 key,
303 tokens_per_min: budget.tokens_per_min,
304 cost_per_day_micros: budget.cost_per_day_micros,
305 scope: budget.scope,
306 });
307 }
308 }
309 }
310 None
311 }
312}
313
314/// The ledger keys the generation's config can still resolve: every
315/// budgeted policy name (team scope, keyed by the policy), plus — for
316/// consumer-scoped budgets — the consumers that can bind them (direct
317/// attachers, and every consumer when the policy is attached at a
318/// level any authenticated consumer can hit:
319/// route/service/listener/global), plus the per-consumer direct
320/// budgets (DW-113, keyed by the consumer name).
321fn derivable_keys(
322 gateway: &crate::config::Gateway,
323 budgets: &HashMap<String, TokenBudget>,
324 consumer_budgets: &HashMap<String, TokenBudget>,
325) -> HashSet<String> {
326 let mut keep: HashSet<String> = HashSet::new();
327 // DW-113: per-consumer direct budgets are always derivable while
328 // the consumer carries the budget.
329 for name in consumer_budgets.keys() {
330 keep.insert(name.clone());
331 }
332 for (name, budget) in budgets {
333 match budget.scope {
334 TokenBudgetScope::Policy => {
335 keep.insert(name.clone());
336 }
337 TokenBudgetScope::Consumer => {
338 for c in &gateway.consumers {
339 if c.policies.iter().any(|p| p == name) {
340 keep.insert(c.name.clone());
341 }
342 }
343 if attached_beyond_consumer(gateway, name) {
344 keep.extend(gateway.consumers.iter().map(|c| c.name.clone()));
345 }
346 }
347 }
348 }
349 keep
350}
351
352/// Whether the policy is attached at any level ANY authenticated
353/// consumer can bind (route, service, listener, global) — not only by
354/// direct consumer attachment.
355fn attached_beyond_consumer(gateway: &crate::config::Gateway, policy: &str) -> bool {
356 let at = |names: &[String]| names.iter().any(|p| p == policy);
357 gateway.routes.iter().any(|r| at(&r.policies))
358 || gateway.services.iter().any(|s| at(&s.policies))
359 || gateway.listeners.iter().any(|l| at(&l.policies))
360 || at(&gateway.global_policies)
361}
362
363/// The resolved budget for one request: a live guard over the ledger.
364pub struct BudgetGuard {
365 ledger: Arc<BudgetLedger>,
366 key: String,
367 tokens_per_min: Option<u64>,
368 cost_per_day_micros: Option<u64>,
369 /// The budget's scope (DW-079): when `Policy`, the `key` IS the
370 /// team name (the policy name) — exposed so the spend recorder
371 /// can attribute the request to the team.
372 scope: TokenBudgetScope,
373}
374
375/// Which budget window denied (the `kind` label of
376/// `dwara_ai_budget_denied_total`).
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum BudgetKind {
379 /// The tokens/min window.
380 Tokens,
381 /// The cost/day window.
382 Cost,
383}
384
385impl BudgetKind {
386 /// The metric label value (`tokens` | `cost`).
387 pub fn as_str(self) -> &'static str {
388 match self {
389 BudgetKind::Tokens => "tokens",
390 BudgetKind::Cost => "cost",
391 }
392 }
393}
394
395/// The pre-check verdict.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum BudgetVerdict {
398 /// The holder has budget remaining in every configured window.
399 Allowed,
400 /// A window is exhausted: retry after this many seconds. `kind` is
401 /// the window that set the Retry-After (the later wall when both
402 /// are exhausted) so the denial metric attributes correctly.
403 Denied {
404 kind: BudgetKind,
405 retry_after_s: u64,
406 },
407}
408
409impl BudgetGuard {
410 /// The minute-window index containing `now_s`.
411 fn minute_index(now_s: u64) -> u64 {
412 now_s / SECS_PER_MIN
413 }
414
415 /// The UTC-day window index containing `now_s`.
416 fn day_index(now_s: u64) -> u64 {
417 now_s / SECS_PER_DAY
418 }
419
420 /// Pre-check BEFORE any provider contact: rejected when either
421 /// window is already exhausted (both windows are checked
422 /// independently — the done-when requirement). `Retry-After` is
423 /// the time to the denying window's boundary (the later wall when
424 /// both are exhausted), and the verdict's kind is the window that
425 /// set it.
426 pub fn check(&self, now_s: u64) -> BudgetVerdict {
427 let mut denial: Option<(BudgetKind, u64)> = None;
428 if let Some(limit) = self.tokens_per_min {
429 let spent = self
430 .ledger
431 .spent(&self.key, WindowKind::Minute, Self::minute_index(now_s))
432 .tokens;
433 if spent >= limit {
434 let wait = SECS_PER_MIN - (now_s % SECS_PER_MIN);
435 denial = later_wall(denial, BudgetKind::Tokens, wait);
436 }
437 }
438 if let Some(limit) = self.cost_per_day_micros {
439 let spent = self
440 .ledger
441 .spent(&self.key, WindowKind::Day, Self::day_index(now_s))
442 .cost_micros;
443 if spent >= limit {
444 let wait = SECS_PER_DAY - (now_s % SECS_PER_DAY);
445 denial = later_wall(denial, BudgetKind::Cost, wait);
446 }
447 }
448 match denial {
449 Some((kind, retry_after_s)) => BudgetVerdict::Denied {
450 kind,
451 retry_after_s,
452 },
453 None => BudgetVerdict::Allowed,
454 }
455 }
456
457 /// Pre-check WITH a local token estimate (PERF-01): like
458 /// [`check`](Self::check) but also rejects when the estimated
459 /// total tokens for this request would push the minute window OVER
460 /// its limit. `estimated_tokens` is the local estimate of the
461 /// request's total token cost (prompt estimate + requested
462 /// `max_tokens`); the check is `spent + estimated_tokens > limit`
463 /// (strictly greater — a request that lands exactly on the limit
464 /// is allowed, matching the original check-then-spend contract
465 /// where a holder at the limit can complete one more request).
466 ///
467 /// The estimate is CONSERVATIVE (overcounts), so a rejection here
468 /// is the honest boundary: the provider call is skipped, the
469 /// client sees 429 `ai_budget_exceeded`, and the actual spend is
470 /// never recorded (no provider contact). The cost window is NOT
471 /// estimated (cost depends on the provider's pricing table and the
472 /// actual token mix, not a pre-call estimate); only the token
473 /// window benefits from the estimate.
474 pub fn check_with_estimate(&self, now_s: u64, estimated_tokens: u64) -> BudgetVerdict {
475 let mut denial: Option<(BudgetKind, u64)> = None;
476 if let Some(limit) = self.tokens_per_min {
477 let spent = self
478 .ledger
479 .spent(&self.key, WindowKind::Minute, Self::minute_index(now_s))
480 .tokens;
481 if spent >= limit {
482 let wait = SECS_PER_MIN - (now_s % SECS_PER_MIN);
483 denial = later_wall(denial, BudgetKind::Tokens, wait);
484 } else if spent.saturating_add(estimated_tokens) > limit {
485 // The estimate would push the window over: reject
486 // before the provider call. Retry-After is the time to
487 // the next minute boundary (the window resets then).
488 let wait = SECS_PER_MIN - (now_s % SECS_PER_MIN);
489 denial = later_wall(denial, BudgetKind::Tokens, wait);
490 }
491 }
492 if let Some(limit) = self.cost_per_day_micros {
493 let spent = self
494 .ledger
495 .spent(&self.key, WindowKind::Day, Self::day_index(now_s))
496 .cost_micros;
497 if spent >= limit {
498 let wait = SECS_PER_DAY - (now_s % SECS_PER_DAY);
499 denial = later_wall(denial, BudgetKind::Cost, wait);
500 }
501 }
502 match denial {
503 Some((kind, retry_after_s)) => BudgetVerdict::Denied {
504 kind,
505 retry_after_s,
506 },
507 None => BudgetVerdict::Allowed,
508 }
509 }
510
511 /// Record the provider-reported usage after the call. Returns
512 /// whether THIS spend crossed the token window (the mid-stream
513 /// cutoff signal; the spend is recorded either way).
514 pub fn spend(&self, now_s: u64, usage: Usage, cost_micros: u64) -> bool {
515 let tokens = usage.total_tokens.unwrap_or_else(|| {
516 usage
517 .prompt_tokens
518 .unwrap_or(0)
519 .saturating_add(usage.completion_tokens.unwrap_or(0))
520 });
521 let (_, crossed) = self.ledger.spend_tokens(
522 &self.key,
523 Self::minute_index(now_s),
524 tokens,
525 self.tokens_per_min,
526 );
527 if cost_micros > 0 {
528 self.ledger
529 .spend_cost(&self.key, Self::day_index(now_s), cost_micros);
530 }
531 crossed
532 }
533
534 /// The SSE event frame for a mid-stream budget cutoff (the
535 /// documented over-budget event; DW-077's terminator follows).
536 pub fn cutoff_frame(rid: &str) -> String {
537 let body = crate::ai::openai_compat::error_body(
538 "the token budget for this window is exhausted; the stream was cut off",
539 "rate_limit_error",
540 Some("ai_budget_exceeded"),
541 rid,
542 );
543 format!("data: {}\n\n", body)
544 }
545
546 /// The team key for spend attribution (DW-079): the policy name
547 /// when the budget is `scope: policy` (a shared team budget), or
548 /// an empty string when the budget is consumer-scoped or no
549 /// budget binds the request.
550 pub fn team_key(&self) -> &str {
551 match self.scope {
552 TokenBudgetScope::Policy => &self.key,
553 TokenBudgetScope::Consumer => "",
554 }
555 }
556}
557
558/// Keep the LATER wall when both windows deny: the Retry-After and
559/// its kind belong to the window that actually binds (a tie keeps the
560/// first — either window denies either way).
561fn later_wall(
562 cur: Option<(BudgetKind, u64)>,
563 kind: BudgetKind,
564 wait: u64,
565) -> Option<(BudgetKind, u64)> {
566 match cur {
567 Some((_, prev)) if prev >= wait => cur,
568 _ => Some((kind, wait)),
569 }
570}
571
572/// The DW-079 pricing seam: micro-USD for one provider-model call
573/// with this usage. Reads prices through a DEFAULT (empty) pricing
574/// table, so this free function always returns 0 (the unknown-model
575/// `allow` policy) — it exists for test/backward-compat call sites
576/// that do not have a dataplane handle. The live path uses the
577/// dataplane's compiled
578/// [`PricingTable`](crate::ai::cost::PricingTable) (stored on the
579/// DataPlane as an ArcSwap, refreshed per generation).
580pub fn cost_micros(provider_model: &str, usage: Usage) -> u64 {
581 crate::ai::cost::PricingTable::default()
582 .cost_micros(provider_model, usage, false)
583 .unwrap_or(0)
584}
585
586/// Resolve a consumer's attached policies from the config (by name).
587pub fn consumer_policies_of<'a>(
588 gateway: &'a crate::config::Gateway,
589 consumer: &str,
590) -> &'a [String] {
591 gateway
592 .consumers
593 .iter()
594 .find(|c| c.name == consumer)
595 .map(|c| c.policies.as_slice())
596 .unwrap_or(&[])
597}
598
599/// The route's service's policy list (empty when the service is
600/// missing — validation rejects that elsewhere).
601pub fn service_policies_of<'a>(gateway: &'a crate::config::Gateway, service: &str) -> &'a [String] {
602 gateway
603 .services
604 .iter()
605 .find(|s| s.name == service)
606 .map(|s| s.policies.as_slice())
607 .unwrap_or(&[])
608}
609
610/// The listener's policy list (by name).
611pub fn listener_policies_of<'a>(
612 gateway: &'a crate::config::Gateway,
613 listener: &str,
614) -> &'a [String] {
615 gateway
616 .listeners
617 .iter()
618 .find(|l| l.name == listener)
619 .map(|l| l.policies.as_slice())
620 .unwrap_or(&[])
621}
622
623// White-box suite: stays in src/ because it inspects private
624// BudgetLedger/resolve internals (window indexing, reload pruning,
625// scope fall-through) that the e2e tests in tests/ai_budget.rs can
626// only observe indirectly.
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use crate::config::{Gateway, Policy, TokenBudget, TokenBudgetScope};
631
632 fn gateway_with(budget: TokenBudget, attach_to_consumer: bool) -> Gateway {
633 // The empty document parses to the all-defaults gateway.
634 let mut g = crate::config::parse_gateway("").expect("empty gateway parses");
635 g.policies = vec![Policy {
636 name: "ai-budget".into(),
637 rate_limit: None,
638 rate_limits: vec![],
639 timeouts: None,
640 dry_run: false,
641 token_budget: Some(budget),
642 anomaly: None,
643 adaptive: None,
644 }];
645 g.consumers = vec![crate::config::Consumer {
646 name: "acme".into(),
647 credentials: vec![],
648 policies: if attach_to_consumer {
649 vec!["ai-budget".into()]
650 } else {
651 vec![]
652 },
653 consumer_type: crate::config::ConsumerType::User,
654 tool_allowlist: vec![],
655 token_budget: None,
656 priority: None,
657 quotas: None,
658 groups: vec![],
659 authorization: None,
660 ai_logging: None,
661 agent: None,
662 }];
663 g
664 }
665
666 // White-box: the window arithmetic and check-then-spend contract
667 // are private behavior; the gateway-level enforcement (429 shape,
668 // mid-stream cutoff) is covered by tests/ai_budget.rs.
669
670 #[test]
671 fn pre_check_rejects_only_when_spent_reaches_the_limit() {
672 let g = gateway_with(
673 TokenBudget {
674 tokens_per_min: Some(100),
675 cost_per_day_micros: None,
676 scope: TokenBudgetScope::Consumer,
677 },
678 true,
679 );
680 let engine = AiBudgetEngine::compile(&g);
681 let guard = engine
682 .resolve(Some("acme"), &["ai-budget".into()], &[], &[], &[], &[])
683 .expect("consumer budget binds");
684
685 let now = 1_000_000;
686 assert_eq!(guard.check(now), BudgetVerdict::Allowed);
687 // One request spends exactly the window: still allowed DURING
688 // (the pre-check passed), the NEXT pre-check denies.
689 assert!(!guard.spend(
690 now,
691 Usage {
692 prompt_tokens: Some(100),
693 completion_tokens: None,
694 total_tokens: None,
695 cached_tokens: None,
696 },
697 0
698 ));
699 match guard.check(now) {
700 BudgetVerdict::Denied {
701 kind,
702 retry_after_s,
703 } => {
704 assert_eq!(kind, BudgetKind::Tokens);
705 assert!(retry_after_s > 0 && retry_after_s <= 60)
706 }
707 v => panic!("expected denial, got {v:?}"),
708 }
709 // The next minute window is fresh.
710 assert_eq!(guard.check(now + 60), BudgetVerdict::Allowed);
711 }
712
713 #[test]
714 fn windows_roll_and_keys_are_separate() {
715 let g = gateway_with(
716 TokenBudget {
717 tokens_per_min: Some(10),
718 cost_per_day_micros: None,
719 scope: TokenBudgetScope::Consumer,
720 },
721 true,
722 );
723 let engine = AiBudgetEngine::compile(&g);
724 let a = engine
725 .resolve(Some("acme"), &["ai-budget".into()], &[], &[], &[], &[])
726 .unwrap();
727 let now = 500;
728 a.spend(
729 now,
730 Usage {
731 prompt_tokens: None,
732 completion_tokens: Some(10),
733 total_tokens: None,
734 cached_tokens: None,
735 },
736 0,
737 );
738 assert_eq!(
739 a.check(now),
740 BudgetVerdict::Denied {
741 kind: BudgetKind::Tokens,
742 retry_after_s: 60 - (now % 60)
743 }
744 );
745 // Another consumer (same policy) has its OWN window.
746 let b = engine
747 .resolve(Some("other"), &["ai-budget".into()], &[], &[], &[], &[])
748 .unwrap();
749 assert_eq!(b.check(now), BudgetVerdict::Allowed);
750 // A team-scoped budget shares by policy name.
751 let g2 = gateway_with(
752 TokenBudget {
753 tokens_per_min: Some(10),
754 cost_per_day_micros: None,
755 scope: TokenBudgetScope::Policy,
756 },
757 true,
758 );
759 let engine2 = AiBudgetEngine::compile_with_ledger(&g2, engine.ledger());
760 let t1 = engine2
761 .resolve(Some("acme"), &["ai-budget".into()], &[], &[], &[], &[])
762 .unwrap();
763 let t2 = engine2
764 .resolve(Some("other"), &["ai-budget".into()], &[], &[], &[], &[])
765 .unwrap();
766 t1.spend(
767 now,
768 Usage {
769 prompt_tokens: Some(10),
770 completion_tokens: None,
771 total_tokens: None,
772 cached_tokens: None,
773 },
774 0,
775 );
776 assert_eq!(
777 t2.check(now),
778 BudgetVerdict::Denied {
779 kind: BudgetKind::Tokens,
780 retry_after_s: 60 - (now % 60)
781 }
782 );
783 }
784
785 #[test]
786 fn cost_window_is_independent_of_tokens() {
787 let g = gateway_with(
788 TokenBudget {
789 tokens_per_min: Some(10),
790 cost_per_day_micros: Some(1_000),
791 scope: TokenBudgetScope::Consumer,
792 },
793 true,
794 );
795 let engine = AiBudgetEngine::compile(&g);
796 let guard = engine
797 .resolve(Some("acme"), &["ai-budget".into()], &[], &[], &[], &[])
798 .unwrap();
799 let now = 10_000_000; // mid-day
800 // Exhaust TOKENS only.
801 guard.spend(
802 now,
803 Usage {
804 prompt_tokens: Some(10),
805 completion_tokens: None,
806 total_tokens: None,
807 cached_tokens: None,
808 },
809 0,
810 );
811 assert_eq!(
812 guard.check(now),
813 BudgetVerdict::Denied {
814 kind: BudgetKind::Tokens,
815 retry_after_s: 60 - (now % 60)
816 }
817 );
818 // ...but the same holder in the NEXT minute is still denied on
819 // tokens only until the minute rolls; with tokens fresh, cost
820 // alone governs: spend cost to the cap via the seam-shaped
821 // call path (cost>0 spends).
822 guard.spend(now + 60, Usage::default(), 1_000);
823 match guard.check(now + 60) {
824 BudgetVerdict::Denied {
825 kind,
826 retry_after_s,
827 } => {
828 // The COST wall: hours-scale retry, not 60s, attributed
829 // to the cost window (the denial metric's kind).
830 assert_eq!(kind, BudgetKind::Cost);
831 assert!(retry_after_s > 3600, "cost-day retry {retry_after_s}");
832 }
833 v => panic!("expected cost denial, got {v:?}"),
834 }
835 }
836
837 #[test]
838 fn precedence_takes_the_most_specific_binding() {
839 let mut g = gateway_with(
840 TokenBudget {
841 tokens_per_min: Some(5),
842 cost_per_day_micros: None,
843 scope: TokenBudgetScope::Consumer,
844 },
845 true,
846 );
847 g.policies.push(Policy {
848 name: "route-budget".into(),
849 rate_limit: None,
850 rate_limits: vec![],
851 timeouts: None,
852 dry_run: false,
853 token_budget: Some(TokenBudget {
854 tokens_per_min: Some(999),
855 cost_per_day_micros: None,
856 scope: TokenBudgetScope::Consumer,
857 }),
858 anomaly: None,
859 adaptive: None,
860 });
861 let engine = AiBudgetEngine::compile(&g);
862 // Consumer-level (5) wins over route-level (999).
863 let guard = engine
864 .resolve(
865 Some("acme"),
866 &["ai-budget".into()],
867 &["route-budget".into()],
868 &[],
869 &[],
870 &[],
871 )
872 .unwrap();
873 let now = 77_777;
874 guard.spend(
875 now,
876 Usage {
877 prompt_tokens: Some(5),
878 completion_tokens: None,
879 total_tokens: None,
880 cached_tokens: None,
881 },
882 0,
883 );
884 assert_eq!(
885 guard.check(now),
886 BudgetVerdict::Denied {
887 kind: BudgetKind::Tokens,
888 retry_after_s: 60 - (now % 60)
889 }
890 );
891 }
892
893 #[test]
894 fn anonymous_caller_skips_consumer_scope_and_binds_less_specific() {
895 // Mixed scopes in one chain: a consumer-scoped budget first
896 // (route level), a policy-scoped (team) one after (global).
897 let mut g = gateway_with(
898 TokenBudget {
899 tokens_per_min: Some(1_000_000),
900 cost_per_day_micros: None,
901 scope: TokenBudgetScope::Consumer,
902 },
903 false,
904 );
905 g.policies.push(Policy {
906 name: "team-budget".into(),
907 rate_limit: None,
908 rate_limits: vec![],
909 timeouts: None,
910 dry_run: false,
911 token_budget: Some(TokenBudget {
912 tokens_per_min: Some(10),
913 cost_per_day_micros: None,
914 scope: TokenBudgetScope::Policy,
915 }),
916 anomaly: None,
917 adaptive: None,
918 });
919 let engine = AiBudgetEngine::compile(&g);
920 // Anonymous: the consumer-scoped candidate cannot bind, the
921 // walk CONTINUES, and the team budget governs (an early None
922 // here would silently unlimit anonymous traffic).
923 let guard = engine
924 .resolve(
925 None,
926 &[],
927 &["ai-budget".into()],
928 &[],
929 &[],
930 &["team-budget".into()],
931 )
932 .expect("the policy-scoped budget binds an anonymous caller");
933 guard.spend(
934 5_000,
935 Usage {
936 prompt_tokens: Some(10),
937 ..Usage::default()
938 },
939 0,
940 );
941 // The bound guard spends under the team key ("team-budget")
942 // and denies at its 10-token window — proving the walk fell
943 // through to the policy-scoped budget.
944 assert_eq!(
945 guard.check(5_000),
946 BudgetVerdict::Denied {
947 kind: BudgetKind::Tokens,
948 retry_after_s: 60 - (5_000 % 60)
949 }
950 );
951 }
952
953 #[test]
954 fn reload_prunes_ledger_keys_the_new_generation_cannot_derive() {
955 let budget = TokenBudget {
956 tokens_per_min: Some(10),
957 cost_per_day_micros: None,
958 scope: TokenBudgetScope::Consumer,
959 };
960 let attached = gateway_with(budget, true);
961 let engine = AiBudgetEngine::compile(&attached);
962 let ledger = engine.ledger();
963 engine
964 .resolve(Some("acme"), &["ai-budget".into()], &[], &[], &[], &[])
965 .expect("consumer budget binds")
966 .spend(
967 960,
968 Usage {
969 prompt_tokens: Some(10),
970 ..Usage::default()
971 },
972 0,
973 );
974 let minute = 960 / SECS_PER_MIN;
975
976 // Reload with the holder intact: the spend carries over.
977 AiBudgetEngine::compile_with_ledger(&attached, Arc::clone(&ledger));
978 assert_eq!(ledger.spent("acme", WindowKind::Minute, minute).tokens, 10);
979
980 // Reload with the attachment removed: "acme" is no longer a
981 // derivable key, so its windows are pruned (a removed or
982 // renamed holder must not be carried forward forever).
983 let detached = gateway_with(budget, false);
984 AiBudgetEngine::compile_with_ledger(&detached, Arc::clone(&ledger));
985 assert_eq!(
986 ledger.spent("acme", WindowKind::Minute, minute),
987 Spent::default()
988 );
989
990 // A team-scoped budget's key stays derivable while the budget
991 // exists, so it survives the same reload.
992 let team = gateway_with(
993 TokenBudget {
994 tokens_per_min: Some(10),
995 cost_per_day_micros: None,
996 scope: TokenBudgetScope::Policy,
997 },
998 true,
999 );
1000 let engine = AiBudgetEngine::compile(&team);
1001 let ledger = engine.ledger();
1002 engine
1003 .resolve(Some("acme"), &["ai-budget".into()], &[], &[], &[], &[])
1004 .unwrap()
1005 .spend(
1006 960,
1007 Usage {
1008 prompt_tokens: Some(10),
1009 ..Usage::default()
1010 },
1011 0,
1012 );
1013 AiBudgetEngine::compile_with_ledger(&team, Arc::clone(&ledger));
1014 assert_eq!(
1015 ledger.spent("ai-budget", WindowKind::Minute, minute).tokens,
1016 10
1017 );
1018 }
1019
1020 #[test]
1021 fn unbudgeted_consumers_resolve_no_guard() {
1022 let g = gateway_with(
1023 TokenBudget {
1024 tokens_per_min: Some(10),
1025 cost_per_day_micros: None,
1026 scope: TokenBudgetScope::Consumer,
1027 },
1028 false, // policy exists but is NOT attached to the consumer
1029 );
1030 let engine = AiBudgetEngine::compile(&g);
1031 assert!(engine
1032 .resolve(Some("acme"), &[], &[], &[], &[], &[])
1033 .is_none());
1034 assert!(engine
1035 .resolve(Some("anon"), &[], &[], &[], &[], &[])
1036 .is_none());
1037 }
1038}