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}