1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex, RwLock};
7use std::time::{Duration, Instant};
8
9use crate::{Daemon, DaemonError};
10
11fn hub_field(path: &Path, field: &str) -> Option<String> {
14 let bytes = std::fs::read(path.parent()?.join("hub.json")).ok()?;
15 let v = serde_json::from_slice::<serde_json::Value>(&bytes).ok()?;
16 let value = v.get(field)?.as_str()?.trim();
17 (!value.is_empty()).then(|| value.to_string())
18}
19
20pub fn model_id_for(path: &Path) -> String {
21 if let Some(id) = hub_field(path, "id") {
22 return id;
23 }
24 let stem = if path.is_dir() { path.file_name() } else { path.file_stem() };
25 stem.map(|s| s.to_string_lossy().into_owned())
26 .filter(|s| !s.is_empty())
27 .unwrap_or_else(|| "superfluid".into())
28}
29
30pub fn distinct_name(path: &Path, taken: &std::collections::HashSet<String>) -> String {
31 let id = model_id_for(path);
32 if !taken.contains(&id) {
33 return id;
34 }
35 if let Some(variant) = hub_field(path, "variant") {
38 let qualified = format!("{id}:{variant}");
39 if !taken.contains(&qualified) {
40 return qualified;
41 }
42 }
43 let full = path
44 .file_name()
45 .map(|n| n.to_string_lossy().into_owned())
46 .filter(|n| !n.is_empty() && n != &id)
47 .unwrap_or_else(|| id.clone());
48 if !taken.contains(&full) {
49 return full;
50 }
51 let parent = path
52 .parent()
53 .and_then(|p| p.file_name())
54 .map(|n| n.to_string_lossy().into_owned())
55 .unwrap_or_default();
56 format!("{parent}/{full}")
57}
58
59const STATE_OWNER: &str = "model-id";
60
61pub fn state_dir(models: &Path, id: &str) -> std::io::Result<PathBuf> {
62 use sha2::{Digest, Sha256};
63 let mut name: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
64 if name.is_empty() {
65 name.push('_');
66 }
67 static CLAIMS: Mutex<()> = Mutex::new(());
68 let _one_at_a_time = CLAIMS.lock().unwrap_or_else(|e| e.into_inner());
69 let plain = models.join(&name);
70 if claim(&plain, id)? {
71 return Ok(plain);
72 }
73 let digest: String = Sha256::digest(id.as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect();
74 let own = models.join(format!("{name}-{digest}"));
75 if claim(&own, id)? {
76 return Ok(own);
77 }
78 Err(std::io::Error::new(
79 std::io::ErrorKind::AlreadyExists,
80 format!("no state directory for model '{id}': {} and {} are other models'", plain.display(), own.display()),
81 ))
82}
83
84fn claim(dir: &Path, id: &str) -> std::io::Result<bool> {
85 use std::io::Write;
86 std::fs::create_dir_all(dir)?;
87 let owner = dir.join(STATE_OWNER);
88 match std::fs::read(&owner) {
89 Ok(theirs) => return Ok(theirs == id.as_bytes()),
90 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
91 Err(e) => return Err(e),
92 }
93 let aside = dir.join(format!(".{STATE_OWNER}-new"));
94 let mut file = std::fs::File::create(&aside)?;
95 file.write_all(id.as_bytes())?;
96 file.sync_all()?;
97 drop(file);
98 std::fs::rename(&aside, &owner)?;
99 if let Ok(d) = std::fs::File::open(dir) {
100 let _ = d.sync_all();
101 }
102 Ok(true)
103}
104
105pub type ModelLoader =
106 Box<dyn Fn(&str, Option<crate::runtime_pick::RuntimeId>) -> Result<Arc<Daemon>, DaemonError> + Send + Sync>;
107
108pub type ModelReloader =
109 Box<dyn Fn(&str, Option<crate::runtime_pick::RuntimeId>, &Daemon) -> Result<(), DaemonError> + Send + Sync>;
110
111pub type RuntimeNames = Box<dyn Fn(&str) -> Result<crate::runtime_pick::RuntimeId, String> + Send + Sync>;
112
113#[derive(Debug)]
114pub enum ResolveError {
115 NotFound,
116 Load(DaemonError),
117}
118
119pub struct ModelRegistry {
120 models: RwLock<HashMap<String, Arc<Daemon>>>,
121 loader: ModelLoader,
122 default_model: RwLock<String>,
123 load_lock: Mutex<()>,
124 known: RwLock<HashMap<String, PathBuf>>,
125 known_runtimes: RwLock<HashMap<String, crate::runtime_pick::RuntimeId>>,
126 last_used: Mutex<HashMap<String, Instant>>,
127 track_idle: AtomicBool,
128 runtime_names: RwLock<RuntimeNames>,
129 sources: RwLock<HashMap<String, (String, Option<crate::runtime_pick::RuntimeId>)>>,
130 reload_failed: Mutex<HashMap<String, Instant>>,
131 reloader: RwLock<Option<ModelReloader>>,
132}
133
134const RELOAD_RETRY: Duration = Duration::from_secs(10);
135
136impl ModelRegistry {
137 pub fn with_initial(name: impl Into<String>, daemon: Arc<Daemon>, loader: ModelLoader) -> ModelRegistry {
138 let name = name.into();
139 let mut m = HashMap::new();
140 m.insert(name.clone(), daemon);
141 ModelRegistry {
142 models: RwLock::new(m),
143 loader,
144 default_model: RwLock::new(name),
145 load_lock: Mutex::new(()),
146 known: RwLock::new(HashMap::new()),
147 known_runtimes: RwLock::new(HashMap::new()),
148 last_used: Mutex::new(HashMap::new()),
149 track_idle: AtomicBool::new(false),
150 runtime_names: RwLock::new(Box::new(|name: &str| {
151 crate::runtime_pick::RuntimeId::parse(name).ok_or_else(|| format!("'{name}' is not a runtime id"))
152 })),
153 sources: RwLock::new(HashMap::new()),
154 reload_failed: Mutex::new(HashMap::new()),
155 reloader: RwLock::new(None),
156 }
157 }
158
159 pub fn set_reloader(&self, reloader: ModelReloader) {
160 *self.reloader.write().expect("registry") = Some(reloader);
161 }
162
163 pub fn remember_source(&self, name: &str, source: &str) {
164 self.remember_source_on(name, source, None);
165 }
166
167 pub fn remember_source_on(&self, name: &str, source: &str, runtime: Option<crate::runtime_pick::RuntimeId>) {
168 let runtime = runtime
169 .filter(|r| r.install().is_some())
170 .or_else(|| self.models.read().expect("registry").get(name).and_then(|d| d.runtime_id()).map(crate::runtime_pick::RuntimeId::new));
171 self.sources.write().expect("registry").insert(name.to_string(), (source.to_string(), runtime));
172 }
173
174 fn fresh(&self, name: &str, daemon: Arc<Daemon>) -> Arc<Daemon> {
175 let Some(why) = daemon.needs_reload() else { return daemon };
176 let Some((source, runtime)) = self.sources.read().expect("registry").get(name).cloned() else {
177 return daemon;
178 };
179 if self.reload_failed.lock().expect("registry").get(name).is_some_and(|t| t.elapsed() < RELOAD_RETRY) {
180 return daemon;
181 }
182 let _guard = self.load_lock.lock().expect("load lock");
183 let still_served = self.models.read().expect("registry").get(name).is_some_and(|cur| Arc::ptr_eq(cur, &daemon));
184 if !still_served || daemon.needs_reload().is_none() {
185 return daemon;
186 }
187 let reloader = self.reloader.read().expect("registry");
188 let Some(reload) = reloader.as_ref() else { return daemon };
189 tracing::warn!(model = name, why = %why, "loading the model again");
190 match reload(&source, runtime, &daemon) {
191 Ok(()) => {
192 self.reload_failed.lock().expect("registry").remove(name);
193 }
194 Err(e) => {
195 tracing::error!(model = name, error = %e, "the model did not load again");
196 self.reload_failed.lock().expect("registry").insert(name.to_string(), Instant::now());
197 }
198 }
199 daemon
200 }
201
202 pub fn reload_pending(&self, model: Option<&str>) -> bool {
203 let name = match model {
204 Some(m) => m.to_string(),
205 None => self.default_model.read().expect("registry").clone(),
206 };
207 self.models.read().expect("registry").get(&name).is_some_and(|d| d.needs_reload().is_some())
208 }
209
210 pub fn set_runtime_names(&self, names: RuntimeNames) {
211 *self.runtime_names.write().expect("registry") = names;
212 }
213
214 pub fn runtime_named(&self, name: &str) -> Result<crate::runtime_pick::RuntimeId, String> {
215 (self.runtime_names.read().expect("registry"))(name)
216 }
217
218 pub fn single(name: impl Into<String>, daemon: Arc<Daemon>) -> ModelRegistry {
219 Self::with_initial(
220 name,
221 daemon,
222 Box::new(|_, _| Err(DaemonError::Config("dynamic model loading is not enabled on this server"))),
223 )
224 }
225
226 pub fn default_name(&self) -> String {
227 self.default_model.read().expect("registry").clone()
228 }
229
230 pub fn names(&self) -> Vec<String> {
231 let mut v: Vec<String> = self.models.read().expect("registry").keys().cloned().collect();
232 v.sort();
233 v
234 }
235
236 pub fn loose_name(&self, requested: &str) -> Option<String> {
239 let mut all = self.names();
240 all.extend(self.known.read().expect("registry").keys().cloned());
241 all.sort();
242 all.dedup();
243 let wanted = requested.to_lowercase();
244 all.iter()
245 .find(|n| *n == requested)
246 .or_else(|| all.iter().find(|n| n.to_lowercase() == wanted))
247 .or_else(|| all.iter().find(|n| n.contains(requested)))
248 .cloned()
249 }
250
251 pub fn resolve(&self, model: Option<&str>) -> Option<(String, Arc<Daemon>)> {
252 self.try_resolve(model).ok()
253 }
254
255 pub fn try_resolve(&self, model: Option<&str>) -> Result<(String, Arc<Daemon>), ResolveError> {
256 let name = {
257 let models = self.models.read().expect("registry");
258 match model {
259 Some(m) if models.contains_key(m) => m.to_string(),
260 Some(m) => {
261 let source = self.known.read().expect("registry").get(m).cloned();
262 let source = source.ok_or(ResolveError::NotFound)?;
263 drop(models);
264 let runtime = self.known_runtimes.read().expect("registry").get(m).cloned();
265 self.load_as_on(m, &source.to_string_lossy(), runtime).map_err(ResolveError::Load)?;
266 m.to_string()
267 }
268 None => self.default_model.read().expect("registry").clone(),
269 }
270 };
271 let daemon = self.models.read().expect("registry").get(&name).map(Arc::clone).ok_or(ResolveError::NotFound)?;
272 let daemon = self.fresh(&name, daemon);
273 if self.track_idle.load(Ordering::Relaxed) {
274 self.last_used.lock().expect("registry").insert(name.clone(), Instant::now());
275 }
276 Ok((name, daemon))
277 }
278
279 pub fn register_known(&self, name: &str, path: &Path) {
280 self.known.write().expect("registry").insert(name.to_string(), path.to_path_buf());
281 }
282
283 pub fn is_known(&self, name: &str) -> bool {
284 self.known.read().expect("registry").contains_key(name)
285 }
286
287 pub fn all_names(&self) -> Vec<String> {
288 let mut names = self.names();
289 for k in self.known.read().expect("registry").keys() {
290 if !names.contains(k) {
291 names.push(k.clone());
292 }
293 }
294 names.sort();
295 names
296 }
297
298 pub fn track_idle(&self) {
299 self.track_idle.store(true, Ordering::Relaxed);
300 }
301
302 pub fn sweep_idle(&self, max_idle: Duration) -> Vec<String> {
303 let default = self.default_name();
304 let now = Instant::now();
305 let candidates: Vec<String> = {
306 let last = self.last_used.lock().expect("registry");
307 self.names()
308 .into_iter()
309 .filter(|n| *n != default && self.is_known(n))
310 .filter(|n| {
311 last.get(n).is_none_or(|t| now.duration_since(*t) > max_idle)
312 })
313 .collect()
314 };
315 let mut unloaded = Vec::new();
316 for name in candidates {
317 if self.unload(&name).unwrap_or(false) {
318 self.last_used.lock().expect("registry").remove(&name);
319 unloaded.push(name);
320 }
321 }
322 unloaded
323 }
324
325 pub fn is_loaded(&self, name: &str) -> bool {
326 self.models.read().expect("registry").contains_key(name)
327 }
328
329 pub(crate) fn peek_loaded(&self, name: &str) -> Option<Arc<Daemon>> {
330 self.models.read().expect("registry").get(name).cloned()
331 }
332
333 pub fn load(&self, name: &str) -> Result<(), DaemonError> {
334 self.load_on(name, None)
335 }
336
337 pub fn load_on(&self, name: &str, runtime: Option<crate::runtime_pick::RuntimeId>) -> Result<(), DaemonError> {
338 let known = self.known.read().expect("registry").get(name).map(|p| p.to_string_lossy().into_owned());
339 match known {
340 Some(source) => {
341 let runtime = runtime.or_else(|| self.known_runtimes.read().expect("registry").get(name).cloned());
342 self.load_as_on(name, &source, runtime)?;
343 }
344 None => {
345 self.load_as_on(name, name, runtime)?;
346 }
347 }
348 *self.default_model.write().expect("registry") = name.to_string();
349 Ok(())
350 }
351
352 pub fn load_as(&self, name: &str, source: &str) -> Result<bool, DaemonError> {
353 self.load_as_on(name, source, None)
354 }
355
356 pub fn load_as_on(
357 &self,
358 name: &str,
359 source: &str,
360 runtime: Option<crate::runtime_pick::RuntimeId>,
361 ) -> Result<bool, DaemonError> {
362 let _guard = self.load_lock.lock().expect("load lock");
363 if let Some(loaded) = self.models.read().expect("registry").get(name) {
364 let have = self
365 .sources
366 .read()
367 .expect("registry")
368 .get(name)
369 .and_then(|(_, r)| *r)
370 .or_else(|| loaded.runtime_id().map(crate::runtime_pick::RuntimeId::new));
371 if let (Some(want), Some(have)) = (runtime, have) {
372 let same = want.base() == have.base() && (want.install().is_none() || want.install() == have.install());
373 if !same {
374 return Err(DaemonError::Unsupported(crate::Refusal::new(
375 "unsupported_runtime",
376 Some("runtime"),
377 format!("model '{name}' is loaded on runtime {have}; unload it to load it on {}", want.name()),
378 )));
379 }
380 }
381 return Ok(false);
382 }
383 let daemon = (self.loader)(source, runtime)?;
384 let ran_on = runtime.filter(|r| r.install().is_some()).or_else(|| daemon.runtime_id().map(crate::runtime_pick::RuntimeId::new));
385 self.models.write().expect("registry").insert(name.to_string(), daemon);
386 self.sources.write().expect("registry").insert(name.to_string(), (source.to_string(), ran_on));
387 Ok(true)
388 }
389
390 pub fn shutdown_all(&self) {
391 let daemons: Vec<_> = self.models.read().expect("registry").values().cloned().collect();
392 for d in &daemons {
393 d.signal_shutdown();
394 }
395 for d in &daemons {
396 d.shutdown();
397 }
398 }
399
400 pub fn unload(&self, name: &str) -> Result<bool, DaemonError> {
401 let mut models = self.models.write().expect("registry");
402 if !models.contains_key(name) {
403 return Ok(false);
404 }
405 if models.len() == 1 {
406 return Err(DaemonError::Config("cannot unload the last remaining model"));
407 }
408 let removed = models.remove(name);
409 if let Some((source, runtime)) = self.sources.write().expect("registry").remove(name) {
412 let path = PathBuf::from(&source);
413 if path.exists() {
414 self.known.write().expect("registry").entry(name.to_string()).or_insert(path);
415 if let Some(rt) = runtime {
416 self.known_runtimes.write().expect("registry").entry(name.to_string()).or_insert(rt);
417 }
418 }
419 }
420 let mut def = self.default_model.write().expect("registry");
421 if *def == name {
422 *def = models.keys().next().cloned().unwrap_or_default();
423 }
424 drop(models);
425 drop(def);
426 drop(removed);
427 Ok(true)
428 }
429}
430
431#[cfg(test)]
432mod distinct_name_tests {
433 use super::*;
434
435 #[test]
436 fn same_stem_artifacts_get_distinct_names() {
437 let mut taken = std::collections::HashSet::new();
438 let base = Path::new("/models/foo.base");
439 let gguf = Path::new("/models/foo.gguf");
440 let a = distinct_name(base, &taken);
441 assert_eq!(a, "foo");
442 taken.insert(a);
443 let b = distinct_name(gguf, &taken);
444 assert_eq!(b, "foo.gguf", "the second keeps its extension");
445 taken.insert(b);
446 assert_eq!(distinct_name(Path::new("/other/foo.gguf"), &taken), "other/foo.gguf");
447 }
448
449 #[test]
450 fn two_variants_of_one_hub_model_are_named_by_variant() {
451 let repo = std::env::temp_dir().join(format!("superfluid-variants-{}", std::process::id())).join("org/Model");
452 let mut taken = std::collections::HashSet::new();
453 for variant in ["default-q4", "default-q8"] {
454 std::fs::create_dir_all(repo.join(variant)).unwrap();
455 let sidecar = format!(r#"{{"id":"org/Model","variant":"{variant}"}}"#);
456 std::fs::write(repo.join(variant).join("hub.json"), sidecar).unwrap();
457 }
458 let q4 = distinct_name(&repo.join("default-q4/model.base"), &taken);
459 assert_eq!(q4, "org/Model");
460 taken.insert(q4);
461 assert_eq!(distinct_name(&repo.join("default-q8/model.base"), &taken), "org/Model:default-q8");
462 }
463}
464
465#[cfg(test)]
466mod state_dir_tests {
467 use super::*;
468
469 fn scratch(tag: &str) -> PathBuf {
470 let d = std::env::temp_dir().join(format!("superfluid-state-dir-{tag}-{}", std::process::id()));
471 let _ = std::fs::remove_dir_all(&d);
472 std::fs::create_dir_all(&d).unwrap();
473 d
474 }
475
476 #[test]
477 fn ids_that_sanitize_alike_keep_their_state_apart() {
478 for order in [["m.gguf", "m_gguf"], ["m_gguf", "m.gguf"]] {
479 let models = scratch("alike");
480 let first = state_dir(&models, order[0]).unwrap();
481 let second = state_dir(&models, order[1]).unwrap();
482 assert_eq!(first, models.join("m_gguf"), "the first to ask has the plain name");
483 assert_ne!(first, second, "{order:?} share a state directory");
484 assert!(second.is_dir() && second.parent() == Some(models.as_path()), "{}", second.display());
485 assert_eq!(state_dir(&models, order[1]).unwrap(), second);
486 assert_eq!(state_dir(&models, order[0]).unwrap(), first);
487 let _ = std::fs::remove_dir_all(&models);
488 }
489 let models = scratch("three");
490 let dirs: std::collections::HashSet<PathBuf> = ["a/b", "a.b", "a_b"].iter().map(|id| state_dir(&models, id).unwrap()).collect();
491 assert_eq!(dirs.len(), 3, "{dirs:?}");
492 let _ = std::fs::remove_dir_all(&models);
493 }
494
495 #[test]
496 fn a_state_directory_from_before_stays_where_it_is() {
497 let models = scratch("before");
498 let old = models.join("Qwen3_6-27B");
499 std::fs::create_dir_all(old.join("park")).unwrap();
500 std::fs::write(old.join("wal.log"), b"records").unwrap();
501 let dir = state_dir(&models, "Qwen3.6-27B").unwrap();
502 assert_eq!(dir, old);
503 assert_eq!(std::fs::read(dir.join("wal.log")).unwrap(), b"records");
504 assert_ne!(state_dir(&models, "Qwen3_6-27B").unwrap(), old, "another id does not get it afterwards");
505 assert_eq!(state_dir(&models, "Qwen3.6-27B").unwrap(), old);
506 let _ = std::fs::remove_dir_all(&models);
507 }
508
509 #[test]
510 fn an_id_names_no_path_outside_the_models_directory() {
511 let models = scratch("inside");
512 for id in ["../x", "..", "a/../../b", "/etc/passwd", "."] {
513 let dir = state_dir(&models, id).unwrap();
514 assert_eq!(dir.parent(), Some(models.as_path()), "{id}: {}", dir.display());
515 assert!(dir.is_dir());
516 }
517 let _ = std::fs::remove_dir_all(&models);
518 }
519}