Skip to main content

frink_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/// The answer to `POST /sleep` and `POST /wake_up`.
103///
104/// `is_sleeping` is the state AFTER the call, so a caller does not
105/// have to follow up with `GET /is_sleeping` to learn whether the
106/// thing it asked for happened.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub struct SleepResponse {
109    pub ok: bool,
110    pub is_sleeping: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114
115pub struct UnloadResponse {
116    pub ok: bool,
117    /// Always `null` on success; stated rather than omitted so the UI
118    /// can use one code path for "what is active now".
119    pub active: Option<String>,
120}
121
122/// A Hub repo plus the file to take from it. `file` may be a literal
123/// name or a `*` glob, which is resolved against the repo's file list.
124/// Both are validated server-side: only `.gguf` targets, and nothing
125/// that could name a path outside the model directory.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct DownloadRequest {
128    pub repo: String,
129    pub file: String,
130}
131
132// ---------------------------------------------------------------------
133// Tasks
134// ---------------------------------------------------------------------
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum TaskKind {
139    Download,
140    Load,
141}
142
143/// `queued`/`running` are live; `done`/`error`/`cancelled` are terminal
144/// and never change again. A UI can stop polling a task the moment it
145/// reads a terminal status.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum TaskStatus {
149    Queued,
150    Running,
151    Done,
152    Error,
153    Cancelled,
154}
155
156impl TaskStatus {
157    pub fn is_terminal(self) -> bool {
158        matches!(
159            self,
160            TaskStatus::Done | TaskStatus::Error | TaskStatus::Cancelled
161        )
162    }
163}
164
165/// Whether the rate/ETA numbers may be shown at all.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "lowercase")]
168pub enum ProgressState {
169    /// Not enough samples yet. `rate_bytes_per_s` and `eta_seconds` are
170    /// `null` and the UI must show "measuring", not a number.
171    Warming,
172    Stable,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
176pub struct TaskProgress {
177    /// `bytes_done / bytes_total`, clamped to `0.0..=1.0`. `null` when
178    /// the total is unknown -- an indeterminate bar is honest, a bar
179    /// pinned at 100% is not.
180    pub fraction: Option<f64>,
181    pub bytes_done: u64,
182    pub bytes_total: Option<u64>,
183    pub rate_bytes_per_s: Option<f64>,
184    pub eta_seconds: Option<f64>,
185    pub state: ProgressState,
186}
187
188impl TaskProgress {
189    /// The only sanctioned way to build one.
190    ///
191    /// A warming report yields `null` rate and `null` ETA no matter
192    /// what the caller believes it knows: the estimator's whole purpose
193    /// is refusing to divide too early, and recomputing around it would
194    /// reintroduce the "123 GB/s" flash it exists to prevent.
195    pub fn from_report(report: RateReport, bytes_done: u64, bytes_total: Option<u64>) -> Self {
196        let stable = report.stable;
197        TaskProgress {
198            fraction: bytes_total
199                .filter(|t| *t > 0)
200                .map(|total| (bytes_done as f64 / total as f64).clamp(0.0, 1.0)),
201            bytes_done,
202            bytes_total,
203            rate_bytes_per_s: stable.then_some(report.bytes_per_second).flatten(),
204            eta_seconds: stable.then_some(report.eta_seconds).flatten(),
205            state: if stable {
206                ProgressState::Stable
207            } else {
208                ProgressState::Warming
209            },
210        }
211    }
212
213    /// A job with nothing measurable in bytes (a model load): no
214    /// fraction, no rate, no pretence of either.
215    pub fn indeterminate() -> Self {
216        TaskProgress {
217            fraction: None,
218            bytes_done: 0,
219            bytes_total: None,
220            rate_bytes_per_s: None,
221            eta_seconds: None,
222            state: ProgressState::Warming,
223        }
224    }
225}
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct TaskView {
229    pub task_id: String,
230    pub kind: TaskKind,
231    /// One human sentence naming what this job is doing, written by the
232    /// server so the UI never has to assemble one from ids and paths.
233    pub label: String,
234    pub status: TaskStatus,
235    pub error: Option<String>,
236    /// Unix epoch milliseconds, from the server's clock. The plan is
237    /// explicit that the browser's clock is not to be trusted for
238    /// ordering, so both timestamps are stated rather than implied.
239    pub started_at_ms: u64,
240    pub updated_at_ms: u64,
241    pub progress: TaskProgress,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct TasksResponse {
246    pub tasks: Vec<TaskView>,
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct CancelResponse {
251    pub ok: bool,
252}
253
254// ---------------------------------------------------------------------
255// Stats
256// ---------------------------------------------------------------------
257
258/// One finished request, as recorded in the ring buffer.
259///
260/// `duration_ms` and `decode_ms` are separate on purpose and must stay
261/// that way: `duration_ms` carries queue wait plus prefill plus decode,
262/// so dividing completion tokens by it reports a 50 tok/s model as 5
263/// whenever the prompt is long. Everything downstream of that number is
264/// then wrong in the same direction.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct RecentRequest {
267    /// The same id the response carried, so a UI can join a log line to
268    /// the message it produced without a claiming heuristic.
269    pub request_id: String,
270    /// Unix epoch milliseconds when the request finished.
271    pub at_ms: u64,
272    pub route: String,
273    /// The model that **served** this request, as `/v1/models` names it.
274    ///
275    /// Not the `model` field the client sent. frink serves whatever is
276    /// loaded and ignores that string, so echoing it back would make
277    /// the log agree with the caller's belief rather than with what
278    /// happened -- and after a model swap those are different answers.
279    /// `null` when nothing was loaded, which is what a 503 row means.
280    pub model: Option<String>,
281    pub status: u16,
282    pub prompt_tokens: usize,
283    pub completion_tokens: usize,
284    pub ttft_ms: Option<f64>,
285    /// Total server-side wall time for the request.
286    pub duration_ms: u64,
287    /// Time inside the decode loop only. `null` when the engine did not
288    /// time itself, or the answer came from cache.
289    pub decode_ms: Option<f64>,
290    pub stream: bool,
291    /// Completion tokens per verification step when this request used
292    /// speculative decoding; `null` when it did not. See
293    /// [`crate::Usage::acceptance_length`].
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub acceptance_length: Option<f64>,
296    /// Accept rate at each position within the draft block. Kept in the
297    /// ring rather than only in the response body because suffix decay
298    /// is a property of the *drafter over time*, and a single request's
299    /// numbers are too few to read it off.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub draft_accept_rate_per_position: Option<Vec<f64>>,
302    /// Which bearer key served this request, as a short fingerprint --
303    /// never the key itself, and never reversible into it.
304    ///
305    /// `null` means the request carried no `Authorization: Bearer`
306    /// header at all, which on a server started without
307    /// `FRINK_API_KEY` is every request. Two rows with the same
308    /// fingerprint were authenticated with the same key; two rows with
309    /// different fingerprints were not. That is the whole of what this
310    /// field claims.
311    ///
312    /// The fingerprint is salted per process, so it is stable within
313    /// one server run and deliberately meaningless across restarts: a
314    /// captured `/admin/stats` payload cannot be used offline to test
315    /// guesses at the key.
316    pub via_api_key: Option<String>,
317    /// The caller's self-declared label, from the `X-Frink-Client`
318    /// request header, truncated and stripped of anything that is not a
319    /// plain label character.
320    ///
321    /// **A claim, not proof.** Frink Studio sends `frink-studio`, and
322    /// so could any other client; nothing here authenticates it. It is
323    /// recorded because a self-declared label plus a key fingerprint is
324    /// still the difference between "an editor is hammering this
325    /// server" and "that was me in the other tab", and because
326    /// inventing the distinction from timing would be worse. A UI that
327    /// shows it must say it is self-declared.
328    pub client: Option<String>,
329}
330
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct StatsResponse {
333    pub uptime_seconds: u64,
334    pub requests_total: u64,
335    pub errors_total: u64,
336    pub cache_hits: u64,
337    pub cache_misses: u64,
338    pub tokens_prompt_total: u64,
339    pub tokens_generated_total: u64,
340    /// Seconds since the last request finished; `null` when none has.
341    pub last_request_age_seconds: Option<f64>,
342    /// Streamed generations decoding right now -- the ones that could
343    /// be stopped by `POST /v1/cancel` at this instant.
344    ///
345    /// Not a queue depth: nothing is queued in front of a decode here,
346    /// so this counts work in progress, not work waiting. Named for
347    /// what it is so no one reads a backlog into it.
348    pub generating_now: usize,
349    /// Requests waiting for a decode slot, from the continuous-batching
350    /// scheduler's own queue.
351    ///
352    /// `null` -- not `0` -- when continuous batching is off, because
353    /// then there is no queue at all: every request goes straight onto
354    /// its own blocking thread. A gauge reading `0` claims an empty
355    /// queue was measured; `null` says there was nothing to measure,
356    /// and a UI must be able to tell those apart.
357    pub queue_depth: Option<usize>,
358    /// Requests the queue turned away because it was full, since start.
359    /// `null` under the same condition as [`Self::queue_depth`].
360    pub queue_rejected_total: Option<u64>,
361    /// Newest last, capped server-side. See [`RecentRequest`].
362    pub recent: Vec<RecentRequest>,
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::progress::RateEstimator;
369
370    fn stable_estimator() -> RateEstimator {
371        let mut est = RateEstimator::new();
372        for i in 0..=4u64 {
373            est.observe(i * 1000, i * 1_000_000);
374        }
375        est
376    }
377
378    #[test]
379    fn a_warming_estimator_yields_no_rate_and_no_eta() {
380        let mut est = RateEstimator::new();
381        est.observe(0, 0);
382        est.observe(2, 8 * 1024 * 1024);
383        let progress =
384            TaskProgress::from_report(est.report(Some(1 << 30)), 8 * 1024 * 1024, Some(1 << 30));
385        assert_eq!(progress.state, ProgressState::Warming);
386        assert_eq!(progress.rate_bytes_per_s, None);
387        assert_eq!(progress.eta_seconds, None);
388        // A fraction is still fine: it is a ratio of two counters, not
389        // a derivative, so no window is needed to trust it.
390        assert!(progress.fraction.is_some());
391    }
392
393    #[test]
394    fn a_stable_estimator_passes_its_numbers_through_unchanged() {
395        let est = stable_estimator();
396        let report = est.report(Some(10_000_000));
397        let progress = TaskProgress::from_report(report, 4_000_000, Some(10_000_000));
398        assert_eq!(progress.state, ProgressState::Stable);
399        assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
400        assert_eq!(progress.eta_seconds, Some(6.0));
401        assert_eq!(progress.fraction, Some(0.4));
402    }
403
404    #[test]
405    fn an_unknown_total_means_no_fraction_rather_than_zero() {
406        let est = stable_estimator();
407        let progress = TaskProgress::from_report(est.report(None), 4_000_000, None);
408        assert_eq!(progress.fraction, None);
409        assert_eq!(progress.eta_seconds, None);
410        assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
411    }
412
413    #[test]
414    fn a_fraction_never_exceeds_one_even_with_bad_metadata() {
415        let est = stable_estimator();
416        let progress = TaskProgress::from_report(est.report(Some(1_000)), 4_000_000, Some(1_000));
417        assert_eq!(progress.fraction, Some(1.0));
418    }
419
420    #[test]
421    fn optional_model_fields_serialize_as_null_rather_than_vanishing() {
422        let entry = ModelEntry {
423            id: "m".into(),
424            path: "/models/m.gguf".into(),
425            size_bytes: 1,
426            arch: None,
427            quant: None,
428            context_length: None,
429            param_count: None,
430            state: ModelState::Available,
431            error: None,
432            resident_bytes: None,
433        };
434        let json: serde_json::Value = serde_json::to_value(&entry).unwrap();
435        for key in [
436            "arch",
437            "quant",
438            "context_length",
439            "param_count",
440            "error",
441            "resident_bytes",
442        ] {
443            assert!(json.get(key).is_some(), "{key} was omitted entirely");
444            assert!(json[key].is_null(), "{key} was not null");
445        }
446        assert_eq!(json["state"], "available");
447    }
448
449    #[test]
450    fn task_statuses_wire_as_the_lowercase_names_the_contract_names() {
451        let view = TaskView {
452            task_id: "t1".into(),
453            kind: TaskKind::Download,
454            label: "Downloading x.gguf".into(),
455            status: TaskStatus::Running,
456            error: None,
457            started_at_ms: 1,
458            updated_at_ms: 2,
459            progress: TaskProgress::indeterminate(),
460        };
461        let json = serde_json::to_value(&view).unwrap();
462        assert_eq!(json["kind"], "download");
463        assert_eq!(json["status"], "running");
464        assert_eq!(json["progress"]["state"], "warming");
465        assert!(json["progress"]["bytes_total"].is_null());
466        assert_eq!(json["progress"]["bytes_done"], 0);
467    }
468
469    #[test]
470    fn terminal_statuses_are_exactly_the_three_that_stop_polling() {
471        assert!(TaskStatus::Done.is_terminal());
472        assert!(TaskStatus::Error.is_terminal());
473        assert!(TaskStatus::Cancelled.is_terminal());
474        assert!(!TaskStatus::Queued.is_terminal());
475        assert!(!TaskStatus::Running.is_terminal());
476    }
477
478    #[test]
479    fn recent_requests_keep_the_two_durations_apart() {
480        let recent = RecentRequest {
481            request_id: "chatcmpl-1".into(),
482            at_ms: 10,
483            route: "/v1/chat/completions".into(),
484            model: Some("Qwen3-0.6B-Q4_K_M".into()),
485            status: 200,
486            prompt_tokens: 100,
487            completion_tokens: 10,
488            ttft_ms: Some(900.0),
489            duration_ms: 1_100,
490            decode_ms: Some(100.0),
491            stream: true,
492            acceptance_length: None,
493            draft_accept_rate_per_position: None,
494            via_api_key: None,
495            client: None,
496        };
497        let json = serde_json::to_value(&recent).unwrap();
498        assert_eq!(json["duration_ms"], 1_100);
499        assert_eq!(json["decode_ms"], 100.0);
500    }
501
502    /// An absent attribution has to survive the wire as `null` rather
503    /// than vanishing: "no key was presented" is a fact the monitor
504    /// shows, and a missing key would read as a UI bug instead.
505    #[test]
506    fn absent_attribution_serializes_as_null_rather_than_vanishing() {
507        let recent = RecentRequest {
508            request_id: "chatcmpl-1".into(),
509            at_ms: 10,
510            route: "/v1/tokenize".into(),
511            model: None,
512            status: 200,
513            prompt_tokens: 0,
514            completion_tokens: 0,
515            ttft_ms: None,
516            duration_ms: 1,
517            decode_ms: None,
518            stream: false,
519            via_api_key: None,
520            client: None,
521            acceptance_length: None,
522            draft_accept_rate_per_position: None,
523        };
524        let json = serde_json::to_value(&recent).unwrap();
525        for key in ["via_api_key", "client", "model"] {
526            assert!(json.get(key).is_some(), "{key} was omitted entirely");
527            assert!(json[key].is_null(), "{key} was not null");
528        }
529    }
530
531    /// The queue gauge is `null` when there is no queue, and a UI must
532    /// be able to tell that from a measured empty one.
533    #[test]
534    fn an_absent_queue_gauge_is_null_not_zero() {
535        let stats = StatsResponse {
536            uptime_seconds: 1,
537            requests_total: 0,
538            errors_total: 0,
539            cache_hits: 0,
540            cache_misses: 0,
541            tokens_prompt_total: 0,
542            tokens_generated_total: 0,
543            last_request_age_seconds: None,
544            generating_now: 0,
545            queue_depth: None,
546            queue_rejected_total: None,
547            recent: Vec::new(),
548        };
549        let json = serde_json::to_value(&stats).unwrap();
550        assert!(json["queue_depth"].is_null());
551        assert!(json["queue_rejected_total"].is_null());
552        assert_eq!(json["generating_now"], 0);
553    }
554}