1use serde::{Deserialize, Serialize};
20
21use crate::progress::RateReport;
22
23#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ModelEntry {
46 pub id: String,
51 pub path: String,
54 pub size_bytes: u64,
56 pub arch: Option<String>,
59 pub quant: Option<String>,
63 pub context_length: Option<u64>,
65 pub param_count: Option<u64>,
68 pub state: ModelState,
69 pub error: Option<String>,
72 pub resident_bytes: Option<u64>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct ModelsResponse {
82 pub model_dir: Option<String>,
86 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#[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 pub active: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct DownloadRequest {
116 pub repo: String,
117 pub file: String,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum TaskKind {
127 Download,
128 Load,
129}
130
131#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156pub enum ProgressState {
157 Warming,
160 Stable,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
164pub struct TaskProgress {
165 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 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 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 pub label: String,
222 pub status: TaskStatus,
223 pub error: Option<String>,
224 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct RecentRequest {
255 pub request_id: String,
258 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 pub duration_ms: u64,
267 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 pub last_request_age_seconds: Option<f64>,
284 pub generating_now: usize,
291 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 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}