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}