Skip to main content

furnace/
api.rs

1use axum::http::{
2    HeaderMap, HeaderName, HeaderValue, Method,
3    header::{
4        ACCEPT, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
5        ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_METHOD,
6        CONTENT_TYPE, ORIGIN, VARY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
7    },
8};
9use axum::{
10    Router,
11    body::Bytes,
12    extract::{DefaultBodyLimit, FromRequest, Path, Request, State},
13    http::StatusCode,
14    middleware::{self, Next},
15    response::{IntoResponse, Json, Response},
16    routing::{MethodRouter, get, post},
17};
18use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor};
19use serde::{Deserialize, Serialize};
20use std::fmt;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
23use std::time::{Duration, Instant, SystemTime};
24use tokio::signal;
25use tokio::sync::Semaphore;
26use tracing::{Instrument, debug, error, info, info_span, warn};
27
28#[cfg(test)]
29mod decode_tests;
30#[cfg(feature = "grpc")]
31pub mod grpc;
32mod infer;
33mod v2;
34
35use infer::{Code, f32s_from_le, f32s_to_le};
36
37use crate::access::{Access, ClientRateLimit, Rejection, constant_time_eq, presented_key};
38pub use crate::batching::BatchConfig;
39use crate::error::{ApiError, FurnaceError, ModelError, Result};
40use crate::model::{MAX_BATCH_SIZE, Model, ModelInfo, ModelStats};
41use crate::nerve::Announced;
42use crate::nerve::serve::{Refused, ServedNeuron, Serving};
43#[cfg(feature = "image")]
44use crate::preprocess::{ImageKind, image_input_size, preprocess_image};
45use crate::tensor::{HostTensor, Values};
46use kuebiko::{Histogram, Percentiles};
47
48/// Media type of the raw little-endian `f32` input/output format
49pub const OCTET_STREAM: &str = "application/octet-stream";
50/// Request: number of items in a binary body (omit for a single item).
51/// Response: number of items in a binary body.
52pub const BATCH_SIZE_HEADER: HeaderName = HeaderName::from_static("x-batch-size");
53/// Binary response: per-item output shape, comma separated (e.g. `1000`)
54pub const OUTPUT_SHAPE_HEADER: HeaderName = HeaderName::from_static("x-output-shape");
55/// Binary response: what `inference_time_ms` holds in the JSON response
56pub const INFERENCE_TIME_HEADER: HeaderName = HeaderName::from_static("x-inference-time-ms");
57/// Request and response: an ID for the request, taken from the client (up to 128 visible ASCII
58/// characters) or generated, attached to every log line of the request
59pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
60
61#[derive(Debug, Serialize)]
62pub struct PredictRequest {
63    #[serde(flatten)]
64    pub input_data: PredictInputData,
65}
66
67#[derive(Debug, Deserialize, Serialize)]
68#[serde(untagged)]
69pub enum PredictInputData {
70    Single { input: Vec<f32> },
71    Batch { inputs: Vec<Vec<f32>> },
72}
73
74// Hand-written so the float arrays are parsed straight into `Vec`s. The derived
75// `flatten` + `untagged` impl first buffers the whole document into serde's generic
76// `Content` tree and then parses it a second time, which doubles the cost on
77// image-sized payloads.
78impl<'de> Deserialize<'de> for PredictRequest {
79    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
80        #[derive(Deserialize)]
81        #[serde(field_identifier, rename_all = "lowercase")]
82        enum Field {
83            Input,
84            Inputs,
85            #[serde(other)]
86            Other,
87        }
88
89        struct RequestVisitor;
90
91        impl<'de> Visitor<'de> for RequestVisitor {
92            type Value = PredictRequest;
93
94            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
95                f.write_str("an object with an `input` or `inputs` field")
96            }
97
98            fn visit_map<A: MapAccess<'de>>(
99                self,
100                mut map: A,
101            ) -> std::result::Result<PredictRequest, A::Error> {
102                let mut input_data = None;
103                while let Some(field) = map.next_key()? {
104                    let value = match field {
105                        Field::Input => PredictInputData::Single {
106                            input: map.next_value()?,
107                        },
108                        Field::Inputs => PredictInputData::Batch {
109                            inputs: map.next_value()?,
110                        },
111                        Field::Other => {
112                            map.next_value::<IgnoredAny>()?;
113                            continue;
114                        }
115                    };
116                    if input_data.replace(value).is_some() {
117                        return Err(de::Error::custom(
118                            "expected exactly one of `input` or `inputs`",
119                        ));
120                    }
121                }
122                input_data
123                    .map(|input_data| PredictRequest { input_data })
124                    .ok_or_else(|| de::Error::custom("missing field `input` or `inputs`"))
125            }
126        }
127
128        deserializer.deserialize_map(RequestVisitor)
129    }
130}
131
132/// A decoded `/predict` body: `batch_size` items back to back in one buffer
133struct PredictInput {
134    data: Vec<f32>,
135    batch_size: usize,
136    /// Answer with `output` instead of `outputs`
137    single: bool,
138}
139
140#[derive(Debug, Serialize, Deserialize)]
141pub struct PredictResponse {
142    #[serde(flatten)]
143    pub output_data: PredictOutputData,
144    pub status: String,
145    pub inference_time_ms: f64,
146    pub timestamp: String,
147    pub batch_size: usize,
148}
149
150#[derive(Debug, Serialize, Deserialize)]
151#[serde(untagged)]
152pub enum PredictOutputData {
153    Single { output: Vec<f32> },
154    Batch { outputs: Vec<Vec<f32>> },
155}
156
157/// What [`PredictResponse`] serializes to, borrowing the output: a batch goes out as its rows
158/// without copying each into a `Vec` of its own
159#[derive(Serialize)]
160struct PredictReply<'a> {
161    #[serde(skip_serializing_if = "Option::is_none")]
162    output: Option<&'a [f32]>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    outputs: Option<Rows<'a>>,
165    status: &'static str,
166    inference_time_ms: f64,
167    timestamp: String,
168    batch_size: usize,
169}
170
171/// A flat buffer as a JSON array of `rows` equal rows
172struct Rows<'a> {
173    values: &'a [f32],
174    rows: usize,
175}
176
177impl Serialize for Rows<'_> {
178    fn serialize<S: serde::Serializer>(
179        &self,
180        serializer: S,
181    ) -> std::result::Result<S::Ok, S::Error> {
182        let width = self.values.len() / self.rows.max(1);
183        serializer.collect_seq(self.values.chunks_exact(width.max(1)).take(self.rows))
184    }
185}
186
187/// `GET /metrics`: server-level counters for monitoring (originally contributed in #32)
188#[derive(Debug, Serialize, Deserialize)]
189pub struct MetricsResponse {
190    pub uptime_seconds: u64,
191    /// `/predict` requests received, including ones rejected before inference
192    pub total_requests: u64,
193    /// `/predict` requests answered with a 4xx/5xx status
194    pub total_errors: u64,
195    /// Requests rejected with `503 SERVER_BUSY`
196    pub rejected_busy: u64,
197    /// Requests rejected with `401 UNAUTHORIZED` for a missing or unknown API key
198    pub rejected_unauthorized: u64,
199    /// Requests rejected with `429 RATE_LIMITED`
200    pub rejected_rate_limited: u64,
201    /// Requests queued for or running inference
202    pub in_flight_inferences: usize,
203    /// `--max-concurrent-requests` (`furnace_max_concurrent_requests` in Prometheus)
204    pub max_concurrent_requests: usize,
205    /// Requests that reached the model (successful or failed)
206    pub total_inferences: u64,
207    /// Forward passes run; with dynamic batching one pass serves several requests
208    pub total_batches: u64,
209    /// Items per forward pass, on average
210    pub average_batch_size: f64,
211    /// `--max-batch-size`; 1 means dynamic batching is off
212    pub max_batch_size: usize,
213    pub average_inference_time_ms: f64,
214    /// When the last successful inference finished
215    pub last_inference_at: Option<String>,
216    /// `/predict` latency from arrival to response, estimated percentiles
217    pub request_latency_ms: Percentiles,
218    /// Successful inference latency, queueing included, estimated percentiles
219    pub inference_latency_ms: Percentiles,
220    /// Whether every model's warm-up inference has run (see `GET /readyz`)
221    pub ready: bool,
222    /// The same per model; the fields above are totals over them
223    pub models: Vec<ModelMetrics>,
224    /// The LLMs (feature `llm`), with their own counters; absent without any
225    #[cfg(feature = "llm")]
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub llm: Vec<crate::llm::LlmMetrics>,
228    /// The nervous system: the served models' pathways, and the neurons added with
229    /// `Server::nerve`
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub nerve: Option<crate::nerve::NerveMetrics>,
232    pub timestamp: String,
233}
234
235/// Per-model part of `GET /metrics`
236#[derive(Debug, Serialize, Deserialize)]
237pub struct ModelMetrics {
238    pub name: String,
239    pub ready: bool,
240    pub inference_count: u64,
241    pub error_count: u64,
242    pub batch_count: u64,
243    pub average_batch_size: f64,
244    pub average_inference_time_ms: f64,
245    pub inference_latency_ms: Percentiles,
246}
247
248/// `GET /models`
249#[derive(Debug, Serialize, Deserialize)]
250pub struct ModelsResponse {
251    /// The model `/predict` and `/model/info` use; empty when the server serves only LLMs
252    pub default_model: String,
253    pub models: Vec<ModelSummary>,
254}
255
256#[derive(Debug, Serialize, Deserialize)]
257pub struct ModelSummary {
258    pub name: String,
259    pub ready: bool,
260    pub input_shape: Vec<usize>,
261    pub output_shape: Vec<usize>,
262    pub backend: String,
263    /// Whether `POST /admin/models/{model}/reload` can reload it
264    pub reloadable: bool,
265}
266
267/// `POST /admin/models/{model}/reload`
268#[derive(Debug, Serialize, Deserialize)]
269pub struct ReloadResponse {
270    pub model: String,
271    /// The weight file given in the request, if any
272    pub weights: Option<String>,
273    /// Loading, warming up and switching to the new model
274    pub reload_ms: f64,
275    pub warm_up_ms: f64,
276    pub timestamp: String,
277}
278
279/// `GET /readyz`
280#[derive(Debug, Serialize, Deserialize)]
281pub struct ReadinessResponse {
282    /// `ready`, or `warming_up` before the warm-up inference has run
283    pub status: String,
284    pub timestamp: String,
285}
286
287#[derive(Debug, Serialize, Deserialize)]
288pub struct HealthResponse {
289    pub status: String,
290    pub model_loaded: bool,
291    pub backend: String,
292    pub uptime_seconds: u64,
293    pub timestamp: String,
294}
295
296#[derive(Debug, Serialize, Deserialize)]
297pub struct ModelInfoResponse {
298    pub model_info: ModelInfo,
299    pub stats: ModelStats,
300    pub timestamp: String,
301}
302
303pub use crate::error::ErrorResponse;
304
305#[derive(Debug, Clone)]
306pub struct ServerConfig {
307    pub host: String,
308    pub port: u16,
309    /// Maximum number of inferences running at once; extra requests get 503
310    pub max_concurrent_requests: usize,
311    /// Maximum request body size in bytes
312    pub max_body_bytes: usize,
313    /// Per-request inference timeout; exceeded requests get 504
314    pub request_timeout: Option<Duration>,
315    /// Dynamic batching of concurrent requests
316    pub batching: BatchConfig,
317    /// Port for the Open Inference Protocol over gRPC (feature `grpc`); `None` serves HTTP only
318    pub grpc_port: Option<u16>,
319    /// Enables `POST /admin/models/{model}/reload` for clients presenting this bearer token;
320    /// `None` leaves the admin API off
321    pub admin_token: Option<String>,
322    /// When not empty, every route but the health probes needs one of these keys, as
323    /// `Authorization: Bearer <key>` or `X-API-Key: <key>`
324    pub api_keys: Vec<String>,
325    /// Per-client rate limit; clients are their API key, or their IP address without keys
326    pub rate_limit: Option<ClientRateLimit>,
327    /// Serve HTTPS (and gRPC over TLS) instead of plain text; needs the `tls` feature
328    pub tls: Option<TlsSettings>,
329}
330
331/// `--tls-cert`, `--tls-key` and `--tls-client-ca`
332#[derive(Debug, Clone, PartialEq)]
333pub struct TlsSettings {
334    /// PEM certificate chain, the server's certificate first
335    pub cert: std::path::PathBuf,
336    /// PEM private key (ECDSA P-256 / P-384 or Ed25519)
337    pub key: std::path::PathBuf,
338    /// PEM CA certificates; when set, clients must present a certificate they signed
339    pub client_ca: Option<std::path::PathBuf>,
340}
341
342/// The peer address of a connection, plain or TLS, for the rate limit
343#[derive(Debug, Clone, Copy)]
344pub struct ClientAddr(pub std::net::SocketAddr);
345
346impl
347    axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, tokio::net::TcpListener>>
348    for ClientAddr
349{
350    fn connect_info(stream: axum::serve::IncomingStream<'_, tokio::net::TcpListener>) -> Self {
351        Self(*stream.remote_addr())
352    }
353}
354
355#[cfg(feature = "tls")]
356impl
357    axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, crate::tls::TlsListener>>
358    for ClientAddr
359{
360    fn connect_info(stream: axum::serve::IncomingStream<'_, crate::tls::TlsListener>) -> Self {
361        Self(*stream.remote_addr())
362    }
363}
364
365impl Default for ServerConfig {
366    fn default() -> Self {
367        Self {
368            host: "127.0.0.1".to_string(),
369            port: 3000,
370            max_concurrent_requests: 100,
371            // A single 224x224 RGB image is ~150k floats, i.e. 1-3 MB of JSON
372            max_body_bytes: 100 * 1024 * 1024,
373            request_timeout: None,
374            batching: BatchConfig::default(),
375            grpc_port: None,
376            admin_token: None,
377            api_keys: Vec::new(),
378            rate_limit: None,
379            tls: None,
380        }
381    }
382}
383
384pub(crate) type AppState = Arc<ServerState>;
385
386/// Loads a model again for `POST /admin/models/{model}/reload`, from the given weight file or,
387/// with `None`, from where it was loaded before. Blocking.
388pub(crate) type Reloader = Arc<dyn Fn(Option<&std::path::Path>) -> Result<Model> + Send + Sync>;
389
390/// A model to serve, and how to reload it (`None`: it cannot be reloaded)
391pub(crate) struct ServedModel {
392    pub(crate) model: Model,
393    pub(crate) reload: Option<Reloader>,
394}
395
396impl From<Model> for ServedModel {
397    fn from(model: Model) -> Self {
398        Self {
399            model,
400            reload: None,
401        }
402    }
403}
404
405/// Serve your own models from Rust, with everything the `furnace` binary does: dynamic
406/// batching, the Open Inference Protocol over HTTP (and gRPC), metrics, readiness, reloads, API
407/// keys, rate limits and TLS, all configured through [`ServerConfig`].
408///
409/// ```ignore
410/// // A Burn model's forward function, through furnace-burn
411/// let model = furnace_burn::from_fn("digits", BackendKind::Cpu, &[1, 28, 28], &[10],
412///     move |x: Tensor<4>| net.forward(x))?;
413/// Server::new().port(8080).model(model).serve().await?;
414/// ```
415///
416/// [`Server::router`] gives the HTTP API as an axum [`Router`] instead, to mount in your own
417/// application.
418pub struct Server {
419    config: ServerConfig,
420    contents: Contents,
421}
422
423/// What a server serves
424#[derive(Default)]
425struct Contents {
426    models: Vec<ServedModel>,
427    #[cfg(feature = "llm")]
428    llms: Vec<Arc<dyn crate::llm::LlmEngine>>,
429    nerve: Option<crate::nerve::FabricBuilder>,
430}
431
432impl Contents {
433    fn is_empty(&self) -> bool {
434        #[cfg(feature = "llm")]
435        if !self.llms.is_empty() {
436            return false;
437        }
438        if self.nerve.is_some() {
439            return false;
440        }
441        self.models.is_empty()
442    }
443}
444
445impl From<Vec<ServedModel>> for Contents {
446    fn from(models: Vec<ServedModel>) -> Self {
447        Self {
448            models,
449            #[cfg(feature = "llm")]
450            llms: Vec::new(),
451            nerve: None,
452        }
453    }
454}
455
456impl Default for Server {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462impl Server {
463    /// A server with [`ServerConfig::default`]: `127.0.0.1:3000`, batching off (see
464    /// [`max_batch_size`](Self::max_batch_size))
465    pub fn new() -> Self {
466        Self::with_config(ServerConfig::default())
467    }
468
469    pub fn with_config(config: ServerConfig) -> Self {
470        Self {
471            config,
472            contents: Contents::default(),
473        }
474    }
475
476    pub fn host(mut self, host: &str) -> Self {
477        self.config.host = host.to_string();
478        self
479    }
480
481    pub fn port(mut self, port: u16) -> Self {
482        self.config.port = port;
483        self
484    }
485
486    /// The most items one batch carries, for the dynamic batching of concurrent requests
487    /// ([`BatchConfig::max_batch_size`]); 1 turns batching off
488    pub fn max_batch_size(mut self, items: usize) -> Self {
489        self.config.batching.max_batch_size = items;
490        self
491    }
492
493    /// How long one inference may take before its request gets 504 Gateway Timeout
494    /// ([`ServerConfig::request_timeout`]; none by default)
495    pub fn request_timeout(mut self, timeout: Duration) -> Self {
496        self.config.request_timeout = Some(timeout);
497        self
498    }
499
500    /// Change any other setting
501    pub fn configure(mut self, change: impl FnOnce(&mut ServerConfig)) -> Self {
502        change(&mut self.config);
503        self
504    }
505
506    /// Serve `model`; the first model added is the one `/predict` uses
507    pub fn model(mut self, model: Model) -> Self {
508        self.contents.models.push(model.into());
509        self
510    }
511
512    /// Serve an LLM (feature `llm`) on `POST /v1/chat/completions`, `POST /v1/completions` and
513    /// `GET /v1/models`, next to the other models: a [`Scheduled`](crate::llm::Scheduled) step
514    /// model, or any [`LlmEngine`](crate::llm::LlmEngine), or an `Arc` of one shared with a
515    /// nerve [`Llm`](crate::nerve::brain::Llm). A request without `model` goes to the first LLM
516    /// added.
517    #[cfg(feature = "llm")]
518    pub fn llm(mut self, engine: impl crate::llm::LlmEngine) -> Self {
519        self.contents.llms.push(Arc::new(engine));
520        self
521    }
522
523    /// Add neurons of your own to the server's nervous system; a fabric that cannot be built
524    /// makes [`serve`](Self::serve) and [`router`](Self::router) return
525    /// [`FurnaceError::Nerve`]. The server wires its models into the same fabric (an entrance
526    /// `in:{model}`, a neuron `model:{model}` and the `serve:respond` effector), so your pathways and its requests share one system. The HTTP
527    /// receptors become routes, the receptors start with the server, `GET /nerve` describes
528    /// every neuron, pathway and measured latency, `GET /nerve/effectors/{name}/events`
529    /// streams an SSE effector's commands, and `/metrics` carries it all. A server may serve
530    /// your neurons alone.
531    pub fn nerve(mut self, fabric: crate::nerve::FabricBuilder) -> Self {
532        self.contents.nerve = Some(fabric);
533        self
534    }
535
536    /// Serve `model` and let `POST /admin/models/{name}/reload` replace it with what `reload`
537    /// returns (given the request's `weights` path, if any). Needs an admin token in the config.
538    pub fn reloadable_model(
539        mut self,
540        model: Model,
541        reload: impl Fn(Option<&std::path::Path>) -> Result<Model> + Send + Sync + 'static,
542    ) -> Self {
543        self.contents.models.push(ServedModel {
544            model,
545            reload: Some(Arc::new(reload)),
546        });
547        self
548    }
549
550    /// The HTTP API as an axum router, to serve or mount yourself, or an error for a server
551    /// with nothing to serve, as [`serve`](Self::serve) gives.
552    ///
553    /// A rate limit without API keys tells clients apart by their address, which axum knows
554    /// only when the router is served with [`ClientAddr`] as connect info; otherwise every
555    /// client shares one budget:
556    ///
557    /// ```no_run
558    /// # async fn run(app: axum::Router) -> std::io::Result<()> {
559    /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
560    /// axum::serve(
561    ///     listener,
562    ///     app.into_make_service_with_connect_info::<furnace::api::ClientAddr>(),
563    /// )
564    /// .await
565    /// # }
566    /// ```
567    pub fn router(self) -> Result<Router> {
568        check_contents(&self.contents)?;
569        warn_if_limited_by_address(&self.config);
570        Ok(router_with_state(
571            server_state(self.contents, &self.config)?,
572            &self.config,
573        ))
574    }
575
576    /// Serve until Ctrl-C / SIGTERM
577    pub async fn serve(self) -> Result<()> {
578        serve(self.config, self.contents).await
579    }
580}
581
582/// A server needs a model, an LLM or neurons to serve
583fn check_contents(contents: &Contents) -> Result<()> {
584    if contents.is_empty() {
585        return Err(ApiError::ServerStartup(
586            "nothing to serve; add a model with Server::model, an LLM with Server::llm \
587             (feature `llm`) or neurons with Server::nerve"
588                .to_string(),
589        )
590        .into());
591    }
592    Ok(())
593}
594
595/// What a reload replaces at once: the model and its readiness. Its pathway in the nervous
596/// system stays; the model's neuron reads the current one.
597struct Loaded {
598    model: Arc<Model>,
599    /// Set once the warm-up inference has run
600    ready: Arc<AtomicBool>,
601}
602
603impl Loaded {
604    /// Serve `model`, warming it up on a background thread (`/readyz` waits for it)
605    fn start(model: Model) -> Arc<Self> {
606        let loaded = Self::new(model);
607        let model = loaded.model.clone();
608        let ready = loaded.ready.clone();
609        std::thread::spawn(move || {
610            match model.warm_up() {
611                Ok(Some(elapsed)) => info!(
612                    "🔥 Warm-up inference of {} took {:.1}ms",
613                    model.get_info().name,
614                    elapsed.as_secs_f64() * 1e3
615                ),
616                Ok(None) => info!(
617                    "No warm-up for {}: an input's shape is only fixed per request",
618                    model.get_info().name
619                ),
620                Err(e) => warn!("Warm-up inference failed: {e}"),
621            }
622            ready.store(true, Ordering::Release);
623        });
624        Arc::new(loaded)
625    }
626
627    fn new(model: Model) -> Self {
628        Self {
629            model: Arc::new(model),
630            ready: Arc::new(AtomicBool::new(false)),
631        }
632    }
633
634    fn ready(&self) -> bool {
635        self.ready.load(Ordering::Acquire)
636    }
637}
638
639/// One served model, by name. Its [`Loaded`] is swapped as a whole on reload; requests already
640/// holding the old one finish on it, and it is dropped with the last of them.
641struct ModelSlot {
642    name: String,
643    current: std::sync::RwLock<Arc<Loaded>>,
644    reload: Option<Reloader>,
645    /// One reload at a time
646    reloading: tokio::sync::Mutex<()>,
647    /// Successful inferences of this model, from decoded input to output (queueing included)
648    inference_latency: Histogram,
649}
650
651impl ModelSlot {
652    fn get(&self) -> Arc<Loaded> {
653        self.current
654            .read()
655            .unwrap_or_else(|e| e.into_inner())
656            .clone()
657    }
658
659    fn replace(&self, loaded: Arc<Loaded>) {
660        *self.current.write().unwrap_or_else(|e| e.into_inner()) = loaded;
661    }
662}
663
664pub(crate) struct ServerState {
665    /// The served models; the first is the default for `/predict` and `/model/info`
666    models: Vec<Arc<ModelSlot>>,
667    start_time: SystemTime,
668    inference_slots: Arc<Semaphore>,
669    /// `--max-concurrent-requests`: `max_concurrent_requests` in the JSON metrics,
670    /// `furnace_max_concurrent_requests` in Prometheus
671    max_concurrent_requests: usize,
672    request_timeout: Option<Duration>,
673    max_batch_size: usize,
674    admin_token: Option<String>,
675    access: Access,
676    total_requests: AtomicU64,
677    total_errors: AtomicU64,
678    rejected_busy: AtomicU64,
679    rejected_unauthorized: AtomicU64,
680    rejected_rate_limited: AtomicU64,
681    /// Inference requests from arrival to response, every status
682    request_latency: Histogram,
683    /// Successful inferences of every model, from decoded input to output (queueing included)
684    inference_latency: Histogram,
685    /// The LLMs, when there are any
686    #[cfg(feature = "llm")]
687    llm: Option<Arc<crate::llm::LlmState>>,
688    /// The nervous system: every model's pathway, and the neurons added with `Server::nerve`
689    serving: Arc<Serving>,
690}
691
692impl ServerState {
693    fn uptime_seconds(&self) -> u64 {
694        self.start_time.elapsed().unwrap_or_default().as_secs()
695    }
696
697    /// `None` when the server serves only LLMs
698    fn default_slot(&self) -> Option<&Arc<ModelSlot>> {
699        self.models.first()
700    }
701
702    fn slot(&self, name: &str) -> Option<&Arc<ModelSlot>> {
703        self.models.iter().find(|slot| slot.name == name)
704    }
705
706    /// Whether every model has run its warm-up
707    fn ready(&self) -> bool {
708        self.models.iter().all(|slot| slot.get().ready())
709    }
710
711    fn model_names(&self) -> Vec<&str> {
712        self.models.iter().map(|slot| slot.name.as_str()).collect()
713    }
714}
715
716/// A router served by its user may lack the peer address the rate limit needs; say so once,
717/// at startup, rather than let every client silently share one budget
718fn warn_if_limited_by_address(config: &ServerConfig) {
719    if config.rate_limit.is_some() && config.api_keys.is_empty() {
720        warn!(
721            "the rate limit tells clients apart by address: serve this router with \
722             into_make_service_with_connect_info::<furnace::api::ClientAddr>(), or every \
723             client shares one budget"
724        );
725    }
726}
727
728/// The shared state of the HTTP and gRPC servers; also starts the models' warm-ups
729fn server_state(contents: Contents, config: &ServerConfig) -> Result<AppState> {
730    let batching = config.batching;
731    let inference_slots = Arc::new(Semaphore::new(config.max_concurrent_requests));
732    let models = contents
733        .models
734        .into_iter()
735        .map(|served| {
736            Arc::new(ModelSlot {
737                name: served.model.get_info().name.clone(),
738                current: std::sync::RwLock::new(Loaded::start(served.model)),
739                reload: served.reload,
740                reloading: tokio::sync::Mutex::new(()),
741                inference_latency: Histogram::new(),
742            })
743        })
744        .collect::<Vec<Arc<ModelSlot>>>();
745    let serving = Serving::build(
746        contents.nerve.unwrap_or_default(),
747        models
748            .iter()
749            .map(|slot| {
750                let model = slot.get().model.clone();
751                let current = slot.clone();
752                ServedNeuron {
753                    name: slot.name.clone(),
754                    batches: model.is_simple() || model.batches_named(),
755                    source: Arc::new(move || current.get().model.clone()),
756                }
757            })
758            .collect(),
759        #[cfg(feature = "llm")]
760        &contents.llms,
761        batching,
762        config.max_concurrent_requests,
763    )?;
764    let serving = Arc::new(serving);
765    #[cfg(feature = "llm")]
766    let llm = (!contents.llms.is_empty()).then(|| {
767        let others = models.iter().map(|slot| slot.name.clone()).collect();
768        Arc::new(crate::llm::LlmState::new(
769            contents.llms,
770            others,
771            inference_slots.clone(),
772            serving.clone(),
773            config.request_timeout,
774        ))
775    });
776    Ok(Arc::new(ServerState {
777        models,
778        start_time: SystemTime::now(),
779        inference_slots,
780        max_concurrent_requests: config.max_concurrent_requests,
781        request_timeout: config.request_timeout,
782        max_batch_size: batching.max_batch_size.max(1),
783        admin_token: config.admin_token.clone(),
784        access: Access::new(&config.api_keys, config.rate_limit),
785        total_requests: AtomicU64::new(0),
786        total_errors: AtomicU64::new(0),
787        rejected_busy: AtomicU64::new(0),
788        rejected_unauthorized: AtomicU64::new(0),
789        rejected_rate_limited: AtomicU64::new(0),
790        request_latency: Histogram::new(),
791        inference_latency: Histogram::new(),
792        #[cfg(feature = "llm")]
793        llm,
794        serving,
795    }))
796}
797
798/// How a route is made: its handler, given the state its layers need
799type MakeRoute = fn(&AppState) -> MethodRouter<AppState>;
800
801/// An inference route, whose requests are counted in the metrics
802fn counted(state: &AppState, route: MethodRouter<AppState>) -> MethodRouter<AppState> {
803    route.layer(middleware::from_fn_with_state(
804        state.clone(),
805        count_requests,
806    ))
807}
808
809/// Every route of the server's own HTTP API: the router serves these, and a test checks that
810/// `docs/openapi.json` describes exactly these plus [`LLM_PATHS`] and [`NERVE_PATHS`]
811const ROUTES: &[(&str, MakeRoute)] = &[
812    ("/healthz", |_| get(health_check)),
813    ("/health", |_| get(health_check)),
814    ("/readyz", |_| get(readiness)),
815    ("/predict", |state| counted(state, post(predict))),
816    ("/models/{model}/predict", |state| {
817        counted(state, post(predict_named))
818    }),
819    ("/model/info", |_| get(model_info)),
820    ("/models", |_| get(list_models)),
821    ("/models/{model}", |_| get(model_info_named)),
822    ("/admin/models/{model}/reload", |_| post(reload_model)),
823    ("/metrics", |_| get(metrics)),
824    ("/v2", |_| get(v2::server_metadata)),
825    ("/v2/health/live", |_| get(v2::live)),
826    ("/v2/health/ready", |_| get(v2::ready)),
827    ("/v2/models/{model}", |_| get(v2::model_metadata)),
828    ("/v2/models/{model}/versions/{version}", |_| {
829        get(v2::model_metadata)
830    }),
831    ("/v2/models/{model}/ready", |_| get(v2::model_ready)),
832    ("/v2/models/{model}/versions/{version}/ready", |_| {
833        get(v2::model_ready)
834    }),
835    ("/v2/models/{model}/infer", |state| {
836        counted(state, post(v2::infer))
837    }),
838    ("/v2/models/{model}/versions/{version}/infer", |state| {
839        counted(state, post(v2::infer))
840    }),
841];
842
843/// The OpenAI routes, served with the feature `llm` and an LLM to serve
844#[cfg(any(test, feature = "llm"))]
845pub(crate) const LLM_PATHS: [&str; 3] = ["/v1/chat/completions", "/v1/completions", "/v1/models"];
846
847/// The nervous system's own routes (the receptors' routes are the user's)
848#[cfg(test)]
849const NERVE_PATHS: [&str; 2] = ["/nerve", "/nerve/effectors/{name}/events"];
850
851fn router_with_state(state: AppState, config: &ServerConfig) -> Router {
852    let router = ROUTES.iter().fold(Router::new(), |router, (path, make)| {
853        router.route(path, make(&state))
854    });
855    #[cfg(feature = "llm")]
856    let router = match &state.llm {
857        Some(llm) => router.merge(crate::llm::routes(llm.clone())),
858        None => router,
859    };
860    let router = router.merge(crate::nerve::http::routes(state.serving.fabric().clone()));
861    // Without API keys or a rate limit every request is admitted: skip the check altogether
862    let router = if state.access.is_open() {
863        router
864    } else {
865        router.layer(middleware::from_fn_with_state(state.clone(), guard))
866    };
867    router
868        .layer(DefaultBodyLimit::max(config.max_body_bytes))
869        .layer(middleware::from_fn(edge))
870        .with_state(state)
871}
872
873/// Health probes, open to everyone and never rate limited
874const PUBLIC_PATHS: &[&str] = &[
875    "/healthz",
876    "/health",
877    "/readyz",
878    "/v2/health/live",
879    "/v2/health/ready",
880];
881
882/// API keys and the rate limit (see [`crate::access`]). The admin API checks its own token.
883async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
884    let path = request.uri().path();
885    if PUBLIC_PATHS.contains(&path) {
886        return next.run(request).await;
887    }
888    let verdict = {
889        let header = |name: &str| request.headers().get(name).and_then(|v| v.to_str().ok());
890        let key = presented_key(header("authorization"), header(API_KEY_HEADER));
891        let ip = request
892            .extensions()
893            .get::<axum::extract::ConnectInfo<ClientAddr>>()
894            .map(|info| info.0.0.ip());
895        state.access.admit(key, ip, !path.starts_with("/admin/"))
896    };
897    match verdict {
898        Ok(()) => next.run(request).await,
899        Err(rejection) => rejection_response(&state, &rejection),
900    }
901}
902
903/// Header carrying an API key, as an alternative to `Authorization: Bearer`
904const API_KEY_HEADER: &str = "x-api-key";
905
906fn rejection_response(state: &ServerState, rejection: &Rejection) -> Response {
907    match rejection {
908        Rejection::Unauthorized => {
909            state.rejected_unauthorized.fetch_add(1, Ordering::Relaxed);
910            let mut response = create_error_response(
911                StatusCode::UNAUTHORIZED,
912                Code::Unauthorized,
913                "Missing or unknown API key; send 'Authorization: Bearer <key>' or 'X-API-Key: <key>'",
914                None,
915            );
916            response.headers_mut().insert(
917                axum::http::header::WWW_AUTHENTICATE,
918                HeaderValue::from_static("Bearer"),
919            );
920            response
921        }
922        Rejection::RateLimited(retry) => {
923            state.rejected_rate_limited.fetch_add(1, Ordering::Relaxed);
924            let seconds = retry.as_secs() + u64::from(retry.subsec_nanos() > 0);
925            let mut response = create_error_response(
926                StatusCode::TOO_MANY_REQUESTS,
927                Code::RateLimited,
928                &format!(
929                    "Rate limit exceeded; retry in {} ms",
930                    retry.as_millis().max(1)
931                ),
932                Some(serde_json::json!({ "retry_after_ms": retry.as_millis() })),
933            );
934            response
935                .headers_mut()
936                .insert(axum::http::header::RETRY_AFTER, HeaderValue::from(seconds));
937            response
938        }
939    }
940}
941
942/// Response headers exposed to browser scripts (CORS)
943const EXPOSED_HEADERS: &str = "x-batch-size, x-output-shape, x-inference-time-ms, x-request-id";
944
945/// What every request goes through, in one layer: an ID (the client's `X-Request-Id` when it is
946/// a reasonable token, else a new UUID) and a `request` span carrying it, CORS open to any
947/// origin, and security headers. The ID is echoed in the response.
948async fn edge(request: Request, next: Next) -> Response {
949    let id = request
950        .headers()
951        .get(&REQUEST_ID_HEADER)
952        .filter(|id| {
953            let id = id.as_bytes();
954            !id.is_empty() && id.len() <= 128 && id.iter().all(u8::is_ascii_graphic)
955        })
956        .cloned()
957        .unwrap_or_else(new_request_id);
958    let preflight = request.method() == Method::OPTIONS
959        && request.headers().contains_key(ORIGIN)
960        && request
961            .headers()
962            .contains_key(ACCESS_CONTROL_REQUEST_METHOD);
963    let mut response = if preflight {
964        let mut response = StatusCode::OK.into_response();
965        let headers = response.headers_mut();
966        let any = HeaderValue::from_static("*");
967        headers.insert(ACCESS_CONTROL_ALLOW_METHODS, any.clone());
968        headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, any);
969        headers.insert(
970            VARY,
971            HeaderValue::from_static(
972                "origin, access-control-request-method, access-control-request-headers",
973            ),
974        );
975        response
976    } else {
977        // Built only when a subscriber listens at INFO; the fields are borrowed strings, so the
978        // span costs no formatting of its own
979        let span = info_span!(
980            "request",
981            id = id.to_str().unwrap_or_default(),
982            method = request.method().as_str(),
983            path = request.uri().path()
984        );
985        next.run(request).instrument(span).await
986    };
987    let headers = response.headers_mut();
988    headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
989    headers.insert(
990        ACCESS_CONTROL_EXPOSE_HEADERS,
991        HeaderValue::from_static(EXPOSED_HEADERS),
992    );
993    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
994    headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
995    headers.insert(REQUEST_ID_HEADER, id);
996    response
997}
998
999/// A new request ID in UUID v4 format: a random per-process value plus a counter, so IDs are
1000/// unique without asking the OS for randomness on every request
1001fn new_request_id() -> HeaderValue {
1002    static SEED: std::sync::LazyLock<u128> = std::sync::LazyLock::new(|| {
1003        (u128::from(process_random(0)) << 64) | u128::from(process_random(1))
1004    });
1005    static NEXT: AtomicU64 = AtomicU64::new(0);
1006    let bits = *SEED ^ u128::from(NEXT.fetch_add(1, Ordering::Relaxed));
1007    let mut bytes = bits.to_be_bytes();
1008    // RFC 9562: version 4 (random) and the variant `10`
1009    bytes[6] = (bytes[6] & 0x0f) | 0x40;
1010    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1011    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
1012    let id = format!(
1013        "{}-{}-{}-{}-{}",
1014        &hex[..8],
1015        &hex[8..12],
1016        &hex[12..16],
1017        &hex[16..20],
1018        &hex[20..]
1019    );
1020    HeaderValue::from_str(&id).expect("a UUID is a valid header value")
1021}
1022
1023/// 64 random bits for the IDs this process hands out, different in every process: `stream`
1024/// hashed with SipHash keys the standard library draws from the OS's randomness. Not for
1025/// secrets; IDs only need not to collide.
1026pub(crate) fn process_random(stream: u64) -> u64 {
1027    use std::hash::{BuildHasher, RandomState};
1028    static KEYS: std::sync::LazyLock<RandomState> = std::sync::LazyLock::new(RandomState::new);
1029    KEYS.hash_one(stream)
1030}
1031
1032async fn serve(config: ServerConfig, contents: Contents) -> Result<()> {
1033    check_contents(&contents)?;
1034    let state = server_state(contents, &config)?;
1035    #[cfg(not(feature = "llm"))]
1036    let names = state.model_names();
1037    #[cfg(feature = "llm")]
1038    let names: Vec<&str> = state
1039        .model_names()
1040        .into_iter()
1041        .chain(state.llm.iter().flat_map(|llm| llm.names()))
1042        .collect();
1043    info!("📚 Serving {}", names.join(", "));
1044    let app = router_with_state(state.clone(), &config);
1045    info!(
1046        "🚦 Concurrency limit set to {} inferences",
1047        config.max_concurrent_requests
1048    );
1049    if config.batching.enabled() {
1050        info!(
1051            "📦 Dynamic batching: up to {} items per batch, waiting at most {}µs, {} worker(s)",
1052            config.batching.max_batch_size,
1053            config.batching.batch_timeout.as_micros(),
1054            config.batching.workers
1055        );
1056    } else {
1057        info!("📦 Dynamic batching off");
1058    }
1059
1060    #[cfg(feature = "tls")]
1061    let tls = match &config.tls {
1062        Some(settings) => {
1063            let configs = crate::tls::TlsConfigs::load(settings.clone())
1064                .map_err(|e| ApiError::ServerStartup(format!("TLS: {e}")))?;
1065            configs.watch();
1066            info!("🔐 TLS 1.3 with {}", crate::tls::describe(settings));
1067            Some(configs)
1068        }
1069        None => None,
1070    };
1071    #[cfg(not(feature = "tls"))]
1072    if config.tls.is_some() {
1073        return Err(ApiError::ServerStartup(missing_feature(
1074            "TLS (--tls-cert, ServerConfig::tls)",
1075            "tls",
1076        ))
1077        .into());
1078    }
1079
1080    let bind_addr = format!("{}:{}", config.host, config.port);
1081    let listener = tokio::net::TcpListener::bind(&bind_addr)
1082        .await
1083        .map_err(|e| {
1084            ApiError::ServerStartup(bind_error(&bind_addr, &e, "--port / Server::port"))
1085        })?;
1086
1087    let scheme = if config.tls.is_some() {
1088        "https"
1089    } else {
1090        "http"
1091    };
1092    info!("✅ Server running on {scheme}://{bind_addr}");
1093
1094    // One signal stops both servers
1095    let (stop, stopped) = tokio::sync::watch::channel(false);
1096    let wait_for_stop = move |mut stopped: tokio::sync::watch::Receiver<bool>| async move {
1097        let _ = stopped.wait_for(|&stop| stop).await;
1098    };
1099    tokio::spawn(async move {
1100        shutdown_signal().await;
1101        let _ = stop.send(true);
1102    });
1103
1104    #[cfg(feature = "grpc")]
1105    let grpc = config.grpc_port.map(|port| {
1106        let addr = format!("{}:{port}", config.host);
1107        let state = state.clone();
1108        let max_message_bytes = config.max_body_bytes;
1109        let shutdown = wait_for_stop(stopped.clone());
1110        #[cfg(feature = "tls")]
1111        let tls = tls.clone();
1112        #[cfg(not(feature = "tls"))]
1113        let tls = None;
1114        tokio::spawn(async move {
1115            let addr = tokio::net::lookup_host(&addr)
1116                .await
1117                .ok()
1118                .and_then(|mut addrs| addrs.next())
1119                .ok_or_else(|| format!("cannot resolve {addr}"))?;
1120            grpc::serve(state, addr, max_message_bytes, tls, shutdown).await
1121        })
1122    });
1123    #[cfg(not(feature = "grpc"))]
1124    if config.grpc_port.is_some() {
1125        return Err(ApiError::ServerStartup(missing_feature(
1126            "gRPC (--grpc-port, ServerConfig::grpc_port)",
1127            "grpc",
1128        ))
1129        .into());
1130    }
1131
1132    if !config.api_keys.is_empty() {
1133        info!(
1134            "🔑 API keys required ({} configured)",
1135            config.api_keys.len()
1136        );
1137    }
1138    if let Some(limit) = config.rate_limit {
1139        info!(
1140            "⏱️ Rate limit: {} requests/s per client, bursts of {}",
1141            limit.per_second, limit.burst
1142        );
1143    }
1144
1145    let make_service = app.into_make_service_with_connect_info::<ClientAddr>();
1146    #[cfg(feature = "tls")]
1147    let served = match tls {
1148        Some(configs) => {
1149            let listener = crate::tls::TlsListener::new(listener, configs)
1150                .map_err(|e| ApiError::ServerStartup(format!("TLS listener: {e}")))?;
1151            axum::serve(listener, make_service)
1152                .with_graceful_shutdown(wait_for_stop(stopped))
1153                .await
1154        }
1155        None => {
1156            axum::serve(listener, make_service)
1157                .with_graceful_shutdown(wait_for_stop(stopped))
1158                .await
1159        }
1160    };
1161    #[cfg(not(feature = "tls"))]
1162    let served = axum::serve(listener, make_service)
1163        .with_graceful_shutdown(wait_for_stop(stopped))
1164        .await;
1165    served.map_err(|e| ApiError::ServerStartup(format!("Server error: {e}")))?;
1166
1167    #[cfg(feature = "grpc")]
1168    if let Some(grpc) = grpc {
1169        match grpc.await {
1170            Ok(Ok(())) => {}
1171            Ok(Err(e)) => return Err(ApiError::ServerStartup(e).into()),
1172            Err(e) => return Err(ApiError::ServerStartup(format!("gRPC task failed: {e}")).into()),
1173        }
1174    }
1175
1176    info!("🛑 Server stopped gracefully");
1177    Ok(())
1178}
1179
1180/// Why a listener could not bind, with what to do when the port is taken
1181pub(crate) fn bind_error(addr: &str, e: &std::io::Error, setting: &str) -> String {
1182    if e.kind() == std::io::ErrorKind::AddrInUse {
1183        format!(
1184            "cannot listen on {addr}: the port is already in use; choose another with {setting}, \
1185             or stop the process that uses it"
1186        )
1187    } else {
1188        format!("cannot listen on {addr}: {e}")
1189    }
1190}
1191
1192/// A setting that needs a feature this build lacks
1193#[cfg(any(not(feature = "tls"), not(feature = "grpc")))]
1194fn missing_feature(setting: &str, feature: &str) -> String {
1195    format!(
1196        "{setting} needs the `{feature}` feature: rebuild with `--features {feature}` (the \
1197         released binaries and the Docker image have it)"
1198    )
1199}
1200
1201/// Ctrl-C or SIGTERM. A handler that cannot be installed is logged and waits forever, so the
1202/// server keeps serving (stopped by the other signal, or killed) instead of losing the task.
1203async fn shutdown_signal() {
1204    let ctrl_c = async {
1205        if let Err(e) = signal::ctrl_c().await {
1206            warn!("Cannot listen for Ctrl+C, stop the server another way: {e}");
1207            std::future::pending::<()>().await;
1208        }
1209    };
1210
1211    #[cfg(unix)]
1212    let terminate = async {
1213        match signal::unix::signal(signal::unix::SignalKind::terminate()) {
1214            Ok(mut terminate) => {
1215                terminate.recv().await;
1216            }
1217            Err(e) => {
1218                warn!("Cannot listen for SIGTERM, stop the server another way: {e}");
1219                std::future::pending::<()>().await;
1220            }
1221        }
1222    };
1223
1224    #[cfg(not(unix))]
1225    let terminate = std::future::pending::<()>();
1226
1227    tokio::select! {
1228        _ = ctrl_c => info!("Received Ctrl+C signal"),
1229        _ = terminate => info!("Received SIGTERM signal"),
1230    }
1231}
1232
1233async fn health_check(State(state): State<AppState>) -> Json<HealthResponse> {
1234    Json(HealthResponse {
1235        status: "healthy".to_string(),
1236        model_loaded: true,
1237        backend: state.default_slot().map_or_else(
1238            || "none".to_string(),
1239            |slot| slot.get().model.get_backend_info(),
1240        ),
1241        uptime_seconds: state.uptime_seconds(),
1242        timestamp: crate::time::rfc3339_now(),
1243    })
1244}
1245
1246/// `GET /readyz`: 200 once the warm-up inference has run, 503 before. Unlike `/healthz`
1247/// (alive), this tells a load balancer when to start sending traffic.
1248async fn readiness(State(state): State<AppState>) -> Response {
1249    let ready = state.ready();
1250    let status = if ready {
1251        StatusCode::OK
1252    } else {
1253        StatusCode::SERVICE_UNAVAILABLE
1254    };
1255    (
1256        status,
1257        Json(ReadinessResponse {
1258            status: if ready { "ready" } else { "warming_up" }.to_string(),
1259            timestamp: crate::time::rfc3339_now(),
1260        }),
1261    )
1262        .into_response()
1263}
1264
1265async fn predict(State(state): State<AppState>, request: Request) -> Response {
1266    let Some(slot) = state.default_slot().cloned() else {
1267        return no_tensor_model_response();
1268    };
1269    predict_with(&state, &slot, request).await
1270}
1271
1272/// For `/predict` and `/model/info` on a server that serves only LLMs
1273fn no_tensor_model_response() -> Response {
1274    create_error_response(
1275        StatusCode::NOT_FOUND,
1276        Code::ModelNotFound,
1277        "This server serves no tensor model; its LLMs, if any, are on /v1/chat/completions",
1278        None,
1279    )
1280}
1281
1282/// `POST /models/{model}/predict`: `/predict` for any served model
1283async fn predict_named(
1284    State(state): State<AppState>,
1285    Path(name): Path<String>,
1286    request: Request,
1287) -> Response {
1288    let Some(slot) = state.slot(&name).cloned() else {
1289        return unknown_model_response(&state, &name);
1290    };
1291    predict_with(&state, &slot, request).await
1292}
1293
1294fn unknown_model_response(state: &ServerState, name: &str) -> Response {
1295    create_error_response(
1296        StatusCode::NOT_FOUND,
1297        Code::ModelNotFound,
1298        &format!(
1299            "Unknown model '{name}'; this server serves {}",
1300            state.model_names().join(", ")
1301        ),
1302        Some(serde_json::json!({ "models": state.model_names() })),
1303    )
1304}
1305
1306async fn predict_with(state: &AppState, slot: &ModelSlot, request: Request) -> Response {
1307    // The model as of now: a reload during this request does not affect it
1308    let loaded = slot.get();
1309    if !loaded.model.is_simple() {
1310        let info = loaded.model.get_info();
1311        return create_error_response(
1312            StatusCode::BAD_REQUEST,
1313            Code::NamedTensorsRequired,
1314            &format!(
1315                "Model '{}' takes named tensors ({}); send them to POST /v2/models/{}/infer",
1316                info.name,
1317                info.inputs
1318                    .iter()
1319                    .map(|t| format!("{} {}", t.name, t.datatype))
1320                    .collect::<Vec<_>>()
1321                    .join(", "),
1322                info.name
1323            ),
1324            None,
1325        );
1326    }
1327    // Announce the request on its pathway, so a batch about to start can wait for it while
1328    // its body is received and decoded
1329    let announced = state.serving.announce(&slot.name);
1330    // Pick the decoder before buffering the body so unsupported types fail fast
1331    let format = match RequestFormat::from_headers(request.headers()) {
1332        Some(format) => format,
1333        None => {
1334            return create_error_response(
1335                StatusCode::UNSUPPORTED_MEDIA_TYPE,
1336                Code::InvalidRequest,
1337                &format!("Expected request with `Content-Type: {SUPPORTED_REQUEST_TYPES}`"),
1338                None,
1339            );
1340        }
1341    };
1342    #[cfg(feature = "image")]
1343    if let RequestFormat::Image(_) = format
1344        && let Err(response) = image_size(&loaded.model)
1345    {
1346        return *response;
1347    }
1348    let binary_response = accepts_octet_stream(request.headers());
1349    // The one header the binary decoder reads, rather than a copy of them all
1350    let batch_header = request.headers().get(BATCH_SIZE_HEADER).cloned();
1351
1352    let body = match Bytes::from_request(request, state).await {
1353        Ok(body) => body,
1354        Err(rejection) => {
1355            warn!("Rejected request body: {}", rejection.body_text());
1356            return create_error_response(
1357                rejection.status(),
1358                Code::InvalidRequest,
1359                &rejection.body_text(),
1360                None,
1361            );
1362        }
1363    };
1364
1365    let decoded = match format {
1366        RequestFormat::Json => decode_json(&loaded.model, &body),
1367        RequestFormat::Binary => decode_binary(&loaded.model, batch_header.as_ref(), &body),
1368        #[cfg(feature = "image")]
1369        RequestFormat::Image(kind) => decode_image(state, &loaded.model, kind, body.clone()).await,
1370    };
1371    // Free the raw body before inference; only the decoded floats are needed now
1372    drop(body);
1373    let PredictInput {
1374        data,
1375        batch_size,
1376        single,
1377    } = match decoded {
1378        Ok(input) => input,
1379        Err(response) => return *response,
1380    };
1381    // `inference_time_ms` excludes receiving and decoding the body
1382    let start_time = Instant::now();
1383    let output = match state
1384        .infer_flat(slot, &loaded, announced, data, batch_size)
1385        .await
1386    {
1387        Ok(output) => output,
1388        Err(e) => return e.into_response(),
1389    };
1390    let inference_time_ms = start_time.elapsed().as_secs_f64() * 1000.0;
1391    // Per request, so debug: at info it would cost more than the server's own work
1392    debug!(
1393        "Prediction completed in {:.2}ms, batch size: {}",
1394        inference_time_ms, batch_size
1395    );
1396    if binary_response {
1397        return binary_output_response(
1398            &output,
1399            batch_size,
1400            &loaded.model.get_info().output_spec.shape,
1401            inference_time_ms,
1402        );
1403    }
1404    Json(PredictReply {
1405        output: single.then_some(&output[..]),
1406        outputs: (!single).then_some(Rows {
1407            values: &output,
1408            rows: batch_size,
1409        }),
1410        status: "success",
1411        inference_time_ms,
1412        timestamp: crate::time::rfc3339_now(),
1413        batch_size,
1414    })
1415    .into_response()
1416}
1417
1418#[cfg(not(feature = "image"))]
1419const SUPPORTED_REQUEST_TYPES: &str = "application/json` or `application/octet-stream";
1420#[cfg(feature = "image")]
1421const SUPPORTED_REQUEST_TYPES: &str =
1422    "application/json`, `application/octet-stream`, `image/jpeg` or `image/png";
1423
1424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1425enum RequestFormat {
1426    Json,
1427    /// Raw little-endian `f32`, see [`decode_binary`]
1428    Binary,
1429    /// One encoded image, see [`decode_image`]
1430    #[cfg(feature = "image")]
1431    Image(ImageKind),
1432}
1433
1434impl RequestFormat {
1435    fn from_headers(headers: &HeaderMap) -> Option<Self> {
1436        let content_type = headers.get(CONTENT_TYPE)?.to_str().ok()?;
1437        let essence = media_type_essence(content_type);
1438        if essence.eq_ignore_ascii_case("application/json")
1439            || essence.to_ascii_lowercase().ends_with("+json")
1440        {
1441            return Some(Self::Json);
1442        }
1443        if essence.eq_ignore_ascii_case(OCTET_STREAM) {
1444            return Some(Self::Binary);
1445        }
1446        #[cfg(feature = "image")]
1447        if let Some(kind) = ImageKind::from_media_type(essence) {
1448            return Some(Self::Image(kind));
1449        }
1450        None
1451    }
1452}
1453
1454/// `type/subtype` without parameters such as `; charset=utf-8`
1455fn media_type_essence(value: &str) -> &str {
1456    value.split(';').next().unwrap_or_default().trim()
1457}
1458
1459/// Whether the client asked for a raw `f32` response with `Accept: application/octet-stream`
1460fn accepts_octet_stream(headers: &HeaderMap) -> bool {
1461    headers
1462        .get_all(ACCEPT)
1463        .iter()
1464        .filter_map(|value| value.to_str().ok())
1465        .flat_map(|value| value.split(','))
1466        .any(|item| media_type_essence(item).eq_ignore_ascii_case(OCTET_STREAM))
1467}
1468
1469fn decode_json(model: &Model, body: &[u8]) -> std::result::Result<PredictInput, Box<Response>> {
1470    // The usual bodies, `{"input": [...]}` and `{"inputs": [[...], ...]}`, take a faster
1471    // parser; anything else, including every malformed body, goes through serde_json
1472    let input_data = match crate::json_fast::parse(body) {
1473        Some(crate::json_fast::FastInput::Single(input)) => PredictInputData::Single { input },
1474        Some(crate::json_fast::FastInput::Batch(inputs)) => PredictInputData::Batch { inputs },
1475        None => decode_json_serde(body)?.input_data,
1476    };
1477    match input_data {
1478        PredictInputData::Single { input } => {
1479            validate_inputs(model, std::slice::from_ref(&input))?;
1480            Ok(PredictInput {
1481                data: input,
1482                batch_size: 1,
1483                single: true,
1484            })
1485        }
1486        PredictInputData::Batch { inputs } => {
1487            validate_inputs(model, &inputs)?;
1488            Ok(PredictInput {
1489                batch_size: inputs.len(),
1490                data: inputs.concat(),
1491                single: false,
1492            })
1493        }
1494    }
1495}
1496
1497fn decode_json_serde(body: &[u8]) -> std::result::Result<PredictRequest, Box<Response>> {
1498    // serde_json directly rather than axum's `Json`, which also tracks the path of
1499    // every element for error messages
1500    serde_json::from_slice(body).map_err(|e| {
1501        let (status, message) = match e.classify() {
1502            serde_json::error::Category::Data => (
1503                StatusCode::UNPROCESSABLE_ENTITY,
1504                format!("Failed to deserialize the JSON body into the target type: {e}"),
1505            ),
1506            _ => (
1507                StatusCode::BAD_REQUEST,
1508                format!("Failed to parse the request body as JSON: {e}"),
1509            ),
1510        };
1511        warn!("Rejected request body: {}", message);
1512        Box::new(create_error_response(
1513            status,
1514            Code::InvalidRequest,
1515            &message,
1516            None,
1517        ))
1518    })
1519}
1520
1521/// Decode a raw body of `batch_size × input_size` little-endian `f32` values.
1522///
1523/// Without an `X-Batch-Size` header the body is one item and the response uses
1524/// `output`; with it the response uses `outputs`, mirroring `input` / `inputs`.
1525fn decode_binary(
1526    model: &Model,
1527    batch_header: Option<&HeaderValue>,
1528    body: &[u8],
1529) -> std::result::Result<PredictInput, Box<Response>> {
1530    let batch_size = match batch_header {
1531        None => 1,
1532        Some(value) => match value.to_str().ok().and_then(|s| s.trim().parse().ok()) {
1533            Some(n) => n,
1534            None => {
1535                return Err(Box::new(create_error_response(
1536                    StatusCode::BAD_REQUEST,
1537                    Code::InvalidRequest,
1538                    "X-Batch-Size must be a non-negative integer",
1539                    None,
1540                )));
1541            }
1542        },
1543    };
1544
1545    check_batch_len(batch_size)?;
1546    if body.is_empty() {
1547        return Err(empty_input_response());
1548    }
1549
1550    let shape = &model.get_info().input_spec.shape;
1551    let input_size: usize = shape.iter().product();
1552    let expected_bytes = batch_size * input_size * size_of::<f32>();
1553    if body.len() != expected_bytes {
1554        return Err(Box::new(create_error_response(
1555            StatusCode::BAD_REQUEST,
1556            Code::InputValidationFailed,
1557            &format!(
1558                "Expected {expected_bytes} bytes ({batch_size} item(s) of shape {shape:?} as \
1559                 little-endian f32), got {}",
1560                body.len()
1561            ),
1562            Some(serde_json::json!({
1563                "expected_shape": shape,
1564                "batch_size": batch_size,
1565                "expected_bytes": expected_bytes,
1566                "received_bytes": body.len()
1567            })),
1568        )));
1569    }
1570
1571    // Length was checked above, so there is no remainder
1572    Ok(PredictInput {
1573        data: f32s_from_le(body),
1574        batch_size,
1575        single: batch_header.is_none(),
1576    })
1577}
1578
1579/// `(H, W)` of a model that takes `[3, H, W]` input, or a 415 for any other model
1580#[cfg(feature = "image")]
1581fn image_size(model: &Model) -> std::result::Result<(u32, u32), Box<Response>> {
1582    let shape = &model.get_info().input_spec.shape;
1583    image_input_size(shape).ok_or_else(|| {
1584        Box::new(create_error_response(
1585            StatusCode::UNSUPPORTED_MEDIA_TYPE,
1586            Code::InvalidRequest,
1587            &format!(
1588                "Image input needs a model with input shape [3, H, W]; this model takes \
1589                 {shape:?}. Send JSON or `{OCTET_STREAM}` instead"
1590            ),
1591            Some(serde_json::json!({ "expected_shape": shape })),
1592        ))
1593    })
1594}
1595
1596/// Decode one JPEG or PNG and apply ImageNet preprocessing (see [`crate::preprocess`]).
1597///
1598/// The response uses `output`, as for `{"input": ...}`.
1599#[cfg(feature = "image")]
1600async fn decode_image(
1601    state: &ServerState,
1602    model: &Model,
1603    kind: ImageKind,
1604    body: Bytes,
1605) -> std::result::Result<PredictInput, Box<Response>> {
1606    let (height, width) = image_size(model)?;
1607    if body.is_empty() {
1608        return Err(empty_input_response());
1609    }
1610
1611    // Decoding can allocate far more than the body size, so it shares the inference
1612    // slots: at most `max_concurrent_requests` decodes or inferences run at once
1613    let Ok(permit) = state.inference_slots.clone().try_acquire_owned() else {
1614        return Err(Box::new(infer::InferError::Busy.into_response()));
1615    };
1616    let task = tokio::task::spawn_blocking(move || {
1617        let _permit = permit;
1618        preprocess_image(&body, kind, height, width)
1619    });
1620
1621    match task.await {
1622        Ok(Ok(data)) => Ok(PredictInput {
1623            data,
1624            batch_size: 1,
1625            single: true,
1626        }),
1627        Ok(Err(e)) => {
1628            warn!("Rejected image body: {}", e);
1629            Err(Box::new(create_error_response(
1630                StatusCode::BAD_REQUEST,
1631                Code::InvalidImage,
1632                &format!("Failed to decode the image: {e}"),
1633                None,
1634            )))
1635        }
1636        Err(e) => {
1637            error!("Image decoding task failed: {}", e);
1638            Err(Box::new(create_error_response(
1639                StatusCode::INTERNAL_SERVER_ERROR,
1640                Code::InternalError,
1641                "Image decoding task panicked",
1642                None,
1643            )))
1644        }
1645    }
1646}
1647
1648/// Raw little-endian `f32` outputs, `batch_size × output_size` values
1649fn binary_output_response(
1650    output: &[f32],
1651    batch_size: usize,
1652    output_shape: &[usize],
1653    inference_time_ms: f64,
1654) -> Response {
1655    let shape = output_shape
1656        .iter()
1657        .map(usize::to_string)
1658        .collect::<Vec<_>>()
1659        .join(",");
1660    let header = |value: String| {
1661        HeaderValue::from_str(&value).expect("digits and commas are a valid header value")
1662    };
1663    (
1664        StatusCode::OK,
1665        [
1666            (CONTENT_TYPE, HeaderValue::from_static(OCTET_STREAM)),
1667            (BATCH_SIZE_HEADER, HeaderValue::from(batch_size)),
1668            (OUTPUT_SHAPE_HEADER, header(shape)),
1669            (
1670                INFERENCE_TIME_HEADER,
1671                header(format!("{inference_time_ms:.3}")),
1672            ),
1673        ],
1674        f32s_to_le(output),
1675    )
1676        .into_response()
1677}
1678
1679fn empty_input_response() -> Box<Response> {
1680    Box::new(create_error_response(
1681        StatusCode::BAD_REQUEST,
1682        Code::InvalidInput,
1683        "Input cannot be empty",
1684        None,
1685    ))
1686}
1687
1688fn check_batch_len(batch_size: usize) -> std::result::Result<(), Box<Response>> {
1689    if batch_size == 0 {
1690        return Err(empty_input_response());
1691    }
1692    if batch_size > MAX_BATCH_SIZE {
1693        return Err(Box::new(create_error_response(
1694            StatusCode::BAD_REQUEST,
1695            Code::BatchTooLarge,
1696            &format!("Batch size {batch_size} exceeds maximum allowed batch size {MAX_BATCH_SIZE}"),
1697            None,
1698        )));
1699    }
1700    Ok(())
1701}
1702
1703/// Cheap request-level checks done before taking an inference slot
1704fn validate_inputs(model: &Model, inputs: &[Vec<f32>]) -> std::result::Result<(), Box<Response>> {
1705    check_batch_len(inputs.len())?;
1706    if inputs.iter().any(Vec::is_empty) {
1707        return Err(empty_input_response());
1708    }
1709
1710    for (i, input) in inputs.iter().enumerate() {
1711        if let Err(e) = model.validate_input_shape(input) {
1712            return Err(Box::new(create_error_response(
1713                StatusCode::BAD_REQUEST,
1714                Code::InputValidationFailed,
1715                &e.to_string(),
1716                Some(serde_json::json!({
1717                    "expected_shape": model.get_info().input_spec.shape,
1718                    "received_size": input.len(),
1719                    "batch_item": i
1720                })),
1721            )));
1722        }
1723    }
1724
1725    Ok(())
1726}
1727
1728/// Count every inference request over HTTP and its outcome, including rejections by the body
1729/// limit (see [`ServerState::count_request`])
1730async fn count_requests(State(state): State<AppState>, request: Request, next: Next) -> Response {
1731    let start = Instant::now();
1732    let response = next.run(request).await;
1733    let status = response.status();
1734    state.count_request(
1735        start.elapsed(),
1736        status.is_client_error() || status.is_server_error(),
1737        response.extensions().get::<infer::Busy>().is_some(),
1738    );
1739    response
1740}
1741
1742/// `GET /metrics`: JSON by default, or the Prometheus text format for a scraper (a request
1743/// whose `Accept` lists `text/plain` or `application/openmetrics-text` before any JSON type)
1744async fn metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
1745    if wants_prometheus(&headers) {
1746        return (
1747            [(
1748                CONTENT_TYPE,
1749                HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
1750            )],
1751            prometheus_metrics(&state),
1752        )
1753            .into_response();
1754    }
1755    Json(json_metrics(&state)).into_response()
1756}
1757
1758/// Whether `Accept` lists `text/plain` or `application/openmetrics-text` before (or without)
1759/// JSON or a wildcard
1760fn wants_prometheus(headers: &HeaderMap) -> bool {
1761    let Some(accept) = headers.get(ACCEPT).and_then(|v| v.to_str().ok()) else {
1762        return false;
1763    };
1764    for part in accept.split(',') {
1765        let media = media_type_essence(part.trim());
1766        if media == "application/json" || media == "*/*" {
1767            return false;
1768        }
1769        if media == "text/plain" || media == "application/openmetrics-text" {
1770            return true;
1771        }
1772    }
1773    false
1774}
1775
1776fn prometheus_metrics(state: &ServerState) -> String {
1777    use kuebiko::{label, render_value};
1778    use std::fmt::Write as _;
1779    let mut out = String::with_capacity(8192);
1780    let _ = writeln!(out, "# HELP furnace_build_info Furnace version");
1781    let _ = writeln!(out, "# TYPE furnace_build_info gauge");
1782    let _ = writeln!(
1783        out,
1784        "furnace_build_info{{version=\"{}\"}} 1",
1785        env!("CARGO_PKG_VERSION")
1786    );
1787    let gauge = "gauge";
1788    let counter = "counter";
1789    let values: [(&str, &str, &str, u64); 10] = [
1790        (
1791            "furnace_uptime_seconds",
1792            gauge,
1793            "Seconds since the server started",
1794            state.uptime_seconds(),
1795        ),
1796        (
1797            "furnace_ready",
1798            gauge,
1799            "1 once every model's warm-up inference has run",
1800            u64::from(state.ready()),
1801        ),
1802        (
1803            "furnace_requests_total",
1804            counter,
1805            "/predict requests received, including rejected ones",
1806            state.total_requests.load(Ordering::Relaxed),
1807        ),
1808        (
1809            "furnace_request_errors_total",
1810            counter,
1811            "/predict requests answered with a 4xx or 5xx status",
1812            state.total_errors.load(Ordering::Relaxed),
1813        ),
1814        (
1815            "furnace_requests_rejected_busy_total",
1816            counter,
1817            "Requests rejected with 503 SERVER_BUSY",
1818            state.rejected_busy.load(Ordering::Relaxed),
1819        ),
1820        (
1821            "furnace_requests_rejected_unauthorized_total",
1822            counter,
1823            "Requests rejected with 401 for a missing or unknown API key",
1824            state.rejected_unauthorized.load(Ordering::Relaxed),
1825        ),
1826        (
1827            "furnace_requests_rejected_rate_limited_total",
1828            counter,
1829            "Requests rejected with 429 by the rate limit",
1830            state.rejected_rate_limited.load(Ordering::Relaxed),
1831        ),
1832        (
1833            "furnace_in_flight_requests",
1834            gauge,
1835            "Requests queued for or running inference",
1836            in_flight(state) as u64,
1837        ),
1838        (
1839            "furnace_max_concurrent_requests",
1840            gauge,
1841            "Most requests queued for or running inference at once",
1842            state.max_concurrent_requests as u64,
1843        ),
1844        (
1845            "furnace_max_batch_size",
1846            gauge,
1847            "Most items per batched forward pass; 1 means batching is off",
1848            state.max_batch_size as u64,
1849        ),
1850    ];
1851    for (name, kind, help, value) in values {
1852        render_value(&mut out, name, kind, help, value);
1853    }
1854    state.request_latency.render(
1855        &mut out,
1856        "furnace_request_duration_seconds",
1857        "Inference request latency from arrival to response",
1858    );
1859    #[cfg(feature = "llm")]
1860    if let Some(llm) = &state.llm {
1861        llm.render_prometheus(&mut out);
1862    }
1863    state.serving.fabric().render_prometheus(&mut out);
1864
1865    // Per model, labelled `model`
1866    let models: Vec<_> = state
1867        .models
1868        .iter()
1869        .map(|slot| {
1870            let loaded = slot.get();
1871            (slot, loaded.model.get_stats(), loaded)
1872        })
1873        .collect();
1874    type Field = fn(&ModelStats, &Loaded) -> String;
1875    let families: [(&str, &str, &str, Field); 7] = [
1876        (
1877            "furnace_model_info",
1878            gauge,
1879            "Served models and their backends",
1880            |_, _| "1".to_string(),
1881        ),
1882        (
1883            "furnace_model_ready",
1884            gauge,
1885            "1 once the model's warm-up inference has run",
1886            |_, l| u8::from(l.ready()).to_string(),
1887        ),
1888        (
1889            "furnace_inferences_total",
1890            counter,
1891            "Requests that reached the model",
1892            |s, _| s.inference_count.to_string(),
1893        ),
1894        (
1895            "furnace_inference_errors_total",
1896            counter,
1897            "Requests the model failed",
1898            |s, _| s.error_count.to_string(),
1899        ),
1900        (
1901            "furnace_batches_total",
1902            counter,
1903            "Forward passes run",
1904            |s, _| s.batch_count.to_string(),
1905        ),
1906        (
1907            "furnace_batch_items_total",
1908            counter,
1909            "Items processed by forward passes",
1910            |s, _| s.batched_items.to_string(),
1911        ),
1912        (
1913            "furnace_model_size_bytes",
1914            gauge,
1915            "Size of the model weights",
1916            |_, l| l.model.get_info().model_size_bytes.to_string(),
1917        ),
1918    ];
1919    for (name, kind, help, field) in families {
1920        let _ = writeln!(out, "# HELP {name} {help}");
1921        let _ = writeln!(out, "# TYPE {name} {kind}");
1922        for (slot, stats, loaded) in &models {
1923            let mut labels = format!("model=\"{}\"", label(&slot.name));
1924            if name == "furnace_model_info" {
1925                let _ = write!(
1926                    labels,
1927                    ",backend=\"{}\"",
1928                    label(&loaded.model.get_info().backend)
1929                );
1930            }
1931            let _ = writeln!(out, "{name}{{{labels}}} {}", field(stats, loaded));
1932        }
1933    }
1934    let name = "furnace_inference_duration_seconds";
1935    let _ = writeln!(
1936        out,
1937        "# HELP {name} Successful inference latency from decoded input to output, queueing included"
1938    );
1939    let _ = writeln!(out, "# TYPE {name} histogram");
1940    for (slot, _, _) in &models {
1941        slot.inference_latency.render_series(
1942            &mut out,
1943            name,
1944            &format!("model=\"{}\"", label(&slot.name)),
1945        );
1946    }
1947    out
1948}
1949
1950fn in_flight(state: &ServerState) -> usize {
1951    state
1952        .max_concurrent_requests
1953        .saturating_sub(state.inference_slots.available_permits())
1954}
1955
1956fn json_metrics(state: &ServerState) -> MetricsResponse {
1957    let models: Vec<ModelMetrics> = state
1958        .models
1959        .iter()
1960        .map(|slot| {
1961            let loaded = slot.get();
1962            let stats = loaded.model.get_stats();
1963            ModelMetrics {
1964                name: slot.name.clone(),
1965                ready: loaded.ready(),
1966                inference_count: stats.inference_count,
1967                error_count: stats.error_count,
1968                batch_count: stats.batch_count,
1969                average_batch_size: average_batch_size(&stats),
1970                average_inference_time_ms: stats.average_inference_time_ms,
1971                inference_latency_ms: slot.inference_latency.percentiles_ms(),
1972            }
1973        })
1974        .collect();
1975    // Totals over the models
1976    let stats: Vec<ModelStats> = state
1977        .models
1978        .iter()
1979        .map(|slot| slot.get().model.get_stats())
1980        .collect();
1981    let success_count: u64 = stats.iter().map(|s| s.success_count).sum();
1982    let total_inference_time_ms: f64 = stats.iter().map(|s| s.total_inference_time_ms).sum();
1983    let stats = ModelStats {
1984        inference_count: stats.iter().map(|s| s.inference_count).sum(),
1985        total_inference_time_ms,
1986        last_inference_time: stats.iter().filter_map(|s| s.last_inference_time).max(),
1987        average_inference_time_ms: if success_count == 0 {
1988            0.0
1989        } else {
1990            total_inference_time_ms / success_count as f64
1991        },
1992        error_count: stats.iter().map(|s| s.error_count).sum(),
1993        success_count,
1994        min_inference_time_ms: stats
1995            .iter()
1996            .filter_map(|s| s.min_inference_time_ms)
1997            .reduce(f64::min),
1998        max_inference_time_ms: stats
1999            .iter()
2000            .filter_map(|s| s.max_inference_time_ms)
2001            .reduce(f64::max),
2002        batch_count: stats.iter().map(|s| s.batch_count).sum(),
2003        batched_items: stats.iter().map(|s| s.batched_items).sum(),
2004    };
2005    MetricsResponse {
2006        uptime_seconds: state.uptime_seconds(),
2007        total_requests: state.total_requests.load(Ordering::Relaxed),
2008        total_errors: state.total_errors.load(Ordering::Relaxed),
2009        rejected_busy: state.rejected_busy.load(Ordering::Relaxed),
2010        rejected_unauthorized: state.rejected_unauthorized.load(Ordering::Relaxed),
2011        rejected_rate_limited: state.rejected_rate_limited.load(Ordering::Relaxed),
2012        in_flight_inferences: in_flight(state),
2013        max_concurrent_requests: state.max_concurrent_requests,
2014        total_inferences: stats.inference_count,
2015        total_batches: stats.batch_count,
2016        average_batch_size: average_batch_size(&stats),
2017        max_batch_size: state.max_batch_size,
2018        average_inference_time_ms: stats.average_inference_time_ms,
2019        last_inference_at: stats.last_inference_time.map(crate::time::rfc3339_utc),
2020        request_latency_ms: state.request_latency.percentiles_ms(),
2021        inference_latency_ms: state.inference_latency.percentiles_ms(),
2022        ready: state.ready(),
2023        models,
2024        #[cfg(feature = "llm")]
2025        llm: state
2026            .llm
2027            .as_ref()
2028            .map(|llm| llm.json_metrics())
2029            .unwrap_or_default(),
2030        nerve: Some(state.serving.fabric().metrics()),
2031        timestamp: crate::time::rfc3339_now(),
2032    }
2033}
2034
2035fn average_batch_size(stats: &ModelStats) -> f64 {
2036    if stats.batch_count == 0 {
2037        0.0
2038    } else {
2039        stats.batched_items as f64 / stats.batch_count as f64
2040    }
2041}
2042
2043fn model_info_response(slot: &ModelSlot) -> ModelInfoResponse {
2044    let loaded = slot.get();
2045    ModelInfoResponse {
2046        model_info: loaded.model.get_info().clone(),
2047        stats: loaded.model.get_stats(),
2048        timestamp: crate::time::rfc3339_now(),
2049    }
2050}
2051
2052/// `GET /model/info`: the default model
2053async fn model_info(State(state): State<AppState>) -> Response {
2054    match state.default_slot() {
2055        Some(slot) => Json(model_info_response(slot)).into_response(),
2056        None => no_tensor_model_response(),
2057    }
2058}
2059
2060/// `GET /models/{model}`
2061async fn model_info_named(State(state): State<AppState>, Path(name): Path<String>) -> Response {
2062    match state.slot(&name) {
2063        Some(slot) => Json(model_info_response(slot)).into_response(),
2064        None => unknown_model_response(&state, &name),
2065    }
2066}
2067
2068/// `GET /models`: every served model
2069async fn list_models(State(state): State<AppState>) -> Json<ModelsResponse> {
2070    let models = state
2071        .models
2072        .iter()
2073        .map(|slot| {
2074            let loaded = slot.get();
2075            let info = loaded.model.get_info();
2076            ModelSummary {
2077                name: slot.name.clone(),
2078                ready: loaded.ready(),
2079                input_shape: info.input_spec.shape.clone(),
2080                output_shape: info.output_spec.shape.clone(),
2081                backend: info.backend.clone(),
2082                reloadable: slot.reload.is_some(),
2083            }
2084        })
2085        .collect();
2086    Json(ModelsResponse {
2087        default_model: state
2088            .default_slot()
2089            .map(|slot| slot.name.clone())
2090            .unwrap_or_default(),
2091        models,
2092    })
2093}
2094
2095#[derive(Debug, Default, Deserialize)]
2096struct ReloadRequest {
2097    /// New weight file; by default the model reloads from where it was loaded before
2098    weights: Option<std::path::PathBuf>,
2099}
2100
2101/// `POST /admin/models/{model}/reload` (bearer token `--admin-token`): load the model again,
2102/// warm it up, and switch to it. Requests in flight finish on the previous one.
2103async fn reload_model(
2104    State(state): State<AppState>,
2105    Path(name): Path<String>,
2106    headers: HeaderMap,
2107    body: Bytes,
2108) -> Response {
2109    let Some(token) = &state.admin_token else {
2110        return create_error_response(
2111            StatusCode::NOT_FOUND,
2112            Code::AdminApiDisabled,
2113            "The admin API is off; start the server with --admin-token to enable it",
2114            None,
2115        );
2116    };
2117    if !authorized(&headers, token) {
2118        let mut response = create_error_response(
2119            StatusCode::UNAUTHORIZED,
2120            Code::Unauthorized,
2121            "Missing or wrong bearer token",
2122            None,
2123        );
2124        response.headers_mut().insert(
2125            axum::http::header::WWW_AUTHENTICATE,
2126            HeaderValue::from_static("Bearer"),
2127        );
2128        return response;
2129    }
2130    let Some(slot) = state.slot(&name).cloned() else {
2131        return unknown_model_response(&state, &name);
2132    };
2133    let Some(reload) = slot.reload.clone() else {
2134        return create_error_response(
2135            StatusCode::CONFLICT,
2136            Code::NotReloadable,
2137            &format!("Model '{name}' cannot be reloaded"),
2138            None,
2139        );
2140    };
2141    let request: ReloadRequest = if body.iter().all(u8::is_ascii_whitespace) {
2142        ReloadRequest::default()
2143    } else {
2144        match serde_json::from_slice(&body) {
2145            Ok(request) => request,
2146            Err(e) => {
2147                return create_error_response(
2148                    StatusCode::BAD_REQUEST,
2149                    Code::InvalidRequest,
2150                    &format!("Expected {{\"weights\": \"<path>\"}} or an empty body: {e}"),
2151                    None,
2152                );
2153            }
2154        }
2155    };
2156    let Ok(_reloading) = slot.reloading.try_lock() else {
2157        return create_error_response(
2158            StatusCode::CONFLICT,
2159            Code::ReloadInProgress,
2160            &format!("Model '{name}' is already being reloaded"),
2161            None,
2162        );
2163    };
2164
2165    let previous = slot.get();
2166    let weights = request.weights.clone();
2167    let start = Instant::now();
2168    let task = tokio::task::spawn_blocking(move || -> Result<(Loaded, f64)> {
2169        let mut model = reload(weights.as_deref())?;
2170        let previous_info = previous.model.get_info();
2171        let info = model.get_info();
2172        if info.name != previous_info.name {
2173            return Err(ModelError::InvalidFormat(format!(
2174                "The reloaded model is named '{}', not '{}'",
2175                info.name, previous_info.name
2176            ))
2177            .into());
2178        }
2179        if info.inputs != previous_info.inputs || info.outputs != previous_info.outputs {
2180            let describe = |tensors: &[crate::tensor::TensorInfo]| {
2181                tensors
2182                    .iter()
2183                    .map(|t| format!("{} {} {:?}", t.name, t.datatype, t.shape))
2184                    .collect::<Vec<_>>()
2185                    .join(", ")
2186            };
2187            return Err(ModelError::InvalidFormat(format!(
2188                "The reloaded model takes [{}] -> [{}], the served one [{}] -> [{}]",
2189                describe(&info.inputs),
2190                describe(&info.outputs),
2191                describe(&previous_info.inputs),
2192                describe(&previous_info.outputs)
2193            ))
2194            .into());
2195        }
2196        model.adopt_stats(&previous.model);
2197        let loaded = Loaded::new(model);
2198        let warm_up = loaded.model.warm_up()?;
2199        loaded.ready.store(true, Ordering::Release);
2200        Ok((loaded, warm_up.map_or(0.0, |d| d.as_secs_f64() * 1e3)))
2201    });
2202    match task.await {
2203        Ok(Ok((loaded, warm_up_ms))) => {
2204            slot.replace(Arc::new(loaded));
2205            let reload_ms = start.elapsed().as_secs_f64() * 1e3;
2206            info!("🔄 Reloaded model {name} in {reload_ms:.1}ms (warm-up {warm_up_ms:.1}ms)");
2207            Json(ReloadResponse {
2208                model: name,
2209                weights: request.weights.map(|p| p.display().to_string()),
2210                reload_ms,
2211                warm_up_ms,
2212                timestamp: crate::time::rfc3339_now(),
2213            })
2214            .into_response()
2215        }
2216        Ok(Err(e)) => {
2217            warn!("Reloading model {name} failed, still serving the previous one: {e}");
2218            create_error_response(
2219                StatusCode::UNPROCESSABLE_ENTITY,
2220                Code::ReloadFailed,
2221                &format!("Reload failed, still serving the previous model: {e}"),
2222                None,
2223            )
2224        }
2225        Err(e) => create_error_response(
2226            StatusCode::INTERNAL_SERVER_ERROR,
2227            Code::ReloadFailed,
2228            &format!("Reload task failed, still serving the previous model: {e}"),
2229            None,
2230        ),
2231    }
2232}
2233
2234/// Whether `Authorization` is `Bearer <token>`, compared in constant time
2235fn authorized(headers: &HeaderMap, token: &str) -> bool {
2236    let Some(given) = headers
2237        .get(axum::http::header::AUTHORIZATION)
2238        .and_then(|v| v.to_str().ok())
2239        .and_then(|v| v.strip_prefix("Bearer "))
2240    else {
2241        return false;
2242    };
2243    constant_time_eq(given.as_bytes(), token.as_bytes())
2244}
2245
2246/// Furnace's JSON error: `{"error", "code", "details", "timestamp"}`
2247fn create_error_response(
2248    status_code: StatusCode,
2249    code: Code,
2250    message: &str,
2251    details: Option<serde_json::Value>,
2252) -> Response {
2253    let error_response =
2254        ErrorResponse::new(message.to_string(), code.as_str().to_string(), details);
2255    (status_code, Json(error_response)).into_response()
2256}
2257
2258#[cfg(test)]
2259pub(crate) mod tests {
2260    use super::*;
2261    use crate::model::DummyModel;
2262    use axum::{body::Body, http::Request};
2263    use tower::util::ServiceExt;
2264
2265    pub(super) fn dummy_model() -> Model {
2266        let dummy = DummyModel::new("test_model".to_string(), vec![784], vec![10]);
2267        Model::new(Box::new(dummy), "dummy", 0)
2268    }
2269
2270    /// The router of a server with `config`, serving `model`
2271    pub(crate) fn router(model: Model, config: &ServerConfig) -> Result<Router> {
2272        Server::with_config(config.clone()).model(model).router()
2273    }
2274
2275    /// [`router`] for several models, reloadable or not
2276    fn router_with_models(models: Vec<ServedModel>, config: &ServerConfig) -> Result<Router> {
2277        let mut server = Server::with_config(config.clone());
2278        server.contents = models.into();
2279        server.router()
2280    }
2281
2282    pub(super) fn test_app() -> Router {
2283        router(dummy_model(), &ServerConfig::default()).unwrap()
2284    }
2285
2286    #[cfg(feature = "grpc")]
2287    pub(super) fn test_app_state() -> AppState {
2288        server_state(
2289            vec![ServedModel::from(dummy_model())].into(),
2290            &ServerConfig::default(),
2291        )
2292        .unwrap()
2293    }
2294
2295    pub(super) async fn send(
2296        app: Router,
2297        request: Request<Body>,
2298    ) -> (StatusCode, axum::body::Bytes) {
2299        let response = app.oneshot(request).await.unwrap();
2300        let status = response.status();
2301        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2302            .await
2303            .unwrap();
2304        (status, body)
2305    }
2306
2307    fn predict_request(body: String) -> Request<Body> {
2308        Request::builder()
2309            .uri("/predict")
2310            .method("POST")
2311            .header("content-type", "application/json")
2312            .body(Body::from(body))
2313            .unwrap()
2314    }
2315
2316    async fn get_metrics(app: Router) -> MetricsResponse {
2317        let request = Request::builder()
2318            .uri("/metrics")
2319            .body(Body::empty())
2320            .unwrap();
2321        let (status, body) = send(app, request).await;
2322        assert_eq!(status, StatusCode::OK);
2323        serde_json::from_slice(&body).unwrap()
2324    }
2325
2326    #[tokio::test]
2327    async fn test_metrics_counts_requests_and_errors() {
2328        let app = test_app();
2329        let initial = get_metrics(app.clone()).await;
2330        assert_eq!(initial.total_requests, 0);
2331        assert_eq!(initial.max_concurrent_requests, 100);
2332        assert!(initial.last_inference_at.is_none());
2333
2334        let ok = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
2335        let (status, _) = send(app.clone(), predict_request(ok)).await;
2336        assert_eq!(status, StatusCode::OK);
2337        let bad = serde_json::json!({ "input": vec![0.5; 3] }).to_string();
2338        let (status, _) = send(app.clone(), predict_request(bad)).await;
2339        assert_eq!(status, StatusCode::BAD_REQUEST);
2340
2341        let m = get_metrics(app).await;
2342        assert_eq!(m.total_requests, 2);
2343        assert_eq!(m.total_errors, 1);
2344        assert_eq!(m.rejected_busy, 0);
2345        assert_eq!(m.total_inferences, 1);
2346        assert_eq!(m.total_batches, 1);
2347        assert_eq!(m.average_batch_size, 1.0);
2348        // Batching is off by default
2349        assert_eq!(m.max_batch_size, 1);
2350        assert_eq!(m.in_flight_inferences, 0);
2351        assert!(m.last_inference_at.is_some());
2352    }
2353
2354    async fn concurrent_predictions(config: &ServerConfig) -> MetricsResponse {
2355        let app = router(dummy_model(), config).unwrap();
2356        let mut tasks = Vec::new();
2357        for i in 0..12 {
2358            let body = if i % 3 == 0 {
2359                serde_json::json!({ "inputs": vec![vec![0.5; 784]; 2] })
2360            } else if i == 4 {
2361                serde_json::json!({ "input": vec![0.5; 3] })
2362            } else {
2363                serde_json::json!({ "input": vec![0.5; 784] })
2364            };
2365            let app = app.clone();
2366            tasks.push(tokio::spawn(async move {
2367                send(app, predict_request(body.to_string())).await
2368            }));
2369        }
2370        for (i, task) in tasks.into_iter().enumerate() {
2371            let (status, body) = task.await.unwrap();
2372            if i == 4 {
2373                assert_eq!(status, StatusCode::BAD_REQUEST);
2374                continue;
2375            }
2376            assert_eq!(status, StatusCode::OK);
2377            let response: PredictResponse = serde_json::from_slice(&body).unwrap();
2378            let expected = if i % 3 == 0 { 2 } else { 1 };
2379            assert_eq!(response.batch_size, expected);
2380            match response.output_data {
2381                PredictOutputData::Batch { outputs } => assert_eq!(outputs.len(), 2),
2382                PredictOutputData::Single { output } => assert_eq!(output.len(), 10),
2383            }
2384        }
2385        get_metrics(app).await
2386    }
2387
2388    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2389    async fn test_concurrent_predictions_with_and_without_batching() {
2390        let batched = concurrent_predictions(&ServerConfig::default()).await;
2391        let unbatched = concurrent_predictions(&ServerConfig {
2392            batching: BatchConfig {
2393                max_batch_size: 1,
2394                ..BatchConfig::default()
2395            },
2396            ..ServerConfig::default()
2397        })
2398        .await;
2399        for m in [&batched, &unbatched] {
2400            // The request with the wrong shape is rejected before reaching the model
2401            assert_eq!(m.total_requests, 12);
2402            assert_eq!(m.total_errors, 1);
2403            assert_eq!(m.total_inferences, 11);
2404            assert!((1..=11).contains(&m.total_batches), "{m:?}");
2405            assert_eq!(m.in_flight_inferences, 0);
2406        }
2407        assert_eq!(unbatched.total_batches, 11);
2408        assert_eq!(unbatched.average_batch_size, 15.0 / 11.0);
2409        assert_eq!(unbatched.max_batch_size, 1);
2410    }
2411
2412    #[tokio::test]
2413    async fn test_metrics_counts_busy_and_body_limit_rejections() {
2414        let config = ServerConfig {
2415            max_concurrent_requests: 0,
2416            max_body_bytes: 64,
2417            ..ServerConfig::default()
2418        };
2419        let app = router(dummy_model(), &config).unwrap();
2420        let small = serde_json::json!({ "input": [0.5] }).to_string();
2421        let (status, _) = send(app.clone(), predict_request(small)).await;
2422        assert_eq!(status, StatusCode::BAD_REQUEST);
2423        let big = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
2424        let (status, _) = send(app.clone(), predict_request(big)).await;
2425        assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
2426
2427        let m = get_metrics(app).await;
2428        assert_eq!(m.total_requests, 2);
2429        assert_eq!(m.total_errors, 2);
2430        assert_eq!(m.total_inferences, 0);
2431    }
2432
2433    #[tokio::test]
2434    async fn test_health_check_endpoints() {
2435        for path in ["/healthz", "/health"] {
2436            let request = Request::builder().uri(path).body(Body::empty()).unwrap();
2437            let (status, body) = send(test_app(), request).await;
2438            assert_eq!(status, StatusCode::OK);
2439            let health: HealthResponse = serde_json::from_slice(&body).unwrap();
2440            assert_eq!(health.status, "healthy");
2441            assert!(health.model_loaded);
2442            assert_eq!(health.backend, "cpu");
2443        }
2444    }
2445
2446    #[tokio::test]
2447    async fn test_security_headers_present() {
2448        let request = Request::builder()
2449            .uri("/healthz")
2450            .body(Body::empty())
2451            .unwrap();
2452        let response = test_app().oneshot(request).await.unwrap();
2453        assert_eq!(response.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2454        assert_eq!(response.headers()[X_FRAME_OPTIONS], "DENY");
2455    }
2456
2457    #[tokio::test]
2458    async fn test_cors_open_to_any_origin() {
2459        // A browser's preflight is answered without reaching the routes
2460        let preflight = Request::builder()
2461            .uri("/predict")
2462            .method("OPTIONS")
2463            .header(ORIGIN, "https://example.com")
2464            .header(ACCESS_CONTROL_REQUEST_METHOD, "POST")
2465            .header("access-control-request-headers", "content-type")
2466            .body(Body::empty())
2467            .unwrap();
2468        let response = test_app().oneshot(preflight).await.unwrap();
2469        assert_eq!(response.status(), StatusCode::OK);
2470        let headers = response.headers();
2471        assert_eq!(headers[ACCESS_CONTROL_ALLOW_ORIGIN], "*");
2472        assert_eq!(headers[ACCESS_CONTROL_ALLOW_METHODS], "*");
2473        assert_eq!(headers[ACCESS_CONTROL_ALLOW_HEADERS], "*");
2474        assert!(headers.contains_key(REQUEST_ID_HEADER));
2475
2476        // Responses let scripts read furnace's own headers
2477        let request = Request::builder()
2478            .uri("/healthz")
2479            .header(ORIGIN, "https://example.com")
2480            .body(Body::empty())
2481            .unwrap();
2482        let response = test_app().oneshot(request).await.unwrap();
2483        assert_eq!(response.headers()[ACCESS_CONTROL_ALLOW_ORIGIN], "*");
2484        let exposed = response.headers()[ACCESS_CONTROL_EXPOSE_HEADERS]
2485            .to_str()
2486            .unwrap();
2487        for name in [
2488            BATCH_SIZE_HEADER,
2489            OUTPUT_SHAPE_HEADER,
2490            INFERENCE_TIME_HEADER,
2491            REQUEST_ID_HEADER,
2492        ] {
2493            assert!(exposed.contains(name.as_str()), "{exposed}");
2494        }
2495    }
2496
2497    /// Every field of `value` is in the `name` schema of docs/openapi.json, and every field the
2498    /// schema requires is in `value`
2499    fn assert_matches_schema(spec: &serde_json::Value, name: &str, value: &serde_json::Value) {
2500        let schema = &spec["components"]["schemas"][name];
2501        let properties = schema["properties"]
2502            .as_object()
2503            .unwrap_or_else(|| panic!("no schema {name}"));
2504        let fields = value
2505            .as_object()
2506            .unwrap_or_else(|| panic!("{name}: not an object"));
2507        for field in fields.keys() {
2508            assert!(
2509                properties.contains_key(field),
2510                "{name}: the server sends `{field}`, the spec does not list it"
2511            );
2512        }
2513        for required in schema["required"].as_array().into_iter().flatten() {
2514            let required = required.as_str().unwrap();
2515            assert!(
2516                fields.contains_key(required),
2517                "{name}: the spec requires `{required}`, the server does not send it"
2518            );
2519        }
2520    }
2521
2522    /// docs/openapi.json describes the routes and the responses the server actually sends
2523    #[tokio::test]
2524    async fn test_openapi_spec_matches_server() {
2525        let spec: serde_json::Value =
2526            serde_json::from_str(include_str!("../docs/openapi.json")).unwrap();
2527        assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION"));
2528        let paths: std::collections::BTreeSet<&str> = spec["paths"]
2529            .as_object()
2530            .unwrap()
2531            .keys()
2532            .map(String::as_str)
2533            .collect();
2534        let served: std::collections::BTreeSet<&str> = ROUTES
2535            .iter()
2536            .map(|(path, _)| *path)
2537            .chain(LLM_PATHS)
2538            .chain(NERVE_PATHS)
2539            .collect();
2540        assert_eq!(paths, served);
2541
2542        // Every error code the server sends, and no other, in the spec and in docs/API.md
2543        let codes: std::collections::BTreeSet<&str> = Code::ALL
2544            .iter()
2545            .map(|code| code.as_str())
2546            .chain(cfg!(not(feature = "image")).then_some("INVALID_IMAGE"))
2547            .collect();
2548        let documented: std::collections::BTreeSet<&str> =
2549            spec["components"]["schemas"]["ErrorResponse"]["properties"]["code"]["enum"]
2550                .as_array()
2551                .expect("the spec lists the codes")
2552                .iter()
2553                .map(|code| code.as_str().unwrap())
2554                .collect();
2555        assert_eq!(documented, codes);
2556        let api_md = include_str!("../docs/API.md");
2557        for code in &codes {
2558            assert!(
2559                api_md.contains(&format!("| `{code}` |")),
2560                "docs/API.md has no table row for {code}"
2561            );
2562        }
2563
2564        let app = test_app();
2565        let get = |path: &'static str| {
2566            let app = app.clone();
2567            let request = Request::builder().uri(path).body(Body::empty()).unwrap();
2568            async move {
2569                let (status, body) = send(app, request).await;
2570                assert_eq!(status, StatusCode::OK, "{path}");
2571                serde_json::from_slice::<serde_json::Value>(&body).unwrap()
2572            }
2573        };
2574        for path in ["/healthz", "/health"] {
2575            assert_matches_schema(&spec, "HealthResponse", &get(path).await);
2576        }
2577
2578        let single = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
2579        let (status, body) = send(app.clone(), predict_request(single)).await;
2580        assert_eq!(status, StatusCode::OK);
2581        let single: serde_json::Value = serde_json::from_slice(&body).unwrap();
2582        assert_matches_schema(&spec, "PredictResponse", &single);
2583        let batch = serde_json::json!({ "inputs": vec![vec![0.5; 784]; 2] }).to_string();
2584        let (status, body) = send(app.clone(), predict_request(batch)).await;
2585        assert_eq!(status, StatusCode::OK);
2586        let batch: serde_json::Value = serde_json::from_slice(&body).unwrap();
2587        assert_matches_schema(&spec, "PredictResponse", &batch);
2588
2589        let bad = serde_json::json!({ "input": vec![0.5; 3] }).to_string();
2590        let (status, body) = send(app.clone(), predict_request(bad)).await;
2591        assert_eq!(status, StatusCode::BAD_REQUEST);
2592        let error: serde_json::Value = serde_json::from_slice(&body).unwrap();
2593        assert_matches_schema(&spec, "ErrorResponse", &error);
2594
2595        let metrics = get("/metrics").await;
2596        assert_matches_schema(&spec, "MetricsResponse", &metrics);
2597        assert_matches_schema(&spec, "Percentiles", &metrics["request_latency_ms"]);
2598        wait_until_ready(&app).await;
2599        assert_matches_schema(&spec, "ReadinessResponse", &get("/readyz").await);
2600
2601        assert_matches_schema(&spec, "V2ServerMetadata", &get("/v2").await);
2602        let model = get("/v2/models/test_model").await;
2603        assert_matches_schema(&spec, "V2ModelMetadata", &model);
2604        assert_matches_schema(&spec, "V2TensorMetadata", &model["inputs"][0]);
2605        let infer = Request::builder()
2606            .uri("/v2/models/test_model/infer")
2607            .method("POST")
2608            .header(CONTENT_TYPE, "application/json")
2609            .body(Body::from(
2610                serde_json::json!({
2611                    "id": "a",
2612                    "inputs": [{ "name": "input", "shape": [1, 784], "datatype": "FP32", "data": vec![0.5; 784] }],
2613                })
2614                .to_string(),
2615            ))
2616            .unwrap();
2617        let (status, body) = send(app.clone(), infer).await;
2618        assert_eq!(status, StatusCode::OK);
2619        let infer: serde_json::Value = serde_json::from_slice(&body).unwrap();
2620        assert_matches_schema(&spec, "V2InferResponse", &infer);
2621        assert_matches_schema(&spec, "V2OutputTensor", &infer["outputs"][0]);
2622        let info = get("/model/info").await;
2623        assert_matches_schema(&spec, "ModelInfoResponse", &info);
2624        assert_matches_schema(&spec, "ModelInfo", &info["model_info"]);
2625        assert_matches_schema(&spec, "ModelStats", &info["stats"]);
2626        assert_matches_schema(&spec, "TensorSpec", &info["model_info"]["input_spec"]);
2627        assert_matches_schema(&spec, "TensorInfo", &info["model_info"]["inputs"][0]);
2628        assert_matches_schema(&spec, "ModelInfoResponse", &get("/models/test_model").await);
2629        assert_matches_schema(&spec, "ModelMetrics", &metrics["models"][0]);
2630        let models = get("/models").await;
2631        assert_matches_schema(&spec, "ModelsResponse", &models);
2632        assert_matches_schema(&spec, "ModelSummary", &models["models"][0]);
2633
2634        let app = multi_app(Some(TOKEN));
2635        let (status, body) = send(app.clone(), reload_request("a", Some(TOKEN), "")).await;
2636        assert_eq!(status, StatusCode::OK);
2637        let reload: serde_json::Value = serde_json::from_slice(&body).unwrap();
2638        assert_matches_schema(&spec, "ReloadResponse", &reload);
2639    }
2640
2641    const TOKEN: &str = "0123456789abcdef-token";
2642    const API_KEY: &str = "api-key-0123456789";
2643
2644    fn guarded_app(api_keys: &[&str], rate_limit: Option<ClientRateLimit>) -> Router {
2645        let config = ServerConfig {
2646            api_keys: api_keys.iter().map(|k| k.to_string()).collect(),
2647            rate_limit,
2648            admin_token: Some(TOKEN.to_string()),
2649            ..ServerConfig::default()
2650        };
2651        router(dummy_model(), &config).unwrap()
2652    }
2653
2654    fn with_header(mut request: Request<Body>, name: &'static str, value: &str) -> Request<Body> {
2655        request
2656            .headers_mut()
2657            .insert(name, HeaderValue::from_str(value).unwrap());
2658        request
2659    }
2660
2661    fn ok_predict() -> Request<Body> {
2662        predict_request(serde_json::json!({ "input": vec![0.5; 784] }).to_string())
2663    }
2664
2665    #[tokio::test]
2666    async fn test_api_keys() {
2667        let app = guarded_app(&[API_KEY, "another-key-0123456789"], None);
2668        let response = app.clone().oneshot(ok_predict()).await.unwrap();
2669        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2670        assert_eq!(response.headers()["www-authenticate"], "Bearer");
2671        let wrong = with_header(ok_predict(), "authorization", "Bearer api-key-012345678");
2672        assert_eq!(send(app.clone(), wrong).await.0, StatusCode::UNAUTHORIZED);
2673
2674        let bearer = with_header(ok_predict(), "authorization", &format!("Bearer {API_KEY}"));
2675        assert_eq!(send(app.clone(), bearer).await.0, StatusCode::OK);
2676        let header = with_header(ok_predict(), "x-api-key", "another-key-0123456789");
2677        assert_eq!(send(app.clone(), header).await.0, StatusCode::OK);
2678
2679        // Probes stay open; everything else is closed
2680        for path in ["/healthz", "/health", "/readyz", "/v2/health/live"] {
2681            let request = Request::builder().uri(path).body(Body::empty()).unwrap();
2682            let status = send(app.clone(), request).await.0;
2683            assert_ne!(status, StatusCode::UNAUTHORIZED, "{path}");
2684        }
2685        for path in [
2686            "/metrics",
2687            "/model/info",
2688            "/models",
2689            "/v2",
2690            "/v2/models/test_model",
2691        ] {
2692            let request = Request::builder().uri(path).body(Body::empty()).unwrap();
2693            assert_eq!(
2694                send(app.clone(), request).await.0,
2695                StatusCode::UNAUTHORIZED,
2696                "{path}"
2697            );
2698        }
2699        // The admin API takes its own token, not an API key
2700        let reload = Request::builder()
2701            .uri("/admin/models/test_model/reload")
2702            .method("POST")
2703            .header("authorization", format!("Bearer {TOKEN}"))
2704            .body(Body::empty())
2705            .unwrap();
2706        assert_ne!(send(app.clone(), reload).await.0, StatusCode::UNAUTHORIZED);
2707
2708        let request = Request::builder()
2709            .uri("/metrics")
2710            .header("x-api-key", API_KEY)
2711            .body(Body::empty())
2712            .unwrap();
2713        let (_, body) = send(app, request).await;
2714        let metrics: MetricsResponse = serde_json::from_slice(&body).unwrap();
2715        assert_eq!(metrics.rejected_unauthorized, 7);
2716    }
2717
2718    #[tokio::test]
2719    async fn test_rate_limit() {
2720        let limit = ClientRateLimit {
2721            per_second: 0.001,
2722            burst: 2,
2723        };
2724        let app = guarded_app(&[], Some(limit));
2725        for _ in 0..2 {
2726            assert_eq!(send(app.clone(), ok_predict()).await.0, StatusCode::OK);
2727        }
2728        let response = app.clone().oneshot(ok_predict()).await.unwrap();
2729        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2730        // One request every 1000 s
2731        let retry: u64 = response.headers()["retry-after"]
2732            .to_str()
2733            .unwrap()
2734            .parse()
2735            .unwrap();
2736        assert!((990..=1000).contains(&retry), "{retry}");
2737        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2738            .await
2739            .unwrap();
2740        let error: serde_json::Value = serde_json::from_slice(&body).unwrap();
2741        assert_eq!(error["code"], "RATE_LIMITED");
2742        // Probes are not limited
2743        let request = Request::builder()
2744            .uri("/healthz")
2745            .body(Body::empty())
2746            .unwrap();
2747        assert_eq!(send(app.clone(), request).await.0, StatusCode::OK);
2748
2749        // With keys, each key has its own budget
2750        let app = guarded_app(&[API_KEY, "another-key-0123456789"], Some(limit));
2751        let auth = |key: &str| with_header(ok_predict(), "x-api-key", key);
2752        for _ in 0..2 {
2753            assert_eq!(send(app.clone(), auth(API_KEY)).await.0, StatusCode::OK);
2754        }
2755        assert_eq!(
2756            send(app.clone(), auth(API_KEY)).await.0,
2757            StatusCode::TOO_MANY_REQUESTS
2758        );
2759        assert_eq!(
2760            send(app.clone(), auth("another-key-0123456789")).await.0,
2761            StatusCode::OK
2762        );
2763    }
2764
2765    fn named_model(name: &str) -> Model {
2766        let dummy = DummyModel::new(name.to_string(), vec![784], vec![10]);
2767        Model::new(Box::new(dummy), "dummy", 0)
2768    }
2769
2770    /// Models "a" (the default) and "b"; reloading with weights "bad" fails, "renamed" loads a
2771    /// model with another name
2772    fn multi_app(admin_token: Option<&str>) -> Router {
2773        let models = ["a", "b"]
2774            .map(|name| {
2775                let name = name.to_string();
2776                ServedModel {
2777                    model: named_model(&name),
2778                    reload: Some(Arc::new(
2779                        move |weights: Option<&std::path::Path>| match weights
2780                            .and_then(|w| w.to_str())
2781                        {
2782                            Some("bad") => {
2783                                Err(ModelError::InvalidFormat("bad weights".to_string()).into())
2784                            }
2785                            Some("renamed") => Ok(named_model("other")),
2786                            _ => Ok(named_model(&name)),
2787                        },
2788                    )),
2789                }
2790            })
2791            .into();
2792        let config = ServerConfig {
2793            admin_token: admin_token.map(str::to_string),
2794            ..ServerConfig::default()
2795        };
2796        router_with_models(models, &config).unwrap()
2797    }
2798
2799    fn reload_request(model: &str, token: Option<&str>, body: &str) -> Request<Body> {
2800        let mut request = Request::builder()
2801            .uri(format!("/admin/models/{model}/reload"))
2802            .method("POST")
2803            .header(CONTENT_TYPE, "application/json");
2804        if let Some(token) = token {
2805            request = request.header("authorization", format!("Bearer {token}"));
2806        }
2807        request.body(Body::from(body.to_string())).unwrap()
2808    }
2809
2810    fn named_predict_request(model: &str) -> Request<Body> {
2811        Request::builder()
2812            .uri(format!("/models/{model}/predict"))
2813            .method("POST")
2814            .header(CONTENT_TYPE, "application/json")
2815            .body(Body::from(
2816                serde_json::json!({ "input": vec![0.5; 784] }).to_string(),
2817            ))
2818            .unwrap()
2819    }
2820
2821    async fn get_json(app: &Router, path: &str) -> (StatusCode, serde_json::Value) {
2822        let request = Request::builder().uri(path).body(Body::empty()).unwrap();
2823        let (status, body) = send(app.clone(), request).await;
2824        (status, serde_json::from_slice(&body).unwrap_or_default())
2825    }
2826
2827    #[tokio::test]
2828    async fn test_several_models_side_by_side() {
2829        let app = multi_app(None);
2830        let ok = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
2831        // `/predict` serves the first model, "a"; the per-model counts below tell them apart
2832        let (status, _) = send(app.clone(), predict_request(ok)).await;
2833        assert_eq!(status, StatusCode::OK);
2834        for _ in 0..2 {
2835            let (status, _) = send(app.clone(), named_predict_request("b")).await;
2836            assert_eq!(status, StatusCode::OK);
2837        }
2838        let (status, body) = send(app.clone(), named_predict_request("c")).await;
2839        assert_eq!(status, StatusCode::NOT_FOUND);
2840        let error: serde_json::Value = serde_json::from_slice(&body).unwrap();
2841        assert_eq!(error["code"], "MODEL_NOT_FOUND");
2842
2843        let (status, models) = get_json(&app, "/models").await;
2844        assert_eq!(status, StatusCode::OK);
2845        assert_eq!(models["default_model"], "a");
2846        assert_eq!(models["models"].as_array().unwrap().len(), 2);
2847        assert_eq!(models["models"][1]["name"], "b");
2848        assert_eq!(models["models"][1]["reloadable"], true);
2849        let (status, info) = get_json(&app, "/models/b").await;
2850        assert_eq!(status, StatusCode::OK);
2851        assert_eq!(info["model_info"]["name"], "b");
2852        assert_eq!(info["stats"]["inference_count"], 2);
2853        let (status, _) = get_json(&app, "/models/c").await;
2854        assert_eq!(status, StatusCode::NOT_FOUND);
2855
2856        // The v2 protocol addresses the models by name too
2857        let infer = |model: &str| {
2858            Request::builder()
2859                .uri(format!("/v2/models/{model}/infer"))
2860                .method("POST")
2861                .header(CONTENT_TYPE, "application/json")
2862                .body(Body::from(
2863                    serde_json::json!({
2864                        "inputs": [{ "name": "input", "shape": [1, 784], "datatype": "FP32", "data": vec![0.5; 784] }],
2865                    })
2866                    .to_string(),
2867                ))
2868                .unwrap()
2869        };
2870        let (status, body) = send(app.clone(), infer("b")).await;
2871        assert_eq!(status, StatusCode::OK);
2872        let response: serde_json::Value = serde_json::from_slice(&body).unwrap();
2873        assert_eq!(response["model_name"], "b");
2874        let (status, body) = send(app.clone(), infer("c")).await;
2875        assert_eq!(status, StatusCode::NOT_FOUND);
2876        let error = String::from_utf8_lossy(&body);
2877        assert!(error.contains("a, b"), "{error}");
2878
2879        let metrics = get_metrics(app.clone()).await;
2880        assert_eq!(metrics.total_inferences, 4);
2881        let per_model: Vec<_> = metrics
2882            .models
2883            .iter()
2884            .map(|m| (m.name.as_str(), m.inference_count))
2885            .collect();
2886        assert_eq!(per_model, [("a", 1), ("b", 3)]);
2887
2888        let request = Request::builder()
2889            .uri("/metrics")
2890            .header(ACCEPT, "text/plain")
2891            .body(Body::empty())
2892            .unwrap();
2893        let (_, body) = send(app.clone(), request).await;
2894        let text = String::from_utf8(body.to_vec()).unwrap();
2895        assert!(
2896            text.contains("furnace_inferences_total{model=\"b\"} 3\n"),
2897            "{text}"
2898        );
2899        assert!(
2900            text.contains("furnace_inference_duration_seconds_count{model=\"a\"} 1\n"),
2901            "{text}"
2902        );
2903        assert!(
2904            text.contains("furnace_model_info{model=\"a\",backend="),
2905            "{text}"
2906        );
2907        // One HELP / TYPE per family, as Prometheus requires
2908        assert_eq!(text.matches("# TYPE furnace_inferences_total ").count(), 1);
2909        assert_eq!(
2910            text.matches("# TYPE furnace_inference_duration_seconds ")
2911                .count(),
2912            1
2913        );
2914    }
2915
2916    #[tokio::test]
2917    async fn test_reload_needs_the_admin_token() {
2918        let (status, body) = send(multi_app(None), reload_request("a", Some(TOKEN), "")).await;
2919        assert_eq!(status, StatusCode::NOT_FOUND);
2920        assert!(String::from_utf8_lossy(&body).contains("ADMIN_API_DISABLED"));
2921
2922        let app = multi_app(Some(TOKEN));
2923        for token in [None, Some("wrong"), Some("0123456789abcdef-tokeN")] {
2924            let response = app
2925                .clone()
2926                .oneshot(reload_request("a", token, ""))
2927                .await
2928                .unwrap();
2929            assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{token:?}");
2930            assert_eq!(response.headers()["www-authenticate"], "Bearer");
2931        }
2932        let (status, _) = send(app.clone(), reload_request("c", Some(TOKEN), "")).await;
2933        assert_eq!(status, StatusCode::NOT_FOUND);
2934    }
2935
2936    #[tokio::test]
2937    async fn a_model_without_a_reloader_is_a_409() {
2938        let config = ServerConfig {
2939            admin_token: Some(TOKEN.to_string()),
2940            ..ServerConfig::default()
2941        };
2942        let app = router(dummy_model(), &config).unwrap();
2943        let (status, body) = send(app, reload_request("test_model", Some(TOKEN), "")).await;
2944        assert_eq!(status, StatusCode::CONFLICT);
2945        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
2946        assert_eq!(error.code, "NOT_RELOADABLE");
2947    }
2948
2949    #[tokio::test]
2950    async fn a_taken_port_says_what_to_do() {
2951        let taken = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2952        let port = taken.local_addr().unwrap().port();
2953        let refused = Server::new()
2954            .port(port)
2955            .model(dummy_model())
2956            .serve()
2957            .await
2958            .unwrap_err()
2959            .to_string();
2960        assert!(refused.contains("already in use"), "{refused}");
2961        assert!(refused.contains("Server::port"), "{refused}");
2962    }
2963
2964    #[test]
2965    fn the_shortcuts_set_the_config() {
2966        let server = Server::new()
2967            .max_batch_size(8)
2968            .request_timeout(Duration::from_millis(250));
2969        assert_eq!(server.config.batching.max_batch_size, 8);
2970        assert_eq!(
2971            server.config.request_timeout,
2972            Some(Duration::from_millis(250))
2973        );
2974    }
2975
2976    #[tokio::test]
2977    async fn an_empty_server_is_an_error() {
2978        assert!(Server::new().router().is_err());
2979        assert!(router_with_models(Vec::new(), &ServerConfig::default()).is_err());
2980        // serve() refuses it, naming what a server can serve
2981        let refused = Server::new().serve().await.unwrap_err().to_string();
2982        assert!(refused.contains("Server::model"), "{refused}");
2983        assert!(refused.contains("Server::llm"), "{refused}");
2984        assert!(refused.contains("Server::nerve"), "{refused}");
2985    }
2986
2987    #[tokio::test]
2988    async fn test_reload_swaps_the_model_and_keeps_its_stats() {
2989        let app = multi_app(Some(TOKEN));
2990        let (status, _) = send(app.clone(), named_predict_request("b")).await;
2991        assert_eq!(status, StatusCode::OK);
2992
2993        let (status, body) = send(app.clone(), reload_request("b", Some(TOKEN), "")).await;
2994        assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
2995        let reload: ReloadResponse = serde_json::from_slice(&body).unwrap();
2996        assert_eq!(reload.model, "b");
2997        assert!(reload.reload_ms >= reload.warm_up_ms);
2998
2999        // The new model is ready at once and carries the old one's counts on
3000        let (_, models) = get_json(&app, "/models").await;
3001        assert_eq!(models["models"][1]["ready"], true);
3002        let (status, _) = send(app.clone(), named_predict_request("b")).await;
3003        assert_eq!(status, StatusCode::OK);
3004        let (_, info) = get_json(&app, "/models/b").await;
3005        assert_eq!(info["stats"]["inference_count"], 2);
3006
3007        // A failed reload leaves the previous model serving
3008        for (body, code) in [
3009            (r#"{"weights": "bad"}"#, StatusCode::UNPROCESSABLE_ENTITY),
3010            (
3011                r#"{"weights": "renamed"}"#,
3012                StatusCode::UNPROCESSABLE_ENTITY,
3013            ),
3014            ("not json", StatusCode::BAD_REQUEST),
3015        ] {
3016            let (status, _) = send(app.clone(), reload_request("b", Some(TOKEN), body)).await;
3017            assert_eq!(status, code, "{body}");
3018            let (status, _) = send(app.clone(), named_predict_request("b")).await;
3019            assert_eq!(status, StatusCode::OK);
3020        }
3021    }
3022
3023    #[tokio::test]
3024    async fn test_requests_during_a_reload_succeed() {
3025        let app = multi_app(Some(TOKEN));
3026        let requests: Vec<_> = (0..64)
3027            .map(|_| tokio::spawn(send(app.clone(), named_predict_request("a"))))
3028            .collect();
3029        let reloads: Vec<_> = (0..4)
3030            .map(|_| tokio::spawn(send(app.clone(), reload_request("a", Some(TOKEN), ""))))
3031            .collect();
3032        for request in requests {
3033            assert_eq!(request.await.unwrap().0, StatusCode::OK);
3034        }
3035        // Reloads of one model run one at a time; any overlapping one is told so
3036        let mut reloaded = 0;
3037        for reload in reloads {
3038            match reload.await.unwrap().0 {
3039                StatusCode::OK => reloaded += 1,
3040                status => assert_eq!(status, StatusCode::CONFLICT),
3041            }
3042        }
3043        assert!(reloaded >= 1);
3044        let metrics = get_metrics(app).await;
3045        assert_eq!(metrics.models[0].inference_count, 64);
3046    }
3047
3048    /// Poll `/readyz` until the warm-up has run
3049    async fn wait_until_ready(app: &Router) {
3050        let deadline = Instant::now() + Duration::from_secs(10);
3051        loop {
3052            let request = Request::builder()
3053                .uri("/readyz")
3054                .body(Body::empty())
3055                .unwrap();
3056            let (status, _) = send(app.clone(), request).await;
3057            if status == StatusCode::OK {
3058                return;
3059            }
3060            assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
3061            assert!(Instant::now() < deadline, "never became ready");
3062            tokio::time::sleep(Duration::from_millis(5)).await;
3063        }
3064    }
3065
3066    #[tokio::test]
3067    async fn test_readiness_after_warm_up() {
3068        let app = test_app();
3069        wait_until_ready(&app).await;
3070        // The warm-up does not count as an inference
3071        let metrics = get_metrics(app).await;
3072        assert_eq!(metrics.total_inferences, 0);
3073        assert!(metrics.ready);
3074    }
3075
3076    #[tokio::test]
3077    async fn test_metrics_latency_percentiles_and_prometheus_format() {
3078        let app = test_app();
3079        let ok = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3080        for _ in 0..3 {
3081            let (status, _) = send(app.clone(), predict_request(ok.clone())).await;
3082            assert_eq!(status, StatusCode::OK);
3083        }
3084        let m = get_metrics(app.clone()).await;
3085        assert!(m.request_latency_ms.p50.is_some());
3086        assert!(m.inference_latency_ms.p99.is_some());
3087
3088        // What Prometheus sends
3089        let request = Request::builder()
3090            .uri("/metrics")
3091            .header(
3092                ACCEPT,
3093                "application/openmetrics-text;version=1.0.0;q=0.5,text/plain;version=0.0.4;q=0.4,*/*;q=0.1",
3094            )
3095            .body(Body::empty())
3096            .unwrap();
3097        let response = app.clone().oneshot(request).await.unwrap();
3098        assert_eq!(response.status(), StatusCode::OK);
3099        assert!(
3100            response.headers()[CONTENT_TYPE]
3101                .to_str()
3102                .unwrap()
3103                .starts_with("text/plain")
3104        );
3105        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3106            .await
3107            .unwrap();
3108        let text = String::from_utf8(body.to_vec()).unwrap();
3109        assert!(text.contains("furnace_requests_total 3\n"), "{text}");
3110        assert!(
3111            text.contains("furnace_inferences_total{model=\"test_model\"} 3\n"),
3112            "{text}"
3113        );
3114        assert!(
3115            text.contains("furnace_request_duration_seconds_count 3\n"),
3116            "{text}"
3117        );
3118        assert!(text.contains("furnace_build_info{version="), "{text}");
3119
3120        // A browser or curl gets JSON
3121        let request = Request::builder()
3122            .uri("/metrics")
3123            .header(ACCEPT, "*/*")
3124            .body(Body::empty())
3125            .unwrap();
3126        let response = app.oneshot(request).await.unwrap();
3127        assert!(
3128            response.headers()[CONTENT_TYPE]
3129                .to_str()
3130                .unwrap()
3131                .starts_with("application/json")
3132        );
3133    }
3134
3135    #[tokio::test]
3136    async fn test_request_id_echoed_or_generated() {
3137        let with_id = |id: &str| {
3138            Request::builder()
3139                .uri("/healthz")
3140                .header(REQUEST_ID_HEADER, id)
3141                .body(Body::empty())
3142                .unwrap()
3143        };
3144        // The client's ID comes back
3145        let response = test_app().oneshot(with_id("trace-42")).await.unwrap();
3146        assert_eq!(response.headers()[REQUEST_ID_HEADER], "trace-42");
3147
3148        // Without one, or with one that is too long or has spaces, a UUID is generated
3149        let plain = Request::builder()
3150            .uri("/healthz")
3151            .body(Body::empty())
3152            .unwrap();
3153        for request in [plain, with_id(&"x".repeat(129)), with_id("a b")] {
3154            let response = test_app().oneshot(request).await.unwrap();
3155            let id = response.headers()[REQUEST_ID_HEADER].to_str().unwrap();
3156            // 8-4-4-4-12 lowercase hex digits, version 4, variant `10`
3157            let groups: Vec<&str> = id.split('-').collect();
3158            let lengths: Vec<usize> = groups.iter().map(|g| g.len()).collect();
3159            assert_eq!(lengths, [8, 4, 4, 4, 12], "{id}");
3160            assert!(
3161                id.chars()
3162                    .all(|c| c == '-' || c.is_ascii_digit() || ('a'..='f').contains(&c)),
3163                "{id}"
3164            );
3165            assert!(groups[2].starts_with('4'), "{id}");
3166            assert!(groups[3].starts_with(['8', '9', 'a', 'b']), "{id}");
3167        }
3168
3169        // Generated IDs do not repeat
3170        let ids: std::collections::HashSet<_> = (0..1000).map(|_| new_request_id()).collect();
3171        assert_eq!(ids.len(), 1000);
3172
3173        // Errors carry it too
3174        let request = Request::builder()
3175            .uri("/predict")
3176            .method("POST")
3177            .header("content-type", "application/json")
3178            .header(REQUEST_ID_HEADER, "bad-1")
3179            .body(Body::from("{"))
3180            .unwrap();
3181        let response = test_app().oneshot(request).await.unwrap();
3182        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3183        assert_eq!(response.headers()[REQUEST_ID_HEADER], "bad-1");
3184    }
3185
3186    #[tokio::test]
3187    async fn test_model_info_endpoint() {
3188        let request = Request::builder()
3189            .uri("/model/info")
3190            .body(Body::empty())
3191            .unwrap();
3192        let (status, body) = send(test_app(), request).await;
3193        assert_eq!(status, StatusCode::OK);
3194        let info: ModelInfoResponse = serde_json::from_slice(&body).unwrap();
3195        assert_eq!(info.model_info.input_spec.shape, vec![784]);
3196        assert_eq!(info.model_info.output_spec.shape, vec![10]);
3197        assert_eq!(info.model_info.model_type, "dummy");
3198    }
3199
3200    #[tokio::test]
3201    async fn test_predict_single() {
3202        let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3203        let (status, body) = send(test_app(), predict_request(body)).await;
3204        assert_eq!(status, StatusCode::OK);
3205        let response: PredictResponse = serde_json::from_slice(&body).unwrap();
3206        assert_eq!(response.status, "success");
3207        assert_eq!(response.batch_size, 1);
3208        match response.output_data {
3209            PredictOutputData::Single { output } => assert_eq!(output.len(), 10),
3210            _ => panic!("Expected single output"),
3211        }
3212    }
3213
3214    #[tokio::test]
3215    async fn test_predict_batch() {
3216        let body = serde_json::json!({ "inputs": vec![vec![0.5; 784]; 3] }).to_string();
3217        let (status, body) = send(test_app(), predict_request(body)).await;
3218        assert_eq!(status, StatusCode::OK);
3219        let response: PredictResponse = serde_json::from_slice(&body).unwrap();
3220        assert_eq!(response.batch_size, 3);
3221        match response.output_data {
3222            PredictOutputData::Batch { outputs } => assert_eq!(outputs.len(), 3),
3223            _ => panic!("Expected batch output"),
3224        }
3225    }
3226
3227    #[tokio::test]
3228    async fn test_predict_accepts_body_larger_than_axum_default() {
3229        // ResNet-sized payloads exceed axum's 2 MB default body limit
3230        let dummy = DummyModel::new("big".to_string(), vec![3, 224, 224], vec![1000]);
3231        let app = router(
3232            Model::new(Box::new(dummy), "dummy", 0),
3233            &ServerConfig::default(),
3234        )
3235        .unwrap();
3236        let body =
3237            serde_json::json!({ "inputs": vec![vec![0.123_456_7f32; 150_528]; 2] }).to_string();
3238        assert!(body.len() > 2 * 1024 * 1024);
3239        let (status, _) = send(app, predict_request(body)).await;
3240        assert_eq!(status, StatusCode::OK);
3241    }
3242
3243    #[tokio::test]
3244    async fn test_predict_body_limit_enforced() {
3245        let config = ServerConfig {
3246            max_body_bytes: 1024,
3247            ..ServerConfig::default()
3248        };
3249        let app = router(dummy_model(), &config).unwrap();
3250        let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3251        let (status, _) = send(app, predict_request(body)).await;
3252        assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
3253    }
3254
3255    /// [`DummyModel`], except that a non-zero input makes it panic or take `delay`; the
3256    /// warm-up's zeros run as usual
3257    #[derive(Debug)]
3258    struct Misbehaving {
3259        dummy: DummyModel,
3260        panics: bool,
3261        delay: Duration,
3262    }
3263
3264    impl crate::TensorModel for Misbehaving {
3265        fn name(&self) -> &str {
3266            self.dummy.name()
3267        }
3268        fn backend(&self) -> &str {
3269            self.dummy.backend()
3270        }
3271        fn inputs(&self) -> Vec<crate::IoSpec> {
3272            self.dummy.inputs()
3273        }
3274        fn outputs(&self) -> Vec<crate::IoSpec> {
3275            self.dummy.outputs()
3276        }
3277        fn run(&self, inputs: Vec<HostTensor>) -> Result<Vec<HostTensor>> {
3278            let zeros = matches!(&inputs[0].values, Values::F32(v) if v.iter().all(|x| *x == 0.0));
3279            if !zeros {
3280                assert!(!self.panics, "the model broke");
3281                std::thread::sleep(self.delay);
3282            }
3283            self.dummy.run(inputs)
3284        }
3285    }
3286
3287    fn misbehaving_app(panics: bool, delay: Duration, config: ServerConfig) -> Router {
3288        let dummy = DummyModel::new("test_model".to_string(), vec![784], vec![10]);
3289        let model = Model::new(
3290            Box::new(Misbehaving {
3291                dummy,
3292                panics,
3293                delay,
3294            }),
3295            "dummy",
3296            0,
3297        );
3298        router(model, &config).unwrap()
3299    }
3300
3301    /// A latch: closed until opened, then open for good
3302    #[derive(Debug, Default)]
3303    struct Gate {
3304        open: std::sync::Mutex<bool>,
3305        opened: std::sync::Condvar,
3306    }
3307
3308    impl Gate {
3309        fn open(&self) {
3310            *self.open.lock().unwrap() = true;
3311            self.opened.notify_all();
3312        }
3313
3314        fn wait(&self) {
3315            let mut open = self.open.lock().unwrap();
3316            while !*open {
3317                open = self.opened.wait(open).unwrap();
3318            }
3319        }
3320    }
3321
3322    /// [`DummyModel`] whose warm-up (all zeros) waits for `warm_up` and whose inferences wait
3323    /// for `inference`, counting those that wait: tests open the gates when they choose instead
3324    /// of racing the model
3325    #[derive(Debug)]
3326    struct Gated {
3327        dummy: DummyModel,
3328        warm_up: Arc<Gate>,
3329        inference: Arc<Gate>,
3330        waiting: Arc<AtomicU64>,
3331    }
3332
3333    impl crate::TensorModel for Gated {
3334        fn name(&self) -> &str {
3335            self.dummy.name()
3336        }
3337        fn backend(&self) -> &str {
3338            self.dummy.backend()
3339        }
3340        fn inputs(&self) -> Vec<crate::IoSpec> {
3341            self.dummy.inputs()
3342        }
3343        fn outputs(&self) -> Vec<crate::IoSpec> {
3344            self.dummy.outputs()
3345        }
3346        fn run(&self, inputs: Vec<HostTensor>) -> Result<Vec<HostTensor>> {
3347            let zeros = matches!(&inputs[0].values, Values::F32(v) if v.iter().all(|x| *x == 0.0));
3348            if zeros {
3349                self.warm_up.wait();
3350            } else {
3351                self.waiting.fetch_add(1, Ordering::SeqCst);
3352                self.inference.wait();
3353            }
3354            self.dummy.run(inputs)
3355        }
3356    }
3357
3358    struct GatedServer {
3359        state: AppState,
3360        app: Router,
3361        warm_up: Arc<Gate>,
3362        inference: Arc<Gate>,
3363        waiting: Arc<AtomicU64>,
3364    }
3365
3366    fn gated_server(config: ServerConfig) -> GatedServer {
3367        let warm_up = Arc::new(Gate::default());
3368        let inference = Arc::new(Gate::default());
3369        let waiting = Arc::new(AtomicU64::new(0));
3370        let model = Model::new(
3371            Box::new(Gated {
3372                dummy: DummyModel::new("test_model".to_string(), vec![784], vec![10]),
3373                warm_up: warm_up.clone(),
3374                inference: inference.clone(),
3375                waiting: waiting.clone(),
3376            }),
3377            "dummy",
3378            0,
3379        );
3380        let state = server_state(vec![ServedModel::from(model)].into(), &config).unwrap();
3381        GatedServer {
3382            app: router_with_state(state.clone(), &config),
3383            state,
3384            warm_up,
3385            inference,
3386            waiting,
3387        }
3388    }
3389
3390    async fn status_of(app: &Router, path: &str) -> StatusCode {
3391        let request = Request::builder().uri(path).body(Body::empty()).unwrap();
3392        send(app.clone(), request).await.0
3393    }
3394
3395    /// Readiness and metadata over `/readyz`, v2 and gRPC, before and after the warm-up
3396    #[tokio::test]
3397    async fn readiness_and_metadata_follow_the_warm_up_on_every_protocol() {
3398        let server = gated_server(ServerConfig::default());
3399        let app = &server.app;
3400        assert!(!server.state.ready());
3401        let probes = [
3402            "/readyz",
3403            "/v2/health/ready",
3404            "/v2/models/test_model/ready",
3405            "/v2/models/test_model/versions/1/ready",
3406        ];
3407        for path in probes {
3408            assert_eq!(
3409                status_of(app, path).await,
3410                StatusCode::SERVICE_UNAVAILABLE,
3411                "{path}"
3412            );
3413        }
3414        assert_eq!(status_of(app, "/v2/health/live").await, StatusCode::OK);
3415        assert_eq!(
3416            status_of(app, "/v2/models/test_model/versions/2/ready").await,
3417            StatusCode::NOT_FOUND
3418        );
3419        #[cfg(feature = "grpc")]
3420        let mut grpc = grpc::tests::client_for(server.state.clone()).await;
3421        #[cfg(feature = "grpc")]
3422        {
3423            use grpc::pb;
3424            let ready = grpc.server_ready(pb::ServerReadyRequest {}).await.unwrap();
3425            assert!(!ready.into_inner().ready);
3426            let model = pb::ModelReadyRequest {
3427                name: "test_model".to_string(),
3428                version: String::new(),
3429            };
3430            assert!(!grpc.model_ready(model).await.unwrap().into_inner().ready);
3431            let metadata = grpc
3432                .server_metadata(pb::ServerMetadataRequest {})
3433                .await
3434                .unwrap()
3435                .into_inner();
3436            assert_eq!(metadata.name, "furnace");
3437            assert_eq!(metadata.version, env!("CARGO_PKG_VERSION"));
3438        }
3439
3440        server.warm_up.open();
3441        wait_until_ready(app).await;
3442        for path in probes {
3443            assert_eq!(status_of(app, path).await, StatusCode::OK, "{path}");
3444        }
3445        let (status, body) = get_json(app, "/v2").await;
3446        assert_eq!(status, StatusCode::OK);
3447        assert_eq!(body["name"], "furnace");
3448        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
3449        let (status, body) = get_json(app, "/v2/models/test_model/versions/1").await;
3450        assert_eq!(status, StatusCode::OK);
3451        assert_eq!(body["inputs"][0]["shape"], serde_json::json!([-1, 784]));
3452        let (status, body) = get_json(app, "/v2/models/other").await;
3453        assert_eq!(status, StatusCode::NOT_FOUND);
3454        assert_eq!(body["code"], "MODEL_NOT_FOUND");
3455        #[cfg(feature = "grpc")]
3456        {
3457            use grpc::pb;
3458            let ready = grpc.server_ready(pb::ServerReadyRequest {}).await.unwrap();
3459            assert!(ready.into_inner().ready);
3460            let model = pb::ModelReadyRequest {
3461                name: "test_model".to_string(),
3462                version: "1".to_string(),
3463            };
3464            assert!(grpc.model_ready(model).await.unwrap().into_inner().ready);
3465            let unknown = pb::ModelReadyRequest {
3466                name: "other".to_string(),
3467                version: String::new(),
3468            };
3469            let status = grpc.model_ready(unknown).await.unwrap_err();
3470            assert_eq!(status.code(), tonic::Code::NotFound);
3471        }
3472    }
3473
3474    /// With the only slot held by a request the model has not answered, every protocol's
3475    /// next request is refused, and counted once as busy; no requests race
3476    #[tokio::test]
3477    async fn a_saturated_server_refuses_on_every_protocol() {
3478        let server = gated_server(ServerConfig {
3479            max_concurrent_requests: 1,
3480            ..ServerConfig::default()
3481        });
3482        server.warm_up.open();
3483        let app = server.app.clone();
3484        let held = tokio::spawn(async move {
3485            let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3486            send(app, predict_request(body)).await.0
3487        });
3488        let deadline = Instant::now() + Duration::from_secs(10);
3489        while server.waiting.load(Ordering::SeqCst) == 0 {
3490            assert!(
3491                Instant::now() < deadline,
3492                "the first request never reached the model"
3493            );
3494            tokio::time::sleep(Duration::from_millis(1)).await;
3495        }
3496
3497        let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3498        let (status, body) = send(server.app.clone(), predict_request(body)).await;
3499        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
3500        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3501        assert_eq!(error.code, "SERVER_BUSY");
3502        let (status, body) = send(server.app.clone(), v2_request(0.5)).await;
3503        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
3504        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3505        assert_eq!(error.code, "SERVER_BUSY");
3506        // /predict and v2, and gRPC when it is built
3507        let busy = 2 + u64::from(cfg!(feature = "grpc"));
3508        #[cfg(feature = "grpc")]
3509        {
3510            let mut grpc = grpc::tests::client_for(server.state.clone()).await;
3511            let status = grpc
3512                .model_infer(grpc::tests::request(
3513                    vec![1, 784],
3514                    Some(vec![0.5; 784]),
3515                    None,
3516                ))
3517                .await
3518                .unwrap_err();
3519            assert_eq!(status.code(), tonic::Code::ResourceExhausted);
3520        }
3521
3522        server.inference.open();
3523        assert_eq!(held.await.unwrap(), StatusCode::OK);
3524        let m = get_metrics(server.app.clone()).await;
3525        assert_eq!(m.rejected_busy, busy);
3526        assert_eq!(m.total_requests, busy + 1);
3527        assert_eq!(m.total_errors, busy);
3528        assert_eq!(m.in_flight_inferences, 0);
3529    }
3530
3531    fn v2_request(value: f32) -> Request<Body> {
3532        Request::builder()
3533            .uri("/v2/models/test_model/infer")
3534            .method("POST")
3535            .header("content-type", "application/json")
3536            .body(Body::from(
3537                serde_json::json!({
3538                    "inputs": [{ "name": "input", "shape": [1, 784], "datatype": "FP32", "data": vec![value; 784] }],
3539                })
3540                .to_string(),
3541            ))
3542            .unwrap()
3543    }
3544
3545    #[tokio::test]
3546    async fn a_model_past_the_request_timeout_is_a_504() {
3547        let config = ServerConfig {
3548            request_timeout: Some(Duration::from_millis(50)),
3549            ..ServerConfig::default()
3550        };
3551        let app = misbehaving_app(false, Duration::from_millis(500), config);
3552        let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3553        let (status, body) = send(app.clone(), predict_request(body)).await;
3554        assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
3555        assert!(String::from_utf8_lossy(&body).contains("INFERENCE_TIMEOUT"));
3556        let (status, body) = send(app, v2_request(0.5)).await;
3557        assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
3558        // v2 errors carry the same code as /predict's, not one guessed from the status
3559        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3560        assert_eq!(error.code, "INFERENCE_TIMEOUT");
3561    }
3562
3563    #[tokio::test]
3564    async fn a_panicking_model_is_a_500_and_the_server_goes_on() {
3565        let batching = ServerConfig {
3566            batching: crate::batching::BatchConfig {
3567                max_batch_size: 8,
3568                ..crate::batching::BatchConfig::default()
3569            },
3570            ..ServerConfig::default()
3571        };
3572        for config in [ServerConfig::default(), batching] {
3573            let app = misbehaving_app(true, Duration::ZERO, config);
3574            let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3575            let (status, body) = send(app.clone(), predict_request(body)).await;
3576            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
3577            assert!(String::from_utf8_lossy(&body).contains("INFERENCE_FAILED"));
3578            let (status, _) = send(app.clone(), v2_request(0.5)).await;
3579            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
3580            // The model's pathway still serves what does not make it panic
3581            let body = serde_json::json!({ "input": vec![0.0; 784] }).to_string();
3582            let (status, _) = send(app, predict_request(body)).await;
3583            assert_eq!(status, StatusCode::OK);
3584        }
3585    }
3586
3587    #[tokio::test]
3588    async fn test_predict_invalid_shape() {
3589        let body = serde_json::json!({ "input": vec![0.5; 100] }).to_string();
3590        let (status, body) = send(test_app(), predict_request(body)).await;
3591        assert_eq!(status, StatusCode::BAD_REQUEST);
3592        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3593        assert_eq!(error.code, "INPUT_VALIDATION_FAILED");
3594    }
3595
3596    #[tokio::test]
3597    async fn test_predict_empty_input() {
3598        let body = serde_json::json!({ "input": [] }).to_string();
3599        let (status, body) = send(test_app(), predict_request(body)).await;
3600        assert_eq!(status, StatusCode::BAD_REQUEST);
3601        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3602        assert_eq!(error.code, "INVALID_INPUT");
3603    }
3604
3605    #[tokio::test]
3606    async fn test_predict_malformed_json() {
3607        let (status, body) = send(test_app(), predict_request("{not json".to_string())).await;
3608        assert_eq!(status, StatusCode::BAD_REQUEST);
3609        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3610        assert_eq!(error.code, "INVALID_REQUEST");
3611    }
3612
3613    #[tokio::test]
3614    async fn test_predict_rejects_when_saturated() {
3615        let config = ServerConfig {
3616            max_concurrent_requests: 0,
3617            ..ServerConfig::default()
3618        };
3619        let app = router(dummy_model(), &config).unwrap();
3620        let body = serde_json::json!({ "input": vec![0.5; 784] }).to_string();
3621        let (status, body) = send(app, predict_request(body)).await;
3622        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
3623        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3624        assert_eq!(error.code, "SERVER_BUSY");
3625    }
3626    fn binary_request(values: &[f32], batch_size: Option<&str>) -> Request<Body> {
3627        let body: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
3628        let mut builder = Request::builder()
3629            .uri("/predict")
3630            .method("POST")
3631            .header("content-type", OCTET_STREAM);
3632        if let Some(n) = batch_size {
3633            builder = builder.header(BATCH_SIZE_HEADER, n);
3634        }
3635        builder.body(Body::from(body)).unwrap()
3636    }
3637
3638    async fn error_code(app: Router, request: Request<Body>) -> (StatusCode, String) {
3639        let (status, body) = send(app, request).await;
3640        let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3641        (status, error.code)
3642    }
3643
3644    #[test]
3645    fn test_predict_request_deserialize() {
3646        let single: PredictRequest =
3647            serde_json::from_str(r#"{"input": [1, 2.5, -3e-2], "extra": {"a": [1]}}"#).unwrap();
3648        match &single.input_data {
3649            PredictInputData::Single { input } => assert_eq!(input, &vec![1.0, 2.5, -0.03]),
3650            _ => panic!("Expected single input"),
3651        }
3652
3653        let batch: PredictRequest = serde_json::from_str(r#"{"inputs": [[1], [2, 3]]}"#).unwrap();
3654        match batch.input_data {
3655            PredictInputData::Batch { inputs } => {
3656                assert_eq!(inputs, vec![vec![1.0], vec![2.0, 3.0]])
3657            }
3658            _ => panic!("Expected batch input"),
3659        }
3660
3661        for invalid in [
3662            r#"{}"#,
3663            r#"{"input": [1], "inputs": [[1]]}"#,
3664            r#"{"input": [1], "input": [2]}"#,
3665            r#"{"input": "nope"}"#,
3666            r#"[1, 2]"#,
3667        ] {
3668            assert!(
3669                serde_json::from_str::<PredictRequest>(invalid).is_err(),
3670                "{invalid}"
3671            );
3672        }
3673
3674        // Serialization is unchanged
3675        let round_trip = serde_json::to_value(&single).unwrap();
3676        assert_eq!(round_trip["input"], serde_json::json!([1.0, 2.5, -0.03f32]));
3677    }
3678
3679    #[tokio::test]
3680    async fn test_predict_json_errors() {
3681        // Valid JSON, wrong structure: 422, like axum's `Json` extractor
3682        let body = serde_json::json!({ "data": [1, 2] }).to_string();
3683        let (status, code) = error_code(test_app(), predict_request(body)).await;
3684        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
3685        assert_eq!(code, "INVALID_REQUEST");
3686
3687        // Content type parameters are allowed
3688        let request = Request::builder()
3689            .uri("/predict")
3690            .method("POST")
3691            .header("content-type", "application/json; charset=utf-8")
3692            .body(Body::from(
3693                serde_json::json!({ "input": vec![0.5; 784] }).to_string(),
3694            ))
3695            .unwrap();
3696        let (status, _) = send(test_app(), request).await;
3697        assert_eq!(status, StatusCode::OK);
3698    }
3699
3700    #[tokio::test]
3701    async fn test_predict_unsupported_content_type() {
3702        for content_type in [Some("text/plain"), None] {
3703            let mut builder = Request::builder().uri("/predict").method("POST");
3704            if let Some(content_type) = content_type {
3705                builder = builder.header("content-type", content_type);
3706            }
3707            let request = builder.body(Body::from("{}")).unwrap();
3708            let (status, code) = error_code(test_app(), request).await;
3709            assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
3710            assert_eq!(code, "INVALID_REQUEST");
3711        }
3712    }
3713
3714    #[tokio::test]
3715    async fn test_predict_binary_single() {
3716        let (status, body) = send(test_app(), binary_request(&[0.5; 784], None)).await;
3717        assert_eq!(status, StatusCode::OK);
3718        let response: PredictResponse = serde_json::from_slice(&body).unwrap();
3719        assert_eq!(response.batch_size, 1);
3720        match response.output_data {
3721            PredictOutputData::Single { output } => assert_eq!(output.len(), 10),
3722            _ => panic!("Expected single output"),
3723        }
3724    }
3725
3726    #[tokio::test]
3727    async fn test_predict_binary_batch() {
3728        let request = binary_request(&[0.5; 784 * 3], Some("3"));
3729        let (status, body) = send(test_app(), request).await;
3730        assert_eq!(status, StatusCode::OK);
3731        let response: PredictResponse = serde_json::from_slice(&body).unwrap();
3732        assert_eq!(response.batch_size, 3);
3733        match response.output_data {
3734            PredictOutputData::Batch { outputs } => {
3735                assert_eq!(outputs.len(), 3);
3736                assert!(outputs.iter().all(|o| o.len() == 10));
3737            }
3738            _ => panic!("Expected batch output"),
3739        }
3740
3741        // An explicit batch of one still answers with `outputs`
3742        let (status, body) = send(test_app(), binary_request(&[0.5; 784], Some("1"))).await;
3743        assert_eq!(status, StatusCode::OK);
3744        let response: PredictResponse = serde_json::from_slice(&body).unwrap();
3745        assert!(matches!(
3746            response.output_data,
3747            PredictOutputData::Batch { .. }
3748        ));
3749    }
3750
3751    #[tokio::test]
3752    async fn test_predict_binary_response() {
3753        let mut request = binary_request(&[0.5; 784 * 2], Some("2"));
3754        request
3755            .headers_mut()
3756            .insert(ACCEPT, HeaderValue::from_static(OCTET_STREAM));
3757        let response = test_app().oneshot(request).await.unwrap();
3758        assert_eq!(response.status(), StatusCode::OK);
3759        let headers = response.headers().clone();
3760        assert_eq!(headers[CONTENT_TYPE], OCTET_STREAM);
3761        assert_eq!(headers[BATCH_SIZE_HEADER], "2");
3762        assert_eq!(headers[OUTPUT_SHAPE_HEADER], "10");
3763        let time: f64 = headers[INFERENCE_TIME_HEADER]
3764            .to_str()
3765            .unwrap()
3766            .parse()
3767            .unwrap();
3768        assert!(time >= 0.0);
3769
3770        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3771            .await
3772            .unwrap();
3773        assert_eq!(body.len(), 2 * 10 * 4);
3774        let (values, _) = body.as_chunks::<4>();
3775        assert!(values.iter().all(|v| f32::from_le_bytes(*v) == 0.5));
3776
3777        // JSON requests can ask for a binary response too
3778        let request = Request::builder()
3779            .uri("/predict")
3780            .method("POST")
3781            .header("content-type", "application/json")
3782            .header("accept", "text/html, application/octet-stream;q=0.9")
3783            .body(Body::from(
3784                serde_json::json!({ "input": vec![0.5; 784] }).to_string(),
3785            ))
3786            .unwrap();
3787        let response = test_app().oneshot(request).await.unwrap();
3788        assert_eq!(response.headers()[CONTENT_TYPE], OCTET_STREAM);
3789        assert_eq!(response.headers()[BATCH_SIZE_HEADER], "1");
3790    }
3791
3792    #[tokio::test]
3793    async fn test_predict_binary_errors() {
3794        let cases = [
3795            // Body length doesn't match the model input
3796            (
3797                binary_request(&[0.5; 100], None),
3798                StatusCode::BAD_REQUEST,
3799                "INPUT_VALIDATION_FAILED",
3800            ),
3801            // Body length doesn't match X-Batch-Size
3802            (
3803                binary_request(&[0.5; 784 * 2], Some("3")),
3804                StatusCode::BAD_REQUEST,
3805                "INPUT_VALIDATION_FAILED",
3806            ),
3807            (
3808                binary_request(&[0.5; 784], Some("abc")),
3809                StatusCode::BAD_REQUEST,
3810                "INVALID_REQUEST",
3811            ),
3812            (
3813                binary_request(&[0.5; 784], Some("0")),
3814                StatusCode::BAD_REQUEST,
3815                "INVALID_INPUT",
3816            ),
3817            (
3818                binary_request(&[], None),
3819                StatusCode::BAD_REQUEST,
3820                "INVALID_INPUT",
3821            ),
3822            (
3823                binary_request(&[0.5; 784], Some("1001")),
3824                StatusCode::BAD_REQUEST,
3825                "BATCH_TOO_LARGE",
3826            ),
3827        ];
3828        for (request, expected_status, expected_code) in cases {
3829            let (status, code) = error_code(test_app(), request).await;
3830            assert_eq!((status, code.as_str()), (expected_status, expected_code));
3831        }
3832
3833        // Odd byte counts are rejected, not truncated
3834        let request = Request::builder()
3835            .uri("/predict")
3836            .method("POST")
3837            .header("content-type", OCTET_STREAM)
3838            .body(Body::from(vec![0u8; 784 * 4 + 1]))
3839            .unwrap();
3840        let (status, code) = error_code(test_app(), request).await;
3841        assert_eq!(
3842            (status, code.as_str()),
3843            (StatusCode::BAD_REQUEST, "INPUT_VALIDATION_FAILED")
3844        );
3845
3846        // Non-finite values are caught by the model, as for JSON
3847        let mut values = vec![0.5; 784];
3848        values[7] = f32::INFINITY;
3849        let (status, code) = error_code(test_app(), binary_request(&values, None)).await;
3850        assert_eq!(
3851            (status, code.as_str()),
3852            (StatusCode::BAD_REQUEST, "INVALID_INPUT_VALUE")
3853        );
3854    }
3855
3856    #[tokio::test]
3857    async fn test_predict_binary_body_limit_enforced() {
3858        let config = ServerConfig {
3859            max_body_bytes: 1024,
3860            ..ServerConfig::default()
3861        };
3862        let app = router(dummy_model(), &config).unwrap();
3863        let (status, code) = error_code(app, binary_request(&[0.5; 784], None)).await;
3864        assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
3865        assert_eq!(code, "INVALID_REQUEST");
3866    }
3867
3868    fn image_request(content_type: &str, body: Vec<u8>) -> Request<Body> {
3869        Request::builder()
3870            .uri("/predict")
3871            .method("POST")
3872            .header("content-type", content_type)
3873            .body(Body::from(body))
3874            .unwrap()
3875    }
3876
3877    #[cfg(not(feature = "image"))]
3878    #[tokio::test]
3879    async fn test_predict_image_needs_feature() {
3880        let request = image_request("image/png", vec![0x89, b'P', b'N', b'G']);
3881        let (status, code) = error_code(test_app(), request).await;
3882        assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
3883        assert_eq!(code, "INVALID_REQUEST");
3884    }
3885
3886    #[cfg(feature = "image")]
3887    mod image_input {
3888        use super::*;
3889        use image::{ImageEncoder, RgbImage, codecs::png::PngEncoder};
3890
3891        fn image_model_app(config: &ServerConfig) -> Router {
3892            let dummy = DummyModel::new("rgb".to_string(), vec![3, 16, 16], vec![10]);
3893            router(Model::new(Box::new(dummy), "dummy", 0), config).unwrap()
3894        }
3895
3896        fn png(width: u32, height: u32) -> Vec<u8> {
3897            let image = RgbImage::from_fn(width, height, |x, y| {
3898                image::Rgb([(x * 7) as u8, (y * 5) as u8, 128])
3899            });
3900            let mut bytes = Vec::new();
3901            PngEncoder::new(&mut bytes)
3902                .write_image(
3903                    image.as_raw(),
3904                    width,
3905                    height,
3906                    image::ExtendedColorType::Rgb8,
3907                )
3908                .unwrap();
3909            bytes
3910        }
3911
3912        #[tokio::test]
3913        async fn test_predict_png() {
3914            let app = image_model_app(&ServerConfig::default());
3915            let request = image_request("image/png", png(40, 30));
3916            let (status, body) = send(app, request).await;
3917            assert_eq!(status, StatusCode::OK);
3918            let response: serde_json::Value = serde_json::from_slice(&body).unwrap();
3919            assert_eq!(response["batch_size"], 1);
3920            assert_eq!(response["output"].as_array().unwrap().len(), 10);
3921        }
3922
3923        #[tokio::test]
3924        async fn test_predict_image_wrong_model_shape() {
3925            // The default test model takes [784]
3926            let (status, body) = send(test_app(), image_request("image/png", png(8, 8))).await;
3927            assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
3928            let error: ErrorResponse = serde_json::from_slice(&body).unwrap();
3929            assert_eq!(error.code, "INVALID_REQUEST");
3930            assert_eq!(
3931                error.details.unwrap()["expected_shape"],
3932                serde_json::json!([784])
3933            );
3934        }
3935
3936        #[tokio::test]
3937        async fn test_predict_invalid_image() {
3938            let app = image_model_app(&ServerConfig::default());
3939            for (content_type, body) in [
3940                ("image/jpeg", b"not a jpeg".to_vec()),
3941                // PNG bytes labelled as JPEG
3942                ("image/jpeg", png(8, 8)),
3943            ] {
3944                let request = image_request(content_type, body);
3945                let (status, code) = error_code(app.clone(), request).await;
3946                assert_eq!(status, StatusCode::BAD_REQUEST);
3947                assert_eq!(code, "INVALID_IMAGE");
3948            }
3949
3950            let (status, code) = error_code(app, image_request("image/png", Vec::new())).await;
3951            assert_eq!(status, StatusCode::BAD_REQUEST);
3952            assert_eq!(code, "INVALID_INPUT");
3953        }
3954
3955        #[tokio::test]
3956        async fn test_predict_image_body_limit_enforced() {
3957            let config = ServerConfig {
3958                max_body_bytes: 1024,
3959                ..ServerConfig::default()
3960            };
3961            let app = image_model_app(&config);
3962            let body = png(256, 256);
3963            assert!(body.len() > 1024);
3964            let (status, code) = error_code(app, image_request("image/png", body)).await;
3965            assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
3966            assert_eq!(code, "INVALID_REQUEST");
3967        }
3968    }
3969}