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    /// The model that **served** this request, as `/v1/models` names it.
262    ///
263    /// Not the `model` field the client sent. ferrox serves whatever is
264    /// loaded and ignores that string, so echoing it back would make
265    /// the log agree with the caller's belief rather than with what
266    /// happened -- and after a model swap those are different answers.
267    /// `null` when nothing was loaded, which is what a 503 row means.
268    pub model: Option<String>,
269    pub status: u16,
270    pub prompt_tokens: usize,
271    pub completion_tokens: usize,
272    pub ttft_ms: Option<f64>,
273    /// Total server-side wall time for the request.
274    pub duration_ms: u64,
275    /// Time inside the decode loop only. `null` when the engine did not
276    /// time itself, or the answer came from cache.
277    pub decode_ms: Option<f64>,
278    pub stream: bool,
279    /// Completion tokens per verification step when this request used
280    /// speculative decoding; `null` when it did not. See
281    /// [`crate::Usage::acceptance_length`].
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub acceptance_length: Option<f64>,
284    /// Accept rate at each position within the draft block. Kept in the
285    /// ring rather than only in the response body because suffix decay
286    /// is a property of the *drafter over time*, and a single request's
287    /// numbers are too few to read it off.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub draft_accept_rate_per_position: Option<Vec<f64>>,
290    /// Which bearer key served this request, as a short fingerprint --
291    /// never the key itself, and never reversible into it.
292    ///
293    /// `null` means the request carried no `Authorization: Bearer`
294    /// header at all, which on a server started without
295    /// `FERROX_API_KEY` is every request. Two rows with the same
296    /// fingerprint were authenticated with the same key; two rows with
297    /// different fingerprints were not. That is the whole of what this
298    /// field claims.
299    ///
300    /// The fingerprint is salted per process, so it is stable within
301    /// one server run and deliberately meaningless across restarts: a
302    /// captured `/admin/stats` payload cannot be used offline to test
303    /// guesses at the key.
304    pub via_api_key: Option<String>,
305    /// The caller's self-declared label, from the `X-Ferrox-Client`
306    /// request header, truncated and stripped of anything that is not a
307    /// plain label character.
308    ///
309    /// **A claim, not proof.** Ferrox Studio sends `ferrox-studio`, and
310    /// so could any other client; nothing here authenticates it. It is
311    /// recorded because a self-declared label plus a key fingerprint is
312    /// still the difference between "an editor is hammering this
313    /// server" and "that was me in the other tab", and because
314    /// inventing the distinction from timing would be worse. A UI that
315    /// shows it must say it is self-declared.
316    pub client: Option<String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320pub struct StatsResponse {
321    pub uptime_seconds: u64,
322    pub requests_total: u64,
323    pub errors_total: u64,
324    pub cache_hits: u64,
325    pub cache_misses: u64,
326    pub tokens_prompt_total: u64,
327    pub tokens_generated_total: u64,
328    /// Seconds since the last request finished; `null` when none has.
329    pub last_request_age_seconds: Option<f64>,
330    /// Streamed generations decoding right now -- the ones that could
331    /// be stopped by `POST /v1/cancel` at this instant.
332    ///
333    /// Not a queue depth: nothing is queued in front of a decode here,
334    /// so this counts work in progress, not work waiting. Named for
335    /// what it is so no one reads a backlog into it.
336    pub generating_now: usize,
337    /// Requests waiting for a decode slot, from the continuous-batching
338    /// scheduler's own queue.
339    ///
340    /// `null` -- not `0` -- when continuous batching is off, because
341    /// then there is no queue at all: every request goes straight onto
342    /// its own blocking thread. A gauge reading `0` claims an empty
343    /// queue was measured; `null` says there was nothing to measure,
344    /// and a UI must be able to tell those apart.
345    pub queue_depth: Option<usize>,
346    /// Requests the queue turned away because it was full, since start.
347    /// `null` under the same condition as [`Self::queue_depth`].
348    pub queue_rejected_total: Option<u64>,
349    /// Newest last, capped server-side. See [`RecentRequest`].
350    pub recent: Vec<RecentRequest>,
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::progress::RateEstimator;
357
358    fn stable_estimator() -> RateEstimator {
359        let mut est = RateEstimator::new();
360        for i in 0..=4u64 {
361            est.observe(i * 1000, i * 1_000_000);
362        }
363        est
364    }
365
366    #[test]
367    fn a_warming_estimator_yields_no_rate_and_no_eta() {
368        let mut est = RateEstimator::new();
369        est.observe(0, 0);
370        est.observe(2, 8 * 1024 * 1024);
371        let progress =
372            TaskProgress::from_report(est.report(Some(1 << 30)), 8 * 1024 * 1024, Some(1 << 30));
373        assert_eq!(progress.state, ProgressState::Warming);
374        assert_eq!(progress.rate_bytes_per_s, None);
375        assert_eq!(progress.eta_seconds, None);
376        // A fraction is still fine: it is a ratio of two counters, not
377        // a derivative, so no window is needed to trust it.
378        assert!(progress.fraction.is_some());
379    }
380
381    #[test]
382    fn a_stable_estimator_passes_its_numbers_through_unchanged() {
383        let est = stable_estimator();
384        let report = est.report(Some(10_000_000));
385        let progress = TaskProgress::from_report(report, 4_000_000, Some(10_000_000));
386        assert_eq!(progress.state, ProgressState::Stable);
387        assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
388        assert_eq!(progress.eta_seconds, Some(6.0));
389        assert_eq!(progress.fraction, Some(0.4));
390    }
391
392    #[test]
393    fn an_unknown_total_means_no_fraction_rather_than_zero() {
394        let est = stable_estimator();
395        let progress = TaskProgress::from_report(est.report(None), 4_000_000, None);
396        assert_eq!(progress.fraction, None);
397        assert_eq!(progress.eta_seconds, None);
398        assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
399    }
400
401    #[test]
402    fn a_fraction_never_exceeds_one_even_with_bad_metadata() {
403        let est = stable_estimator();
404        let progress = TaskProgress::from_report(est.report(Some(1_000)), 4_000_000, Some(1_000));
405        assert_eq!(progress.fraction, Some(1.0));
406    }
407
408    #[test]
409    fn optional_model_fields_serialize_as_null_rather_than_vanishing() {
410        let entry = ModelEntry {
411            id: "m".into(),
412            path: "/models/m.gguf".into(),
413            size_bytes: 1,
414            arch: None,
415            quant: None,
416            context_length: None,
417            param_count: None,
418            state: ModelState::Available,
419            error: None,
420            resident_bytes: None,
421        };
422        let json: serde_json::Value = serde_json::to_value(&entry).unwrap();
423        for key in [
424            "arch",
425            "quant",
426            "context_length",
427            "param_count",
428            "error",
429            "resident_bytes",
430        ] {
431            assert!(json.get(key).is_some(), "{key} was omitted entirely");
432            assert!(json[key].is_null(), "{key} was not null");
433        }
434        assert_eq!(json["state"], "available");
435    }
436
437    #[test]
438    fn task_statuses_wire_as_the_lowercase_names_the_contract_names() {
439        let view = TaskView {
440            task_id: "t1".into(),
441            kind: TaskKind::Download,
442            label: "Downloading x.gguf".into(),
443            status: TaskStatus::Running,
444            error: None,
445            started_at_ms: 1,
446            updated_at_ms: 2,
447            progress: TaskProgress::indeterminate(),
448        };
449        let json = serde_json::to_value(&view).unwrap();
450        assert_eq!(json["kind"], "download");
451        assert_eq!(json["status"], "running");
452        assert_eq!(json["progress"]["state"], "warming");
453        assert!(json["progress"]["bytes_total"].is_null());
454        assert_eq!(json["progress"]["bytes_done"], 0);
455    }
456
457    #[test]
458    fn terminal_statuses_are_exactly_the_three_that_stop_polling() {
459        assert!(TaskStatus::Done.is_terminal());
460        assert!(TaskStatus::Error.is_terminal());
461        assert!(TaskStatus::Cancelled.is_terminal());
462        assert!(!TaskStatus::Queued.is_terminal());
463        assert!(!TaskStatus::Running.is_terminal());
464    }
465
466    #[test]
467    fn recent_requests_keep_the_two_durations_apart() {
468        let recent = RecentRequest {
469            request_id: "chatcmpl-1".into(),
470            at_ms: 10,
471            route: "/v1/chat/completions".into(),
472            model: Some("Qwen3-0.6B-Q4_K_M".into()),
473            status: 200,
474            prompt_tokens: 100,
475            completion_tokens: 10,
476            ttft_ms: Some(900.0),
477            duration_ms: 1_100,
478            decode_ms: Some(100.0),
479            stream: true,
480            acceptance_length: None,
481            draft_accept_rate_per_position: None,
482            via_api_key: None,
483            client: None,
484        };
485        let json = serde_json::to_value(&recent).unwrap();
486        assert_eq!(json["duration_ms"], 1_100);
487        assert_eq!(json["decode_ms"], 100.0);
488    }
489
490    /// An absent attribution has to survive the wire as `null` rather
491    /// than vanishing: "no key was presented" is a fact the monitor
492    /// shows, and a missing key would read as a UI bug instead.
493    #[test]
494    fn absent_attribution_serializes_as_null_rather_than_vanishing() {
495        let recent = RecentRequest {
496            request_id: "chatcmpl-1".into(),
497            at_ms: 10,
498            route: "/v1/tokenize".into(),
499            model: None,
500            status: 200,
501            prompt_tokens: 0,
502            completion_tokens: 0,
503            ttft_ms: None,
504            duration_ms: 1,
505            decode_ms: None,
506            stream: false,
507            via_api_key: None,
508            client: None,
509            acceptance_length: None,
510            draft_accept_rate_per_position: None,
511        };
512        let json = serde_json::to_value(&recent).unwrap();
513        for key in ["via_api_key", "client", "model"] {
514            assert!(json.get(key).is_some(), "{key} was omitted entirely");
515            assert!(json[key].is_null(), "{key} was not null");
516        }
517    }
518
519    /// The queue gauge is `null` when there is no queue, and a UI must
520    /// be able to tell that from a measured empty one.
521    #[test]
522    fn an_absent_queue_gauge_is_null_not_zero() {
523        let stats = StatsResponse {
524            uptime_seconds: 1,
525            requests_total: 0,
526            errors_total: 0,
527            cache_hits: 0,
528            cache_misses: 0,
529            tokens_prompt_total: 0,
530            tokens_generated_total: 0,
531            last_request_age_seconds: None,
532            generating_now: 0,
533            queue_depth: None,
534            queue_rejected_total: None,
535            recent: Vec::new(),
536        };
537        let json = serde_json::to_value(&stats).unwrap();
538        assert!(json["queue_depth"].is_null());
539        assert!(json["queue_rejected_total"].is_null());
540        assert_eq!(json["generating_now"], 0);
541    }
542}