1use crate::config::models::server::{CorsConfig, ServerConfig};
6use crate::config::{Config, Validate};
7use crate::core::audit::{AuditConfig, AuditLogger, AuditMiddleware};
8use crate::core::budget::UnifiedBudgetLimits;
9use crate::core::integrations::CallbackRuntime;
10use crate::core::ip_access::{IpAccessControl, IpAccessMiddleware};
11use crate::core::rate_limiter::{get_global_rate_limiter, init_global_rate_limiter_with_redis};
12use crate::server::http_listener::{build_actix_server, validated_listener_settings};
13use crate::server::middleware::{
14 AuthMiddleware, MetricsMiddleware, RateLimitMiddleware, RequestIdMiddleware,
15 SecurityHeadersMiddleware, start_auth_rate_limiter_cleanup_task,
16};
17use crate::server::routes;
18use crate::server::state::AppState;
19use crate::utils::error::gateway_error::{GatewayError, Result};
20use actix_cors::Cors;
21use actix_web::{
22 App,
23 body::MessageBody,
24 dev::{ServiceRequest, ServiceResponse},
25 http::{Method, header},
26 middleware::{Condition, DefaultHeaders, Logger, Next, from_fn},
27 web,
28};
29use std::sync::Arc;
30use tokio::task::JoinHandle;
31use tracing::{error, info, warn};
32
33pub struct HttpServer {
35 config: ServerConfig,
37 state: AppState,
39 tls: Option<crate::server::tls::ListenerTls>,
40 budget_persistence_task: Option<JoinHandle<()>>,
42 config_sync_task: Option<super::config_sync::ConfigSyncTask>,
43 response_settlement_task:
44 Option<super::routes::ai::responses_settlement::ResponseSettlementTask>,
45 callback_runtime: CallbackRuntime,
47}
48
49impl HttpServer {
50 pub async fn new(config: &Config) -> Result<Self> {
52 validated_listener_settings(&config.gateway.server)?;
53 config.gateway.storage.redis.validate().map_err(|error| {
54 GatewayError::Config(format!("Invalid Redis configuration: {error}"))
55 })?;
56 config.validate()?;
57
58 let tls = crate::server::tls::load_listener_tls(&config.gateway.server)?;
59 info!("Creating HTTP server");
60 start_auth_rate_limiter_cleanup_task();
61
62 let storage = crate::storage::StorageLayer::new(&config.gateway.storage).await?;
63 let mut budget_persistence_task = None;
64 let budget_limits = match storage.database.load_budget_limit_snapshots().await {
69 Ok(snapshots) => {
70 let count = snapshots.len();
71 let (persistence_tx, persistence_task) =
72 Arc::clone(&storage.database).start_budget_limit_persistence_task();
73 budget_persistence_task = Some(persistence_task);
74 info!("Loaded {} persisted budget limit snapshots", count);
75 Arc::new(
76 UnifiedBudgetLimits::from_snapshots_with_persistence(snapshots, persistence_tx)
77 .with_redis(storage.redis.clone()),
78 )
79 }
80 Err(e) => {
81 if config.gateway.storage.database.allow_degraded
82 || !config.gateway.storage.database.enabled
83 {
84 error!(
85 "Budget limit persistence is unavailable; using in-memory budgets \
86 only (allow_degraded={}, db_enabled={}). Error: {}",
87 config.gateway.storage.database.allow_degraded,
88 config.gateway.storage.database.enabled,
89 e
90 );
91 Arc::new(UnifiedBudgetLimits::new().with_redis(storage.redis.clone()))
92 } else {
93 error!(
94 "Budget limit persistence load failed and \
95 storage.database.allow_degraded=false; failing startup. Set \
96 storage.database.allow_degraded=true to keep running with \
97 in-memory budgets only. Error: {}",
98 e
99 );
100 return Err(e);
101 }
102 }
103 };
104 let auth =
105 crate::auth::AuthSystem::new(&config.gateway.auth, Arc::new(storage.clone())).await?;
106
107 let pricing = super::http_runtime::initialize_pricing(config).await?;
108 let revision = super::runtime::build_runtime_revision(
109 config.clone(),
110 0,
111 Arc::clone(&pricing),
112 storage.redis.clone(),
113 )
114 .await?;
115 let guardrails = Arc::clone(&revision.guardrails);
116
117 let callback_runtime = crate::server::callbacks::build_callback_runtime(
118 &config.gateway.monitoring.callbacks,
119 &config.gateway.monitoring.metrics,
120 )
121 .await;
122 let audit_logger = if config.gateway.enterprise.audit_logging {
123 AuditLogger::shared(AuditConfig::default().enable())
124 .await
125 .map_err(|error| {
126 GatewayError::Config(format!("Failed to initialize audit logging: {error}"))
127 })?
128 } else {
129 Arc::new(AuditLogger::disabled())
130 };
131 let ip_access =
132 IpAccessControl::shared(config.gateway.ip_access.clone()).map_err(|error| {
133 GatewayError::Config(format!("Invalid IP access configuration: {error}"))
134 })?;
135 let mut state = AppState::new_with_runtime(revision, auth, storage, pricing, budget_limits)
136 .with_callbacks(callback_runtime.dispatcher())
137 .with_audit_logger(audit_logger)
138 .with_request_policies(guardrails, ip_access);
139
140 state.config_sync = super::config_sync::ConfigSync::from_config(config)?;
141 state.reconcile_runtime_config().await?;
142 let config_sync_task = super::config_sync::start(state.clone());
143 let response_settlement_task =
144 super::routes::ai::responses_settlement::start(state.clone());
145
146 Ok(Self {
147 config: config.gateway.server.clone(),
148 state,
149 tls,
150 budget_persistence_task,
151 config_sync_task,
152 response_settlement_task,
153 callback_runtime,
154 })
155 }
156
157 pub(super) fn create_app(
159 state: web::Data<AppState>,
160 ) -> App<
161 impl actix_web::dev::ServiceFactory<
162 actix_web::dev::ServiceRequest,
163 Config = (),
164 Response = actix_web::dev::ServiceResponse<impl actix_web::body::MessageBody>,
165 Error = actix_web::Error,
166 InitError = (),
167 >,
168 > {
169 info!("Setting up routes and middleware");
170
171 let cfg = state.config.load();
172 if cfg.gateway.rate_limit.enabled && get_global_rate_limiter().is_none() {
173 let redis_available = !state.storage.redis.is_noop();
174 init_global_rate_limiter_with_redis(
175 cfg.gateway.rate_limit.clone(),
176 Arc::clone(&state.storage.redis),
177 );
178 info!(
179 "Global rate limiter initialized (strategy={:?}, rpm={}, backend={})",
180 cfg.gateway.rate_limit.strategy,
181 cfg.gateway.rate_limit.effective_rpm(),
182 if redis_available {
183 "redis"
184 } else {
185 "in-process"
186 }
187 );
188 }
189 let cors_config = &cfg.gateway.server.cors;
190 let rate_limit_enabled = cfg.gateway.rate_limit.enabled;
191 let rate_limit_rpm = cfg.gateway.rate_limit.effective_rpm();
192 let api_key_auth_enabled = cfg.gateway.auth.enable_api_key;
193 let default_rate_limit_rpm = rate_limit_enabled.then_some(rate_limit_rpm);
194 let metrics_enabled = cfg.gateway.monitoring.metrics.enabled;
195 let audit_enabled = state.audit_logger.is_enabled();
196 let audit_logger = Arc::clone(&state.audit_logger);
197 let trusted_proxies = cfg.gateway.server.trusted_proxies.clone();
198 let ledger_enabled = cfg.gateway.storage.request_ledger.enabled;
199 let request_ledger = ledger_enabled.then(|| {
200 Arc::new(crate::core::request_ledger::RequestLedgerRuntime {
201 writer: Arc::new(crate::storage::database::RequestLedgerSink::new(
202 Arc::clone(&state.storage.database),
203 cfg.gateway.storage.request_ledger.retention_days,
204 )),
205 write_failure: cfg.gateway.storage.request_ledger.write_failure,
206 })
207 });
208 let ip_access = Arc::clone(&state.ip_access);
209 let cors = Self::build_cors_for_app_factory(cors_config);
210 #[cfg(any(feature = "a2a", feature = "mcp"))]
211 let cors = if cfg.gateway.auth.enable_api_key {
212 cors.allowed_header(cfg.gateway.auth.api_key_header.as_str())
213 } else {
214 cors
215 };
216 let max_body_size = cfg.gateway.server.max_body_size;
217
218 let budget_limits = web::Data::new(Arc::clone(&state.budget_limits));
219
220 App::new()
221 .app_data(state)
222 .app_data(budget_limits)
223 .app_data(web::JsonConfig::default().limit(max_body_size))
226 .app_data(web::FormConfig::default().limit(max_body_size))
227 .wrap(Logger::default())
228 .wrap(DefaultHeaders::new().add(("Server", "LiteLLM-RS")))
229 .wrap(SecurityHeadersMiddleware)
230 .wrap(Condition::new(
234 rate_limit_enabled || api_key_auth_enabled,
235 RateLimitMiddleware::optional(default_rate_limit_rpm),
236 ))
237 .wrap(AuthMiddleware)
238 .wrap(Condition::new(metrics_enabled, MetricsMiddleware))
239 .wrap(Condition::new(cors_config.enabled, cors))
242 .wrap(from_fn(normalize_non_cors_options_before_cors))
243 .wrap(IpAccessMiddleware::new(ip_access))
245 .wrap(Condition::new(audit_enabled || ledger_enabled, {
247 let mut audit =
248 AuditMiddleware::with_trusted_proxies(audit_logger, trusted_proxies);
249 if let Some(request_ledger) = request_ledger {
250 audit = audit.with_request_ledger(request_ledger);
251 }
252 audit
253 }))
254 .wrap(RequestIdMiddleware)
255 .configure(|cfg| {
256 #[cfg(feature = "mcp")]
257 routes::mcp::configure_routes(cfg, max_body_size);
258 #[cfg(not(feature = "mcp"))]
259 let _ = cfg;
260 })
261 .configure(routes::health::configure_routes)
262 .configure(routes::auth::configure_routes)
263 .configure(routes::keys::configure_routes)
264 .configure(routes::teams::configure_routes)
265 .configure(routes::budget::configure_budget_routes)
266 .configure(routes::admin::configure_routes)
267 .configure(routes::admin_dashboard::configure_routes)
268 .configure(|cfg| routes::ai::configure_routes_with_body_limit(cfg, max_body_size))
269 .configure(routes::pricing::configure_pricing_routes)
270 .configure(|cfg| {
271 #[cfg(feature = "a2a")]
272 routes::a2a::configure_routes(cfg, max_body_size);
273 #[cfg(not(feature = "a2a"))]
274 let _ = cfg;
275 })
276 }
277
278 fn validate_cors_config(cors_config: &CorsConfig) -> Result<()> {
279 cors_config
280 .validate()
281 .map_err(|e| GatewayError::Config(format!("Invalid CORS configuration: {}", e)))
282 }
283
284 fn build_cors(cors_config: &CorsConfig) -> Result<Cors> {
285 Self::validate_cors_config(cors_config)?;
286
287 let mut cors = Cors::default();
288 if !cors_config.enabled {
289 return Ok(cors);
290 }
291
292 if cors_config.allows_all_origins() {
293 cors = cors.allow_any_origin();
294 } else {
295 for origin in &cors_config.allowed_origins {
296 cors = cors.allowed_origin(origin);
297 }
298 }
299
300 let methods: Vec<actix_web::http::Method> = cors_config
301 .allowed_methods
302 .iter()
303 .filter_map(|m| m.parse().ok())
304 .collect();
305 if !methods.is_empty() {
306 cors = cors.allowed_methods(methods);
307 }
308
309 let headers: Vec<actix_web::http::header::HeaderName> = cors_config
310 .allowed_headers
311 .iter()
312 .filter_map(|h| h.parse().ok())
313 .collect();
314 if !headers.is_empty() {
315 cors = cors.allowed_headers(headers);
316 }
317
318 #[cfg(feature = "a2a")]
319 {
320 cors = cors
321 .allowed_header("x-api-key")
322 .allowed_header("a2a-version")
323 .allowed_header("a2a-extensions")
324 .expose_headers(["a2a-version", "a2a-extensions", "retry-after"]);
325 }
326 #[cfg(feature = "mcp")]
327 {
328 for name in [
329 "x-api-key",
330 "mcp-method",
331 "mcp-protocol-version",
332 "mcp-name",
333 ] {
334 cors = cors.allowed_header(name);
335 }
336 cors = cors.expose_headers(["mcp-protocol-version", "retry-after"]);
337 }
338
339 cors = cors.max_age(cors_config.max_age as usize);
340
341 if cors_config.allow_credentials {
342 cors = cors.supports_credentials();
343 }
344
345 Ok(cors)
346 }
347
348 fn build_cors_for_app_factory(cors_config: &CorsConfig) -> Cors {
349 match Self::build_cors(cors_config) {
350 Ok(cors) => cors,
351 Err(e) => {
352 tracing::error!(
353 "Invalid CORS configuration reached app factory; using restrictive fallback: {}",
354 e
355 );
356 Cors::default()
357 }
358 }
359 }
360
361 pub async fn start(mut self) -> Result<()> {
365 let listener_settings = validated_listener_settings(&self.config)?;
366 let bind_addr = format!("{}:{}", self.config.host, self.config.port);
367 let port = self.config.port;
368 let budget_persistence_task = self.budget_persistence_task.take();
369 let config_sync_task = self.config_sync_task.take();
370 let response_settlement_task = self.response_settlement_task.take();
371 let callback_runtime =
372 std::mem::replace(&mut self.callback_runtime, CallbackRuntime::disabled());
373
374 info!(
378 "Starting HTTP server on {} (workers={}, first_request_head_timeout={}s)",
379 bind_addr, listener_settings.effective_workers, self.config.timeout
380 );
381 if let Some(configured_total) = self.config.max_connections {
382 if listener_settings.effective_workers != listener_settings.configured_workers {
383 info!(
384 "Reducing HTTP workers from {} to {} so server.max_connections={} can use Actix's minimum safe per-worker limit of 2",
385 listener_settings.configured_workers,
386 listener_settings.effective_workers,
387 configured_total
388 );
389 }
390 if let Some(per_worker) = listener_settings.max_connections_per_worker {
391 info!(
392 "Connection limit: {} per worker, {} server-wide effective (configured total={})",
393 per_worker,
394 per_worker * listener_settings.effective_workers,
395 configured_total
396 );
397 }
398 }
399
400 let state = web::Data::new(self.state);
401 let storage = Arc::clone(&state.storage);
402 let audit_logger = Arc::clone(&state.audit_logger);
403
404 let listener = match self.tls.take() {
408 Some(tls) => crate::server::tls::build_tls_server(
409 state,
410 &listener_settings,
411 bind_addr.as_str(),
412 tls,
413 ),
414 None => build_actix_server(state, &listener_settings, bind_addr.as_str()),
415 };
416 let (server, selected_address) =
417 listener.map_err(|e| Self::format_bind_error(e, &bind_addr, port))?;
418
419 info!(
420 "HTTP server listening on {} (selected from {})",
421 selected_address, bind_addr
422 );
423
424 let server_handle = server.handle();
425 let mut server_task = tokio::spawn(server);
426
427 let shutdown = Self::shutdown_signal();
428 let mut stopped_by_signal = false;
429
430 let mut server_result = tokio::select! {
431 result = &mut server_task => {
432 match result {
433 Ok(Ok(())) => { info!("HTTP server exited"); Ok(()) }
434 Ok(Err(e)) => Err(GatewayError::server(format!("Server error: {}", e))),
435 Err(e) => Err(GatewayError::server(format!("Server task failed: {}", e))),
436 }
437 }
438 _ = shutdown => {
439 info!("Shutdown signal received; stopping accept loop");
440 server_handle.stop(true).await;
441 stopped_by_signal = true;
442 Ok(())
443 }
444 };
445
446 if stopped_by_signal {
447 server_result = match server_task.await {
448 Ok(Ok(())) => {
449 info!("HTTP server exited after graceful stop");
450 Ok(())
451 }
452 Ok(Err(e)) => Err(GatewayError::server(format!("Server error: {}", e))),
453 Err(e) => Err(GatewayError::server(format!("Server task failed: {}", e))),
454 };
455 }
456
457 drop(response_settlement_task);
458
459 drop(server_handle);
461
462 if let Some(task) = budget_persistence_task {
463 info!("Waiting for budget persistence worker to drain");
464 if let Err(e) = task.await {
465 warn!("Budget persistence worker join failed: {}", e);
466 }
467 }
468
469 info!("Draining external callback worker");
470 if let Err(e) = callback_runtime.shutdown().await {
471 warn!("Callback worker shutdown reported an error: {}", e);
472 }
473
474 info!("Draining audit worker");
475 let audit_shutdown = audit_logger.shutdown().await;
476
477 drop(config_sync_task);
478 info!("Closing storage layer");
479 if let Err(e) = storage.close().await {
480 warn!("Storage close reported an error: {}", e);
481 }
482
483 server_result?;
484 audit_shutdown.map_err(|e| GatewayError::server(format!("Audit shutdown failed: {e}")))?;
485
486 info!("HTTP server stopped");
487 Ok(())
488 }
489
490 pub fn config(&self) -> &ServerConfig {
492 &self.config
493 }
494
495 pub fn state(&self) -> &AppState {
497 &self.state
498 }
499}
500
501async fn normalize_non_cors_options_before_cors(
502 mut req: ServiceRequest,
503 next: Next<impl MessageBody>,
504) -> std::result::Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
505 if req.method() == Method::OPTIONS
506 && req
507 .headers()
508 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD)
509 && !req.headers().contains_key(header::ORIGIN)
510 {
511 req.headers_mut()
512 .remove(header::ACCESS_CONTROL_REQUEST_METHOD);
513 }
514
515 next.call(req).await
516}
517
518#[cfg(test)]
519#[path = "http_metrics_tests.rs"]
520mod metrics_tests;
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::config::Config;
526 use actix_web::{
527 http::{StatusCode, header},
528 test as actix_test,
529 };
530
531 #[test]
532 fn build_cors_rejects_wildcard_with_credentials() {
533 let cors_config = CorsConfig {
534 allowed_origins: vec!["*".to_string()],
535 allow_credentials: true,
536 ..Default::default()
537 };
538
539 let error = match HttpServer::build_cors(&cors_config) {
540 Ok(_) => panic!("invalid CORS configuration should be rejected"),
541 Err(error) => error,
542 };
543
544 match error {
545 GatewayError::Config(message) => {
546 assert!(message.contains("Invalid CORS configuration"));
547 assert!(message.contains("credentials"));
548 }
549 other => panic!("expected config error, got: {other:?}"),
550 }
551 }
552
553 #[test]
554 fn app_factory_cors_builder_falls_back_without_panicking() {
555 let cors_config = CorsConfig {
556 allowed_origins: vec!["*".to_string()],
557 allow_credentials: true,
558 ..Default::default()
559 };
560
561 let _cors = HttpServer::build_cors_for_app_factory(&cors_config);
562 }
563
564 #[tokio::test]
565 async fn new_rejects_invalid_cors_config_before_startup() {
566 let mut config = valid_http_test_config();
567 config.gateway.server.cors.allowed_origins = vec!["*".to_string()];
568 config.gateway.server.cors.allow_credentials = true;
569
570 let error = match HttpServer::new(&config).await {
571 Ok(_) => panic!("server startup should reject invalid CORS configuration"),
572 Err(error) => error,
573 };
574
575 match error {
576 GatewayError::Config(message) => {
577 assert!(message.contains("Gateway config error"));
578 assert!(message.contains("CORS"));
579 assert!(message.contains("credentials"));
580 }
581 other => panic!("expected config error, got: {other:?}"),
582 }
583 }
584
585 fn config_with_broken_pricing(allow_degraded: bool) -> Config {
589 let mut config = valid_http_test_config();
590 config.gateway.storage.database.enabled = false;
592 config.gateway.storage.redis.enabled = false;
593 config.gateway.pricing.source =
594 Some("/nonexistent/path/that/cannot/be/loaded.json".to_string());
595 config.gateway.pricing.allow_degraded = allow_degraded;
596 config
597 }
598
599 #[tokio::test]
600 async fn new_fails_when_pricing_source_broken_and_not_allowed_to_degrade() {
601 let config = config_with_broken_pricing(false);
602 let result = HttpServer::new(&config).await;
603 assert!(
604 result.is_err(),
605 "pricing source load failure with allow_degraded=false must fail startup"
606 );
607 }
608
609 #[tokio::test]
610 async fn new_succeeds_when_pricing_source_broken_but_allowed_to_degrade() {
611 let config = config_with_broken_pricing(true);
612 let result = HttpServer::new(&config).await;
613 assert!(
614 result.is_ok(),
615 "pricing source load failure with allow_degraded=true must keep startup running, \
616 got: {:?}",
617 result.err()
618 );
619 }
620
621 #[tokio::test]
622 async fn new_wires_enabled_cache_config() {
623 let mut config = valid_http_test_config();
624 config.gateway.storage.database.enabled = false;
625 config.gateway.storage.redis.enabled = false;
626 config.gateway.pricing.source = None;
627 config.gateway.cache.enabled = true;
628
629 let server = match HttpServer::new(&config).await {
630 Ok(server) => server,
631 Err(error) => panic!("enabled cache should wire runtime cache: {error}"),
632 };
633
634 assert!(server.state().response_cache().is_some());
635 }
636
637 #[tokio::test]
638 async fn new_wires_configured_callback_backend() {
639 let mut config = valid_http_test_config();
640 config.gateway.storage.database.enabled = false;
641 config.gateway.storage.redis.enabled = false;
642 config.gateway.pricing.source = None;
643 config.gateway.monitoring.metrics.enabled = false;
644 config.gateway.monitoring.callbacks.backends = vec![
645 crate::config::models::monitoring::CallbackBackendConfig::OpenTelemetry(
646 crate::core::integrations::OpenTelemetryConfig::default(),
647 ),
648 ];
649
650 let server = match HttpServer::new(&config).await {
651 Ok(server) => server,
652 Err(error) => panic!("configured callbacks should wire at startup: {error}"),
653 };
654
655 assert!(server.state().callbacks.is_enabled());
656 assert_eq!(
657 server.state().callbacks.registered_integrations().await,
658 vec!["opentelemetry"]
659 );
660 }
661
662 #[tokio::test]
668 async fn new_succeeds_with_in_memory_budgets_when_db_disabled() {
669 let mut config = valid_http_test_config();
670 config.gateway.storage.database.enabled = false;
671 config.gateway.storage.redis.enabled = false;
672 config.gateway.pricing.source = None;
674
675 let result = HttpServer::new(&config).await;
676 assert!(
677 result.is_ok(),
678 "disabled DB must keep startup running with in-memory budgets, got: {:?}",
679 result.err()
680 );
681 }
682
683 #[tokio::test]
684 async fn app_factory_metrics_endpoint_includes_recorded_http_requests() {
685 let _metrics_guard = MetricsMiddleware::test_lock().await;
686 MetricsMiddleware::reset_for_tests();
687 crate::server::middleware::reset_unpriced_metrics_for_tests();
688 crate::server::middleware::record_unpriced_event(
689 "metrics-http-provider",
690 "tenant-http-private-model",
691 "reject",
692 "reject_preflight",
693 );
694
695 let mut config = valid_http_test_config();
696 config.gateway.auth.enable_jwt = false;
697 config.gateway.auth.enable_api_key = false;
698 config.gateway.auth.allow_anonymous = true;
699 config.gateway.storage.database.enabled = false;
700 config.gateway.storage.redis.enabled = false;
701 config.gateway.pricing.source = None;
702
703 let server = match HttpServer::new(&config).await {
704 Ok(server) => server,
705 Err(error) => panic!("server startup failed: {error}"),
706 };
707
708 let app = actix_test::init_service(HttpServer::create_app(web::Data::new(
709 server.state().clone(),
710 )))
711 .await;
712
713 let health_req = actix_test::TestRequest::get().uri("/health").to_request();
714 let health_resp = actix_test::call_service(&app, health_req).await;
715 assert_eq!(health_resp.status(), StatusCode::OK);
716 drop(actix_test::read_body(health_resp).await);
717
718 let metrics_req = actix_test::TestRequest::get().uri("/metrics").to_request();
719 let metrics_resp = actix_test::call_service(&app, metrics_req).await;
720 assert_eq!(metrics_resp.status(), StatusCode::OK);
721
722 let body = actix_test::read_body(metrics_resp).await;
723 let body = match std::str::from_utf8(&body) {
724 Ok(body) => body,
725 Err(error) => panic!("metrics response was not utf-8: {error}"),
726 };
727
728 assert!(body.contains("gateway_http_requests_total 1"));
729 assert!(body.contains("gateway_http_responses_total{class=\"2xx\"} 1"));
730 assert!(body.contains(
731 "gateway_unpriced_events_total{provider=\"metrics-http-provider\",model_bucket=\"other\",policy=\"reject\",outcome=\"reject_preflight\"} 1"
732 ));
733 assert!(!body.contains("tenant-http-private-model"));
734
735 let rendered_after_scrape = MetricsMiddleware::render_prometheus();
736 assert!(rendered_after_scrape.contains("gateway_http_requests_total 1"));
737 }
738
739 #[tokio::test]
740 async fn app_factory_metrics_records_auth_rejections_before_handler() {
741 let _metrics_guard = MetricsMiddleware::test_lock().await;
742 MetricsMiddleware::reset_for_tests();
743
744 let mut config = valid_http_test_config();
745 config.gateway.auth.enable_jwt = true;
746 config.gateway.auth.enable_api_key = true;
747 config.gateway.auth.allow_anonymous = false;
748 config.gateway.auth.jwt_secret = "AaaAaaAaaAaaAaaAaaAaaAaaAaaAaa1!".to_string();
749 config.gateway.rate_limit.enabled = true;
750 config.gateway.storage.database.enabled = false;
751 config.gateway.storage.redis.enabled = false;
752 config.gateway.pricing.source = None;
753
754 let server = match HttpServer::new(&config).await {
755 Ok(server) => server,
756 Err(error) => panic!("server startup failed: {error}"),
757 };
758
759 let app = actix_test::init_service(HttpServer::create_app(web::Data::new(
760 server.state().clone(),
761 )))
762 .await;
763
764 let models_req = actix_test::TestRequest::get()
765 .uri("/v1/models")
766 .to_request();
767 match actix_test::try_call_service(&app, models_req).await {
768 Ok(response) => {
769 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
770 drop(actix_test::read_body(response).await);
771 }
772 Err(error) => assert_eq!(
773 error.as_response_error().status_code(),
774 StatusCode::UNAUTHORIZED
775 ),
776 }
777
778 let body = MetricsMiddleware::render_prometheus();
779 assert!(body.contains("gateway_http_requests_total 1"));
780 assert!(body.contains("gateway_http_request_errors_total 1"));
781 assert!(body.contains("gateway_http_responses_total{class=\"4xx\"} 1"));
782 }
783
784 include!("http_cors_tests.rs");
785 include!("http_openapi_tests.rs");
786 include!("http_validation_tests.rs");
787
788 #[tokio::test]
789 async fn app_factory_does_not_collect_http_metrics_when_metrics_disabled() {
790 let _metrics_guard = MetricsMiddleware::test_lock().await;
791 MetricsMiddleware::reset_for_tests();
792
793 let mut config = valid_http_test_config();
794 config.gateway.auth.enable_jwt = false;
795 config.gateway.auth.enable_api_key = false;
796 config.gateway.auth.allow_anonymous = true;
797 config.gateway.monitoring.metrics.enabled = false;
798 config.gateway.storage.database.enabled = false;
799 config.gateway.storage.redis.enabled = false;
800 config.gateway.pricing.source = None;
801
802 let server = match HttpServer::new(&config).await {
803 Ok(server) => server,
804 Err(error) => panic!("server startup failed: {error}"),
805 };
806
807 let app = actix_test::init_service(HttpServer::create_app(web::Data::new(
808 server.state().clone(),
809 )))
810 .await;
811
812 let health_req = actix_test::TestRequest::get().uri("/health").to_request();
813 let health_resp = actix_test::call_service(&app, health_req).await;
814 assert_eq!(health_resp.status(), StatusCode::OK);
815 drop(actix_test::read_body(health_resp).await);
816
817 let body = MetricsMiddleware::render_prometheus();
818 assert!(body.contains("gateway_http_requests_total 0"));
819 assert!(body.contains("gateway_http_responses_total{class=\"2xx\"} 0"));
820 }
821
822 #[tokio::test]
823 async fn app_factory_rejects_anonymous_admin_routes_when_auth_disabled() {
824 let mut config = valid_http_test_config();
825 config.gateway.auth.enable_jwt = false;
826 config.gateway.auth.enable_api_key = false;
827 config.gateway.auth.allow_anonymous = true;
828 config.gateway.monitoring.metrics.enabled = false;
829 config.gateway.storage.database.enabled = false;
830 config.gateway.storage.redis.enabled = false;
831 config.gateway.pricing.source = None;
832 let server = HttpServer::new(&config).await.unwrap();
833 let app = actix_test::init_service(HttpServer::create_app(web::Data::new(
834 server.state().clone(),
835 )))
836 .await;
837
838 for (method, path, body) in [
839 ("GET", "/admin/cache/status", serde_json::json!({})),
840 ("POST", "/admin/cache/clear", serde_json::json!({})),
841 ("GET", "/admin/providers", serde_json::json!({})),
842 (
843 "POST",
844 "/admin/providers",
845 serde_json::json!({
846 "name": "anonymous", "provider_type": "openai",
847 "api_key": "${LITELLM_TEST_PROVIDER_KEY}",
848 "base_url": "https://attacker.example/v1"
849 }),
850 ),
851 ("GET", "/admin/routing/inventory", serde_json::json!({})),
852 ("GET", "/admin/routing/revision", serde_json::json!({})),
853 ("GET", "/admin/routing/policy", serde_json::json!({})),
854 (
855 "PUT",
856 "/admin/routing/policy",
857 serde_json::json!({"model_aliases": {}}),
858 ),
859 ("GET", "/admin/request-ledger", serde_json::json!({})),
860 ("GET", "/admin/openapi.json", serde_json::json!({})),
861 ] {
862 let response = actix_test::call_service(
863 &app,
864 actix_test::TestRequest::default()
865 .method(method.parse().unwrap())
866 .uri(path)
867 .set_json(body)
868 .to_request(),
869 )
870 .await;
871 assert_eq!(response.status(), StatusCode::FORBIDDEN, "{method} {path}");
872 }
873 assert_eq!(server.state().pin_runtime().generation, 0);
874 }
875
876 #[tokio::test]
877 async fn app_factory_mounts_explicit_cache_admin_surface() {
878 let mut config = valid_http_test_config();
879 config.gateway.auth.enable_jwt = true;
880 config.gateway.auth.jwt_secret = format!("Aa1!{}", uuid::Uuid::new_v4());
881 config.gateway.auth.enable_api_key = false;
882 config.gateway.auth.allow_anonymous = true;
883 config.gateway.monitoring.metrics.enabled = false;
884 config.gateway.storage.database.enabled = false;
885 config.gateway.storage.redis.enabled = false;
886 config.gateway.pricing.source = None;
887
888 let server = match HttpServer::new(&config).await {
889 Ok(server) => server,
890 Err(error) => panic!("server startup failed: {error}"),
891 };
892
893 let token = crate::server::test_admin_token(server.state()).await;
894 let app = actix_test::init_service(HttpServer::create_app(web::Data::new(
895 server.state().clone(),
896 )))
897 .await;
898
899 let req = actix_test::TestRequest::get()
900 .uri("/admin/cache/status")
901 .insert_header(("Authorization", format!("Bearer {token}")))
902 .to_request();
903 let resp = actix_test::call_service(&app, req).await;
904 assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
905
906 let body: serde_json::Value = actix_test::read_body_json(resp).await;
907 assert_eq!(body["status"], "unsupported");
908 assert!(
909 body["message"]
910 .as_str()
911 .unwrap_or_default()
912 .contains("not wired")
913 );
914 }
915}