1use anyhow::{Context, Result, anyhow, bail};
30use rlx_core::gguf_config::{
31 DINOV2_GGUF_ARCHES, FLUX_GGUF_ARCHES, SAM_GGUF_ARCHES, SAM2_GGUF_ARCHES, SAM3_GGUF_ARCHES,
32 VJEPA2_GGUF_ARCHES, W2V_BERT_GGUF_ARCHES,
33};
34use rlx_core::gguf_support::{
35 gguf_architecture_from_path, gguf_family_for_arch, resolve_weights_file,
36};
37use std::path::{Path, PathBuf};
38
39use crate::registry::run_registered;
40
41pub fn run_auto(args: &[String]) -> Result<()> {
48 let Some(first) = args.first() else {
49 bail!(
50 "auto: expected WEIGHTS path as the first argument\n\
51 usage: rlx-run auto <weights-path> [runner-args...]"
52 );
53 };
54 if matches!(first.as_str(), "-h" | "--help" | "help") {
55 println!(
56 "rlx-run auto — sniff a GGUF / safetensors file and dispatch to the right runner\n\
57 \n\
58 USAGE:\n rlx-run auto <weights-path> [runner-args...]\n\
59 \n\
60 The first argument is forwarded as the runner's --weights value;\n\
61 remaining arguments are passed through unchanged."
62 );
63 return Ok(());
64 }
65 let path = Path::new(first);
66 let sniff = auto_sniff(path)?;
67 eprintln!(
68 "[rlx-run auto] {} → runner `{}` (from {:?})",
69 sniff.path.display(),
70 sniff.runner_name,
71 sniff.from
72 );
73 let rest: Vec<String> = args[1..].to_vec();
76 let has_weights_flag = rest
77 .iter()
78 .any(|a| a == "--weights" || a.starts_with("--weights="));
79 let mut forwarded: Vec<String> = Vec::with_capacity(rest.len() + 2);
80 if !has_weights_flag {
81 forwarded.push("--weights".into());
82 forwarded.push(sniff.path.display().to_string());
83 }
84 forwarded.extend(rest);
85 match run_registered(sniff.runner_name, &forwarded)? {
86 Some(()) => Ok(()),
87 None => bail!(
88 "auto: runner `{}` not registered (sniffed from {:?}); register it via \
89 `register_cli` in your binary's main",
90 sniff.runner_name,
91 sniff.from
92 ),
93 }
94}
95
96#[derive(Debug, Clone)]
98pub enum SniffedFrom {
99 GgufArch(String),
101 SafetensorsConfig(String),
103}
104
105#[derive(Debug, Clone)]
107pub struct SniffedRunner {
108 pub path: PathBuf,
110 pub runner_name: &'static str,
112 pub from: SniffedFrom,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct UnimplementedArch {
121 pub family: &'static str,
123 pub milestone: &'static str,
125 pub note: &'static str,
127}
128
129mod families {
132 use super::UnimplementedArch;
133 pub static MISTRAL: UnimplementedArch = UnimplementedArch {
134 family: "Mistral 3+ / Ministral",
135 milestone: "M4",
136 note: "Llama-shaped with newer RoPE; share `rlx-llama-base` per PLAN.md M4",
137 };
138 pub static PHIMOE: UnimplementedArch = UnimplementedArch {
139 family: "Phi MoE",
140 milestone: "M4 + M5",
141 note: "Phi + MoE routing; depends on shared MoE block — PLAN.md M4/M5",
142 };
143 pub static BONSAI: UnimplementedArch = UnimplementedArch {
144 family: "Bonsai",
145 milestone: "M4",
146 note: "Llama-shaped; HF model_type only — usually ships as llama GGUF — PLAN.md M4",
147 };
148 pub static OMNICODER: UnimplementedArch = UnimplementedArch {
149 family: "OmniCoder",
150 milestone: "M4",
151 note: "Qwen3-coder shaped — PLAN.md M4 (often tagged `qwen3` in GGUF)",
152 };
153 pub static MINIMAX: UnimplementedArch = UnimplementedArch {
154 family: "MiniMax M2",
155 milestone: "M5",
156 note: "Lightning Attention; depends on `rlx-ssm` upstream — PLAN.md M5",
157 };
158 pub static GLM: UnimplementedArch = UnimplementedArch {
159 family: "GLM 4 / 5",
160 milestone: "M5",
161 note: "GLM RoPE + RMSNorm placement — PLAN.md M5",
162 };
163 pub static GLM_MOE: UnimplementedArch = UnimplementedArch {
164 family: "GLM 4 MoE",
165 milestone: "M5",
166 note: "GLM + MoE routing — PLAN.md M5",
167 };
168 pub static GPT_OSS: UnimplementedArch = UnimplementedArch {
169 family: "gpt-oss",
170 milestone: "M5",
171 note: "OpenAI gpt-oss — confirm arch shape — PLAN.md M5",
172 };
173 pub static NEMOTRON: UnimplementedArch = UnimplementedArch {
174 family: "Nemotron",
175 milestone: "M5",
176 note: "Dense Nemotron arch — PLAN.md M5",
177 };
178 pub static NEMOTRON_H: UnimplementedArch = UnimplementedArch {
179 family: "Nemotron-H",
180 milestone: "M5",
181 note: "Mamba+attention hybrid; depends on `rlx-ssm` upstream — PLAN.md M5/M7",
182 };
183 #[allow(dead_code)]
184 pub static LFM: UnimplementedArch = UnimplementedArch {
185 family: "LFM 2 / 2.5",
186 milestone: "M5",
187 note: "Liquid Foundation Models with custom SSM layers — PLAN.md M5",
188 };
189 pub static LFM_MOE: UnimplementedArch = UnimplementedArch {
190 family: "LFM 2 MoE",
191 milestone: "M5",
192 note: "LFM + MoE — PLAN.md M5",
193 };
194 pub static QWEN3_MOE: UnimplementedArch = UnimplementedArch {
195 family: "Qwen3 MoE",
196 milestone: "M5",
197 note: "Qwen3 + MoE routing block — PLAN.md M5 (often loadable via qwen3 runner once MoE lands)",
198 };
199 pub static QWEN3_NEXT: UnimplementedArch = UnimplementedArch {
200 family: "Qwen3-Next",
201 milestone: "M5",
202 note: "Qwen3-Next variant — confirm arch deltas vs qwen3 — PLAN.md M5",
203 };
204 pub static GEMMA4: UnimplementedArch = UnimplementedArch {
205 family: "Gemma 4 MoE (A4B)",
206 milestone: "M2",
207 note: "Gemma 4 MoE A4B routing block — dense + E2B/E4B run via the `gemma` runner — PLAN.md M2",
208 };
209 pub static QWEN3_VL: UnimplementedArch = UnimplementedArch {
210 family: "Qwen3-VL",
211 milestone: "M7",
212 note: "vision tower + projector + LM (dense or MoE) — PLAN.md M7",
213 };
214 pub static QWEN3_MTP: UnimplementedArch = UnimplementedArch {
215 family: "Qwen3 / Qwen3.6 + MTP",
216 milestone: "M6",
217 note: "multi-token-prediction draft heads — PLAN.md M6",
218 };
219 pub static LLADA: UnimplementedArch = UnimplementedArch {
220 family: "LLaDA / LLaDA MoE (text-only)",
221 milestone: "M5",
222 note: "dense LLaDA arch in llama.cpp; rlx-llada2 currently targets the diffusion runner — PLAN.md M5",
223 };
224 pub static GRANITE: UnimplementedArch = UnimplementedArch {
225 family: "Granite (IBM)",
226 milestone: "M4",
227 note: "Llama-shaped — PLAN.md M4",
228 };
229 pub static DEEPSEEK: UnimplementedArch = UnimplementedArch {
230 family: "DeepSeek 2",
231 milestone: "M5",
232 note: "MoE + MLA attention — needs MoE block + MLA primitive — PLAN.md M5",
233 };
234 pub static COHERE: UnimplementedArch = UnimplementedArch {
235 family: "Command-R / Cohere",
236 milestone: "M4",
237 note: "Llama-shaped — PLAN.md M4",
238 };
239}
240
241static KNOWN_UNIMPLEMENTED: phf::Map<&'static str, &'static UnimplementedArch> = phf::phf_map! {
257 "mistral3" => &families::MISTRAL,
259 "mistral4" => &families::MISTRAL,
260 "phimoe" => &families::PHIMOE,
262 "bonsai" => &families::BONSAI,
264 "omnicoder" => &families::OMNICODER,
265 "minimax-m2" => &families::MINIMAX,
267 "minimax_m2" => &families::MINIMAX,
268 "minimax" => &families::MINIMAX,
269 "glm4" => &families::GLM,
270 "glm5" => &families::GLM,
271 "chatglm" => &families::GLM,
272 "glm4moe" => &families::GLM_MOE,
273 "gpt-oss" => &families::GPT_OSS,
274 "gpt_oss" => &families::GPT_OSS,
275 "nemotron" => &families::NEMOTRON,
276 "nemotron_h" => &families::NEMOTRON_H,
277 "nemotron_h_moe" => &families::NEMOTRON_H,
278 "lfm2moe" => &families::LFM_MOE,
282 "qwen3moe" => &families::QWEN3_MOE,
284 "qwen3next" => &families::QWEN3_NEXT,
285 "gemma4moe" => &families::GEMMA4,
288 "qwen3vl" => &families::QWEN3_VL,
289 "qwen3vlmoe" => &families::QWEN3_VL,
290 "qwen3_vl" => &families::QWEN3_VL,
291 "qwen3-vl" => &families::QWEN3_VL,
292 "qwen3_mtp" => &families::QWEN3_MTP,
293 "qwen3-mtp" => &families::QWEN3_MTP,
294 "qwen36_mtp" => &families::QWEN3_MTP,
295 "llada" => &families::LLADA,
297 "llada-moe" => &families::LLADA,
298 "granite" => &families::GRANITE,
299 "granitemoe" => &families::GRANITE,
300 "granitehybrid" => &families::GRANITE,
301 "deepseek2" => &families::DEEPSEEK,
302 "deepseek2-ocr" => &families::DEEPSEEK,
303 "command-r" => &families::COHERE,
304 "cohere2" => &families::COHERE,
305};
306
307pub fn known_unimplemented_arch(arch_or_model_type: &str) -> Option<UnimplementedArch> {
309 KNOWN_UNIMPLEMENTED.get(arch_or_model_type).map(|p| **p)
310}
311
312pub fn known_unimplemented_keys() -> impl Iterator<Item = (&'static str, &'static UnimplementedArch)>
315{
316 KNOWN_UNIMPLEMENTED.entries().map(|(k, v)| (*k, *v))
317}
318
319pub fn arch_runner_name(arch: &str) -> Option<&'static str> {
326 match arch {
327 "phi3" | "phi4" => return Some("phi"),
328 _ => {}
329 }
330 if let Some(fam) = gguf_family_for_arch(arch) {
331 return Some(fam.runner_name());
332 }
333 if FLUX_GGUF_ARCHES.contains(&arch) {
334 return Some("flux2");
335 }
336 if DINOV2_GGUF_ARCHES.contains(&arch) {
337 return Some("dinov2");
338 }
339 if VJEPA2_GGUF_ARCHES.contains(&arch) {
340 return Some("vjepa2");
341 }
342 if SAM3_GGUF_ARCHES.contains(&arch) {
343 return Some("sam3");
344 }
345 if SAM2_GGUF_ARCHES.contains(&arch) {
346 return Some("sam2");
347 }
348 if SAM_GGUF_ARCHES.contains(&arch) {
349 return Some("sam1");
350 }
351 if W2V_BERT_GGUF_ARCHES.contains(&arch) {
352 return Some("wav2vec2-bert");
353 }
354 None
355}
356
357pub fn model_type_runner_name(model_type: &str) -> Option<&'static str> {
362 match model_type {
363 "qwen3" | "qwen3_moe" | "qwen3moe" | "qwen25" | "qwen2_5" | "qwen2.5" | "qwen251"
367 | "qwen2_5_1" => Some("qwen3"),
368 "qwen35" | "qwen3_5" | "qwen35_moe" | "qwen35moe" => Some("qwen35"),
369 "qwen36" | "qwen3_6" | "qwen36_moe" | "qwen36moe" => Some("qwen35"),
371 "llama" | "llama2" | "llama3" => Some("llama32"),
372 "gemma"
377 | "gemma2"
378 | "gemma3"
379 | "gemma3n"
380 | "gemma4"
381 | "gemma4_text"
382 | "gemma4_unified"
383 | "gemma4_unified_text" => Some("gemma"),
384 "dinov2" | "dinov2_with_registers" => Some("dinov2"),
385 "vjepa2" | "vjepa" => Some("vjepa2"),
386 "sam" | "sam_vit" | "mobile-sam" | "mobile_sam" => Some("sam1"),
387 "sam2" => Some("sam2"),
388 "sam3" => Some("sam3"),
389 "whisper" => Some("whisper"),
390 "wav2vec2-bert" | "wav2vec2_bert" | "w2v-bert" | "w2v_bert" => Some("wav2vec2-bert"),
391 "flux" | "flux2" => Some("flux2"),
392 _ => None,
393 }
394}
395
396fn read_model_type_from_sidecar(path: &Path) -> Result<Option<String>> {
398 let dir = path
399 .parent()
400 .ok_or_else(|| anyhow!("safetensors path {path:?} has no parent dir"))?;
401 let cfg = dir.join("config.json");
402 if !cfg.is_file() {
403 return Ok(None);
404 }
405 let bytes = std::fs::read(&cfg).with_context(|| format!("reading {cfg:?}"))?;
406 let v: serde_json::Value =
407 serde_json::from_slice(&bytes).with_context(|| format!("parsing {cfg:?}"))?;
408 Ok(v.get("model_type")
409 .and_then(serde_json::Value::as_str)
410 .map(str::to_owned))
411}
412
413pub fn auto_sniff(path: &Path) -> Result<SniffedRunner> {
415 let file = resolve_weights_file(path)?;
416 let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("");
417 match ext {
418 "gguf" => {
419 let arch = gguf_architecture_from_path(&file)?;
420 let runner = arch_runner_name(&arch).ok_or_else(|| {
421 if let Some(u) = known_unimplemented_arch(&arch) {
422 anyhow!(
423 "{file:?}: GGUF architecture `{arch}` is {} ({}) — not yet implemented in rlx-models. {}",
424 u.family, u.milestone, u.note
425 )
426 } else {
427 anyhow!(
428 "{file:?}: GGUF architecture `{arch}` has no registered rlx runner; \
429 see `rlx-run` for supported families"
430 )
431 }
432 })?;
433 Ok(SniffedRunner {
434 path: file,
435 runner_name: runner,
436 from: SniffedFrom::GgufArch(arch),
437 })
438 }
439 "safetensors" => {
440 let model_type = read_model_type_from_sidecar(&file)?.ok_or_else(|| {
441 anyhow!("{file:?}: no `model_type` in sidecar config.json (auto-dispatch needs it)")
442 })?;
443 let runner = model_type_runner_name(&model_type).ok_or_else(|| {
444 if let Some(u) = known_unimplemented_arch(&model_type) {
445 anyhow!(
446 "{file:?}: safetensors model_type `{model_type}` is {} ({}) — not yet implemented in rlx-models. {}",
447 u.family, u.milestone, u.note
448 )
449 } else {
450 anyhow!(
451 "{file:?}: safetensors model_type `{model_type}` has no registered rlx runner"
452 )
453 }
454 })?;
455 Ok(SniffedRunner {
456 path: file,
457 runner_name: runner,
458 from: SniffedFrom::SafetensorsConfig(model_type),
459 })
460 }
461 other => {
462 bail!("{file:?}: unsupported extension `.{other}` (expected .gguf or .safetensors)")
463 }
464 }
465}
466
467pub fn auto_runner_name(path: &Path) -> Result<&'static str> {
469 Ok(auto_sniff(path)?.runner_name)
470}
471
472pub fn auto_dispatch(path: &Path, args: &[String]) -> Result<&'static str> {
477 let sniff = auto_sniff(path)?;
478 match run_registered(sniff.runner_name, args)? {
479 Some(()) => Ok(sniff.runner_name),
480 None => bail!(
481 "runner `{}` not registered (sniffed from {:?}); register it via \
482 `register_cli` before calling auto_dispatch",
483 sniff.runner_name,
484 sniff.from
485 ),
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn arch_runner_maps_lm_families() {
495 assert_eq!(arch_runner_name("qwen3"), Some("qwen3"));
496 assert_eq!(arch_runner_name("qwen2"), Some("qwen3"));
500 assert_eq!(arch_runner_name("qwen35"), Some("qwen35"));
501 assert_eq!(arch_runner_name("qwen35moe"), Some("qwen35"));
502 assert_eq!(arch_runner_name("qwen36"), Some("qwen35"));
506 assert_eq!(arch_runner_name("qwen36moe"), Some("qwen35"));
507 assert_eq!(arch_runner_name("qwen25"), Some("qwen3"));
510 assert_eq!(arch_runner_name("qwen2_5"), Some("qwen3"));
511 assert_eq!(arch_runner_name("llama"), Some("llama32"));
512 assert_eq!(arch_runner_name("phi3"), Some("phi"));
513 assert_eq!(arch_runner_name("gemma"), Some("gemma"));
514 assert_eq!(arch_runner_name("gemma2"), Some("gemma"));
515 assert_eq!(arch_runner_name("gemma3"), Some("gemma"));
516 }
517
518 #[test]
519 fn arch_runner_maps_vision_and_diffusion() {
520 assert_eq!(arch_runner_name("dinov2"), Some("dinov2"));
521 assert_eq!(arch_runner_name("sam"), Some("sam1"));
522 assert_eq!(arch_runner_name("mobile-sam"), Some("sam1"));
523 assert_eq!(arch_runner_name("sam2"), Some("sam2"));
524 assert_eq!(arch_runner_name("sam3"), Some("sam3"));
525 assert_eq!(arch_runner_name("flux"), Some("flux2"));
526 assert_eq!(arch_runner_name("vjepa2"), Some("vjepa2"));
527 assert_eq!(arch_runner_name("w2v-bert"), Some("wav2vec2-bert"));
528 }
529
530 #[test]
531 fn arch_runner_returns_none_for_embed_and_unknown() {
532 assert_eq!(arch_runner_name("bert"), None);
534 assert_eq!(arch_runner_name("nomic-bert"), None);
535 assert_eq!(arch_runner_name("totally-fake-arch"), None);
536 }
537
538 #[test]
539 fn known_unimplemented_covers_plan_families() {
540 assert_eq!(
542 known_unimplemented_arch("mistral3").map(|u| u.milestone),
543 Some("M4")
544 );
545 assert_eq!(known_unimplemented_arch("phi3"), None);
546 assert_eq!(known_unimplemented_arch("phi4"), None);
547 assert_eq!(known_unimplemented_arch("gemma3"), None);
548 assert_eq!(known_unimplemented_arch("gemma3n"), None);
549 assert_eq!(
550 known_unimplemented_arch("bonsai").map(|u| u.milestone),
551 Some("M4")
552 );
553 assert_eq!(
555 known_unimplemented_arch("minimax-m2").map(|u| u.milestone),
556 Some("M5")
557 );
558 assert_eq!(
559 known_unimplemented_arch("glm4").map(|u| u.milestone),
560 Some("M5")
561 );
562 assert_eq!(
563 known_unimplemented_arch("nemotron_h").map(|u| u.milestone),
564 Some("M5")
565 );
566 assert_eq!(
568 known_unimplemented_arch("qwen3_mtp").map(|u| u.milestone),
569 Some("M6")
570 );
571 assert_eq!(
573 known_unimplemented_arch("qwen3vl").map(|u| u.milestone),
574 Some("M7")
575 );
576 assert_eq!(known_unimplemented_arch("qwen3"), None);
579 assert_eq!(known_unimplemented_arch("mistral"), None);
580 assert_eq!(known_unimplemented_arch("totally-fake"), None);
581 }
582
583 #[test]
584 fn auto_sniff_error_points_at_milestone_for_known_unimplemented() {
585 let mut buf: Vec<u8> = Vec::new();
587 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
588 buf.extend_from_slice(&3u32.to_le_bytes());
589 buf.extend_from_slice(&1u64.to_le_bytes());
590 buf.extend_from_slice(&1u64.to_le_bytes());
591 let k = "general.architecture";
592 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
593 buf.extend_from_slice(k.as_bytes());
594 buf.extend_from_slice(&8u32.to_le_bytes());
595 let v = "mistral3";
596 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
597 buf.extend_from_slice(v.as_bytes());
598 let name = "w";
599 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
600 buf.extend_from_slice(name.as_bytes());
601 buf.extend_from_slice(&1u32.to_le_bytes());
602 buf.extend_from_slice(&4u64.to_le_bytes());
603 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
604 buf.extend_from_slice(&0u64.to_le_bytes());
605 while !buf
606 .len()
607 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
608 {
609 buf.push(0);
610 }
611 for _ in 0..4 {
612 buf.extend_from_slice(&1.0f32.to_le_bytes());
613 }
614 let path = std::env::temp_dir().join("rlx_auto_dispatch_mistral3_hint.gguf");
615 std::fs::write(&path, &buf).unwrap();
616 let err = auto_sniff(&path).expect_err("should error");
617 let s = format!("{err:#}");
618 assert!(s.contains("Mistral"), "expected family name in error: {s}");
619 assert!(s.contains("M4"), "expected milestone tag in error: {s}");
620 std::fs::remove_file(&path).ok();
621 }
622
623 #[test]
624 fn model_type_runner_maps_known() {
625 assert_eq!(model_type_runner_name("qwen3"), Some("qwen3"));
626 assert_eq!(model_type_runner_name("qwen3_moe"), Some("qwen3"));
627 assert_eq!(model_type_runner_name("llama"), Some("llama32"));
628 assert_eq!(model_type_runner_name("gemma3"), Some("gemma"));
629 assert_eq!(model_type_runner_name("gemma4"), Some("gemma"));
631 assert_eq!(model_type_runner_name("gemma4_text"), Some("gemma"));
632 assert_eq!(model_type_runner_name("gemma4_unified"), Some("gemma"));
633 assert!(known_unimplemented_arch("gemma4").is_none());
635 assert!(known_unimplemented_arch("gemma4moe").is_some());
636 assert_eq!(
637 model_type_runner_name("dinov2_with_registers"),
638 Some("dinov2")
639 );
640 assert_eq!(model_type_runner_name("whisper"), Some("whisper"));
641 assert_eq!(model_type_runner_name("unknown"), None);
642 }
643
644 #[test]
647 fn auto_sniff_reads_gguf_arch() {
648 let mut buf: Vec<u8> = Vec::new();
649 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
650 buf.extend_from_slice(&3u32.to_le_bytes());
651 buf.extend_from_slice(&1u64.to_le_bytes()); buf.extend_from_slice(&1u64.to_le_bytes()); let write_string = |buf: &mut Vec<u8>, k: &str, v: &str| {
654 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
655 buf.extend_from_slice(k.as_bytes());
656 buf.extend_from_slice(&8u32.to_le_bytes());
657 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
658 buf.extend_from_slice(v.as_bytes());
659 };
660 write_string(&mut buf, "general.architecture", "qwen3");
661 let name = "w";
663 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
664 buf.extend_from_slice(name.as_bytes());
665 buf.extend_from_slice(&1u32.to_le_bytes());
666 buf.extend_from_slice(&4u64.to_le_bytes());
667 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
668 buf.extend_from_slice(&0u64.to_le_bytes());
669 while !buf
670 .len()
671 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
672 {
673 buf.push(0);
674 }
675 for _ in 0..4 {
676 buf.extend_from_slice(&1.0f32.to_le_bytes());
677 }
678 let path = std::env::temp_dir().join("rlx_auto_dispatch_sniff.gguf");
679 std::fs::write(&path, &buf).unwrap();
680 let sniff = auto_sniff(&path).expect("sniff");
681 assert_eq!(sniff.runner_name, "qwen3");
682 match sniff.from {
683 SniffedFrom::GgufArch(a) => assert_eq!(a, "qwen3"),
684 other => panic!("wrong sniff source: {other:?}"),
685 }
686 std::fs::remove_file(&path).ok();
687 }
688
689 #[test]
692 fn run_auto_injects_weights_flag_when_missing() {
693 use crate::registry::{ModelRunner, register_runner};
694 use std::sync::{Mutex, OnceLock};
695
696 static CAPTURED: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
697 fn captured() -> &'static Mutex<Vec<String>> {
698 CAPTURED.get_or_init(|| Mutex::new(Vec::new()))
699 }
700
701 struct Capture;
702 impl ModelRunner for Capture {
703 fn name(&self) -> &'static str {
704 "qwen3"
705 }
706 fn description(&self) -> &'static str {
707 "test capture"
708 }
709 fn run(&self, args: &[String]) -> Result<()> {
710 *captured().lock().unwrap() = args.to_vec();
711 Ok(())
712 }
713 }
714 register_runner(Box::new(Capture));
715
716 let dir = std::env::temp_dir().join("rlx_auto_dispatch_run_auto");
718 std::fs::create_dir_all(&dir).unwrap();
719 let path = dir.join("model.gguf");
720 let mut buf: Vec<u8> = Vec::new();
721 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
722 buf.extend_from_slice(&3u32.to_le_bytes());
723 buf.extend_from_slice(&1u64.to_le_bytes());
724 buf.extend_from_slice(&1u64.to_le_bytes());
725 let k = "general.architecture";
726 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
727 buf.extend_from_slice(k.as_bytes());
728 buf.extend_from_slice(&8u32.to_le_bytes());
729 let v = "qwen3";
730 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
731 buf.extend_from_slice(v.as_bytes());
732 let name = "w";
733 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
734 buf.extend_from_slice(name.as_bytes());
735 buf.extend_from_slice(&1u32.to_le_bytes());
736 buf.extend_from_slice(&4u64.to_le_bytes());
737 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
738 buf.extend_from_slice(&0u64.to_le_bytes());
739 while !buf
740 .len()
741 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
742 {
743 buf.push(0);
744 }
745 for _ in 0..4 {
746 buf.extend_from_slice(&1.0f32.to_le_bytes());
747 }
748 std::fs::write(&path, &buf).unwrap();
749
750 run_auto(&[path.display().to_string(), "--prompt".into(), "hi".into()]).unwrap();
752 let got = captured().lock().unwrap().clone();
753 assert_eq!(
754 got,
755 vec![
756 "--weights".to_string(),
757 path.display().to_string(),
758 "--prompt".into(),
759 "hi".into()
760 ]
761 );
762
763 run_auto(&[
765 path.display().to_string(),
766 "--weights".into(),
767 "/other/path".into(),
768 "--prompt".into(),
769 "hi".into(),
770 ])
771 .unwrap();
772 let got = captured().lock().unwrap().clone();
773 assert_eq!(
774 got,
775 vec![
776 "--weights".to_string(),
777 "/other/path".into(),
778 "--prompt".into(),
779 "hi".into(),
780 ]
781 );
782
783 std::fs::remove_dir_all(&dir).ok();
784 }
785
786 #[test]
787 fn auto_sniff_reads_safetensors_sidecar() {
788 let dir = std::env::temp_dir().join("rlx_auto_dispatch_sidecar");
789 std::fs::create_dir_all(&dir).unwrap();
790 let cfg = dir.join("config.json");
791 std::fs::write(&cfg, br#"{"model_type":"llama"}"#).unwrap();
792 let st = dir.join("model.safetensors");
793 std::fs::write(&st, b"").unwrap();
795 let sniff = auto_sniff(&st).expect("sniff");
796 assert_eq!(sniff.runner_name, "llama32");
797 std::fs::remove_dir_all(&dir).ok();
798 }
799}