Skip to main content

superfluid_worker/
check.rs

1//! `<worker> check [--model <path>]`.
2
3use std::path::PathBuf;
4
5use superfluid_engine::artifact::{Device, Format, Rule, RuntimeInfo, TokenizerSource};
6use serde_json::{json, Value};
7
8pub struct ModelCheck {
9    pub path: PathBuf,
10    pub reads: Result<(), String>,
11    pub facts: Option<Result<String, String>>,
12    pub sizing: Option<Result<String, String>>,
13}
14
15pub struct Report {
16    pub info: RuntimeInfo,
17    pub version: Option<String>,
18    pub available: Result<(), String>,
19    pub devices: Vec<Device>,
20    pub capabilities: String,
21    pub model: Option<ModelCheck>,
22    pub pull: Option<Vec<(String, bool, String)>>,
23    pub named: Option<String>,
24}
25
26fn verdict(r: &Result<(), String>) -> Value {
27    match r {
28        Ok(()) => Value::Bool(true),
29        Err(why) => Value::String(why.clone()),
30    }
31}
32
33fn parsed(text: &str) -> Value {
34    serde_json::from_str(text).unwrap_or(Value::Null)
35}
36
37fn strings(v: &Value) -> Vec<String> {
38    v.as_array().map(|a| a.iter().filter_map(|e| e.as_str().map(str::to_string)).collect()).unwrap_or_default()
39}
40
41pub fn format_json(f: &Format) -> Value {
42    let mut v = json!({"id": f.id, "describe": f.describe});
43    match &f.rule {
44        Rule::File { extensions, magic } => {
45            v["file"] = json!({"extensions": extensions});
46            if let Some(m) = magic {
47                v["file"]["magic"] = json!(String::from_utf8_lossy(m));
48            }
49        }
50        Rule::Directory { files, extensions } => v["directory"] = json!({"files": files, "extensions": extensions}),
51    }
52    v
53}
54
55pub fn formats_from(v: &Value) -> Vec<Format> {
56    let one = |f: &Value| -> Option<Format> {
57        let rule = if let Some(file) = f.get("file") {
58            Rule::File {
59                extensions: strings(&file["extensions"]),
60                magic: file["magic"].as_str().map(|m| m.as_bytes().to_vec()),
61            }
62        } else {
63            let d = f.get("directory")?;
64            Rule::Directory { files: strings(&d["files"]), extensions: strings(&d["extensions"]) }
65        };
66        Some(Format { id: f["id"].as_str()?.to_string(), describe: f["describe"].as_str()?.to_string(), rule })
67    };
68    v.as_array().map(|a| a.iter().filter_map(one).collect()).unwrap_or_default()
69}
70
71pub fn tokenizer_json(t: &TokenizerSource) -> Value {
72    match t {
73        TokenizerSource::Library(name) => json!({"from": "library", "name": name}),
74        TokenizerSource::HuggingFace => json!({"from": "huggingface"}),
75        TokenizerSource::Engine => json!({"from": "engine"}),
76    }
77}
78
79pub fn tokenizer_from(v: &Value) -> Option<TokenizerSource> {
80    match v["from"].as_str()? {
81        "library" => Some(TokenizerSource::Library(v["name"].as_str()?.to_string())),
82        "huggingface" => Some(TokenizerSource::HuggingFace),
83        "engine" => Some(TokenizerSource::Engine),
84        _ => None,
85    }
86}
87
88pub fn device_json(d: &Device) -> Value {
89    json!({"backend": d.backend, "name": d.name, "memory": d.memory})
90}
91
92pub fn devices_from(v: &Value) -> Vec<Device> {
93    let one = |d: &Value| -> Option<Device> {
94        Some(Device {
95            backend: d["backend"].as_str()?.to_string(),
96            name: d["name"].as_str().unwrap_or_default().to_string(),
97            memory: d["memory"].as_u64().unwrap_or(0),
98        })
99    };
100    v.as_array().map(|a| a.iter().filter_map(one).collect()).unwrap_or_default()
101}
102
103impl Report {
104    pub fn to_json(&self) -> Value {
105        let mut v = json!({
106            "runtime": {"id": self.info.id, "version": self.version, "aliases": self.info.aliases},
107            "link_w": [crate::server::PROTO_VERSION],
108            "available": verdict(&self.available),
109            "formats": self.info.formats.iter().map(format_json).collect::<Vec<_>>(),
110            "tokenizer": tokenizer_json(&self.info.tokenizer),
111            "devices": self.devices.iter().map(device_json).collect::<Vec<_>>(),
112            "capabilities": parsed(&self.capabilities),
113        });
114        if let Some(options) = &self.pull {
115            v["pull"] = json!({
116                "options": options.iter().map(|(name, value, about)| json!({"name": name, "value": value, "about": about})).collect::<Vec<_>>(),
117            });
118        }
119        if let Some(by) = &self.named {
120            v["named"] = json!(by);
121        }
122        if let Some(m) = &self.model {
123            v["model"] = json!({"path": m.path.to_string_lossy(), "reads": verdict(&m.reads)});
124            for (key, said) in [("facts", &m.facts), ("sizing", &m.sizing)] {
125                match said {
126                    Some(Ok(text)) => v["model"][key] = parsed(text),
127                    Some(Err(why)) => v["model"][key] = Value::String(why.clone()),
128                    None => {}
129                }
130            }
131        }
132        v
133    }
134
135    pub fn exit_code(&self) -> i32 {
136        let reads = self.model.as_ref().is_none_or(|m| m.reads.is_ok());
137        if self.available.is_ok() && reads {
138            0
139        } else {
140            1
141        }
142    }
143
144    pub fn finish(&self) -> ! {
145        println!("{}", self.to_json());
146        std::process::exit(self.exit_code())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn report() -> Report {
155        Report {
156            info: RuntimeInfo {
157                id: "llamacpp",
158                aliases: &["llama.cpp"],
159                formats: vec![Format::file("gguf", "a GGUF file", &["gguf"], Some(b"GGUF"))],
160                tokenizer: TokenizerSource::Library("superfluid_tokenizer_llamacpp".into()),
161            },
162            version: Some("llama.cpp b11284".into()),
163            available: Ok(()),
164            devices: vec![Device { backend: "Metal".into(), name: "Apple M5 Pro".into(), memory: 1 << 30 }],
165            capabilities: r#"{"descriptor_version":1}"#.into(),
166            model: None,
167            pull: None,
168            named: None,
169        }
170    }
171
172    #[test]
173    fn a_usable_runtime_reports_itself_and_exits_zero() {
174        let r = report();
175        assert_eq!(
176            r.to_json(),
177            json!({
178                "runtime": {"id": "llamacpp", "version": "llama.cpp b11284", "aliases": ["llama.cpp"]},
179                "link_w": [crate::server::PROTO_VERSION],
180                "available": true,
181                "formats": [{"id": "gguf", "describe": "a GGUF file", "file": {"extensions": ["gguf"], "magic": "GGUF"}}],
182                "tokenizer": {"from": "library", "name": "superfluid_tokenizer_llamacpp"},
183                "devices": [{"backend": "Metal", "name": "Apple M5 Pro", "memory": 1u64 << 30}],
184                "capabilities": {"descriptor_version": 1},
185            })
186        );
187        assert_eq!(r.exit_code(), 0);
188    }
189
190    #[test]
191    fn what_a_report_declares_reads_back() {
192        let dir = Format::directory("mlx", "an MLX model directory", &["config.json"], &["safetensors"]);
193        let mut r = report();
194        r.info.formats.push(dir);
195        let v = r.to_json();
196        assert_eq!(formats_from(&v["formats"]), r.info.formats);
197        assert_eq!(devices_from(&v["devices"]), r.devices);
198        assert_eq!(tokenizer_from(&v["tokenizer"]), Some(r.info.tokenizer.clone()));
199        for t in [TokenizerSource::HuggingFace, TokenizerSource::Engine] {
200            assert_eq!(tokenizer_from(&tokenizer_json(&t)), Some(t));
201        }
202        assert_eq!(formats_from(&json!([{"id": "x", "describe": "x", "archive": {}}])), vec![]);
203        assert_eq!(tokenizer_from(&json!({"from": "sentencepiece"})), None);
204    }
205
206    #[test]
207    fn what_is_not_usable_says_why_and_exits_one() {
208        let mut r = report();
209        r.version = None;
210        r.available = Err("ModuleNotFoundError: No module named 'mlx'".into());
211        assert_eq!(r.to_json()["available"], "ModuleNotFoundError: No module named 'mlx'");
212        assert!(r.to_json().get("named").is_none());
213        r.named = Some("SUPERFLUID_LLAMA_LIB".into());
214        assert_eq!(r.to_json()["named"], "SUPERFLUID_LLAMA_LIB");
215        assert_eq!(r.to_json()["runtime"]["version"], Value::Null);
216        assert_eq!(r.exit_code(), 1);
217
218        let mut r = report();
219        r.model = Some(ModelCheck { path: "/m".into(), reads: Err("/m is not a GGUF file".into()), facts: None, sizing: None });
220        assert_eq!(r.to_json()["model"], json!({"path": "/m", "reads": "/m is not a GGUF file"}));
221        assert_eq!(r.exit_code(), 1);
222
223        r.model = Some(ModelCheck {
224            path: "/m.gguf".into(),
225            reads: Ok(()),
226            facts: Some(Ok(r#"{"architecture":"qwen3"}"#.into())),
227            sizing: Some(Ok(r#"{"trained_context":40960}"#.into())),
228        });
229        assert_eq!(r.to_json()["model"]["facts"], json!({"architecture": "qwen3"}));
230        assert_eq!(r.to_json()["model"]["sizing"], json!({"trained_context": 40960}));
231        assert_eq!(r.exit_code(), 0);
232        r.model.as_mut().unwrap().sizing = Some(Err("no GPU here to size a window against".into()));
233        assert_eq!(r.to_json()["model"]["sizing"], "no GPU here to size a window against");
234        assert_eq!(r.exit_code(), 0);
235    }
236}