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}