use serde::{Deserialize, Serialize};
use crate::progress::RateReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelState {
Loaded,
Loading,
Available,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelEntry {
pub id: String,
pub path: String,
pub size_bytes: u64,
pub arch: Option<String>,
pub quant: Option<String>,
pub context_length: Option<u64>,
pub param_count: Option<u64>,
pub state: ModelState,
pub error: Option<String>,
pub resident_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelsResponse {
pub model_dir: Option<String>,
pub active: Option<String>,
pub models: Vec<ModelEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LoadModelRequest {
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskAccepted {
pub task_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnloadResponse {
pub ok: bool,
pub active: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DownloadRequest {
pub repo: String,
pub file: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskKind {
Download,
Load,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Queued,
Running,
Done,
Error,
Cancelled,
}
impl TaskStatus {
pub fn is_terminal(self) -> bool {
matches!(
self,
TaskStatus::Done | TaskStatus::Error | TaskStatus::Cancelled
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProgressState {
Warming,
Stable,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TaskProgress {
pub fraction: Option<f64>,
pub bytes_done: u64,
pub bytes_total: Option<u64>,
pub rate_bytes_per_s: Option<f64>,
pub eta_seconds: Option<f64>,
pub state: ProgressState,
}
impl TaskProgress {
pub fn from_report(report: RateReport, bytes_done: u64, bytes_total: Option<u64>) -> Self {
let stable = report.stable;
TaskProgress {
fraction: bytes_total
.filter(|t| *t > 0)
.map(|total| (bytes_done as f64 / total as f64).clamp(0.0, 1.0)),
bytes_done,
bytes_total,
rate_bytes_per_s: stable.then_some(report.bytes_per_second).flatten(),
eta_seconds: stable.then_some(report.eta_seconds).flatten(),
state: if stable {
ProgressState::Stable
} else {
ProgressState::Warming
},
}
}
pub fn indeterminate() -> Self {
TaskProgress {
fraction: None,
bytes_done: 0,
bytes_total: None,
rate_bytes_per_s: None,
eta_seconds: None,
state: ProgressState::Warming,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskView {
pub task_id: String,
pub kind: TaskKind,
pub label: String,
pub status: TaskStatus,
pub error: Option<String>,
pub started_at_ms: u64,
pub updated_at_ms: u64,
pub progress: TaskProgress,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TasksResponse {
pub tasks: Vec<TaskView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CancelResponse {
pub ok: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecentRequest {
pub request_id: String,
pub at_ms: u64,
pub route: String,
pub status: u16,
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub ttft_ms: Option<f64>,
pub duration_ms: u64,
pub decode_ms: Option<f64>,
pub stream: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatsResponse {
pub uptime_seconds: u64,
pub requests_total: u64,
pub errors_total: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub tokens_prompt_total: u64,
pub tokens_generated_total: u64,
pub last_request_age_seconds: Option<f64>,
pub generating_now: usize,
pub recent: Vec<RecentRequest>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::progress::RateEstimator;
fn stable_estimator() -> RateEstimator {
let mut est = RateEstimator::new();
for i in 0..=4u64 {
est.observe(i * 1000, i * 1_000_000);
}
est
}
#[test]
fn a_warming_estimator_yields_no_rate_and_no_eta() {
let mut est = RateEstimator::new();
est.observe(0, 0);
est.observe(2, 8 * 1024 * 1024);
let progress =
TaskProgress::from_report(est.report(Some(1 << 30)), 8 * 1024 * 1024, Some(1 << 30));
assert_eq!(progress.state, ProgressState::Warming);
assert_eq!(progress.rate_bytes_per_s, None);
assert_eq!(progress.eta_seconds, None);
assert!(progress.fraction.is_some());
}
#[test]
fn a_stable_estimator_passes_its_numbers_through_unchanged() {
let est = stable_estimator();
let report = est.report(Some(10_000_000));
let progress = TaskProgress::from_report(report, 4_000_000, Some(10_000_000));
assert_eq!(progress.state, ProgressState::Stable);
assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
assert_eq!(progress.eta_seconds, Some(6.0));
assert_eq!(progress.fraction, Some(0.4));
}
#[test]
fn an_unknown_total_means_no_fraction_rather_than_zero() {
let est = stable_estimator();
let progress = TaskProgress::from_report(est.report(None), 4_000_000, None);
assert_eq!(progress.fraction, None);
assert_eq!(progress.eta_seconds, None);
assert_eq!(progress.rate_bytes_per_s, Some(1_000_000.0));
}
#[test]
fn a_fraction_never_exceeds_one_even_with_bad_metadata() {
let est = stable_estimator();
let progress = TaskProgress::from_report(est.report(Some(1_000)), 4_000_000, Some(1_000));
assert_eq!(progress.fraction, Some(1.0));
}
#[test]
fn optional_model_fields_serialize_as_null_rather_than_vanishing() {
let entry = ModelEntry {
id: "m".into(),
path: "/models/m.gguf".into(),
size_bytes: 1,
arch: None,
quant: None,
context_length: None,
param_count: None,
state: ModelState::Available,
error: None,
resident_bytes: None,
};
let json: serde_json::Value = serde_json::to_value(&entry).unwrap();
for key in [
"arch",
"quant",
"context_length",
"param_count",
"error",
"resident_bytes",
] {
assert!(json.get(key).is_some(), "{key} was omitted entirely");
assert!(json[key].is_null(), "{key} was not null");
}
assert_eq!(json["state"], "available");
}
#[test]
fn task_statuses_wire_as_the_lowercase_names_the_contract_names() {
let view = TaskView {
task_id: "t1".into(),
kind: TaskKind::Download,
label: "Downloading x.gguf".into(),
status: TaskStatus::Running,
error: None,
started_at_ms: 1,
updated_at_ms: 2,
progress: TaskProgress::indeterminate(),
};
let json = serde_json::to_value(&view).unwrap();
assert_eq!(json["kind"], "download");
assert_eq!(json["status"], "running");
assert_eq!(json["progress"]["state"], "warming");
assert!(json["progress"]["bytes_total"].is_null());
assert_eq!(json["progress"]["bytes_done"], 0);
}
#[test]
fn terminal_statuses_are_exactly_the_three_that_stop_polling() {
assert!(TaskStatus::Done.is_terminal());
assert!(TaskStatus::Error.is_terminal());
assert!(TaskStatus::Cancelled.is_terminal());
assert!(!TaskStatus::Queued.is_terminal());
assert!(!TaskStatus::Running.is_terminal());
}
#[test]
fn recent_requests_keep_the_two_durations_apart() {
let recent = RecentRequest {
request_id: "chatcmpl-1".into(),
at_ms: 10,
route: "/v1/chat/completions".into(),
status: 200,
prompt_tokens: 100,
completion_tokens: 10,
ttft_ms: Some(900.0),
duration_ms: 1_100,
decode_ms: Some(100.0),
stream: true,
};
let json = serde_json::to_value(&recent).unwrap();
assert_eq!(json["duration_ms"], 1_100);
assert_eq!(json["decode_ms"], 100.0);
}
}