Skip to main content

switchyard_server/
lib.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Rust HTTP server for libsy algorithms.
5
6pub mod config;
7mod metrics;
8mod observability;
9mod response;
10mod routing_log;
11mod shutdown;
12mod sse;
13mod stats;
14mod usage_metrics;
15
16use std::collections::BTreeMap;
17use std::error::Error;
18use std::fmt::{Display, Formatter};
19use std::future::Future;
20use std::io::IsTerminal;
21use std::net::SocketAddr;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use axum::extract::rejection::{JsonRejection, QueryRejection};
27use axum::extract::{DefaultBodyLimit, Query, Request as HttpRequest, State};
28use axum::http::header::CONTENT_TYPE;
29use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
30use axum::middleware::Next;
31use axum::response::{IntoResponse, Response};
32use axum::routing::{get, post};
33use axum::{Extension, Json, Router};
34use axum_server::tls_rustls::RustlsConfig;
35use libsy::{Algorithm, LibsyError, RunObservation, RunObserver};
36use parking_lot::Mutex;
37use serde::Deserialize;
38use serde_json::{Value, json};
39use switchyard_llm_client::TranslatingLlmClient;
40use switchyard_protocol::{Context, Decision, LlmClientError, Metadata, Request, Usage};
41use tokio::net::{TcpListener, TcpSocket};
42use tokio::task;
43use tracing::{Instrument, Level};
44
45use switchyard_translation::{WireFormat, decode_request};
46
47use crate::response::into_http_response;
48use crate::stats::{StatsAccumulator, StatsSnapshot, prefix_probe, tracking_enabled_from_env};
49
50pub use observability::{flush_observability, initialize_observability};
51
52/// Default TCP listen backlog used by the Rust server.
53pub const DEFAULT_LISTEN_BACKLOG: u32 = 65_535;
54
55/// Default time allowed for active requests to finish during shutdown.
56pub const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Maximum buffered JSON request size accepted by the LLM endpoints.
59pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;
60
61const HEADER_SELECTED_MODEL: &str = "x-model-router-selected-model";
62const HEADER_RATIONALE: &str = "x-model-router-rationale";
63const MAX_ROUTING_HEADER_VALUE_LEN: usize = 512;
64const STARTUP_BANNER_ART: &str = include_str!("../assets/startup_banner.txt");
65
66/// Error returned while configuring or running the server.
67#[derive(Debug)]
68pub struct ServerError {
69    message: String,
70}
71
72impl ServerError {
73    /// Creates a server error with a user-facing message.
74    pub fn new(message: impl Into<String>) -> Self {
75        Self {
76            message: message.into(),
77        }
78    }
79}
80
81impl Display for ServerError {
82    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
83        formatter.write_str(&self.message)
84    }
85}
86
87impl Error for ServerError {}
88
89/// Result returned by server setup and lifecycle operations.
90pub type ServerResult<T> = std::result::Result<T, ServerError>;
91
92/// Capabilities that one route advertises on `GET /v1/models`.
93///
94/// An unset capability is undeclared: it serializes as `null` in the OpenAI
95/// `data` entry, and the Codex entry falls back to a safe default for it.
96#[derive(Clone, Copy, Default)]
97struct ModelCapabilities {
98    context_window: Option<u32>,
99    tool_calling: Option<bool>,
100    // Whether the routed model takes reasoning controls. The server cannot probe
101    // this, so a route opts in via config; undeclared routes advertise as
102    // non-reasoning to Codex (fail closed).
103    reasoning: Option<bool>,
104}
105
106/// A registered algorithm route and its server-owned endpoint metadata.
107struct RouteEntry {
108    algorithm: Arc<dyn Algorithm>,
109    capabilities: ModelCapabilities,
110    count_tokens_target: Option<CountTokensTarget>,
111}
112
113/// Exact upstream model used by the server's Anthropic token-count endpoint.
114#[derive(Clone)]
115struct CountTokensTarget {
116    model: String,
117    client: Arc<TranslatingLlmClient>,
118}
119
120impl CountTokensTarget {
121    async fn count_tokens(&self, request: Request) -> Result<Value, LlmClientError> {
122        self.client.count_tokens(&self.model, request).await
123    }
124}
125
126/// Shared server state used by all endpoint handlers.
127#[derive(Clone)]
128pub struct ServerState {
129    routes: Arc<BTreeMap<String, RouteEntry>>,
130    metrics: prometheus::Registry,
131    stats: StatsAccumulator,
132    routing_log: Option<SharedRoutingLog>,
133    track_cache_eligibility: bool,
134}
135
136#[derive(Clone)]
137struct SharedRoutingLog {
138    writer: Arc<Mutex<routing_log::RoutingLog>>,
139    path: PathBuf,
140}
141
142impl SharedRoutingLog {
143    fn new(path: PathBuf) -> ServerResult<Self> {
144        Ok(Self {
145            writer: Arc::new(Mutex::new(routing_log::RoutingLog::new(path.clone())?)),
146            path,
147        })
148    }
149
150    fn append(
151        &self,
152        context: routing_log::RoutingLogContext,
153        model: &str,
154        tier: Option<&str>,
155        usage: &Usage,
156    ) {
157        if let Err(error) = self.writer.lock().append(context, model, tier, usage) {
158            tracing::warn!(path = %self.path.display(), %error, "routing log append failed");
159        }
160    }
161
162    fn snapshot_session(
163        &self,
164        session_id: &str,
165    ) -> std::io::Result<Option<routing_log::SessionStatsSnapshot>> {
166        routing_log::snapshot(&self.path, session_id)
167    }
168}
169
170impl ServerState {
171    /// Creates server state from route model IDs and their libsy algorithms.
172    pub fn new(
173        routes: impl IntoIterator<Item = (String, Arc<dyn Algorithm>)>,
174    ) -> ServerResult<Self> {
175        Self::new_with_capabilities(
176            routes
177                .into_iter()
178                .map(|(model, algorithm)| (model, algorithm, ModelCapabilities::default(), None)),
179        )
180    }
181
182    fn new_with_capabilities(
183        routes: impl IntoIterator<
184            Item = (
185                String,
186                Arc<dyn Algorithm>,
187                ModelCapabilities,
188                Option<CountTokensTarget>,
189            ),
190        >,
191    ) -> ServerResult<Self> {
192        let mut entries = BTreeMap::new();
193        for (model, algorithm, capabilities, count_tokens_target) in routes {
194            let model = model.trim();
195            if model.is_empty() {
196                return Err(ServerError::new("route model must not be empty"));
197            }
198            let entry = RouteEntry {
199                algorithm,
200                capabilities,
201                count_tokens_target,
202            };
203            if entries.insert(model.to_string(), entry).is_some() {
204                return Err(ServerError::new(format!("duplicate route model {model}")));
205            }
206        }
207        if entries.is_empty() {
208            return Err(ServerError::new("at least one algorithm route is required"));
209        }
210        let metrics = metrics::registry().map_err(ServerError::new)?;
211        Ok(Self {
212            routes: Arc::new(entries),
213            metrics,
214            stats: StatsAccumulator::default(),
215            routing_log: None,
216            track_cache_eligibility: tracking_enabled_from_env(),
217        })
218    }
219
220    /// Enables durable per-request routing records at `path`.
221    pub fn with_routing_log(mut self, path: impl Into<PathBuf>) -> ServerResult<Self> {
222        self.routing_log = Some(SharedRoutingLog::new(path.into())?);
223        Ok(self)
224    }
225
226    /// Returns the route model IDs served by the configured algorithms.
227    pub fn models(&self) -> impl Iterator<Item = &str> {
228        self.routes.keys().map(String::as_str)
229    }
230
231    fn route_for_model(&self, model: &str) -> Option<&RouteEntry> {
232        self.routes.get(model)
233    }
234}
235
236/// Runtime options shared by server entry points.
237#[derive(Clone, Debug)]
238pub struct ServerRunOptions {
239    /// Socket address to bind.
240    pub addr: SocketAddr,
241    /// TCP listen backlog.
242    pub backlog: u32,
243    /// Validate runtime construction without binding a socket.
244    pub dry_run: bool,
245    /// Maximum time active requests may drain after shutdown begins.
246    pub shutdown_timeout: Duration,
247    /// TLS certificate configuration, when HTTPS is enabled.
248    pub tls: Option<TlsOptions>,
249}
250
251/// TLS certificate paths used by the server.
252#[derive(Clone, Debug)]
253pub struct TlsOptions {
254    /// TLS certificate path in PEM format.
255    pub cert: PathBuf,
256    /// TLS private-key path in PEM format.
257    pub key: PathBuf,
258}
259
260impl ServerRunOptions {
261    fn is_tls(&self) -> bool {
262        self.tls.is_some()
263    }
264}
265
266/// Validates the runtime and starts the HTTP server unless `dry_run` is set.
267pub async fn run_server(state: ServerState, options: ServerRunOptions) -> ServerResult<()> {
268    if options.dry_run {
269        println!("{}", dry_run_summary(&state));
270        return Ok(());
271    }
272
273    let server = BoundServer::bind(state, options)?;
274    println!("{}", server.startup_banner(std::io::stdout().is_terminal()));
275    server.serve(shutdown::signal()).await
276}
277
278/// A configured server with its listening socket already bound.
279pub struct BoundServer {
280    listener: TcpListener,
281    router: Router,
282    options: ServerRunOptions,
283    state: ServerState,
284}
285
286impl BoundServer {
287    /// Binds the configured address and prepares the HTTP router.
288    pub fn bind(state: ServerState, options: ServerRunOptions) -> ServerResult<Self> {
289        let listener = bind_tcp_listener(options.addr, options.backlog)?;
290        let addr = listener.local_addr().map_err(server_io_error)?;
291        Ok(Self {
292            listener,
293            router: build_switchyard_router(state.clone()),
294            options: ServerRunOptions { addr, ..options },
295            state,
296        })
297    }
298
299    /// Returns the actual bound address, including an OS-selected port.
300    pub fn local_addr(&self) -> SocketAddr {
301        self.options.addr
302    }
303
304    /// Serves requests until the supplied shutdown future resolves.
305    pub async fn serve(
306        self,
307        shutdown: impl Future<Output = ()> + Send + 'static,
308    ) -> ServerResult<()> {
309        let shutdown_timeout = self.options.shutdown_timeout;
310        if let Some(tls) = self.options.tls {
311            serve_tls(self.listener, self.router, tls, shutdown_timeout, shutdown).await
312        } else {
313            serve(self.listener, self.router, shutdown_timeout, shutdown).await
314        }
315    }
316
317    fn startup_banner(&self, color: bool) -> String {
318        startup_banner(&self.options, &self.state, color)
319    }
320}
321
322async fn serve_tls(
323    listener: TcpListener,
324    router: Router,
325    tls: TlsOptions,
326    shutdown_timeout: Duration,
327    shutdown: impl Future<Output = ()> + Send + 'static,
328) -> ServerResult<()> {
329    if let Err(error) = rustls::crypto::aws_lc_rs::default_provider().install_default() {
330        tracing::debug!(?error, "TLS crypto provider was already installed");
331    }
332
333    let config = RustlsConfig::from_pem_file(tls.cert, tls.key)
334        .await
335        .map_err(server_io_error)?;
336    let std_listener = listener.into_std().map_err(server_io_error)?;
337    let server = axum_server::from_tcp_rustls(std_listener, config).map_err(server_io_error)?;
338    let handle = axum_server::Handle::new();
339    let server = server
340        .handle(handle.clone())
341        .serve(router.into_make_service());
342    serve_until_shutdown(server, handle, shutdown_timeout, shutdown).await
343}
344
345async fn serve(
346    listener: TcpListener,
347    router: Router,
348    shutdown_timeout: Duration,
349    shutdown: impl Future<Output = ()> + Send + 'static,
350) -> ServerResult<()> {
351    let std_listener = listener.into_std().map_err(server_io_error)?;
352    let server = axum_server::from_tcp(std_listener).map_err(server_io_error)?;
353    let handle = axum_server::Handle::new();
354    let server = server
355        .handle(handle.clone())
356        .serve(router.into_make_service());
357    serve_until_shutdown(server, handle, shutdown_timeout, shutdown).await
358}
359
360/// Runs the server until it exits or shutdown begins, then drains active requests.
361async fn serve_until_shutdown(
362    server: impl Future<Output = std::io::Result<()>>,
363    handle: axum_server::Handle<SocketAddr>,
364    timeout: Duration,
365    shutdown: impl Future<Output = ()> + Send + 'static,
366) -> ServerResult<()> {
367    tokio::pin!(server);
368    tokio::select! {
369        result = &mut server => result.map_err(server_io_error),
370        _ = shutdown => {
371            tracing::info!(
372                ?timeout,
373                "shutdown signal received; draining active requests"
374            );
375            handle.graceful_shutdown(Some(timeout));
376            server.await.map_err(server_io_error)
377        }
378    }
379}
380
381/// Ingress timestamp for one request, taken before any body is read.
382#[derive(Clone, Copy)]
383struct RequestStart(Instant);
384
385/// Tier recorded for classifier and judge calls in the routing log, distinguishing
386/// routing overhead from the routed tiers a session was served by.
387const CLASSIFIER_TIER: &str = "classifier";
388
389/// Maps routed call observations to backend stats, non-routed calls to classifier/judge
390/// stats, and records routing overhead once the algorithm run completes.
391///
392/// Successful classifier/judge calls are also appended to `classifier_log` when one is
393/// configured, so per-session routing snapshots account for judge token overhead. Routed
394/// calls stay off this path: the served call is logged with its terminal usage in
395/// [`usage_metrics::observe`], which would make a second append here a double count.
396fn stats_observer(
397    stats: StatsAccumulator,
398    classifier_log: Option<(SharedRoutingLog, routing_log::RoutingLogContext)>,
399) -> RunObserver {
400    Arc::new(move |observation| match observation {
401        RunObservation::LlmCall(call) => {
402            let latency_ms = call.duration.as_secs_f64() * 1_000.0;
403            if call.is_routed {
404                if call.is_success {
405                    stats.record_success(&call.selected_model, latency_ms, call.tier.as_deref());
406                } else {
407                    stats.record_error(&call.selected_model, call.tier.as_deref());
408                }
409            } else if call.is_success {
410                if let (Some((log, context)), Some(usage)) =
411                    (classifier_log.as_ref(), call.usage.as_ref())
412                {
413                    log.append(
414                        context.clone(),
415                        &call.selected_model,
416                        Some(CLASSIFIER_TIER),
417                        usage,
418                    );
419                }
420                stats.record_classifier_success(
421                    call.selected_model,
422                    call.usage.as_ref().map(usage_metrics::token_usage),
423                    latency_ms,
424                );
425            } else {
426                stats.record_classifier_error(call.selected_model);
427            }
428        }
429        RunObservation::RoutingOverhead(duration) => {
430            stats.record_routing_overhead(duration.as_secs_f64() * 1_000.0);
431        }
432    })
433}
434
435/// Stamps the ingress instant into request extensions. Runs as a router layer,
436/// so it executes before the handlers' `Json` extractor buffers the body —
437/// request-latency measurements therefore include body read and decode.
438async fn stamp_request_start(mut request: HttpRequest, next: Next) -> Response {
439    request
440        .extensions_mut()
441        .insert(RequestStart(Instant::now()));
442    next.run(request).await
443}
444
445/// Builds an Axum router for the supported LLM wire formats.
446pub fn build_switchyard_router(state: ServerState) -> Router {
447    let mut router = Router::new()
448        .route("/v1/chat/completions", post(openai_chat_completions))
449        .route("/v1/messages", post(anthropic_messages))
450        .route("/v1/responses", post(openai_responses))
451        .route("/v1/messages/count_tokens", post(anthropic_count_tokens))
452        .route("/v1/models", get(models))
453        .route("/v1/stats", get(get_stats))
454        .route("/v1/stats/reset", post(reset_stats))
455        .route("/metrics", get(prometheus_metrics))
456        .route("/health", get(health));
457    if state.routing_log.is_some() {
458        router = router.route("/v1/routing/session-stats", get(get_session_stats));
459    }
460    router
461        .fallback(not_found)
462        .layer(DefaultBodyLimit::max(DEFAULT_MAX_REQUEST_BODY_BYTES))
463        // `layer` only wraps routes registered before it, so this stays last.
464        .layer(axum::middleware::from_fn(stamp_request_start))
465        .with_state(state)
466}
467
468fn bind_tcp_listener(addr: SocketAddr, backlog: u32) -> ServerResult<TcpListener> {
469    let socket = if addr.is_ipv4() {
470        TcpSocket::new_v4()
471    } else {
472        TcpSocket::new_v6()
473    }
474    .map_err(server_io_error)?;
475
476    socket.set_reuseaddr(true).map_err(server_io_error)?;
477    socket.bind(addr).map_err(server_io_error)?;
478    socket.listen(backlog).map_err(server_io_error)
479}
480
481fn server_io_error(error: std::io::Error) -> ServerError {
482    ServerError::new(error.to_string())
483}
484
485async fn openai_chat_completions(
486    State(state): State<ServerState>,
487    Extension(started): Extension<RequestStart>,
488    headers: HeaderMap,
489    body: std::result::Result<Json<Value>, JsonRejection>,
490) -> Response {
491    handle_endpoint(state, started, headers, body, WireFormat::OpenAiChat).await
492}
493
494async fn anthropic_messages(
495    State(state): State<ServerState>,
496    Extension(started): Extension<RequestStart>,
497    headers: HeaderMap,
498    body: std::result::Result<Json<Value>, JsonRejection>,
499) -> Response {
500    handle_endpoint(state, started, headers, body, WireFormat::AnthropicMessages).await
501}
502
503async fn openai_responses(
504    State(state): State<ServerState>,
505    Extension(started): Extension<RequestStart>,
506    headers: HeaderMap,
507    body: std::result::Result<Json<Value>, JsonRejection>,
508) -> Response {
509    handle_endpoint(state, started, headers, body, WireFormat::OpenAiResponses).await
510}
511
512/// Anthropic token counting against the route's explicitly configured target.
513async fn anthropic_count_tokens(
514    State(state): State<ServerState>,
515    headers: HeaderMap,
516    body: std::result::Result<Json<Value>, JsonRejection>,
517) -> Response {
518    let body = match llm_json_body(body) {
519        Ok(body) => body,
520        Err(message) => return anthropic_error_response(invalid_body_error(message)),
521    };
522    let (route, request) = match resolve_route(
523        &state,
524        metadata_from_headers(headers),
525        body,
526        WireFormat::AnthropicMessages,
527    ) {
528        Ok(resolved) => resolved,
529        Err(response) => return anthropic_error_response(response),
530    };
531    let Some(target) = route.count_tokens_target.as_ref() else {
532        return anthropic_error_response(error_response(
533            StatusCode::BAD_REQUEST,
534            "route has no Anthropic target for token counting",
535            "invalid_request_error",
536            "count_tokens_unsupported",
537        ));
538    };
539    anthropic_error_response(match target.count_tokens(request).await {
540        Ok(payload) => (StatusCode::OK, Json(payload)).into_response(),
541        Err(error) => count_tokens_error(error),
542    })
543}
544
545/// Maps a token-count client failure with the same policy as a routed client call.
546fn count_tokens_error(error: LlmClientError) -> Response {
547    client_error(&error)
548}
549
550async fn handle_endpoint(
551    state: ServerState,
552    started: RequestStart,
553    headers: HeaderMap,
554    body: std::result::Result<Json<Value>, JsonRejection>,
555    wire_format: WireFormat,
556) -> Response {
557    let span = observability::request_span(&headers);
558    handle_endpoint_inner(state, started, headers, body, wire_format)
559        .instrument(span)
560        .await
561}
562
563async fn handle_endpoint_inner(
564    state: ServerState,
565    started: RequestStart,
566    headers: HeaderMap,
567    body: std::result::Result<Json<Value>, JsonRejection>,
568    wire_format: WireFormat,
569) -> Response {
570    let routing_log_context = state
571        .routing_log
572        .as_ref()
573        .map(|_| routing_log::RoutingLogContext::from_headers(&headers));
574    let metadata = metadata_from_headers(headers);
575    let request_log = RequestLogContext {
576        started: started.0,
577        wire_format,
578        requested_model: body
579            .as_ref()
580            .ok()
581            .and_then(|body| body.0.get("model"))
582            .and_then(Value::as_str)
583            .map(str::to_string),
584        streaming: body
585            .as_ref()
586            .ok()
587            .and_then(|body| body.0.get("stream"))
588            .and_then(Value::as_bool)
589            .unwrap_or(false),
590        session_id: metadata.session_id.clone(),
591        correlation_id: metadata.correlation_id.clone(),
592    };
593
594    let response = match llm_json_body(body) {
595        Ok(body) => {
596            handle_llm_request(
597                state,
598                started,
599                metadata,
600                body,
601                wire_format,
602                routing_log_context,
603            )
604            .await
605        }
606        Err(message) => invalid_body_error(message),
607    };
608    let response = render_error_response(response, wire_format);
609    metrics::record_client_response(response.status().as_u16());
610    request_log.emit(&response);
611    response
612}
613
614fn llm_json_body(
615    body: std::result::Result<Json<Value>, JsonRejection>,
616) -> std::result::Result<Value, String> {
617    match body {
618        Ok(Json(value)) if value.is_object() => Ok(value),
619        Ok(_) => Err("Request body must be a JSON object".to_string()),
620        Err(error) => Err(format!("Request body must be valid JSON: {error}")),
621    }
622}
623
624/// Decode `body`, resolve the route named by its `model`, and build the
625/// [`Request`]. Shared by the completion and `count_tokens` handlers. Returns
626/// the resolved route and the built request — or an error [`Response`]
627/// (invalid body, empty `model` → 400, unknown route → 404).
628// Both callers immediately return the `Err(Response)` as the HTTP response, so
629// the large error type is intentional, not propagated up a call stack.
630#[allow(clippy::type_complexity, clippy::result_large_err)]
631fn resolve_route(
632    state: &ServerState,
633    metadata: Metadata,
634    body: Value,
635    wire_format: WireFormat,
636) -> std::result::Result<(&RouteEntry, Request), Response> {
637    let llm_request = decode_request(wire_format, &body)
638        .map_err(|error| invalid_body_error(error.to_string()))?;
639    let requested_model = llm_request
640        .model
641        .clone()
642        .filter(|model| !model.trim().is_empty())
643        .ok_or_else(|| {
644            error_response(
645                StatusCode::BAD_REQUEST,
646                "request body must include a non-empty string `model`",
647                "invalid_request_error",
648                "invalid_request_error",
649            )
650        })?;
651    let route = state.route_for_model(&requested_model).ok_or_else(|| {
652        error_response(
653            StatusCode::NOT_FOUND,
654            format!("No route registered for model {requested_model}"),
655            "model_not_found",
656            "model_not_found",
657        )
658    })?;
659    let request = Request {
660        llm_request,
661        raw_request: Some(body),
662        metadata: Some(metadata),
663    };
664    Ok((route, request))
665}
666
667async fn handle_llm_request(
668    state: ServerState,
669    started: RequestStart,
670    metadata: Metadata,
671    body: Value,
672    wire_format: WireFormat,
673    routing_log_context: Option<routing_log::RoutingLogContext>,
674) -> Response {
675    let cache_probe = state.track_cache_eligibility.then(|| prefix_probe(&body));
676    let (route, request) = match resolve_route(&state, metadata, body, wire_format) {
677        Ok(resolved) => resolved,
678        Err(response) => return response,
679    };
680    let algorithm = Arc::clone(&route.algorithm);
681    let observer = stats_observer(
682        state.stats.clone(),
683        state.routing_log.clone().zip(routing_log_context.clone()),
684    );
685    let (trace, response) = match algorithm
686        .run_observed(Context::default(), request, Some(observer))
687        .await
688    {
689        Ok(result) => result,
690        Err(error) => return algorithm_error(error),
691    };
692    for reason in trace
693        .iter()
694        .filter_map(|decision| decision.fallback_reason())
695    {
696        state.stats.record_routing_fallback(reason);
697    }
698
699    // Metrics, response body, and routing header all read the same decision, so
700    // the model they name can never disagree. An empty trace leaves the body with
701    // the id the upstream reported.
702    let decision = trace.last();
703    let response = if let Some(decision) = decision {
704        let routing_log_context = routing_log_context
705            .map(|context| context.with_fallback_reason(decision.fallback_reason()));
706        let cache_eligible = cache_probe
707            .as_ref()
708            .map(|probe| {
709                state
710                    .stats
711                    .prefix_eligibility(decision.selected_model(), probe)
712            })
713            .unwrap_or(0.0);
714        usage_metrics::observe(
715            response,
716            decision.selected_model(),
717            decision.routing_tier(),
718            started.0,
719            state.stats,
720            cache_eligible,
721            state.routing_log.zip(routing_log_context),
722        )
723    } else {
724        response
725    };
726
727    let served_model = decision.map(|decision| decision.selected_model().to_string());
728    let mut response = match into_http_response(response, wire_format, served_model) {
729        Ok(response) => response,
730        Err(error) => return server_error(error.to_string()),
731    };
732    if let Some(decision) = decision {
733        attach_routing_headers(&mut response, decision.as_ref());
734    }
735    response
736}
737
738// Request metadata held until the terminal response determines the event level.
739struct RequestLogContext {
740    started: Instant,
741    wire_format: WireFormat,
742    requested_model: Option<String>,
743    streaming: bool,
744    session_id: Option<String>,
745    correlation_id: Option<String>,
746}
747
748// Error text carried separately so terminal logging never consumes an HTTP body.
749#[derive(Clone)]
750struct RequestLogError(String);
751
752impl RequestLogContext {
753    fn emit(self, response: &Response) {
754        let selected_model = response
755            .headers()
756            .get(HEADER_SELECTED_MODEL)
757            .and_then(|value| value.to_str().ok())
758            .unwrap_or("");
759        let duration_ms = self.started.elapsed().as_secs_f64() * 1_000.0;
760        let error = response
761            .extensions()
762            .get::<RequestLogError>()
763            .map(|error| error.0.as_str())
764            .unwrap_or("");
765
766        macro_rules! emit {
767            ($level:expr, $message:literal) => {
768                tracing::event!(
769                    target: "switchyard_server::request",
770                    $level,
771                    wire_format = %self.wire_format,
772                    status = response.status().as_u16(),
773                    requested_model = self.requested_model.as_deref().unwrap_or(""),
774                    selected_model,
775                    streaming = self.streaming,
776                    session_id = self.session_id.as_deref().unwrap_or(""),
777                    correlation_id = self.correlation_id.as_deref().unwrap_or(""),
778                    handling_duration_ms = duration_ms,
779                    error,
780                    $message
781                )
782            };
783        }
784
785        match request_log_level(response.status()) {
786            Level::ERROR => emit!(Level::ERROR, "LLM request failed"),
787            Level::WARN => emit!(Level::WARN, "LLM request failed"),
788            _ => emit!(Level::INFO, "LLM request handled"),
789        }
790    }
791}
792
793fn request_log_level(status: StatusCode) -> Level {
794    if status.is_server_error() {
795        Level::ERROR
796    } else if status.is_success() {
797        Level::INFO
798    } else {
799        Level::WARN
800    }
801}
802
803fn metadata_from_headers(headers: HeaderMap) -> Metadata {
804    let mut metadata = Metadata::from_headers(&headers);
805    metadata.http_headers = Some(headers);
806    metadata
807}
808
809fn attach_routing_headers(response: &mut Response, decision: &dyn Decision) {
810    insert_routing_header(response, HEADER_SELECTED_MODEL, decision.selected_model());
811    if let Some(reasoning) = decision.reasoning() {
812        insert_routing_header(response, HEADER_RATIONALE, reasoning);
813    }
814}
815
816fn insert_routing_header(response: &mut Response, name: &'static str, value: &str) {
817    let Some(value) = sanitize_routing_header_value(value) else {
818        return;
819    };
820    let Ok(value) = HeaderValue::from_str(&value) else {
821        return;
822    };
823    response
824        .headers_mut()
825        .insert(HeaderName::from_static(name), value);
826}
827
828fn sanitize_routing_header_value(value: &str) -> Option<String> {
829    let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
830    (!value.is_empty()).then(|| value.chars().take(MAX_ROUTING_HEADER_VALUE_LEN).collect())
831}
832
833fn algorithm_error(error: LibsyError) -> Response {
834    let LibsyError::ClientCall { source, .. } = &error else {
835        return server_error(error.to_string());
836    };
837    client_error(source)
838}
839
840fn client_error(error: &LlmClientError) -> Response {
841    match error {
842        LlmClientError::InvalidRequest { message }
843        | LlmClientError::RequestTranslation(message) => error_response(
844            StatusCode::BAD_REQUEST,
845            message,
846            "invalid_request_error",
847            "invalid_request_error",
848        ),
849        LlmClientError::Configuration { message } => error_response(
850            StatusCode::BAD_GATEWAY,
851            message,
852            "upstream_error",
853            "upstream_configuration_error",
854        ),
855        LlmClientError::ContextWindowExceeded { message, .. } => error_response(
856            StatusCode::BAD_REQUEST,
857            message,
858            "invalid_request_error",
859            "context_length_exceeded",
860        ),
861        LlmClientError::UpstreamHttp { status, body } => error_response(
862            StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY),
863            upstream_error_message(body),
864            "upstream_error",
865            "upstream_error",
866        ),
867        LlmClientError::Transport { source } | LlmClientError::InvalidResponse { source } => {
868            error_response(
869                StatusCode::BAD_GATEWAY,
870                source.to_string(),
871                "upstream_error",
872                "upstream_error",
873            )
874        }
875        LlmClientError::ResponseTranslation(message) => error_response(
876            StatusCode::BAD_GATEWAY,
877            message,
878            "upstream_error",
879            "upstream_error",
880        ),
881        LlmClientError::Timeout { source } => error_response(
882            StatusCode::GATEWAY_TIMEOUT,
883            source.to_string(),
884            "upstream_error",
885            "upstream_timeout",
886        ),
887        LlmClientError::RequestEncoding(message) => server_error(message),
888        _ => server_error(error.to_string()),
889    }
890}
891
892// Provider errors are often JSON documents; expose their message without
893// embedding the entire document as an escaped string in our error envelope.
894fn upstream_error_message(body: &str) -> String {
895    serde_json::from_str::<Value>(body)
896        .ok()
897        .and_then(|body| {
898            body.pointer("/error/message")
899                .and_then(Value::as_str)
900                .map(str::to_string)
901        })
902        .unwrap_or_else(|| body.to_string())
903}
904
905// Error metadata retained until the client-facing endpoint selects an envelope.
906#[derive(Clone)]
907struct ApiError {
908    status: StatusCode,
909    message: String,
910    error_type: &'static str,
911    code: &'static str,
912}
913
914impl ApiError {
915    fn new(
916        status: StatusCode,
917        message: impl Into<String>,
918        error_type: &'static str,
919        code: &'static str,
920    ) -> Self {
921        Self {
922            status,
923            message: message.into(),
924            error_type,
925            code,
926        }
927    }
928
929    fn into_response(self, wire_format: WireFormat) -> Response {
930        let body = match wire_format {
931            WireFormat::AnthropicMessages => json!({
932                "type": "error",
933                "error": {
934                    "type": anthropic_error_type(self.status),
935                    "message": self.message.clone(),
936                }
937            }),
938            WireFormat::OpenAiChat | WireFormat::OpenAiResponses => json!({
939                "error": {
940                    "message": self.message.clone(),
941                    "type": self.error_type,
942                    "code": self.code,
943                }
944            }),
945        };
946        let mut response = (self.status, Json(body)).into_response();
947        response
948            .extensions_mut()
949            .insert(RequestLogError(self.message.clone()));
950        response.extensions_mut().insert(self);
951        response
952    }
953}
954
955fn render_error_response(response: Response, wire_format: WireFormat) -> Response {
956    let Some(error) = response.extensions().get::<ApiError>().cloned() else {
957        return response;
958    };
959    error.into_response(wire_format)
960}
961
962fn anthropic_error_response(response: Response) -> Response {
963    render_error_response(response, WireFormat::AnthropicMessages)
964}
965
966fn anthropic_error_type(status: StatusCode) -> &'static str {
967    match status {
968        StatusCode::BAD_REQUEST => "invalid_request_error",
969        StatusCode::UNAUTHORIZED => "authentication_error",
970        StatusCode::FORBIDDEN => "permission_error",
971        StatusCode::NOT_FOUND => "not_found_error",
972        StatusCode::PAYLOAD_TOO_LARGE => "request_too_large",
973        StatusCode::TOO_MANY_REQUESTS => "rate_limit_error",
974        status if status.as_u16() == 529 => "overloaded_error",
975        _ => "api_error",
976    }
977}
978
979fn server_error(message: impl Into<String>) -> Response {
980    error_response(
981        StatusCode::INTERNAL_SERVER_ERROR,
982        message,
983        "server_error",
984        "server_error",
985    )
986}
987
988fn invalid_body_error(message: impl Into<String>) -> Response {
989    error_response(
990        StatusCode::BAD_REQUEST,
991        message,
992        "invalid_request_error",
993        "invalid_body",
994    )
995}
996
997fn error_response(
998    status: StatusCode,
999    message: impl Into<String>,
1000    error_type: &'static str,
1001    code: &'static str,
1002) -> Response {
1003    ApiError::new(status, message, error_type, code).into_response(WireFormat::OpenAiChat)
1004}
1005
1006async fn models(State(state): State<ServerState>) -> Json<Value> {
1007    Json(model_list_payload(
1008        state
1009            .routes
1010            .iter()
1011            .map(|(model, entry)| (model.as_str(), entry.capabilities)),
1012    ))
1013}
1014
1015async fn get_stats(State(state): State<ServerState>) -> Json<StatsSnapshot> {
1016    Json(state.stats.snapshot())
1017}
1018
1019async fn reset_stats(State(state): State<ServerState>) -> Json<Value> {
1020    state.stats.reset();
1021    Json(json!({"status": "reset"}))
1022}
1023
1024#[derive(Deserialize)]
1025struct SessionStatsQuery {
1026    session_id: String,
1027}
1028
1029// TODO: This loads the entire file. It should stream the JSONL instead.
1030// Huge files will crash the demo server.
1031async fn get_session_stats(
1032    State(state): State<ServerState>,
1033    query: std::result::Result<Query<SessionStatsQuery>, QueryRejection>,
1034) -> Response {
1035    let Query(query) = match query {
1036        Ok(query) => query,
1037        Err(error) => {
1038            return error_response(
1039                StatusCode::BAD_REQUEST,
1040                error.to_string(),
1041                "invalid_request_error",
1042                "invalid_query",
1043            );
1044        }
1045    };
1046    let Some(routing_log) = state.routing_log else {
1047        // Should be unreachable
1048        return not_found().await;
1049    };
1050    let session_id = query.session_id.clone();
1051    // Loading and de-serializing a large file is a time consuming blocking operation
1052    let snapshot =
1053        match task::spawn_blocking(move || routing_log.snapshot_session(&session_id)).await {
1054            Ok(s) => s,
1055            Err(err) => {
1056                return server_error(format!("failed to snapshot: {err}"));
1057            }
1058        };
1059
1060    match snapshot {
1061        Ok(Some(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(),
1062        Ok(None) => error_response(
1063            StatusCode::NOT_FOUND,
1064            "Routing session not found",
1065            "not_found",
1066            "routing_session_not_found",
1067        ),
1068        Err(error) => server_error(format!("failed to read routing log: {error}")),
1069    }
1070}
1071
1072async fn health() -> Json<Value> {
1073    Json(json!({"status": "ok"}))
1074}
1075
1076async fn prometheus_metrics(State(state): State<ServerState>) -> Response {
1077    match metrics::encode(&state.metrics) {
1078        Ok(body) => ([(CONTENT_TYPE, metrics::CONTENT_TYPE)], body).into_response(),
1079        Err(error) => server_error(error),
1080    }
1081}
1082
1083async fn not_found() -> Response {
1084    error_response(
1085        StatusCode::NOT_FOUND,
1086        "Not Found",
1087        "not_found",
1088        "endpoint_not_found",
1089    )
1090}
1091
1092fn model_list_payload<'a>(
1093    entries: impl IntoIterator<Item = (&'a str, ModelCapabilities)>,
1094) -> Value {
1095    let entries = entries.into_iter().collect::<Vec<_>>();
1096    let model_ids = entries.iter().map(|(model, _)| *model).collect::<Vec<_>>();
1097    let first_id = model_ids.first().copied();
1098    let last_id = model_ids.last().copied();
1099    json!({
1100        "object": "list",
1101        "data": entries.iter().map(|(model, caps)| model_entry_json(model, *caps)).collect::<Vec<_>>(),
1102        "models": entries
1103            .iter()
1104            .enumerate()
1105            .map(|(priority, (model, caps))| codex_model_entry_json(model, *caps, priority))
1106            .collect::<Vec<_>>(),
1107        "first_id": first_id,
1108        "last_id": last_id,
1109        "has_more": false,
1110        "default_model": first_id,
1111        "model_pool": model_ids,
1112    })
1113}
1114
1115fn model_entry_json(model: &str, capabilities: ModelCapabilities) -> Value {
1116    json!({
1117        "id": model,
1118        "object": "model",
1119        "type": "model",
1120        "created": 0,
1121        "owned_by": "switchyard",
1122        "display_name": model,
1123        "capabilities": {
1124            "streaming": true,
1125            "tool_calling": capabilities.tool_calling,
1126            "context_window": capabilities.context_window,
1127            "supported_inbound_formats": [
1128                "openai-chat-completions",
1129                "openai-responses",
1130                "anthropic-messages",
1131            ],
1132        },
1133    })
1134}
1135
1136// Builds the metadata Codex requires when it discovers models from a direct provider.
1137//
1138// This mirrors Codex's `ModelInfo` card. The launcher path builds the same card in
1139// `switchyard/cli/launchers/codex_model_catalog.py`; keep the two in sync when Codex
1140// changes the shape. Every field below is either derived from the route's declared
1141// capabilities or a required `ModelInfo` field the server has no better value for.
1142//
1143// Two kinds of fields live here. context_window, tool_calling, and reasoning are model
1144// facts a backend can publish; the route declares them in config today. The rest
1145// (shell_type, apply_patch_tool_type, base_instructions, the reasoning-level presets,
1146// truncation_policy) are Codex client conventions no backend returns, so they stay
1147// constant.
1148//
1149// TODO: source context_window, tool_calling, and reasoning from the backend, not route
1150// config. Switchyard is a proxy, so it should re-publish what the backend advertises
1151// when it can — OpenRouter's /api/v1/models exposes context_length and
1152// supported_parameters — and fall back to the route's declared value. Some backends
1153// publish nothing (the NVIDIA gateway returns id-only models and blocks /model/info),
1154// so keep failing closed to config.
1155fn codex_model_entry_json(model: &str, capabilities: ModelCapabilities, priority: usize) -> Value {
1156    // Codex is non-functional without shell and apply_patch, so an undeclared tool
1157    // capability defaults to enabled here; the OpenAI `data` entry reports the raw
1158    // Option separately for clients that want the undeclared state.
1159    let tool_calling = capabilities.tool_calling.unwrap_or(true);
1160    let reasoning = capabilities.reasoning.unwrap_or(false);
1161    json!({
1162        "slug": model,
1163        "display_name": model,
1164        "description": "Switchyard-routed model.",
1165        "default_reasoning_level": if reasoning { json!("xhigh") } else { Value::Null },
1166        "supported_reasoning_levels": if reasoning { reasoning_levels() } else { json!([]) },
1167        "shell_type": if tool_calling { "shell_command" } else { "disabled" },
1168        "visibility": "list",
1169        "supported_in_api": true,
1170        // Catalog list position (routes are listed in sorted id order), not a quality rank.
1171        "priority": priority,
1172        "additional_speed_tiers": [],
1173        "availability_nux": null,
1174        "upgrade": null,
1175        // Required `ModelInfo` string. Unlike the launcher, the server cannot read
1176        // Codex's bundled prompt, so it sends a minimal stub.
1177        "base_instructions": "You are Codex, a coding agent.",
1178        "supports_reasoning_summaries": reasoning,
1179        "default_reasoning_summary": "none",
1180        "support_verbosity": reasoning,
1181        "default_verbosity": if reasoning { json!("low") } else { Value::Null },
1182        "apply_patch_tool_type": if tool_calling { Some("freeform") } else { None },
1183        "web_search_tool_type": "text",
1184        "truncation_policy": {"mode": "tokens", "limit": 10_000},
1185        "supports_parallel_tool_calls": tool_calling,
1186        "supports_image_detail_original": false,
1187        "context_window": capabilities.context_window,
1188        "max_context_window": capabilities.context_window,
1189        "effective_context_window_percent": 95,
1190        "experimental_supported_tools": [],
1191        "input_modalities": ["text"],
1192        "supports_search_tool": false,
1193    })
1194}
1195
1196// The reasoning-effort presets Codex offers for a reasoning-capable route. Kept in
1197// step with the launcher template in `codex_model_catalog.py`.
1198fn reasoning_levels() -> Value {
1199    json!([
1200        {"effort": "low", "description": "Fast responses with lighter reasoning"},
1201        {"effort": "medium", "description": "Balances speed and reasoning depth"},
1202        {"effort": "high", "description": "Greater reasoning depth"},
1203        {"effort": "xhigh", "description": "Extra high reasoning depth"},
1204    ])
1205}
1206
1207fn startup_banner(options: &ServerRunOptions, state: &ServerState, color: bool) -> String {
1208    let scheme = if options.is_tls() { "https" } else { "http" };
1209    let listen_url = url_for_addr(scheme, options.addr);
1210    let request_url = request_url_for_addr(scheme, options.addr);
1211    let routes = state.models().collect::<Vec<_>>();
1212    let route_list = routes.join(", ");
1213    let example_model = routes.first().copied().unwrap_or("switchyard/route");
1214    let example_body = json!({
1215        "model": example_model,
1216        "messages": [{"role": "user", "content": "Hello from Switchyard"}],
1217    });
1218    let example_url = shell_quote(&format!("{request_url}/v1/chat/completions"));
1219    let example_body = shell_quote(&example_body.to_string());
1220    format!(
1221        "{}\nSwitchyard libsy server\n  listening: {}\n  routes: {}\n\nendpoints:\n{}\n\nexample:\n  curl -s {} \\\n    -H 'Content-Type: application/json' \\\n    -d {}",
1222        render_startup_banner_art(color),
1223        listen_url,
1224        route_list,
1225        endpoint_listing(state.routing_log.is_some()),
1226        example_url,
1227        example_body,
1228    )
1229}
1230
1231// Keep redirected logs plain. Terminal output applies NVIDIA-green ANSI truecolor per line.
1232fn render_startup_banner_art(color: bool) -> String {
1233    let banner = STARTUP_BANNER_ART.trim_end();
1234    if !color {
1235        return banner.to_string();
1236    }
1237
1238    let (red, green, blue) = (118, 185, 0);
1239    let mut rendered = String::new();
1240    for line in banner.lines() {
1241        rendered.push_str(&format!("\x1b[38;2;{red};{green};{blue}m{line}\x1b[0m\n"));
1242    }
1243    rendered.trim_end_matches('\n').to_string()
1244}
1245
1246fn dry_run_summary(state: &ServerState) -> String {
1247    format!(
1248        "server OK: {}",
1249        state.models().collect::<Vec<_>>().join(", ")
1250    )
1251}
1252
1253fn url_for_addr(scheme: &'static str, addr: SocketAddr) -> String {
1254    format!("{scheme}://{}:{}", host_for_url(addr.ip()), addr.port())
1255}
1256
1257// Use loopback in request examples when the listener binds all interfaces.
1258fn request_url_for_addr(scheme: &'static str, addr: SocketAddr) -> String {
1259    let host = if addr.ip().is_unspecified() {
1260        match addr.ip() {
1261            std::net::IpAddr::V4(_) => "127.0.0.1".to_string(),
1262            std::net::IpAddr::V6(_) => "[::1]".to_string(),
1263        }
1264    } else {
1265        host_for_url(addr.ip())
1266    };
1267    format!("{scheme}://{host}:{}", addr.port())
1268}
1269
1270// Bracket IPv6 literals so they are valid inside a URL authority.
1271fn host_for_url(ip: std::net::IpAddr) -> String {
1272    match ip {
1273        std::net::IpAddr::V4(ip) => ip.to_string(),
1274        std::net::IpAddr::V6(ip) => format!("[{ip}]"),
1275    }
1276}
1277
1278fn shell_quote(value: &str) -> String {
1279    format!("'{}'", value.replace('\'', "'\\''"))
1280}
1281
1282fn endpoint_listing(has_routing_log: bool) -> String {
1283    let mut endpoints = vec![
1284        "  POST /v1/chat/completions    OpenAI Chat Completions",
1285        "  POST /v1/messages            Anthropic Messages",
1286        "  POST /v1/responses           OpenAI Responses",
1287        "  POST /v1/messages/count_tokens",
1288        "  GET  /v1/models              configured routes",
1289        "  GET  /v1/stats               routing stats",
1290        "  POST /v1/stats/reset",
1291        "  GET  /metrics                Prometheus metrics",
1292        "  GET  /health",
1293    ];
1294    if has_routing_log {
1295        endpoints.push("  GET  /v1/routing/session-stats");
1296    }
1297    endpoints.join("\n")
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use libsy::LlmCallObservation;
1303    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1304    use tokio::sync::{Notify, oneshot};
1305
1306    use super::*;
1307
1308    /// A successful judge call lands in the per-session routing snapshot under its
1309    /// model id with the classifier tier, while routed calls stay off the observer's
1310    /// log path — they are logged with terminal usage when the served response is
1311    /// observed, so an append here would double count them.
1312    #[test]
1313    fn stats_observer_logs_judge_calls_to_the_routing_log() {
1314        let dir = tempfile::tempdir().expect("temp dir");
1315        let log = SharedRoutingLog::new(dir.path().join("routing.jsonl")).expect("routing log");
1316        let mut headers = HeaderMap::new();
1317        headers.insert("proxy_x_session_id", "session-1".parse().expect("header"));
1318        let context = routing_log::RoutingLogContext::from_headers(&headers);
1319        let observer = stats_observer(StatsAccumulator::default(), Some((log.clone(), context)));
1320
1321        let call = |model: &str, is_routed: bool| {
1322            RunObservation::LlmCall(LlmCallObservation {
1323                selected_model: model.to_string(),
1324                tier: None,
1325                is_routed,
1326                is_success: true,
1327                duration: Duration::from_millis(3),
1328                usage: Some(Usage {
1329                    input_tokens: Some(100),
1330                    output_tokens: Some(7),
1331                    ..Usage::default()
1332                }),
1333            })
1334        };
1335        observer(call("judge-model", false));
1336        observer(call("routed-model", true));
1337
1338        let snapshot = log
1339            .snapshot_session("session-1")
1340            .expect("read log")
1341            .expect("session recorded");
1342        let snapshot = serde_json::to_value(&snapshot).expect("serializable snapshot");
1343        assert_eq!(snapshot["models"]["judge-model"]["calls"], 1);
1344        assert_eq!(snapshot["models"]["judge-model"]["prompt_tokens"], 100);
1345        assert_eq!(snapshot["models"]["judge-model"]["completion_tokens"], 7);
1346        assert!(snapshot["models"].get("routed-model").is_none());
1347    }
1348
1349    #[derive(Clone)]
1350    struct ShutdownTestState {
1351        started: Arc<Notify>,
1352        release: Arc<Notify>,
1353    }
1354
1355    struct ShutdownTestServer {
1356        state: ShutdownTestState,
1357        shutdown: oneshot::Sender<()>,
1358        server: task::JoinHandle<ServerResult<()>>,
1359        request: task::JoinHandle<std::io::Result<Vec<u8>>>,
1360    }
1361
1362    async fn blocked_request(State(state): State<ShutdownTestState>) -> &'static str {
1363        state.started.notify_one();
1364        state.release.notified().await;
1365        "done"
1366    }
1367
1368    async fn raw_request(addr: SocketAddr) -> std::io::Result<Vec<u8>> {
1369        let mut stream = tokio::net::TcpStream::connect(addr).await?;
1370        stream
1371            .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
1372            .await?;
1373        let mut response = Vec::new();
1374        stream.read_to_end(&mut response).await?;
1375        Ok(response)
1376    }
1377
1378    fn shutdown_test_server(shutdown_timeout: Duration) -> ShutdownTestServer {
1379        let state = ShutdownTestState {
1380            started: Arc::new(Notify::new()),
1381            release: Arc::new(Notify::new()),
1382        };
1383        let router = Router::new()
1384            .route("/", get(blocked_request))
1385            .with_state(state.clone());
1386        let listener = bind_tcp_listener("127.0.0.1:0".parse().expect("valid address"), 16)
1387            .expect("listener binds");
1388        let addr = listener.local_addr().expect("listener has an address");
1389        let (shutdown, shutdown_receiver) = oneshot::channel();
1390        let server = tokio::spawn(serve(listener, router, shutdown_timeout, async move {
1391            let _ = shutdown_receiver.await;
1392        }));
1393        let request = tokio::spawn(raw_request(addr));
1394        ShutdownTestServer {
1395            state,
1396            shutdown,
1397            server,
1398            request,
1399        }
1400    }
1401
1402    // Active requests may finish within the grace period, while stuck requests are bounded.
1403    #[tokio::test]
1404    async fn shutdown_drains_until_configured_deadline() {
1405        let ShutdownTestServer {
1406            state,
1407            shutdown,
1408            mut server,
1409            request,
1410        } = shutdown_test_server(Duration::from_secs(1));
1411        state.started.notified().await;
1412        shutdown.send(()).expect("server receives shutdown");
1413        assert!(
1414            tokio::time::timeout(Duration::from_millis(25), &mut server)
1415                .await
1416                .is_err(),
1417            "server must wait for the active request"
1418        );
1419        state.release.notify_one();
1420        tokio::time::timeout(Duration::from_secs(1), server)
1421            .await
1422            .expect("server stops after request drains")
1423            .expect("server task completes")
1424            .expect("server exits cleanly");
1425        let response = request
1426            .await
1427            .expect("request task completes")
1428            .expect("request succeeds");
1429        assert!(response.windows(8).any(|part| part == b"200 OK\r\n"));
1430        assert!(response.ends_with(b"done"));
1431
1432        let ShutdownTestServer {
1433            state,
1434            shutdown,
1435            server,
1436            request,
1437        } = shutdown_test_server(Duration::from_millis(25));
1438        state.started.notified().await;
1439        shutdown.send(()).expect("server receives shutdown");
1440        tokio::time::timeout(Duration::from_secs(1), server)
1441            .await
1442            .expect("shutdown deadline is enforced")
1443            .expect("server task completes")
1444            .expect("server exits cleanly");
1445        state.release.notify_one();
1446        request.abort();
1447    }
1448
1449    // Terminal request severity follows HTTP status instead of error-path bookkeeping.
1450    #[test]
1451    fn request_log_level_follows_http_status() {
1452        assert_eq!(request_log_level(StatusCode::OK), Level::INFO);
1453        assert_eq!(request_log_level(StatusCode::BAD_REQUEST), Level::WARN);
1454        assert_eq!(
1455            request_log_level(StatusCode::INTERNAL_SERVER_ERROR),
1456            Level::ERROR
1457        );
1458    }
1459
1460    // Canonical error text remains available without consuming the response body.
1461    #[test]
1462    fn error_response_carries_request_log_error() {
1463        let response = error_response(
1464            StatusCode::BAD_REQUEST,
1465            "invalid request",
1466            "invalid_request_error",
1467            "invalid_request_error",
1468        );
1469
1470        assert_eq!(
1471            response
1472                .extensions()
1473                .get::<RequestLogError>()
1474                .map(|error| error.0.as_str()),
1475            Some("invalid request")
1476        );
1477    }
1478}