Skip to main content

litellm_rs/server/
http.rs

1//! HTTP server core implementation
2//!
3//! This module provides the HttpServer struct and its core methods.
4
5use 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
33/// HTTP server
34pub struct HttpServer {
35    /// Server configuration
36    config: ServerConfig,
37    /// Application state
38    state: AppState,
39    tls: Option<crate::server::tls::ListenerTls>,
40    /// Background worker that drains budget persistence events on shutdown.
41    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    /// Background worker that delivers configured callback events.
46    callback_runtime: CallbackRuntime,
47}
48
49impl HttpServer {
50    /// Create a new HTTP server
51    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        // Budget persistence is co-located with the database backend. We treat
65        // a missing/disabled database as "budget persistence disabled" (no
66        // error), and a failed snapshot load on a configured database as a
67        // hard startup failure unless storage.database.allow_degraded=true.
68        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    /// Create the Actix-web application
158    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            // server.max_body_size bounds JSON and form bodies; file/audio
224            // uploads enforce their own larger multipart limits instead.
225            .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            // Actix executes wraps in reverse registration order. Register the
231            // limiter before auth so successful auth context is available when
232            // rate-limit keys are chosen.
233            .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            // CORS must run outside auth/rate-limit, but only standard browser
240            // preflight may be short-circuited before those layers.
241            .wrap(Condition::new(cors_config.enabled, cors))
242            .wrap(from_fn(normalize_non_cors_options_before_cors))
243            // IP policy remains before authentication/provider side effects.
244            .wrap(IpAccessMiddleware::new(ip_access))
245            // Audit wraps IP denials; request IDs wrap the complete lifecycle.
246            .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    /// Start the HTTP server
362    ///
363    /// Gracefully stops requests, drains workers, then closes storage.
364    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        // server.timeout bounds only how long a client may take to deliver
375        // the first request head (408 afterwards); it never bounds handler or
376        // streaming duration — those follow the outbound-client policy.
377        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        // Try resolved addresses one at a time. Passing the hostname directly
405        // to Actix would create one full worker set per successful address,
406        // multiplying the configured server-wide connection cap.
407        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        // AppState clones are gone, so worker queues can now drain.
460        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    /// Get server configuration
491    pub fn config(&self) -> &ServerConfig {
492        &self.config
493    }
494
495    /// Get application state
496    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    /// Build a config whose only optional dependency that is *configured* is
586    /// the pricing source, which points at a non-existent file so the initial
587    /// load fails deterministically.
588    fn config_with_broken_pricing(allow_degraded: bool) -> Config {
589        let mut config = valid_http_test_config();
590        // Disable enterprise/storage subsystems that would require real I/O.
591        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    /// In-memory budget snapshots load succeeds (returns empty) on sqlite, so
663    /// we can't trigger a real "load failed" path from `Config::default()`
664    /// alone without a mock. The disabled-DB branch is covered here: when the
665    /// database is disabled we use the in-memory sqlite backend which always
666    /// returns an empty snapshot set, exercising the "Ok(snapshots)" arm.
667    #[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        // Disable pricing so we don't conflate with the broken-pricing tests.
673        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}