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}