Skip to main content

ferrox_api/
admin.rs

1//! Wire shapes for the `/admin` control surface: the model inventory,
2//! the one long-running-task contract, and the server's own counters.
3//!
4//! Two rules run through all of it.
5//!
6//! **Absent means absent.** Every field the UI reads is always present
7//! in the JSON, and a value that could not be established cheaply is
8//! `null` rather than a plausible-looking default. A `0` context length
9//! and an unknown context length are different facts, and a UI that
10//! cannot tell them apart will print the wrong one with confidence.
11//! That is why the optional fields here are *not* `skip_serializing_if`
12//! -- the key stays, the value goes to `null`.
13//!
14//! **Rates come from the estimator or not at all.** [`TaskProgress`] is
15//! built from [`crate::progress::RateReport`], which refuses to divide
16//! until its window is long enough. Nothing here may compute a rate on
17//! the side; see [`TaskProgress::from_report`].
18
19use serde::{Deserialize, Serialize};
20
21use crate::progress::RateReport;
22
23// ---------------------------------------------------------------------
24// Models
25// ---------------------------------------------------------------------
26
27/// What a model on disk is doing right now.
28///
29/// `available` is the resting state -- present, readable, not loaded.
30/// `error` is sticky: it records that the *last* attempt to load this
31/// model failed, so the UI can show why without the user having to
32/// retry to find out.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ModelState {
36    Loaded,
37    Loading,
38    Available,
39    Error,
40}
41
42/// One model the server can serve, described from its GGUF header
43/// alone. Nothing here requires reading a single weight.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ModelEntry {
46    /// Stable within a server run: the file stem for a `.gguf`, the
47    /// directory name for a checkpoint directory. This is what
48    /// [`LoadModelRequest`] takes, and the only way to name a model --
49    /// there is deliberately no "load this path" endpoint.
50    pub id: String,
51    /// Absolute path, for display. A client cannot ask the server to
52    /// load an arbitrary one.
53    pub path: String,
54    /// On-disk size, summed across shards for a split checkpoint.
55    pub size_bytes: u64,
56    /// `general.architecture`, verbatim. `null` when the header does
57    /// not carry it.
58    pub arch: Option<String>,
59    /// Quantization name (`Q4_K_M`, `F16`, ...) from `general.file_type`
60    /// when it maps to a name this server knows, else the dominant
61    /// tensor dtype, else `null`. Never guessed from the filename.
62    pub quant: Option<String>,
63    /// `{arch}.context_length` from the header.
64    pub context_length: Option<u64>,
65    /// `general.parameter_count` when present, else the summed element
66    /// count of every tensor in the header.
67    pub param_count: Option<u64>,
68    pub state: ModelState,
69    /// Why the last load attempt failed. `null` unless `state` is
70    /// [`ModelState::Error`].
71    pub error: Option<String>,
72    /// Bytes actually resident for this model. `null` for anything not
73    /// loaded, and `null` for a loaded model whose footprint the server
74    /// cannot measure -- an mmap-resident checkpoint's true RSS is a
75    /// property of the page cache, not of this process, and reporting
76    /// the file size as "resident" would be a lie in both directions.
77    pub resident_bytes: Option<u64>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct ModelsResponse {
82    /// The directory that was scanned. `null` when no model path is
83    /// configured at all, which is also when `models` is empty for a
84    /// reason the UI should explain rather than read as "none found".
85    pub model_dir: Option<String>,
86    /// Id of the loaded model, or `null` when nothing is loaded.
87    pub active: Option<String>,
88    pub models: Vec<ModelEntry>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct LoadModelRequest {
93    pub id: String,
94}
95
96/// `202` body for anything that starts a background job.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct TaskAccepted {
99    pub task_id: String,
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct UnloadResponse {
104    pub ok: bool,
105    /// Always `null` on success; stated rather than omitted so the UI
106    /// can use one code path for "what is active now".
107    pub active: Option<String>,
108}
109
110/// A Hub repo plus the file to take from it. `file` may be a literal
111/// name or a `*` glob, which is resolved against the repo's file list.
112/// Both are validated server-side: only `.gguf` targets, and nothing
113/// that could name a path outside the model directory.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct DownloadRequest {
116    pub repo: String,
117    pub file: String,
118}
119
120// ---------------------------------------------------------------------
121// Tasks
122// ---------------------------------------------------------------------
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum TaskKind {
127    Download,
128    Load,
129}
130
131/// `queued`/`running` are live; `done`/`error`/`cancelled` are terminal
132/// and never change again. A UI can stop polling a task the moment it
133/// reads a terminal status.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum TaskStatus {
137    Queued,
138    Running,
139    Done,
140    Error,
141    Cancelled,
142}
143
144impl TaskStatus {
145    pub fn is_terminal(self) -> bool {
146        matches!(
147            self,
148            TaskStatus::Done | TaskStatus::Error | TaskStatus::Cancelled
149        )
150    }
151}
152
153/// Whether the rate/ETA numbers may be shown at all.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156pub enum ProgressState {
157    /// Not enough samples yet. `rate_bytes_per_s` and `eta_seconds` are
158    /// `null` and the UI must show "measuring", not a number.
159    Warming,
160    Stable,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
164pub struct TaskProgress {
165    /// `bytes_done / bytes_total`, clamped to `0.0..=1.0`. `null` when
166    /// the total is unknown -- an indeterminate bar is honest, a bar
167    /// pinned at 100% is not.
168    pub fraction: Option<f64>,
169    pub bytes_done: u64,
170    pub bytes_total: Option<u64>,
171    pub rate_bytes_per_s: Option<f64>,
172    pub eta_seconds: Option<f64>,
173    pub state: ProgressState,
174}
175
176impl TaskProgress {
177    /// The only sanctioned way to build one.
178    ///
179    /// A warming report yields `null` rate and `null` ETA no matter
180    /// what the caller believes it knows: the estimator's whole purpose
181    /// is refusing to divide too early, and recomputing around it would
182    /// reintroduce the "123 GB/s" flash it exists to prevent.
183    pub fn from_report(report: RateReport, bytes_done: u64, bytes_total: Option<u64>) -> Self {
184        let stable = report.stable;
185        TaskProgress {
186            fraction: bytes_total
187                .filter(|t| *t > 0)
188                .map(|total| (bytes_done as f64 / total as f64).clamp(0.0, 1.0)),
189            bytes_done,
190            bytes_total,
191            rate_bytes_per_s: stable.then_some(report.bytes_per_second).flatten(),
192            eta_seconds: stable.then_some(report.eta_seconds).flatten(),
193            state: if stable {
194                ProgressState::Stable
195            } else {
196                ProgressState::Warming
197            },
198        }
199    }
200
201    /// A job with nothing measurable in bytes (a model load): no
202    /// fraction, no rate, no pretence of either.
203    pub fn indeterminate() -> Self {
204        TaskProgress {
205            fraction: None,
206            bytes_done: 0,
207            bytes_total: None,
208            rate_bytes_per_s: None,
209            eta_seconds: None,
210            state: ProgressState::Warming,
211        }
212    }
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct TaskView {
217    pub task_id: String,
218    pub kind: TaskKind,
219    /// One human sentence naming what this job is doing, written by the
220    /// server so the UI never has to assemble one from ids and paths.
221    pub label: String,
222    pub status: TaskStatus,
223    pub error: Option<String>,
224    /// Unix epoch milliseconds, from the server's clock. The plan is
225    /// explicit that the browser's clock is not to be trusted for
226    /// ordering, so both timestamps are stated rather than implied.
227    pub started_at_ms: u64,
228    pub updated_at_ms: u64,
229    pub progress: TaskProgress,
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct TasksResponse {
234    pub tasks: Vec<TaskView>,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct CancelResponse {
239    pub ok: bool,
240}
241
242// ---------------------------------------------------------------------
243// Stats
244// ---------------------------------------------------------------------
245
246/// One finished request, as recorded in the ring buffer.
247///
248/// `duration_ms` and `decode_ms` are separate on purpose and must stay
249/// that way: `duration_ms` carries queue wait plus prefill plus decode,
250/// so dividing completion tokens by it reports a 50 tok/s model as 5
251/// whenever the prompt is long. Everything downstream of that number is
252/// then wrong in the same direction.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct RecentRequest {
255    /// The same id the response carried, so a UI can join a log line to
256    /// the message it produced without a claiming heuristic.
257    pub request_id: String,
258    /// Unix epoch milliseconds when the request finished.
259    pub at_ms: u64,
260    pub route: String,
261    pub status: u16,
262    pub prompt_tokens: usize,
263    pub completion_tokens: usize,
264    pub ttft_ms: Option<f64>,
265    /// Total server-side wall time for the request.
266    pub duration_ms: u64,
267    /// Time inside the decode loop only. `null` when the engine did not
268    /// time itself, or the answer came from cache.
269    pub decode_ms: Option<f64>,
270    pub stream: bool,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct StatsResponse {
275    pub uptime_seconds: u64,
276    pub requests_total: u64,
277    pub errors_total: u64,
278    pub cache_hits: u64,
279    pub cache_misses: u64,
280    pub tokens_prompt_total: u64,
281    pub tokens_generated_total: u64,
282    /// Seconds since the last request finished; `null` when none has.
283    pub last_request_age_seconds: Option<f64>,
284    /// Streamed generations decoding right now -- the ones that could
285    /// be stopped by `POST /v1/cancel` at this instant.
286    ///
287    /// Not a queue depth: nothing is queued in front of a decode here,
288    /// so this counts work in progress, not work waiting. Named for
289    /// what it is so no one reads a backlog into it.
290    pub generating_now: usize,
291    /// Newest last, capped server-side. See [`RecentRequest`].
292    pub recent: Vec<RecentRequest>,
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::progress::RateEstimator;
299
300    fn stable_estimator() -> RateEstimator {
301        let mut est = RateEstimator::new();
302        for i in 0..=4u64 {
303            est.observe(i * 1000, i * 1_000_000);
304        }
305        est
306    }
307
308    #[test]
309    fn a_warming_estimator_yields_no_rate_and_no_eta() {
310        let mut est = RateEstimator::new();
311        est.observe(0, 0);
312        est.observe(2, 8 * 1024 * 1024);
313        let progress =
314            TaskProgress::from_report(est.report(Some(1 << 30)), 8 * 1024 * 1024, Some(1 << 30));
315        assert_eq!(progress.state, ProgressState::Warming);
316        assert_eq!(progress.rate_bytes_per_s, None);
317        assert_eq!(progress.eta_seconds, None);
318        // A fraction is still fine: it is a ratio of two counters, not
319        // a derivative, so no window is needed to trust it.
320        assert!(progress.fraction.is_some());
321    }
322
323    #[test]
324    fn a_stable_estimator_passes_its_numbers_through_unchanged() {
325        let est = stable_estimator();
326        let report = est.report(Some(10_000_000));
327        let progress = TaskProgress::from_report(report, 4_000_000, Some(10_000_000));
328        assert_eq!(progress.state, ProgressState::Stable);
329        assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
330        assert_eq!(progress.eta_seconds, Some(6.0));
331        assert_eq!(progress.fraction, Some(0.4));
332    }
333
334    #[test]
335    fn an_unknown_total_means_no_fraction_rather_than_zero() {
336        let est = stable_estimator();
337        let progress = TaskProgress::from_report(est.report(None), 4_000_000, None);
338        assert_eq!(progress.fraction, None);
339        assert_eq!(progress.eta_seconds, None);
340        assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
341    }
342
343    #[test]
344    fn a_fraction_never_exceeds_one_even_with_bad_metadata() {
345        let est = stable_estimator();
346        let progress = TaskProgress::from_report(est.report(Some(1_000)), 4_000_000, Some(1_000));
347        assert_eq!(progress.fraction, Some(1.0));
348    }
349
350    #[test]
351    fn optional_model_fields_serialize_as_null_rather_than_vanishing() {
352        let entry = ModelEntry {
353            id: "m".into(),
354            path: "/models/m.gguf".into(),
355            size_bytes: 1,
356            arch: None,
357            quant: None,
358            context_length: None,
359            param_count: None,
360            state: ModelState::Available,
361            error: None,
362            resident_bytes: None,
363        };
364        let json: serde_json::Value = serde_json::to_value(&entry).unwrap();
365        for key in [
366            "arch",
367            "quant",
368            "context_length",
369            "param_count",
370            "error",
371            "resident_bytes",
372        ] {
373            assert!(json.get(key).is_some(), "{key} was omitted entirely");
374            assert!(json[key].is_null(), "{key} was not null");
375        }
376        assert_eq!(json["state"], "available");
377    }
378
379    #[test]
380    fn task_statuses_wire_as_the_lowercase_names_the_contract_names() {
381        let view = TaskView {
382            task_id: "t1".into(),
383            kind: TaskKind::Download,
384            label: "Downloading x.gguf".into(),
385            status: TaskStatus::Running,
386            error: None,
387            started_at_ms: 1,
388            updated_at_ms: 2,
389            progress: TaskProgress::indeterminate(),
390        };
391        let json = serde_json::to_value(&view).unwrap();
392        assert_eq!(json["kind"], "download");
393        assert_eq!(json["status"], "running");
394        assert_eq!(json["progress"]["state"], "warming");
395        assert!(json["progress"]["bytes_total"].is_null());
396        assert_eq!(json["progress"]["bytes_done"], 0);
397    }
398
399    #[test]
400    fn terminal_statuses_are_exactly_the_three_that_stop_polling() {
401        assert!(TaskStatus::Done.is_terminal());
402        assert!(TaskStatus::Error.is_terminal());
403        assert!(TaskStatus::Cancelled.is_terminal());
404        assert!(!TaskStatus::Queued.is_terminal());
405        assert!(!TaskStatus::Running.is_terminal());
406    }
407
408    #[test]
409    fn recent_requests_keep_the_two_durations_apart() {
410        let recent = RecentRequest {
411            request_id: "chatcmpl-1".into(),
412            at_ms: 10,
413            route: "/v1/chat/completions".into(),
414            status: 200,
415            prompt_tokens: 100,
416            completion_tokens: 10,
417            ttft_ms: Some(900.0),
418            duration_ms: 1_100,
419            decode_ms: Some(100.0),
420            stream: true,
421        };
422        let json = serde_json::to_value(&recent).unwrap();
423        assert_eq!(json["duration_ms"], 1_100);
424        assert_eq!(json["decode_ms"], 100.0);
425    }
426}