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