Skip to main content

ferrox_api/
health.rs

1//! `GET /health` as a capability handshake rather than a boolean.
2//!
3//! Three states, not two. `detecting` exists because a UI that renders
4//! its *guess* while backends are still being probed paints a
5//! greyed-out GPU control that is pixel-identical to a measured
6//! "unsupported" -- and the user reads the guess as a verdict. With a
7//! third state the client can hold: show a spinner, not a conclusion.
8//!
9//! Every capability carries **both** a stable machine `reason` and a
10//! human `detail` sentence. The client greys the control and puts
11//! `detail` in the tooltip; it never re-derives the explanation from the
12//! flag, because the server is the only side that knows whether Metal is
13//! missing (no device) or merely not compiled in (`--features metal`),
14//! and those two produce completely different advice.
15
16use serde::{Deserialize, Serialize};
17
18/// Stable machine-readable reason codes. Clients may switch on these;
19/// the human `detail` string beside them is free-form and may be
20/// reworded at any time.
21pub mod reason {
22    /// The capability is present and usable.
23    pub const AVAILABLE: &str = "available";
24    /// Built with Metal support, but no usable Metal device was found.
25    pub const METAL_UNAVAILABLE: &str = "metal_unavailable";
26    /// This binary was compiled without `--features metal`.
27    pub const METAL_NOT_BUILT: &str = "metal_not_built";
28    /// Built with CUDA support, but no CUDA device was found.
29    pub const CUDA_UNAVAILABLE: &str = "cuda_unavailable";
30    /// This binary was compiled without `--features cuda`.
31    pub const CUDA_NOT_BUILT: &str = "cuda_not_built";
32    /// No GPU backend is usable; work runs on CPU kernels.
33    pub const CPU_ONLY: &str = "cpu_only";
34    /// Backend probing is still in flight; nothing beside this reason
35    /// is a measurement yet.
36    pub const DETECTING: &str = "detecting";
37    /// Backend probing did not finish inside its budget, so the answer
38    /// beside this reason is provisional and may improve.
39    pub const DETECTION_TIMED_OUT: &str = "detection_timed_out";
40    /// Supported by this build and this host, but switched off by
41    /// configuration -- the fix is a flag, not new hardware.
42    pub const DISABLED: &str = "disabled";
43    /// No model is loaded, so generation endpoints will fail.
44    pub const MODEL_NOT_LOADED: &str = "model_not_loaded";
45}
46
47/// Well-known capability ids, so a client can look one up rather than
48/// pattern-matching on position in the list.
49pub mod capability {
50    pub const CPU: &str = "cpu";
51    pub const METAL: &str = "metal";
52    pub const CUDA: &str = "cuda";
53    /// Whether the loaded weights are real, or the synthetic
54    /// random-weight demo model that ferrox falls back to.
55    pub const REAL_WEIGHTS: &str = "real_weights";
56    /// Continuous batching (`FERROX_CONTINUOUS_BATCHING=1`).
57    pub const CONTINUOUS_BATCHING: &str = "continuous_batching";
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum HealthState {
63    /// Backends are still being probed. Anything capability-shaped in
64    /// this response is provisional; hold the UI rather than rendering
65    /// a verdict.
66    Detecting,
67    /// Model loaded, backends probed, ready to serve.
68    Ready,
69    /// The port is bound (this response arrived) but the server cannot
70    /// serve generation. `reason`/`detail` say why.
71    Unavailable,
72}
73
74impl HealthState {
75    /// The HTTP status a server should answer with. `detecting` is a
76    /// 200: the process is alive and the client is expected to poll,
77    /// and a 503 there would trip generic "backend down" logic in
78    /// proxies and supervisors. Only `unavailable` is a 503.
79    pub fn http_status(self) -> u16 {
80        match self {
81            HealthState::Detecting | HealthState::Ready => 200,
82            HealthState::Unavailable => 503,
83        }
84    }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Capability {
89    /// See [`capability`] for the well-known ids.
90    pub id: String,
91    pub available: bool,
92    /// One of [`reason`]. Stable; safe to switch on.
93    pub reason: String,
94    /// A sentence to show the user. Free-form; never parse it.
95    pub detail: String,
96}
97
98impl Capability {
99    pub fn available(id: &str, detail: impl Into<String>) -> Self {
100        Capability {
101            id: id.to_string(),
102            available: true,
103            reason: reason::AVAILABLE.to_string(),
104            detail: detail.into(),
105        }
106    }
107
108    pub fn unavailable(id: &str, reason: &str, detail: impl Into<String>) -> Self {
109        Capability {
110            id: id.to_string(),
111            available: false,
112            reason: reason.to_string(),
113            detail: detail.into(),
114        }
115    }
116}
117
118/// Summary of what is loaded, so the client does not need a second
119/// round-trip to `/v1/models` just to label the connection.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct ModelSummary {
122    pub id: String,
123    pub tokenizer: String,
124    /// True when the weights are the synthetic random-weight demo, not
125    /// a real checkpoint. Output from such a model is noise; a UI that
126    /// does not say so invites a bug report about "quality".
127    pub synthetic_weights: bool,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct HealthResponse {
132    pub state: HealthState,
133    /// Machine code for a non-`ready` state (see [`reason`]).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub reason: Option<String>,
136    /// Human sentence for a non-`ready` state.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub detail: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub model: Option<ModelSummary>,
141    pub capabilities: Vec<Capability>,
142    pub version: String,
143    pub pid: u32,
144    pub uptime_seconds: f64,
145    /// Server wall clock at the moment of the answer. The browser's own
146    /// clock is not trusted for anything the server timestamps -- a
147    /// skewed client would otherwise render negative durations.
148    pub server_time_unix_ms: u64,
149    /// Seconds since this process last finished a request, when it has
150    /// served one. A GPU saturated by a long decode can starve the
151    /// health handler; a client that sees recent request activity has
152    /// positive evidence of liveness and must not declare the backend
153    /// dead on a single slow poll.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub last_request_age_seconds: Option<f64>,
156}
157
158impl HealthResponse {
159    /// Look up a capability by id (see [`capability`]).
160    pub fn capability(&self, id: &str) -> Option<&Capability> {
161        self.capabilities.iter().find(|c| c.id == id)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn detecting_is_not_an_error_status() {
171        assert_eq!(HealthState::Detecting.http_status(), 200);
172        assert_eq!(HealthState::Ready.http_status(), 200);
173        assert_eq!(HealthState::Unavailable.http_status(), 503);
174    }
175
176    #[test]
177    fn states_serialize_as_snake_case_strings() {
178        let json = serde_json::to_string(&HealthState::Detecting).unwrap();
179        assert_eq!(json, "\"detecting\"");
180    }
181
182    #[test]
183    fn unavailable_capability_keeps_both_a_code_and_a_sentence() {
184        let cap = Capability::unavailable(
185            capability::CUDA,
186            reason::CUDA_NOT_BUILT,
187            "This build has no CUDA kernels; rebuild with --features cuda.",
188        );
189        // The UI must be able to switch on `reason` and show `detail`
190        // without ever inferring one from the other.
191        assert!(!cap.available);
192        assert_eq!(cap.reason, "cuda_not_built");
193        assert!(cap.detail.contains("--features cuda"));
194    }
195
196    #[test]
197    fn optional_fields_are_omitted_rather_than_null() {
198        let health = HealthResponse {
199            state: HealthState::Ready,
200            reason: None,
201            detail: None,
202            model: None,
203            capabilities: vec![Capability::available(capability::CPU, "CPU kernels")],
204            version: "0.5.0".into(),
205            pid: 1,
206            uptime_seconds: 1.0,
207            server_time_unix_ms: 0,
208            last_request_age_seconds: None,
209        };
210        let json = serde_json::to_string(&health).unwrap();
211        assert!(!json.contains("null"), "{json}");
212        let back: HealthResponse = serde_json::from_str(&json).unwrap();
213        assert_eq!(back, health);
214        assert!(back.capability(capability::CPU).is_some());
215    }
216}