Skip to main content

edgeguard/
budget.rs

1//! LLM hard budgets (gateway L1).
2//!
3//! L0 ([`crate::llm`]) *meters* token spend; L1 *enforces* a hard cap on it. A budget is a ceiling
4//! on tokens (or cost) over a fixed window, enforced **fail-closed** and **atomically across
5//! replicas** so a fleet behind a load balancer can't collectively overshoot — the failure mode
6//! that makes naive per-replica caps leak.
7//!
8//! The enforcement model is **reserve → reconcile** (the same shape a payment hold uses):
9//!
10//!  1. **reserve** an *estimate* (prompt size + the request's `max_tokens`) before forwarding. If it
11//!     would exceed the budget, the request is denied `429` and never reaches the upstream — so the
12//!     cap is a true ceiling, not a post-hoc overshoot.
13//!  2. **reconcile** to the upstream's *actual* `usage` once known: release the over-reserved
14//!     remainder, or charge the extra if the estimate was low. A request that never produced usage
15//!     (error, client hangup) reconciles to zero — a full release.
16//!
17//! Structure mirrors [`crate::limiter`]: a pure decision ([`would_reserve`]) split from the store
18//! ([`Store::Memory`] for a single replica / tests, [`Store::Redis`] running the same arithmetic as
19//! an atomic Lua script). The window is encoded in the key (`…:{window_index}`) so it resets at the
20//! boundary with no sweeper, and the key TTLs out after the window passes.
21//!
22//! **Honesty note (mirrors the limiter / ACME):** the Redis backend is implemented and compiled but
23//! the live transport isn't exercised by `cargo test` (no Redis in CI) — only the pure decision and
24//! the in-memory store are. The `#[ignore]`d `redis_*_live` tests prove it against a real server.
25
26use std::collections::HashMap;
27use std::sync::Mutex;
28use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
29
30use anyhow::{Context, Result};
31use tracing::warn;
32
33use crate::config::{BudgetCfg, LlmCfg};
34
35/// The unit a budget caps.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum BudgetUnit {
38    /// Total tokens (prompt + completion).
39    Tokens,
40    /// Cost in micro-dollars (1e-6 USD), via the model price book.
41    UsdMicros,
42}
43
44impl BudgetUnit {
45    fn parse(s: &str) -> Result<BudgetUnit> {
46        match s.trim().to_ascii_lowercase().as_str() {
47            "tokens" | "token" | "" => Ok(BudgetUnit::Tokens),
48            "usd" | "usd_micros" | "cost" => Ok(BudgetUnit::UsdMicros),
49            other => anyhow::bail!("invalid llm budget unit {other:?} (expected tokens|usd)"),
50        }
51    }
52}
53
54/// Which dimension a budget is keyed by.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum BudgetScope {
57    /// One shared budget across all traffic.
58    Global,
59    /// One budget per authenticated principal (API-key id / JWT subject).
60    PerKey,
61    /// One budget per requested model.
62    PerModel,
63    /// One budget per team/tag (from the team header/claim) — the chargeback dimension a
64    /// multi-tenant deployment bills on.
65    PerTeam,
66}
67
68impl BudgetScope {
69    fn parse(s: &str) -> Result<BudgetScope> {
70        match s.trim().to_ascii_lowercase().as_str() {
71            "global" | "" => Ok(BudgetScope::Global),
72            "key" | "per_key" | "per-key" => Ok(BudgetScope::PerKey),
73            "model" | "per_model" | "per-model" => Ok(BudgetScope::PerModel),
74            "team" | "per_team" | "per-team" | "tag" => Ok(BudgetScope::PerTeam),
75            other => {
76                anyhow::bail!("invalid llm budget scope {other:?} (expected global|key|model|team)")
77            }
78        }
79    }
80
81    /// The stable metric/log label for this scope. Kept in sync with `BUDGET_SCOPES` in
82    /// [`crate::metrics`].
83    pub fn label(&self) -> &'static str {
84        match self {
85            BudgetScope::Global => "global",
86            BudgetScope::PerKey => "key",
87            BudgetScope::PerModel => "model",
88            BudgetScope::PerTeam => "team",
89        }
90    }
91}
92
93/// One compiled budget: a `limit` (in `unit`) over `window_secs`, keyed by `scope`.
94#[derive(Debug, Clone)]
95struct Budget {
96    name: String,
97    scope: BudgetScope,
98    unit: BudgetUnit,
99    limit: u64,
100    window_secs: u64,
101}
102
103impl Budget {
104    /// Compile a [`BudgetCfg`]. A USD limit (float dollars) becomes integer micro-dollars; a token
105    /// limit is taken as-is. Rejects a zero/negative limit or window so a typo fails at startup.
106    fn build(cfg: &BudgetCfg) -> Result<Budget> {
107        anyhow::ensure!(
108            !cfg.name.trim().is_empty(),
109            "llm budget name must not be empty"
110        );
111        let unit = BudgetUnit::parse(&cfg.unit)?;
112        let scope = BudgetScope::parse(&cfg.scope)?;
113        let window_secs = crate::config::parse_duration(&cfg.window)
114            .with_context(|| format!("llm budget {:?} window", cfg.name))?
115            .as_secs();
116        anyhow::ensure!(
117            window_secs > 0,
118            "llm budget {:?} window must be > 0",
119            cfg.name
120        );
121        anyhow::ensure!(
122            cfg.limit.is_finite() && cfg.limit > 0.0,
123            "llm budget {:?} limit must be > 0",
124            cfg.name
125        );
126        let limit = match unit {
127            BudgetUnit::Tokens => cfg.limit.round() as u64,
128            BudgetUnit::UsdMicros => (cfg.limit * 1_000_000.0).round() as u64,
129        };
130        anyhow::ensure!(
131            limit > 0,
132            "llm budget {:?} limit rounds to zero; use a larger value",
133            cfg.name
134        );
135        Ok(Budget {
136            name: cfg.name.clone(),
137            scope,
138            unit,
139            limit,
140            window_secs,
141        })
142    }
143
144    /// The store key for this budget given the request's dimensions and the current time. The
145    /// window index (`now / window_secs`) is part of the key, so the budget resets at the window
146    /// boundary with no sweeper — the previous window's key simply ages out via TTL.
147    fn key(&self, prefix: &str, dims: &Dims, now_secs: u64) -> String {
148        let dim = match self.scope {
149            BudgetScope::Global => "_",
150            BudgetScope::PerKey => dims.principal.unwrap_or("_anon"),
151            BudgetScope::PerModel => dims.model,
152            BudgetScope::PerTeam => dims.team.unwrap_or("_none"),
153        };
154        let window = now_secs / self.window_secs;
155        format!("{prefix}:budget:{}:{dim}:{window}", self.name)
156    }
157
158    /// The amount this budget would charge for a `(tokens, cost_micros)` estimate, in its own unit.
159    fn amount(&self, tokens: u64, cost_micros: u64) -> u64 {
160        match self.unit {
161            BudgetUnit::Tokens => tokens,
162            BudgetUnit::UsdMicros => cost_micros,
163        }
164    }
165
166    /// TTL (ms) to set on the key: two windows, so an in-progress window stays live while a passed
167    /// one is reclaimed well before its index could recur.
168    fn ttl_ms(&self) -> u64 {
169        self.window_secs.saturating_mul(2_000).max(1)
170    }
171}
172
173/// The request dimensions a budget keys on (which subset it uses depends on the budget's scope).
174#[derive(Debug, Clone, Copy, Default)]
175pub struct Dims<'a> {
176    /// Authenticated principal (API-key id / JWT subject) — the `key` scope.
177    pub principal: Option<&'a str>,
178    /// Requested model — the `model` scope.
179    pub model: &'a str,
180    /// Team/tag — the `team` scope (chargeback dimension).
181    pub team: Option<&'a str>,
182}
183
184/// The pure admission decision: may `amount` be reserved against `used` without exceeding `limit`?
185/// Shared by every [`Store`] so all backends agree bit-for-bit.
186fn would_reserve(used: u64, amount: u64, limit: u64) -> bool {
187    used.saturating_add(amount) <= limit
188}
189
190/// The result of a reserve attempt against one key: whether it was admitted and the balance *after*
191/// (unchanged from the current used total when denied). The post-reserve total drives the near-limit
192/// consumed-ratio gauge.
193#[derive(Debug, Clone, Copy)]
194struct ReserveOutcome {
195    admitted: bool,
196    used_after: u64,
197}
198
199/// A shared-state store that performs reserve / reconcile atomically per key.
200enum Store {
201    Memory(MemoryStore),
202    Redis(Box<RedisStore>),
203}
204
205impl Store {
206    /// Reserve `amount` against `key` (capped at `limit`), returning admission + the post-reserve
207    /// balance. `Err` on store failure.
208    async fn reserve(
209        &self,
210        key: &str,
211        amount: u64,
212        limit: u64,
213        ttl_ms: u64,
214    ) -> Result<ReserveOutcome> {
215        match self {
216            Store::Memory(s) => Ok(s.reserve(key, amount, limit, ttl_ms)),
217            Store::Redis(s) => s.reserve(key, amount, limit, ttl_ms).await,
218        }
219    }
220
221    /// Apply `delta` (actual − reserved; may be negative) to `key`, flooring at 0, **idempotently**
222    /// via `marker` (a repeated settle is a no-op). Returns `true` on success. On the Redis path this
223    /// retries a transient error (safe because the settle is idempotent); a final failure is logged
224    /// and returns `false` so the caller can count the drift (`edgeguard_llm_budget_reconcile_failures_total`).
225    async fn reconcile(&self, key: &str, delta: i64, ttl_ms: u64, marker: &str) -> bool {
226        match self {
227            Store::Memory(s) => {
228                s.reconcile(key, delta, ttl_ms, marker);
229                true
230            }
231            Store::Redis(s) => match s.reconcile(key, delta, ttl_ms, marker).await {
232                Ok(()) => true,
233                Err(e) => {
234                    warn!(error = %format!("{e:#}"), "llm budget reconcile failed after retries — counter has drifted (alert on edgeguard_llm_budget_reconcile_failures_total)");
235                    false
236                }
237            },
238        }
239    }
240}
241
242/// In-process shared store: `key → (used, expiry)`. Single-replica / reference backend the tests
243/// drive. Tracks per-entry expiry so keys are evicted lazily on every write, preventing unbounded
244/// growth when many distinct budget keys (different principals or window indexes) are created over
245/// time. The expiry mirrors the TTL the Redis store sets on the key.
246#[derive(Default)]
247struct MemoryStore {
248    used: Mutex<HashMap<String, (u64, Instant)>>,
249    /// Settle markers (`marker → expiry`) for **idempotent** reconcile: a repeated settle for the
250    /// same marker is a no-op, mirroring the Redis `SET NX` marker so both backends dedupe a retry.
251    settled: Mutex<HashMap<String, Instant>>,
252}
253
254impl MemoryStore {
255    fn reserve(&self, key: &str, amount: u64, limit: u64, ttl_ms: u64) -> ReserveOutcome {
256        let now = Instant::now();
257        let expires = now + Duration::from_millis(ttl_ms);
258        let mut map = self.used.lock().expect("budget store mutex poisoned");
259        map.retain(|_, (_, exp)| *exp > now);
260        let used = map.get(key).map(|(v, _)| *v).unwrap_or(0);
261        if would_reserve(used, amount, limit) {
262            let used_after = used.saturating_add(amount);
263            map.insert(key.to_string(), (used_after, expires));
264            ReserveOutcome {
265                admitted: true,
266                used_after,
267            }
268        } else {
269            ReserveOutcome {
270                admitted: false,
271                used_after: used,
272            }
273        }
274    }
275
276    fn reconcile(&self, key: &str, delta: i64, ttl_ms: u64, marker: &str) {
277        let now = Instant::now();
278        // Idempotency: an unexpired marker means this exact settle already applied → no-op, so a
279        // retry (or a double call) can't double-apply the signed delta.
280        {
281            let mut seen = self.settled.lock().expect("budget settled mutex poisoned");
282            seen.retain(|_, exp| *exp > now);
283            if seen.contains_key(marker) {
284                return;
285            }
286            seen.insert(
287                marker.to_string(),
288                now + Duration::from_millis(MARKER_TTL_MS),
289            );
290        }
291        let expires = now + Duration::from_millis(ttl_ms);
292        let mut map = self.used.lock().expect("budget store mutex poisoned");
293        map.retain(|_, (_, exp)| *exp > now);
294        let used = map.get(key).map(|(v, _)| *v).unwrap_or(0) as i64;
295        let next = (used + delta).max(0) as u64;
296        map.insert(key.to_string(), (next, expires));
297    }
298}
299
300/// Reserve as a Redis Lua script: GET the used total, run the same check as [`would_reserve`], and
301/// INCRBY + refresh the TTL only when admitted — all atomic server-side, so concurrent replicas
302/// can't race the check against the update. Returns `1` to admit, `0` to deny.
303const RESERVE_LUA: &str = r#"
304local used = tonumber(redis.call('GET', KEYS[1]) or '0')
305local amount = tonumber(ARGV[1])
306local limit = tonumber(ARGV[2])
307local ttl = tonumber(ARGV[3])
308if used + amount > limit then
309  return {0, used}
310end
311local newv = redis.call('INCRBY', KEYS[1], amount)
312redis.call('PEXPIRE', KEYS[1], ttl)
313return {1, newv}
314"#;
315
316/// Reconcile as a Lua script — **idempotent**: a per-settle marker (`KEYS[2]`, set `NX`) makes a
317/// given settle apply *at most once*, so a retry after a lost response can't double-apply the signed
318/// delta (the correctness trap that makes a naive delta retry unsafe). On the first call it applies
319/// the delta (flooring at 0) and refreshes the counter TTL; a repeat call is a no-op returning the
320/// current value. The marker's own TTL only needs to outlive the retry window, not the whole budget
321/// window, so marker keys are short-lived and don't accumulate.
322/// KEYS[1]=counter, KEYS[2]=settle-marker · ARGV[1]=delta, ARGV[2]=counter_ttl_ms, ARGV[3]=marker_ttl_ms
323const RECONCILE_LUA: &str = r#"
324if redis.call('SET', KEYS[2], '1', 'NX', 'PX', tonumber(ARGV[3])) == false then
325  return tonumber(redis.call('GET', KEYS[1]) or '0')
326end
327local new = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
328if new < 0 then
329  redis.call('SET', KEYS[1], 0)
330  new = 0
331end
332redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]))
333return new
334"#;
335
336/// How long a settle marker lives — long enough to cover the reconcile retry window (retries happen
337/// within ~100ms), short enough that markers don't accumulate. NOT the budget window.
338const MARKER_TTL_MS: u64 = 300_000;
339/// Reconcile retry policy against the shared store. Safe to retry *because* the settle is idempotent
340/// (the marker dedupes), so a transient Redis blip no longer silently leaks a reserve (upward drift).
341const RECONCILE_ATTEMPTS: u32 = 3;
342const RECONCILE_BACKOFF: Duration = Duration::from_millis(25);
343
344/// Retry an async fallible op up to `attempts` (>=1) times with a fixed backoff. Returns on the first
345/// success, else the last error after exhausting attempts.
346async fn retry_async<F, Fut, T>(attempts: u32, backoff: Duration, mut op: F) -> Result<T>
347where
348    F: FnMut() -> Fut,
349    Fut: std::future::Future<Output = Result<T>>,
350{
351    let attempts = attempts.max(1);
352    let mut last: Option<anyhow::Error> = None;
353    for i in 0..attempts {
354        match op().await {
355            Ok(v) => return Ok(v),
356            Err(e) => {
357                last = Some(e);
358                if i + 1 < attempts {
359                    tokio::time::sleep(backoff).await;
360                }
361            }
362        }
363    }
364    Err(last.expect("retry_async ran at least one attempt"))
365}
366
367/// Redis-backed shared store. Connection established lazily and auto-reconnecting, mirroring the
368/// rate limiter's [`crate::limiter`] store.
369struct RedisStore {
370    client: redis::Client,
371    conn: tokio::sync::OnceCell<redis::aio::ConnectionManager>,
372    reserve: redis::Script,
373    reconcile: redis::Script,
374}
375
376impl RedisStore {
377    fn new(url: &str) -> Result<RedisStore> {
378        anyhow::ensure!(
379            !url.trim().is_empty(),
380            "llm.redis_url is required when llm.store = \"redis\""
381        );
382        let client = redis::Client::open(url)
383            .with_context(|| format!("opening redis client for {url:?} (llm.redis_url)"))?;
384        Ok(RedisStore {
385            client,
386            conn: tokio::sync::OnceCell::new(),
387            reserve: redis::Script::new(RESERVE_LUA),
388            reconcile: redis::Script::new(RECONCILE_LUA),
389        })
390    }
391
392    async fn manager(&self) -> Result<redis::aio::ConnectionManager> {
393        self.conn
394            .get_or_try_init(|| redis::aio::ConnectionManager::new(self.client.clone()))
395            .await
396            .context("connecting to redis llm-budget store")
397            .cloned()
398    }
399
400    async fn reserve(
401        &self,
402        key: &str,
403        amount: u64,
404        limit: u64,
405        ttl_ms: u64,
406    ) -> Result<ReserveOutcome> {
407        let mut conn = self.manager().await?;
408        let (admitted, used_after): (i64, i64) = self
409            .reserve
410            .key(key)
411            .arg(amount)
412            .arg(limit)
413            .arg(ttl_ms)
414            .invoke_async(&mut conn)
415            .await
416            .context("evaluating redis budget reserve script")?;
417        Ok(ReserveOutcome {
418            admitted: admitted == 1,
419            used_after: used_after.max(0) as u64,
420        })
421    }
422
423    async fn reconcile(&self, key: &str, delta: i64, ttl_ms: u64, marker: &str) -> Result<()> {
424        // Retry is safe: the idempotent Lua (marker set NX) applies the delta at most once, so a
425        // retried settle after a lost response can't double-count.
426        retry_async(RECONCILE_ATTEMPTS, RECONCILE_BACKOFF, || async {
427            let mut conn = self.manager().await?;
428            let _: i64 = self
429                .reconcile
430                .key(key)
431                .key(marker)
432                .arg(delta)
433                .arg(ttl_ms)
434                .arg(MARKER_TTL_MS)
435                .invoke_async(&mut conn)
436                .await
437                .context("evaluating redis budget reconcile script")?;
438            Ok(())
439        })
440        .await
441    }
442}
443
444/// An estimate (or actual) spend, carrying both units so a budget can charge whichever it caps.
445#[derive(Debug, Clone, Copy, Default)]
446pub struct Spend {
447    pub tokens: u64,
448    pub cost_micros: u64,
449}
450
451/// One budget's post-reserve consumption, surfaced so the caller can feed the near-limit gauge.
452#[derive(Debug, Clone)]
453pub struct Observation {
454    /// The budget's name (the gauge's `budget` label).
455    pub name: String,
456    /// `used / limit` after this reserve, in `[0.0, 1.0]` (a request is never admitted past 1.0).
457    pub consumed_ratio: f64,
458}
459
460/// A budget denial: which budget rejected the request, its scope (the block-counter label) and unit
461/// (so a cost cap can answer `402` while a token cap answers `429`).
462#[derive(Debug, Clone)]
463pub struct Denial {
464    pub name: String,
465    pub scope: BudgetScope,
466    pub unit: BudgetUnit,
467    /// Budgets reserved before this denial that failed to roll back against the store — drift,
468    /// like a failed [`BudgetEngine::reconcile`]/[`BudgetEngine::release`] settle. The caller
469    /// should feed this into the same `edgeguard_llm_budget_reconcile_failures_total` counter.
470    pub rollback_failures: usize,
471}
472
473/// A held reservation: the per-budget amounts charged at reserve time, to be reconciled (or
474/// released) once the actual usage is known. Opaque to the caller beyond passing it back, except for
475/// the [`Observation`]s it exposes for metrics.
476#[derive(Debug, Default)]
477pub struct Reservation {
478    /// Unique id for this reservation; part of each budget's settle marker so a retried settle
479    /// dedupes to a single application (idempotent reconcile).
480    id: String,
481    /// `(store key, reserved amount, budget index)` per budget that admitted.
482    held: Vec<(String, u64, usize)>,
483    /// Post-reserve consumption per admitted budget, for the near-limit gauge.
484    observations: Vec<Observation>,
485    /// Rollback settle failures from an earlier, aborted reserve attempt in the *same* call —
486    /// only ever non-zero on the fail-open path (a store error rolled back what was already
487    /// held, then admitted anyway). See [`Denial::rollback_failures`] for the denied-path twin.
488    rollback_failures: usize,
489}
490
491impl Reservation {
492    pub fn is_empty(&self) -> bool {
493        self.held.is_empty()
494    }
495
496    /// The per-budget consumed ratios observed at reserve time (for `edgeguard_llm_budget_consumed_ratio`).
497    pub fn observations(&self) -> &[Observation] {
498        &self.observations
499    }
500
501    /// Rollback settle failures to feed into `edgeguard_llm_budget_reconcile_failures_total`,
502    /// like the return value of [`BudgetEngine::reconcile`]/[`BudgetEngine::release`].
503    pub fn rollback_failures(&self) -> usize {
504        self.rollback_failures
505    }
506}
507
508/// The outcome of a reserve attempt.
509#[derive(Debug)]
510pub enum Reserved {
511    /// Admitted under every budget; hold the [`Reservation`] to reconcile later.
512    Ok(Reservation),
513    /// Denied by a budget — the request must be rejected (`429`, or `402` for a cost cap). Any budgets
514    /// reserved before the denial have already been rolled back.
515    Denied(Denial),
516    /// A store error and `fail_open` is off — reject `503` (fail-closed). With `fail_open` set this
517    /// is never returned (the engine admits instead). Carries any rollback settle failures from
518    /// budgets already held before the error, for `edgeguard_llm_budget_reconcile_failures_total`.
519    Error { rollback_failures: usize },
520}
521
522/// Enforces the configured LLM budgets over a [`Store`]. Built once per config (re)load and carried
523/// on the proxy [`Runtime`](crate::proxy::Runtime).
524pub struct BudgetEngine {
525    budgets: Vec<Budget>,
526    store: Store,
527    prefix: String,
528    fail_open: bool,
529}
530
531impl BudgetEngine {
532    /// Build from `[llm]` config when budgets are present. Returns `Ok(None)` when no budgets are
533    /// configured (the engine is then absent and the proxy skips enforcement entirely).
534    pub fn build(cfg: &LlmCfg) -> Result<Option<BudgetEngine>> {
535        if cfg.budgets.is_empty() {
536            return Ok(None);
537        }
538        let store = match crate::limiter::StoreMode::parse(&cfg.store)? {
539            // `local` is meaningless for a shared budget (it would be per-replica); treat the
540            // single-process default as the in-memory shared store.
541            crate::limiter::StoreMode::Local | crate::limiter::StoreMode::Memory => {
542                Store::Memory(MemoryStore::default())
543            }
544            crate::limiter::StoreMode::Redis => {
545                Store::Redis(Box::new(RedisStore::new(&cfg.redis_url)?))
546            }
547        };
548        let budgets = cfg
549            .budgets
550            .iter()
551            .map(Budget::build)
552            .collect::<Result<Vec<_>>>()?;
553        Ok(Some(BudgetEngine {
554            budgets,
555            store,
556            prefix: if cfg.redis_prefix.trim().is_empty() {
557                "edgeguard".to_string()
558            } else {
559                cfg.redis_prefix.clone()
560            },
561            fail_open: cfg.fail_open,
562        }))
563    }
564
565    /// Reserve `estimate` against every budget that applies to `dims`. Reserves in order; on the first
566    /// denial, releases everything already reserved and returns [`Reserved::Denied`], so a partial
567    /// reservation never leaks. Budgets that don't match the scope are skipped. The returned
568    /// [`Reservation`] carries each admitted budget's post-reserve consumed ratio for the near-limit gauge.
569    pub async fn reserve(&self, dims: Dims<'_>, estimate: Spend) -> Reserved {
570        let now = now_secs();
571        let mut held = Vec::new();
572        let mut observations = Vec::new();
573        for (idx, budget) in self.budgets.iter().enumerate() {
574            let amount = budget.amount(estimate.tokens, estimate.cost_micros);
575            // A zero-amount charge (e.g. a cost budget on an unpriced model) can't exceed anything;
576            // skip it so it neither denies nor needs reconciling.
577            if amount == 0 {
578                continue;
579            }
580            let key = budget.key(&self.prefix, &dims, now);
581            match self
582                .store
583                .reserve(&key, amount, budget.limit, budget.ttl_ms())
584                .await
585            {
586                Ok(outcome) if outcome.admitted => {
587                    held.push((key, amount, idx));
588                    observations.push(Observation {
589                        name: budget.name.clone(),
590                        consumed_ratio: ratio(outcome.used_after, budget.limit),
591                    });
592                }
593                Ok(_) => {
594                    let rollback_failures = self.rollback(&held).await;
595                    return Reserved::Denied(Denial {
596                        name: budget.name.clone(),
597                        scope: budget.scope,
598                        unit: budget.unit,
599                        rollback_failures,
600                    });
601                }
602                Err(e) => {
603                    let rollback_failures = self.rollback(&held).await;
604                    if self.fail_open {
605                        warn!(error = %format!("{e:#}"), budget = %budget.name, "llm budget store error; failing open (allowing request)");
606                        return Reserved::Ok(Reservation {
607                            rollback_failures,
608                            ..Reservation::default()
609                        });
610                    }
611                    warn!(error = %format!("{e:#}"), budget = %budget.name, "llm budget store error; failing closed (503)");
612                    return Reserved::Error { rollback_failures };
613                }
614            }
615        }
616        Reserved::Ok(Reservation {
617            id: uuid::Uuid::new_v4().to_string(),
618            held,
619            observations,
620            rollback_failures: 0,
621        })
622    }
623
624    /// Reconcile a held reservation to the `actual` spend: for each budget, apply `actual − reserved`
625    /// in that budget's unit (releasing the over-estimate, or charging a low one). Idempotent per
626    /// reservation (the settle marker dedupes a retry). Returns the number of budgets whose settle
627    /// **failed** against the store — non-zero means the distributed counter has drifted, which the
628    /// caller records to `edgeguard_llm_budget_reconcile_failures_total`.
629    pub async fn reconcile(&self, reservation: &Reservation, actual: Spend) -> usize {
630        let mut failures = 0usize;
631        for (key, reserved, idx) in &reservation.held {
632            let budget = &self.budgets[*idx];
633            let actual_amount = budget.amount(actual.tokens, actual.cost_micros);
634            let delta = actual_amount as i64 - *reserved as i64;
635            if delta != 0 {
636                // Marker = counter key + reservation id, so THIS reservation's settle applies once.
637                let marker = format!("{key}:s:{}", reservation.id);
638                if !self
639                    .store
640                    .reconcile(key, delta, budget.ttl_ms(), &marker)
641                    .await
642                {
643                    failures += 1;
644                }
645            }
646        }
647        failures
648    }
649
650    /// Release a reservation in full (actual spend was zero — upstream error / no usage produced).
651    /// Returns the count of failed settles (drift), like [`Self::reconcile`].
652    pub async fn release(&self, reservation: &Reservation) -> usize {
653        self.reconcile(reservation, Spend::default()).await
654    }
655
656    /// Roll back the amounts reserved so far (used when a later budget denies / errors). Each rollback
657    /// gets a unique marker (it runs once), so a retry of that rollback still dedupes. Returns the
658    /// count of failed settles (drift), like [`Self::reconcile`]/[`Self::release`] — a failed
659    /// rollback settle leaks that budget's hold exactly like a failed reconcile does.
660    async fn rollback(&self, held: &[(String, u64, usize)]) -> usize {
661        let mut failures = 0usize;
662        for (key, amount, idx) in held {
663            let budget = &self.budgets[*idx];
664            let marker = format!("{key}:rb:{}", uuid::Uuid::new_v4());
665            if !self
666                .store
667                .reconcile(key, -(*amount as i64), budget.ttl_ms(), &marker)
668                .await
669            {
670                failures += 1;
671            }
672        }
673        failures
674    }
675}
676
677/// `used / limit` as a ratio in `[0.0, ∞)` (0.0 when the limit is 0, which `Budget::build` already
678/// rejects, so this is just belt-and-suspenders against a divide-by-zero).
679fn ratio(used: u64, limit: u64) -> f64 {
680    if limit == 0 {
681        return 0.0;
682    }
683    used as f64 / limit as f64
684}
685
686/// Current wall-clock time in whole seconds since the Unix epoch (the budget window's basis).
687fn now_secs() -> u64 {
688    SystemTime::now()
689        .duration_since(UNIX_EPOCH)
690        .map(|d| d.as_secs())
691        .unwrap_or(0)
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use crate::config::BudgetCfg;
698
699    fn token_budget(limit: f64, window: &str) -> BudgetCfg {
700        BudgetCfg {
701            name: "test".into(),
702            scope: "key".into(),
703            unit: "tokens".into(),
704            limit,
705            window: window.into(),
706        }
707    }
708
709    fn engine(budgets: Vec<BudgetCfg>) -> BudgetEngine {
710        BudgetEngine::build(&LlmCfg {
711            enabled: true,
712            budgets,
713            store: "memory".into(),
714            ..Default::default()
715        })
716        .unwrap()
717        .expect("budgets configured")
718    }
719
720    /// Test shorthand for the request dimensions (no team).
721    fn dims<'a>(principal: Option<&'a str>, model: &'a str) -> Dims<'a> {
722        Dims {
723            principal,
724            model,
725            team: None,
726        }
727    }
728
729    #[test]
730    fn would_reserve_caps_at_limit() {
731        assert!(would_reserve(0, 100, 100));
732        assert!(would_reserve(90, 10, 100));
733        assert!(!would_reserve(90, 11, 100));
734        // Saturating add: a huge amount never wraps to admit.
735        assert!(!would_reserve(u64::MAX, 1, 100));
736    }
737
738    #[test]
739    fn unit_and_scope_parse() {
740        assert_eq!(BudgetUnit::parse("tokens").unwrap(), BudgetUnit::Tokens);
741        assert_eq!(BudgetUnit::parse("USD").unwrap(), BudgetUnit::UsdMicros);
742        assert!(BudgetUnit::parse("bananas").is_err());
743        assert_eq!(BudgetScope::parse("global").unwrap(), BudgetScope::Global);
744        assert_eq!(BudgetScope::parse("per-key").unwrap(), BudgetScope::PerKey);
745        assert!(BudgetScope::parse("galaxy").is_err());
746    }
747
748    #[test]
749    fn build_is_none_without_budgets() {
750        let none = BudgetEngine::build(&LlmCfg::default()).unwrap();
751        assert!(none.is_none());
752    }
753
754    #[test]
755    fn limit_rounding_to_zero_is_rejected() {
756        // 0.3 tokens > 0.0 but rounds to 0 — a nonsensical budget; catch it at startup.
757        assert!(Budget::build(&BudgetCfg {
758            name: "tiny".into(),
759            scope: "global".into(),
760            unit: "tokens".into(),
761            limit: 0.3,
762            window: "1h".into(),
763        })
764        .is_err());
765    }
766
767    #[test]
768    fn usd_budget_compiles_to_micros() {
769        let b = Budget::build(&BudgetCfg {
770            name: "spend".into(),
771            scope: "global".into(),
772            unit: "usd".into(),
773            limit: 2.50,
774            window: "24h".into(),
775        })
776        .unwrap();
777        assert_eq!(b.limit, 2_500_000); // $2.50 -> micro-dollars
778        assert_eq!(b.unit, BudgetUnit::UsdMicros);
779    }
780
781    #[tokio::test]
782    async fn retry_async_succeeds_after_transient_failures() {
783        use std::sync::atomic::{AtomicU32, Ordering};
784        let calls = AtomicU32::new(0);
785        let r: Result<u32> = retry_async(3, Duration::from_millis(0), || {
786            let n = calls.fetch_add(1, Ordering::SeqCst);
787            async move {
788                if n < 2 {
789                    anyhow::bail!("transient blip")
790                } else {
791                    Ok(n)
792                }
793            }
794        })
795        .await;
796        assert_eq!(r.unwrap(), 2);
797        assert_eq!(calls.load(Ordering::SeqCst), 3);
798    }
799
800    #[tokio::test]
801    async fn retry_async_gives_up_after_exhausting_attempts() {
802        use std::sync::atomic::{AtomicU32, Ordering};
803        let calls = AtomicU32::new(0);
804        let r: Result<()> = retry_async(2, Duration::from_millis(0), || {
805            calls.fetch_add(1, Ordering::SeqCst);
806            async move { anyhow::bail!("always fails") }
807        })
808        .await;
809        assert!(r.is_err());
810        assert_eq!(calls.load(Ordering::SeqCst), 2);
811    }
812
813    #[test]
814    fn memory_reconcile_is_idempotent_per_marker() {
815        // The core of the distributed-reconcile hardening: a settle applies at most once per marker,
816        // so a retry (or a double call) can't double-apply the delta and drift the counter.
817        let store = MemoryStore::default();
818        assert!(store.reserve("k", 100, 1000, 60_000).admitted); // used = 100
819        store.reconcile("k", -40, 60_000, "settle-1"); // used = 60
820        store.reconcile("k", -40, 60_000, "settle-1"); // same marker → no-op (NOT 20)
821        assert_eq!(store.reserve("k", 0, 1000, 60_000).used_after, 60);
822        // A different settle (different marker) applies.
823        store.reconcile("k", -10, 60_000, "settle-2");
824        assert_eq!(store.reserve("k", 0, 1000, 60_000).used_after, 50);
825    }
826
827    #[tokio::test]
828    async fn reserve_admits_until_limit_then_denies() {
829        let eng = engine(vec![token_budget(100.0, "1h")]);
830        let est = Spend {
831            tokens: 60,
832            cost_micros: 0,
833        };
834        // 60 + 60 = 120 > 100, so the second reserve is denied.
835        assert!(matches!(
836            eng.reserve(dims(Some("alice"), "gpt-4o"), est).await,
837            Reserved::Ok(_)
838        ));
839        assert!(matches!(
840            eng.reserve(dims(Some("alice"), "gpt-4o"), est).await,
841            Reserved::Denied(d) if d.name == "test"
842        ));
843        // A different principal has its own budget.
844        assert!(matches!(
845            eng.reserve(dims(Some("bob"), "gpt-4o"), est).await,
846            Reserved::Ok(_)
847        ));
848    }
849
850    #[tokio::test]
851    async fn reconcile_releases_overestimate() {
852        let eng = engine(vec![token_budget(100.0, "1h")]);
853        // Reserve 80 (estimate), then reconcile to an actual of 30 → 50 released.
854        let reserved = match eng
855            .reserve(
856                dims(Some("c"), "m"),
857                Spend {
858                    tokens: 80,
859                    cost_micros: 0,
860                },
861            )
862            .await
863        {
864            Reserved::Ok(r) => r,
865            other => panic!("expected Ok, got {other:?}"),
866        };
867        eng.reconcile(
868            &reserved,
869            Spend {
870                tokens: 30,
871                cost_micros: 0,
872            },
873        )
874        .await;
875        // Used is now 30; a new 70 fits (30 + 70 = 100), but 71 would not.
876        assert!(matches!(
877            eng.reserve(
878                dims(Some("c"), "m"),
879                Spend {
880                    tokens: 70,
881                    cost_micros: 0
882                }
883            )
884            .await,
885            Reserved::Ok(_)
886        ));
887    }
888
889    #[tokio::test]
890    async fn release_returns_full_reservation() {
891        let eng = engine(vec![token_budget(100.0, "1h")]);
892        let reserved = match eng
893            .reserve(
894                dims(Some("d"), "m"),
895                Spend {
896                    tokens: 100,
897                    cost_micros: 0,
898                },
899            )
900            .await
901        {
902            Reserved::Ok(r) => r,
903            other => panic!("expected Ok, got {other:?}"),
904        };
905        // Full budget reserved → next is denied…
906        assert!(matches!(
907            eng.reserve(
908                dims(Some("d"), "m"),
909                Spend {
910                    tokens: 1,
911                    cost_micros: 0
912                }
913            )
914            .await,
915            Reserved::Denied(_)
916        ));
917        // …but after releasing the whole hold (upstream errored), the budget is free again.
918        eng.release(&reserved).await;
919        assert!(matches!(
920            eng.reserve(
921                dims(Some("d"), "m"),
922                Spend {
923                    tokens: 100,
924                    cost_micros: 0
925                }
926            )
927            .await,
928            Reserved::Ok(_)
929        ));
930    }
931
932    #[tokio::test]
933    async fn multi_budget_denial_rolls_back_prior_reserve() {
934        // Two budgets: a generous token budget and a tight cost budget. A request that fits the
935        // first but not the second must leave the first budget unconsumed.
936        let eng = engine(vec![
937            BudgetCfg {
938                name: "tok".into(),
939                scope: "global".into(),
940                unit: "tokens".into(),
941                limit: 1000.0,
942                window: "1h".into(),
943            },
944            BudgetCfg {
945                name: "cost".into(),
946                scope: "global".into(),
947                unit: "usd".into(),
948                limit: 0.000010, // 10 micro-dollars
949                window: "1h".into(),
950            },
951        ]);
952        // tokens=100 fits "tok"; cost=20 micro exceeds "cost" (10) → denied by "cost", "tok" rolled back.
953        assert!(matches!(
954            eng.reserve(dims(None, "m"), Spend { tokens: 100, cost_micros: 20 }).await,
955            Reserved::Denied(d) if d.name == "cost" && d.unit == BudgetUnit::UsdMicros
956        ));
957        // "tok" must be untouched: a 1000-token request still fits.
958        assert!(matches!(
959            eng.reserve(
960                dims(None, "m"),
961                Spend {
962                    tokens: 1000,
963                    cost_micros: 0
964                }
965            )
966            .await,
967            Reserved::Ok(_)
968        ));
969    }
970
971    #[tokio::test]
972    async fn per_team_scope_is_keyed_by_team() {
973        let eng = engine(vec![BudgetCfg {
974            name: "team-cap".into(),
975            scope: "team".into(),
976            unit: "tokens".into(),
977            limit: 100.0,
978            window: "1h".into(),
979        }]);
980        let est = Spend {
981            tokens: 60,
982            cost_micros: 0,
983        };
984        let team_a = Dims {
985            principal: Some("alice"),
986            model: "gpt-4o",
987            team: Some("team-a"),
988        };
989        // team-a fills to 60, then 120 > 100 denies — even though the principal differs, the team
990        // key is shared.
991        assert!(matches!(eng.reserve(team_a, est).await, Reserved::Ok(_)));
992        let team_a_bob = Dims {
993            principal: Some("bob"),
994            model: "gpt-4o",
995            team: Some("team-a"),
996        };
997        assert!(matches!(
998            eng.reserve(team_a_bob, est).await,
999            Reserved::Denied(d) if d.scope == BudgetScope::PerTeam
1000        ));
1001        // A different team has its own budget.
1002        let team_b = Dims {
1003            principal: Some("alice"),
1004            model: "gpt-4o",
1005            team: Some("team-b"),
1006        };
1007        assert!(matches!(eng.reserve(team_b, est).await, Reserved::Ok(_)));
1008    }
1009
1010    #[tokio::test]
1011    async fn reserve_reports_consumed_ratio() {
1012        let eng = engine(vec![token_budget(100.0, "1h")]);
1013        let r = match eng
1014            .reserve(
1015                dims(Some("alice"), "m"),
1016                Spend {
1017                    tokens: 75,
1018                    cost_micros: 0,
1019                },
1020            )
1021            .await
1022        {
1023            Reserved::Ok(r) => r,
1024            other => panic!("expected Ok, got {other:?}"),
1025        };
1026        let obs = r.observations();
1027        assert_eq!(obs.len(), 1);
1028        assert_eq!(obs[0].name, "test");
1029        assert!(
1030            (obs[0].consumed_ratio - 0.75).abs() < 1e-9,
1031            "{}",
1032            obs[0].consumed_ratio
1033        );
1034    }
1035
1036    // ---- Live-Redis proof (mirrors the limiter's #[ignore]d tests) ----------------------------
1037    //
1038    //   docker run --rm -p 6379:6379 redis:7-alpine
1039    //   cargo test -p eggrd --lib budget::tests::redis_ -- --ignored
1040
1041    fn redis_url() -> String {
1042        std::env::var("EDGEGUARD_TEST_REDIS_URL")
1043            .unwrap_or_else(|_| "redis://127.0.0.1:6379".into())
1044    }
1045
1046    #[tokio::test]
1047    #[ignore = "requires a live Redis (EDGEGUARD_TEST_REDIS_URL, default redis://127.0.0.1:6379)"]
1048    async fn redis_budget_reserve_and_reconcile_live() {
1049        let eng = BudgetEngine::build(&LlmCfg {
1050            enabled: true,
1051            store: "redis".into(),
1052            redis_url: redis_url(),
1053            redis_prefix: format!("egtest:budget:{}:{}", std::process::id(), now_secs()),
1054            budgets: vec![token_budget(100.0, "1h")],
1055            ..Default::default()
1056        })
1057        .unwrap()
1058        .expect("budgets configured");
1059
1060        let est = Spend {
1061            tokens: 60,
1062            cost_micros: 0,
1063        };
1064        // First reserve proves reachability; a store error (Redis down) fails closed → skip.
1065        let reserved = match eng.reserve(dims(Some("alice"), "m"), est).await {
1066            Reserved::Ok(r) => r,
1067            Reserved::Error { .. } => {
1068                eprintln!("skipping redis_budget_reserve_and_reconcile_live: Redis unreachable");
1069                return;
1070            }
1071            other => panic!("unexpected first reserve: {other:?}"),
1072        };
1073        // 60 + 60 > 100 → second denied.
1074        assert!(matches!(
1075            eng.reserve(dims(Some("alice"), "m"), est).await,
1076            Reserved::Denied(_)
1077        ));
1078        // Reconcile the first down to 10 actual → frees room for another 60.
1079        eng.reconcile(
1080            &reserved,
1081            Spend {
1082                tokens: 10,
1083                cost_micros: 0,
1084            },
1085        )
1086        .await;
1087        assert!(matches!(
1088            eng.reserve(dims(Some("alice"), "m"), est).await,
1089            Reserved::Ok(_)
1090        ));
1091    }
1092}