1use std::collections::HashMap;
33use std::sync::{Mutex, OnceLock, RwLock};
34use std::time::{Duration, Instant};
35
36use axum::http::StatusCode;
37use axum::response::{IntoResponse, Response};
38
39use crate::core::policy::{BudgetRules, RoutingPolicyRules};
40
41const SNAPSHOT_TTL: Duration = Duration::from_mins(1);
45
46#[derive(Debug, Clone, Default, PartialEq)]
48pub struct GateRules {
49 pub allowed_models: Vec<String>,
50 pub forbid_downgrade_for: Vec<String>,
51 pub max_cost_usd_per_person_per_day: Option<f64>,
52 pub max_cost_usd_per_project_per_month: Option<f64>,
53 pub max_requests_per_minute_per_person: Option<u32>,
54}
55
56impl GateRules {
57 fn from_policy(routing: &RoutingPolicyRules, budgets: &BudgetRules) -> Option<Self> {
58 if routing.is_empty() && budgets.is_empty() {
59 return None;
60 }
61 Some(Self {
62 allowed_models: routing.allowed_models.clone(),
63 forbid_downgrade_for: routing.forbid_downgrade_for.clone(),
64 max_cost_usd_per_person_per_day: budgets.max_cost_usd_per_person_per_day,
65 max_cost_usd_per_project_per_month: budgets.max_cost_usd_per_project_per_month,
66 max_requests_per_minute_per_person: budgets.max_requests_per_minute_per_person,
67 })
68 }
69}
70
71struct CachedSnapshot {
72 rules: Option<GateRules>,
73 loaded_at: Instant,
74}
75
76static SNAPSHOT: RwLock<Option<CachedSnapshot>> = RwLock::new(None);
77
78#[cfg(test)]
81#[derive(Clone, Default)]
82enum TestOverride {
83 #[default]
84 Unset,
85 Pinned(Option<GateRules>),
86}
87
88#[cfg(test)]
89static TEST_OVERRIDE: Mutex<TestOverride> = Mutex::new(TestOverride::Unset);
90
91#[must_use]
94pub fn active_rules() -> Option<GateRules> {
95 #[cfg(test)]
96 if let TestOverride::Pinned(pinned) = TEST_OVERRIDE
97 .lock()
98 .unwrap_or_else(std::sync::PoisonError::into_inner)
99 .clone()
100 {
101 return pinned;
102 }
103 {
104 let guard = SNAPSHOT
105 .read()
106 .unwrap_or_else(std::sync::PoisonError::into_inner);
107 if let Some(cached) = guard.as_ref()
108 && cached.loaded_at.elapsed() < SNAPSHOT_TTL
109 {
110 return cached.rules.clone();
111 }
112 }
113 let rules = crate::core::policy::org::active_resolved()
114 .and_then(|p| GateRules::from_policy(&p.routing, &p.budgets));
115 let mut guard = SNAPSHOT
116 .write()
117 .unwrap_or_else(std::sync::PoisonError::into_inner);
118 *guard = Some(CachedSnapshot {
119 rules: rules.clone(),
120 loaded_at: Instant::now(),
121 });
122 rules
123}
124
125#[cfg(test)]
127pub fn test_set_rules(rules: Option<GateRules>) {
128 *TEST_OVERRIDE
129 .lock()
130 .unwrap_or_else(std::sync::PoisonError::into_inner) = TestOverride::Pinned(rules);
131}
132
133#[cfg(test)]
134pub fn test_clear_rules() {
135 *TEST_OVERRIDE
136 .lock()
137 .unwrap_or_else(std::sync::PoisonError::into_inner) = TestOverride::Unset;
138}
139
140fn pattern_matches(pattern: &str, model: &str) -> bool {
145 fn rec(p: &[u8], m: &[u8]) -> bool {
146 match p.first() {
147 None => m.is_empty(),
148 Some(b'*') => {
149 (0..=m.len()).any(|k| rec(&p[1..], &m[k..]))
152 }
153 Some(&c) => m.first() == Some(&c) && rec(&p[1..], &m[1..]),
154 }
155 }
156 rec(pattern.trim().as_bytes(), model.trim().as_bytes())
157}
158
159#[must_use]
162pub fn model_allowed(rules: &GateRules, model: &str) -> bool {
163 rules.allowed_models.is_empty()
164 || rules
165 .allowed_models
166 .iter()
167 .any(|p| pattern_matches(p, model))
168}
169
170#[must_use]
172pub fn downgrade_forbidden(rules: &GateRules, project: Option<&str>) -> bool {
173 project.is_some_and(|p| rules.forbid_downgrade_for.iter().any(|f| f == p))
174}
175
176fn window_keys_at(now: chrono::DateTime<chrono::Utc>) -> (u32, u32) {
180 use chrono::Datelike;
181 let day = now.year() as u32 * 10_000 + now.month() * 100 + now.day();
182 let month = now.year() as u32 * 100 + now.month();
183 (day, month)
184}
185
186fn window_keys() -> (u32, u32) {
187 window_keys_at(chrono::Utc::now())
188}
189
190#[derive(Default)]
197struct BudgetLedger {
198 day_key: u32,
199 month_key: u32,
200 baseline_person_day: HashMap<String, f64>,
201 live_person_day: HashMap<String, f64>,
202 baseline_project_month: HashMap<String, f64>,
203 live_project_month: HashMap<String, f64>,
204}
205
206impl BudgetLedger {
207 fn roll(&mut self, day: u32, month: u32) {
209 if self.day_key != day {
210 self.day_key = day;
211 self.baseline_person_day.clear();
212 self.live_person_day.clear();
213 }
214 if self.month_key != month {
215 self.month_key = month;
216 self.baseline_project_month.clear();
217 self.live_project_month.clear();
218 }
219 }
220
221 fn add(&mut self, person: Option<&str>, project: Option<&str>, cost_usd: f64) {
222 let (day, month) = window_keys();
223 self.roll(day, month);
224 if let Some(p) = person {
225 *self.live_person_day.entry(p.to_string()).or_default() += cost_usd;
226 }
227 if let Some(p) = project {
228 *self.live_project_month.entry(p.to_string()).or_default() += cost_usd;
229 }
230 }
231
232 fn person_day_spend(&self, person: &str) -> f64 {
233 self.baseline_person_day.get(person).copied().unwrap_or(0.0)
234 + self.live_person_day.get(person).copied().unwrap_or(0.0)
235 }
236
237 fn project_month_spend(&self, project: &str) -> f64 {
238 self.baseline_project_month
239 .get(project)
240 .copied()
241 .unwrap_or(0.0)
242 + self.live_project_month.get(project).copied().unwrap_or(0.0)
243 }
244}
245
246fn ledger() -> &'static Mutex<BudgetLedger> {
247 static LEDGER: OnceLock<Mutex<BudgetLedger>> = OnceLock::new();
248 LEDGER.get_or_init(|| Mutex::new(BudgetLedger::default()))
249}
250
251#[derive(Default)]
258struct RateLedger {
259 minute_key: u64,
260 accepted: HashMap<String, u32>,
261}
262
263impl RateLedger {
264 fn try_accept(&mut self, person: &str, limit: u32, minute: u64) -> bool {
267 if self.minute_key != minute {
268 self.minute_key = minute;
269 self.accepted.clear();
270 }
271 let count = self.accepted.entry(person.to_string()).or_default();
272 if *count >= limit {
273 return false;
274 }
275 *count += 1;
276 true
277 }
278}
279
280fn rate_ledger() -> &'static Mutex<RateLedger> {
281 static RATE: OnceLock<Mutex<RateLedger>> = OnceLock::new();
282 RATE.get_or_init(|| Mutex::new(RateLedger::default()))
283}
284
285fn minute_now() -> (u64, u64) {
287 let secs = std::time::SystemTime::now()
288 .duration_since(std::time::UNIX_EPOCH)
289 .map_or(0, |d| d.as_secs());
290 (secs / 60, 60 - (secs % 60))
291}
292
293pub fn record_spend(person: Option<&str>, project: Option<&str>, cost_usd: f64) {
296 if cost_usd <= 0.0 || (person.is_none() && project.is_none()) {
297 return;
298 }
299 ledger()
300 .lock()
301 .unwrap_or_else(std::sync::PoisonError::into_inner)
302 .add(person, project, cost_usd);
303}
304
305pub fn seed_from_store(person_day: HashMap<String, f64>, project_month: HashMap<String, f64>) {
309 let (day, month) = window_keys();
310 let mut guard = ledger()
311 .lock()
312 .unwrap_or_else(std::sync::PoisonError::into_inner);
313 guard.roll(day, month);
314 guard.baseline_person_day = person_day;
315 guard.live_person_day.clear();
316 guard.baseline_project_month = project_month;
317 guard.live_project_month.clear();
318}
319
320#[cfg(test)]
321fn test_reset_ledger() {
322 let mut guard = ledger()
323 .lock()
324 .unwrap_or_else(std::sync::PoisonError::into_inner);
325 *guard = BudgetLedger::default();
326}
327
328#[derive(Debug, Clone, PartialEq)]
332pub enum Refusal {
333 ModelNotAllowed {
334 model: String,
335 },
336 PersonBudgetExceeded {
337 person: String,
338 cap_usd: f64,
339 spent_usd: f64,
340 },
341 ProjectBudgetExceeded {
342 project: String,
343 cap_usd: f64,
344 spent_usd: f64,
345 },
346 PersonRateLimited {
347 person: String,
348 limit_rpm: u32,
349 retry_after_secs: u64,
350 },
351}
352
353static BLOCKED_MODEL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
355static BLOCKED_BUDGET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
356static BLOCKED_RATE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
357
358#[must_use]
360pub fn blocked_counters() -> (u64, u64, u64) {
361 (
362 BLOCKED_MODEL.load(std::sync::atomic::Ordering::Relaxed),
363 BLOCKED_BUDGET.load(std::sync::atomic::Ordering::Relaxed),
364 BLOCKED_RATE.load(std::sync::atomic::Ordering::Relaxed),
365 )
366}
367
368pub fn enforce(
372 rules: &GateRules,
373 requested_model: Option<&str>,
374 tags: &super::gateway_identity::GatewayTags,
375) -> Result<(), Refusal> {
376 if let Some(model) = requested_model
377 && !model_allowed(rules, model)
378 {
379 BLOCKED_MODEL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
380 return Err(Refusal::ModelNotAllowed {
381 model: model.to_string(),
382 });
383 }
384
385 {
386 let guard = ledger()
387 .lock()
388 .unwrap_or_else(std::sync::PoisonError::into_inner);
389 if let (Some(cap), Some(person)) = (
390 rules.max_cost_usd_per_person_per_day,
391 tags.person.as_deref(),
392 ) {
393 let spent = guard.person_day_spend(person);
394 if spent >= cap {
395 BLOCKED_BUDGET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
396 return Err(Refusal::PersonBudgetExceeded {
397 person: person.to_string(),
398 cap_usd: cap,
399 spent_usd: spent,
400 });
401 }
402 }
403 if let (Some(cap), Some(project)) = (
404 rules.max_cost_usd_per_project_per_month,
405 tags.project.as_deref(),
406 ) {
407 let spent = guard.project_month_spend(project);
408 if spent >= cap {
409 BLOCKED_BUDGET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
410 return Err(Refusal::ProjectBudgetExceeded {
411 project: project.to_string(),
412 cap_usd: cap,
413 spent_usd: spent,
414 });
415 }
416 }
417 }
418
419 if let (Some(limit), Some(person)) = (
422 rules.max_requests_per_minute_per_person,
423 tags.person.as_deref(),
424 ) {
425 let (minute, secs_left) = minute_now();
426 let accepted = rate_ledger()
427 .lock()
428 .unwrap_or_else(std::sync::PoisonError::into_inner)
429 .try_accept(person, limit, minute);
430 if !accepted {
431 BLOCKED_RATE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
432 return Err(Refusal::PersonRateLimited {
433 person: person.to_string(),
434 limit_rpm: limit,
435 retry_after_secs: secs_left,
436 });
437 }
438 }
439 Ok(())
440}
441
442#[must_use]
445pub fn refusal_response(refusal: &Refusal, provider_label: &str) -> Response {
446 let openai_shape = matches!(provider_label, "OpenAI" | "ChatGPT");
447 let (status, code, message) = match refusal {
448 Refusal::ModelNotAllowed { model } => (
449 StatusCode::FORBIDDEN,
450 "org_policy_model_blocked",
451 format!(
452 "model '{model}' is not allowed by your organization's AI gateway policy — \
453 choose an approved model or contact your gateway admin"
454 ),
455 ),
456 Refusal::PersonBudgetExceeded {
457 person,
458 cap_usd,
459 spent_usd,
460 } => (
461 StatusCode::TOO_MANY_REQUESTS,
462 "org_budget_exceeded",
463 format!(
464 "daily AI budget exhausted for '{person}' \
465 (${spent_usd:.2} of ${cap_usd:.2} spent) — resets at midnight UTC"
466 ),
467 ),
468 Refusal::ProjectBudgetExceeded {
469 project,
470 cap_usd,
471 spent_usd,
472 } => (
473 StatusCode::TOO_MANY_REQUESTS,
474 "org_budget_exceeded",
475 format!(
476 "monthly AI budget exhausted for project '{project}' \
477 (${spent_usd:.2} of ${cap_usd:.2} spent) — resets on the 1st (UTC)"
478 ),
479 ),
480 Refusal::PersonRateLimited {
481 person, limit_rpm, ..
482 } => (
483 StatusCode::TOO_MANY_REQUESTS,
484 "org_policy_rate_limited",
485 format!(
486 "request rate limit reached for '{person}' \
487 ({limit_rpm} requests/minute by org policy) — retry shortly"
488 ),
489 ),
490 };
491
492 let body = if openai_shape {
493 serde_json::json!({
494 "error": {
495 "message": message,
496 "type": if status == StatusCode::FORBIDDEN {
497 "invalid_request_error"
498 } else {
499 "insufficient_quota"
500 },
501 "code": code,
502 }
503 })
504 } else {
505 serde_json::json!({
508 "type": "error",
509 "error": {
510 "type": if status == StatusCode::FORBIDDEN {
511 "permission_error"
512 } else {
513 "rate_limit_error"
514 },
515 "message": message,
516 }
517 })
518 };
519
520 let mut response = (status, axum::Json(body)).into_response();
521 if status == StatusCode::TOO_MANY_REQUESTS {
522 let retry_after = match refusal {
526 Refusal::PersonRateLimited {
527 retry_after_secs, ..
528 } => (*retry_after_secs).clamp(1, 60).to_string(),
529 _ => "3600".to_string(),
530 };
531 if let Ok(value) = retry_after.parse() {
532 response
533 .headers_mut()
534 .insert(axum::http::header::RETRY_AFTER, value);
535 }
536 }
537 response
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::proxy::gateway_identity::GatewayTags;
544
545 fn tags(person: &str, project: &str) -> GatewayTags {
546 GatewayTags {
547 person: Some(person.to_string()),
548 team: None,
549 project: Some(project.to_string()),
550 }
551 }
552
553 fn rules() -> GateRules {
554 GateRules {
555 allowed_models: vec!["claude-*".into(), "gpt-4o-mini".into()],
556 forbid_downgrade_for: vec!["prod".into()],
557 max_cost_usd_per_person_per_day: Some(50.0),
558 max_cost_usd_per_project_per_month: Some(1000.0),
559 max_requests_per_minute_per_person: None,
560 }
561 }
562
563 fn test_reset_rate_ledger() {
564 let mut guard = rate_ledger()
565 .lock()
566 .unwrap_or_else(std::sync::PoisonError::into_inner);
567 *guard = RateLedger::default();
568 }
569
570 #[test]
571 fn glob_lite_matches_prefix_and_exact() {
572 assert!(pattern_matches("claude-*", "claude-sonnet-4-5"));
573 assert!(pattern_matches("gpt-4o-mini", "gpt-4o-mini"));
574 assert!(pattern_matches("*", "anything"));
575 assert!(!pattern_matches("claude-*", "gpt-5.2"));
576 assert!(!pattern_matches("gpt-4o-mini", "gpt-4o"));
577 assert!(pattern_matches("*sonnet*", "claude-sonnet-4-5"));
578 }
579
580 #[test]
581 fn empty_allowlist_means_no_restriction() {
582 let r = GateRules::default();
583 assert!(model_allowed(&r, "any-model"));
584 }
585
586 #[test]
587 #[serial_test::serial(policy_gate_ledger)]
588 fn model_ceiling_blocks_unlisted_model() {
589 test_reset_ledger();
590 let err = enforce(&rules(), Some("o3-pro"), &tags("a", "p")).unwrap_err();
591 assert_eq!(
592 err,
593 Refusal::ModelNotAllowed {
594 model: "o3-pro".into()
595 }
596 );
597 assert!(enforce(&rules(), Some("claude-haiku-4-5"), &tags("a", "p")).is_ok());
599 }
600
601 #[test]
602 #[serial_test::serial(policy_gate_ledger)]
603 fn person_day_budget_blocks_after_cap() {
604 test_reset_ledger();
605 record_spend(Some("mara"), Some("web"), 49.0);
606 assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_ok());
607 record_spend(Some("mara"), Some("web"), 2.0);
608 let err = enforce(&rules(), Some("claude-x"), &tags("mara", "web")).unwrap_err();
609 match err {
610 Refusal::PersonBudgetExceeded {
611 person,
612 cap_usd,
613 spent_usd,
614 } => {
615 assert_eq!(person, "mara");
616 assert!((cap_usd - 50.0).abs() < f64::EPSILON);
617 assert!(spent_usd >= 51.0 - 1e-9);
618 }
619 other => panic!("expected person budget refusal, got {other:?}"),
620 }
621 test_reset_ledger();
622 }
623
624 #[test]
625 #[serial_test::serial(policy_gate_ledger)]
626 fn project_month_budget_blocks_after_cap() {
627 test_reset_ledger();
628 record_spend(Some("a"), Some("ml-pipeline"), 600.0);
629 record_spend(Some("b"), Some("ml-pipeline"), 500.0);
630 let err = enforce(&rules(), Some("claude-x"), &tags("c", "ml-pipeline")).unwrap_err();
631 assert!(matches!(err, Refusal::ProjectBudgetExceeded { .. }));
632 assert!(enforce(&rules(), Some("claude-x"), &tags("c", "other")).is_ok());
634 test_reset_ledger();
635 }
636
637 #[test]
638 #[serial_test::serial(policy_gate_ledger)]
639 fn seeding_replaces_baseline_and_clears_live() {
640 test_reset_ledger();
641 record_spend(Some("mara"), Some("web"), 10.0);
642 seed_from_store(HashMap::from([("mara".to_string(), 49.5)]), HashMap::new());
643 assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_ok());
645 record_spend(Some("mara"), Some("web"), 0.6);
646 assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_err());
647 test_reset_ledger();
648 }
649
650 #[test]
651 fn windows_roll_over() {
652 let mut l = BudgetLedger::default();
653 let (d1, m1) = (20_260_701, 202_607);
654 l.roll(d1, m1);
655 l.live_person_day.insert("a".into(), 100.0);
656 l.live_project_month.insert("p".into(), 100.0);
657 l.roll(20_260_702, m1);
659 assert_eq!(l.person_day_spend("a"), 0.0);
660 assert_eq!(l.project_month_spend("p"), 100.0);
661 l.roll(20_260_801, 202_608);
663 assert_eq!(l.project_month_spend("p"), 0.0);
664 }
665
666 #[test]
667 #[serial_test::serial(policy_gate_ledger)]
668 fn anonymous_requests_pass_budget_checks() {
669 test_reset_ledger();
670 let err = enforce(&rules(), Some("claude-x"), &GatewayTags::default());
672 assert!(err.is_ok());
673 }
674
675 #[test]
676 fn downgrade_exemption_matches_project() {
677 let r = rules();
678 assert!(downgrade_forbidden(&r, Some("prod")));
679 assert!(!downgrade_forbidden(&r, Some("web")));
680 assert!(!downgrade_forbidden(&r, None));
681 }
682
683 #[test]
684 fn refusal_bodies_match_wire_shape() {
685 let model_block = Refusal::ModelNotAllowed { model: "o3".into() };
686 let resp = refusal_response(&model_block, "Anthropic");
687 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
688
689 let budget_block = Refusal::PersonBudgetExceeded {
690 person: "a".into(),
691 cap_usd: 50.0,
692 spent_usd: 51.0,
693 };
694 let resp = refusal_response(&budget_block, "OpenAI");
695 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
696 assert!(resp.headers().contains_key(axum::http::header::RETRY_AFTER));
697 }
698
699 #[test]
700 #[serial_test::serial(policy_gate_rate)]
701 fn rate_limit_blocks_after_n_accepted_requests() {
702 let mut r = rules();
703 r.max_requests_per_minute_per_person = Some(3);
704
705 let err = (0..2)
708 .find_map(|_| {
709 test_reset_rate_ledger();
710 for _ in 0..3 {
711 assert!(enforce(&r, Some("claude-x"), &tags("mara", "web")).is_ok());
712 }
713 enforce(&r, Some("claude-x"), &tags("mara", "web")).err()
714 })
715 .expect("4th request within one minute window must be refused");
716 match err {
717 Refusal::PersonRateLimited {
718 person,
719 limit_rpm,
720 retry_after_secs,
721 } => {
722 assert_eq!(person, "mara");
723 assert_eq!(limit_rpm, 3);
724 assert!((1..=60).contains(&retry_after_secs), "{retry_after_secs}");
725 }
726 other => panic!("expected rate refusal, got {other:?}"),
727 }
728 assert!(enforce(&r, Some("claude-x"), &tags("erik", "web")).is_ok());
730 test_reset_rate_ledger();
731 }
732
733 #[test]
734 #[serial_test::serial(policy_gate_rate)]
735 fn rate_window_rolls_per_minute() {
736 test_reset_rate_ledger();
737 let mut l = RateLedger::default();
738 assert!(l.try_accept("mara", 2, 100));
739 assert!(l.try_accept("mara", 2, 100));
740 assert!(!l.try_accept("mara", 2, 100), "limit reached");
741 assert!(l.try_accept("mara", 2, 101));
743 test_reset_rate_ledger();
744 }
745
746 #[test]
747 #[serial_test::serial(policy_gate_rate)]
748 fn rate_limit_absent_or_anonymous_is_noop() {
749 test_reset_rate_ledger();
750 for _ in 0..50 {
752 assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_ok());
753 }
754 let mut r = rules();
756 r.max_requests_per_minute_per_person = Some(1);
757 for _ in 0..5 {
758 assert!(enforce(&r, Some("claude-x"), &GatewayTags::default()).is_ok());
759 }
760 test_reset_rate_ledger();
761 }
762
763 #[test]
764 fn rate_refusal_wire_shape_has_honest_retry_after() {
765 let refusal = Refusal::PersonRateLimited {
766 person: "mara".into(),
767 limit_rpm: 30,
768 retry_after_secs: 17,
769 };
770 let resp = refusal_response(&refusal, "OpenAI");
771 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
772 assert_eq!(
773 resp.headers()
774 .get(axum::http::header::RETRY_AFTER)
775 .and_then(|v| v.to_str().ok()),
776 Some("17")
777 );
778
779 let resp = refusal_response(&refusal, "Anthropic");
780 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
781 }
782}