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 PHI: UnimplementedArch = UnimplementedArch {
139 family: "Phi 3 / Phi 4",
140 milestone: "M4",
141 note: "Phi3/4 share llama.cpp arch tag — PLAN.md M4",
142 };
143 pub static PHIMOE: UnimplementedArch = UnimplementedArch {
144 family: "Phi MoE",
145 milestone: "M4 + M5",
146 note: "Phi + MoE routing; depends on shared MoE block — PLAN.md M4/M5",
147 };
148 pub static BONSAI: UnimplementedArch = UnimplementedArch {
149 family: "Bonsai",
150 milestone: "M4",
151 note: "Llama-shaped; HF model_type only — usually ships as llama GGUF — PLAN.md M4",
152 };
153 pub static OMNICODER: UnimplementedArch = UnimplementedArch {
154 family: "OmniCoder",
155 milestone: "M4",
156 note: "Qwen3-coder shaped — PLAN.md M4 (often tagged `qwen3` in GGUF)",
157 };
158 pub static MINIMAX: UnimplementedArch = UnimplementedArch {
159 family: "MiniMax M2",
160 milestone: "M5",
161 note: "Lightning Attention; depends on `rlx-ssm` upstream — PLAN.md M5",
162 };
163 pub static GLM: UnimplementedArch = UnimplementedArch {
164 family: "GLM 4 / 5",
165 milestone: "M5",
166 note: "GLM RoPE + RMSNorm placement — PLAN.md M5",
167 };
168 pub static GLM_MOE: UnimplementedArch = UnimplementedArch {
169 family: "GLM 4 MoE",
170 milestone: "M5",
171 note: "GLM + MoE routing — PLAN.md M5",
172 };
173 pub static GPT_OSS: UnimplementedArch = UnimplementedArch {
174 family: "gpt-oss",
175 milestone: "M5",
176 note: "OpenAI gpt-oss — confirm arch shape — PLAN.md M5",
177 };
178 pub static NEMOTRON: UnimplementedArch = UnimplementedArch {
179 family: "Nemotron",
180 milestone: "M5",
181 note: "Dense Nemotron arch — PLAN.md M5",
182 };
183 pub static NEMOTRON_H: UnimplementedArch = UnimplementedArch {
184 family: "Nemotron-H",
185 milestone: "M5",
186 note: "Mamba+attention hybrid; depends on `rlx-ssm` upstream — PLAN.md M5/M7",
187 };
188 #[allow(dead_code)]
189 pub static LFM: UnimplementedArch = UnimplementedArch {
190 family: "LFM 2 / 2.5",
191 milestone: "M5",
192 note: "Liquid Foundation Models with custom SSM layers — PLAN.md M5",
193 };
194 pub static LFM_MOE: UnimplementedArch = UnimplementedArch {
195 family: "LFM 2 MoE",
196 milestone: "M5",
197 note: "LFM + MoE — PLAN.md M5",
198 };
199 pub static QWEN3_MOE: UnimplementedArch = UnimplementedArch {
200 family: "Qwen3 MoE",
201 milestone: "M5",
202 note: "Qwen3 + MoE routing block — PLAN.md M5 (often loadable via qwen3 runner once MoE lands)",
203 };
204 pub static QWEN3_NEXT: UnimplementedArch = UnimplementedArch {
205 family: "Qwen3-Next",
206 milestone: "M5",
207 note: "Qwen3-Next variant — confirm arch deltas vs qwen3 — PLAN.md M5",
208 };
209 pub static GEMMA3: UnimplementedArch = UnimplementedArch {
210 family: "Gemma 3",
211 milestone: "M2",
212 note: "Gemma 3 (270m / 4b / 12b / 27b) adds per-layer sliding window + new RoPE — \
213 needs rlx-gemma config branch — PLAN.md M2",
214 };
215 pub static GEMMA3N: UnimplementedArch = UnimplementedArch {
216 family: "Gemma 3n",
217 milestone: "M2",
218 note: "Gemma 3n (mobile/edge Matformer variant) — PLAN.md M2",
219 };
220 pub static GEMMA4: UnimplementedArch = UnimplementedArch {
221 family: "Gemma 4 MoE (A4B)",
222 milestone: "M2",
223 note: "Gemma 4 MoE A4B routing block — dense + E2B/E4B run via the `gemma` runner — PLAN.md M2",
224 };
225 pub static QWEN3_VL: UnimplementedArch = UnimplementedArch {
226 family: "Qwen3-VL",
227 milestone: "M7",
228 note: "vision tower + projector + LM (dense or MoE) — PLAN.md M7",
229 };
230 pub static QWEN3_MTP: UnimplementedArch = UnimplementedArch {
231 family: "Qwen3 / Qwen3.6 + MTP",
232 milestone: "M6",
233 note: "multi-token-prediction draft heads — PLAN.md M6",
234 };
235 pub static LLADA: UnimplementedArch = UnimplementedArch {
236 family: "LLaDA / LLaDA MoE (text-only)",
237 milestone: "M5",
238 note: "dense LLaDA arch in llama.cpp; rlx-llada2 currently targets the diffusion runner — PLAN.md M5",
239 };
240 pub static GRANITE: UnimplementedArch = UnimplementedArch {
241 family: "Granite (IBM)",
242 milestone: "M4",
243 note: "Llama-shaped — PLAN.md M4",
244 };
245 pub static DEEPSEEK: UnimplementedArch = UnimplementedArch {
246 family: "DeepSeek 2",
247 milestone: "M5",
248 note: "MoE + MLA attention — needs MoE block + MLA primitive — PLAN.md M5",
249 };
250 pub static COHERE: UnimplementedArch = UnimplementedArch {
251 family: "Command-R / Cohere",
252 milestone: "M4",
253 note: "Llama-shaped — PLAN.md M4",
254 };
255}
256
257static KNOWN_UNIMPLEMENTED: phf::Map<&'static str, &'static UnimplementedArch> = phf::phf_map! {
273 "mistral3" => &families::MISTRAL,
275 "mistral4" => &families::MISTRAL,
276 "phi3" => &families::PHI,
280 "phi4" => &families::PHI,
281 "phimoe" => &families::PHIMOE,
282 "bonsai" => &families::BONSAI,
284 "omnicoder" => &families::OMNICODER,
285 "minimax-m2" => &families::MINIMAX,
287 "minimax_m2" => &families::MINIMAX,
288 "minimax" => &families::MINIMAX,
289 "glm4" => &families::GLM,
290 "glm5" => &families::GLM,
291 "chatglm" => &families::GLM,
292 "glm4moe" => &families::GLM_MOE,
293 "gpt-oss" => &families::GPT_OSS,
294 "gpt_oss" => &families::GPT_OSS,
295 "nemotron" => &families::NEMOTRON,
296 "nemotron_h" => &families::NEMOTRON_H,
297 "nemotron_h_moe" => &families::NEMOTRON_H,
298 "lfm2moe" => &families::LFM_MOE,
302 "qwen3moe" => &families::QWEN3_MOE,
304 "qwen3next" => &families::QWEN3_NEXT,
305 "gemma3" => &families::GEMMA3,
309 "gemma3n" => &families::GEMMA3N,
310 "gemma4moe" => &families::GEMMA4,
311 "qwen3vl" => &families::QWEN3_VL,
312 "qwen3vlmoe" => &families::QWEN3_VL,
313 "qwen3_vl" => &families::QWEN3_VL,
314 "qwen3-vl" => &families::QWEN3_VL,
315 "qwen3_mtp" => &families::QWEN3_MTP,
316 "qwen3-mtp" => &families::QWEN3_MTP,
317 "qwen36_mtp" => &families::QWEN3_MTP,
318 "llada" => &families::LLADA,
320 "llada-moe" => &families::LLADA,
321 "granite" => &families::GRANITE,
322 "granitemoe" => &families::GRANITE,
323 "granitehybrid" => &families::GRANITE,
324 "deepseek2" => &families::DEEPSEEK,
325 "deepseek2-ocr" => &families::DEEPSEEK,
326 "command-r" => &families::COHERE,
327 "cohere2" => &families::COHERE,
328};
329
330pub fn known_unimplemented_arch(arch_or_model_type: &str) -> Option<UnimplementedArch> {
332 KNOWN_UNIMPLEMENTED.get(arch_or_model_type).map(|p| **p)
333}
334
335pub fn known_unimplemented_keys() -> impl Iterator<Item = (&'static str, &'static UnimplementedArch)>
338{
339 KNOWN_UNIMPLEMENTED.entries().map(|(k, v)| (*k, *v))
340}
341
342pub fn arch_runner_name(arch: &str) -> Option<&'static str> {
349 if let Some(fam) = gguf_family_for_arch(arch) {
350 return Some(fam.runner_name());
351 }
352 if FLUX_GGUF_ARCHES.contains(&arch) {
353 return Some("flux2");
354 }
355 if DINOV2_GGUF_ARCHES.contains(&arch) {
356 return Some("dinov2");
357 }
358 if VJEPA2_GGUF_ARCHES.contains(&arch) {
359 return Some("vjepa2");
360 }
361 if SAM3_GGUF_ARCHES.contains(&arch) {
362 return Some("sam3");
363 }
364 if SAM2_GGUF_ARCHES.contains(&arch) {
365 return Some("sam2");
366 }
367 if SAM_GGUF_ARCHES.contains(&arch) {
368 return Some("sam1");
369 }
370 if W2V_BERT_GGUF_ARCHES.contains(&arch) {
371 return Some("wav2vec2-bert");
372 }
373 None
374}
375
376pub fn model_type_runner_name(model_type: &str) -> Option<&'static str> {
381 match model_type {
382 "qwen3" | "qwen3_moe" | "qwen3moe" | "qwen25" | "qwen2_5" | "qwen2.5" | "qwen251"
386 | "qwen2_5_1" => Some("qwen3"),
387 "qwen35" | "qwen3_5" | "qwen35_moe" | "qwen35moe" => Some("qwen35"),
388 "qwen36" | "qwen3_6" | "qwen36_moe" | "qwen36moe" => Some("qwen35"),
390 "llama" | "llama2" | "llama3" => Some("llama32"),
391 "gemma"
396 | "gemma2"
397 | "gemma3"
398 | "gemma3n"
399 | "gemma4"
400 | "gemma4_text"
401 | "gemma4_unified"
402 | "gemma4_unified_text" => Some("gemma"),
403 "dinov2" | "dinov2_with_registers" => Some("dinov2"),
404 "vjepa2" | "vjepa" => Some("vjepa2"),
405 "sam" | "sam_vit" | "mobile-sam" | "mobile_sam" => Some("sam1"),
406 "sam2" => Some("sam2"),
407 "sam3" => Some("sam3"),
408 "whisper" => Some("whisper"),
409 "wav2vec2-bert" | "wav2vec2_bert" | "w2v-bert" | "w2v_bert" => Some("wav2vec2-bert"),
410 "flux" | "flux2" => Some("flux2"),
411 _ => None,
412 }
413}
414
415fn read_model_type_from_sidecar(path: &Path) -> Result<Option<String>> {
417 let dir = path
418 .parent()
419 .ok_or_else(|| anyhow!("safetensors path {path:?} has no parent dir"))?;
420 let cfg = dir.join("config.json");
421 if !cfg.is_file() {
422 return Ok(None);
423 }
424 let bytes = std::fs::read(&cfg).with_context(|| format!("reading {cfg:?}"))?;
425 let v: serde_json::Value =
426 serde_json::from_slice(&bytes).with_context(|| format!("parsing {cfg:?}"))?;
427 Ok(v.get("model_type")
428 .and_then(serde_json::Value::as_str)
429 .map(str::to_owned))
430}
431
432pub fn auto_sniff(path: &Path) -> Result<SniffedRunner> {
434 let file = resolve_weights_file(path)?;
435 let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("");
436 match ext {
437 "gguf" => {
438 let arch = gguf_architecture_from_path(&file)?;
439 let runner = arch_runner_name(&arch).ok_or_else(|| {
440 if let Some(u) = known_unimplemented_arch(&arch) {
441 anyhow!(
442 "{file:?}: GGUF architecture `{arch}` is {} ({}) — not yet implemented in rlx-models. {}",
443 u.family, u.milestone, u.note
444 )
445 } else {
446 anyhow!(
447 "{file:?}: GGUF architecture `{arch}` has no registered rlx runner; \
448 see `rlx-run` for supported families"
449 )
450 }
451 })?;
452 Ok(SniffedRunner {
453 path: file,
454 runner_name: runner,
455 from: SniffedFrom::GgufArch(arch),
456 })
457 }
458 "safetensors" => {
459 let model_type = read_model_type_from_sidecar(&file)?.ok_or_else(|| {
460 anyhow!("{file:?}: no `model_type` in sidecar config.json (auto-dispatch needs it)")
461 })?;
462 let runner = model_type_runner_name(&model_type).ok_or_else(|| {
463 if let Some(u) = known_unimplemented_arch(&model_type) {
464 anyhow!(
465 "{file:?}: safetensors model_type `{model_type}` is {} ({}) — not yet implemented in rlx-models. {}",
466 u.family, u.milestone, u.note
467 )
468 } else {
469 anyhow!(
470 "{file:?}: safetensors model_type `{model_type}` has no registered rlx runner"
471 )
472 }
473 })?;
474 Ok(SniffedRunner {
475 path: file,
476 runner_name: runner,
477 from: SniffedFrom::SafetensorsConfig(model_type),
478 })
479 }
480 other => {
481 bail!("{file:?}: unsupported extension `.{other}` (expected .gguf or .safetensors)")
482 }
483 }
484}
485
486pub fn auto_runner_name(path: &Path) -> Result<&'static str> {
488 Ok(auto_sniff(path)?.runner_name)
489}
490
491pub fn auto_dispatch(path: &Path, args: &[String]) -> Result<&'static str> {
496 let sniff = auto_sniff(path)?;
497 match run_registered(sniff.runner_name, args)? {
498 Some(()) => Ok(sniff.runner_name),
499 None => bail!(
500 "runner `{}` not registered (sniffed from {:?}); register it via \
501 `register_cli` before calling auto_dispatch",
502 sniff.runner_name,
503 sniff.from
504 ),
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn arch_runner_maps_lm_families() {
514 assert_eq!(arch_runner_name("qwen3"), Some("qwen3"));
515 assert_eq!(arch_runner_name("qwen2"), Some("qwen3"));
519 assert_eq!(arch_runner_name("qwen35"), Some("qwen35"));
520 assert_eq!(arch_runner_name("qwen35moe"), Some("qwen35"));
521 assert_eq!(arch_runner_name("qwen36"), Some("qwen35"));
525 assert_eq!(arch_runner_name("qwen36moe"), Some("qwen35"));
526 assert_eq!(arch_runner_name("qwen25"), Some("qwen3"));
529 assert_eq!(arch_runner_name("qwen2_5"), Some("qwen3"));
530 assert_eq!(arch_runner_name("llama"), Some("llama32"));
531 assert_eq!(arch_runner_name("gemma"), Some("gemma"));
532 assert_eq!(arch_runner_name("gemma2"), Some("gemma"));
533 }
534
535 #[test]
536 fn arch_runner_maps_vision_and_diffusion() {
537 assert_eq!(arch_runner_name("dinov2"), Some("dinov2"));
538 assert_eq!(arch_runner_name("sam"), Some("sam1"));
539 assert_eq!(arch_runner_name("mobile-sam"), Some("sam1"));
540 assert_eq!(arch_runner_name("sam2"), Some("sam2"));
541 assert_eq!(arch_runner_name("sam3"), Some("sam3"));
542 assert_eq!(arch_runner_name("flux"), Some("flux2"));
543 assert_eq!(arch_runner_name("vjepa2"), Some("vjepa2"));
544 assert_eq!(arch_runner_name("w2v-bert"), Some("wav2vec2-bert"));
545 }
546
547 #[test]
548 fn arch_runner_returns_none_for_embed_and_unknown() {
549 assert_eq!(arch_runner_name("bert"), None);
551 assert_eq!(arch_runner_name("nomic-bert"), None);
552 assert_eq!(arch_runner_name("totally-fake-arch"), None);
553 }
554
555 #[test]
556 fn known_unimplemented_covers_plan_families() {
557 assert_eq!(
559 known_unimplemented_arch("mistral3").map(|u| u.milestone),
560 Some("M4")
561 );
562 assert_eq!(
563 known_unimplemented_arch("phi3").map(|u| u.milestone),
564 Some("M4")
565 );
566 assert_eq!(
567 known_unimplemented_arch("phi4").map(|u| u.milestone),
568 Some("M4")
569 );
570 assert_eq!(
571 known_unimplemented_arch("bonsai").map(|u| u.milestone),
572 Some("M4")
573 );
574 assert_eq!(
576 known_unimplemented_arch("minimax-m2").map(|u| u.milestone),
577 Some("M5")
578 );
579 assert_eq!(
580 known_unimplemented_arch("glm4").map(|u| u.milestone),
581 Some("M5")
582 );
583 assert_eq!(
584 known_unimplemented_arch("nemotron_h").map(|u| u.milestone),
585 Some("M5")
586 );
587 assert_eq!(
589 known_unimplemented_arch("qwen3_mtp").map(|u| u.milestone),
590 Some("M6")
591 );
592 assert_eq!(
594 known_unimplemented_arch("qwen3vl").map(|u| u.milestone),
595 Some("M7")
596 );
597 assert_eq!(known_unimplemented_arch("qwen3"), None);
600 assert_eq!(known_unimplemented_arch("mistral"), None);
601 assert_eq!(known_unimplemented_arch("totally-fake"), None);
602 }
603
604 #[test]
605 fn auto_sniff_error_points_at_milestone_for_known_unimplemented() {
606 let mut buf: Vec<u8> = Vec::new();
608 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
609 buf.extend_from_slice(&3u32.to_le_bytes());
610 buf.extend_from_slice(&1u64.to_le_bytes());
611 buf.extend_from_slice(&1u64.to_le_bytes());
612 let k = "general.architecture";
613 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
614 buf.extend_from_slice(k.as_bytes());
615 buf.extend_from_slice(&8u32.to_le_bytes());
616 let v = "mistral3";
617 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
618 buf.extend_from_slice(v.as_bytes());
619 let name = "w";
620 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
621 buf.extend_from_slice(name.as_bytes());
622 buf.extend_from_slice(&1u32.to_le_bytes());
623 buf.extend_from_slice(&4u64.to_le_bytes());
624 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
625 buf.extend_from_slice(&0u64.to_le_bytes());
626 while !buf
627 .len()
628 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
629 {
630 buf.push(0);
631 }
632 for _ in 0..4 {
633 buf.extend_from_slice(&1.0f32.to_le_bytes());
634 }
635 let path = std::env::temp_dir().join("rlx_auto_dispatch_mistral3_hint.gguf");
636 std::fs::write(&path, &buf).unwrap();
637 let err = auto_sniff(&path).expect_err("should error");
638 let s = format!("{err:#}");
639 assert!(s.contains("Mistral"), "expected family name in error: {s}");
640 assert!(s.contains("M4"), "expected milestone tag in error: {s}");
641 std::fs::remove_file(&path).ok();
642 }
643
644 #[test]
645 fn model_type_runner_maps_known() {
646 assert_eq!(model_type_runner_name("qwen3"), Some("qwen3"));
647 assert_eq!(model_type_runner_name("qwen3_moe"), Some("qwen3"));
648 assert_eq!(model_type_runner_name("llama"), Some("llama32"));
649 assert_eq!(model_type_runner_name("gemma3"), Some("gemma"));
650 assert_eq!(model_type_runner_name("gemma4"), Some("gemma"));
652 assert_eq!(model_type_runner_name("gemma4_text"), Some("gemma"));
653 assert_eq!(model_type_runner_name("gemma4_unified"), Some("gemma"));
654 assert!(known_unimplemented_arch("gemma4").is_none());
656 assert!(known_unimplemented_arch("gemma4moe").is_some());
657 assert_eq!(
658 model_type_runner_name("dinov2_with_registers"),
659 Some("dinov2")
660 );
661 assert_eq!(model_type_runner_name("whisper"), Some("whisper"));
662 assert_eq!(model_type_runner_name("unknown"), None);
663 }
664
665 #[test]
668 fn auto_sniff_reads_gguf_arch() {
669 let mut buf: Vec<u8> = Vec::new();
670 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
671 buf.extend_from_slice(&3u32.to_le_bytes());
672 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| {
675 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
676 buf.extend_from_slice(k.as_bytes());
677 buf.extend_from_slice(&8u32.to_le_bytes());
678 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
679 buf.extend_from_slice(v.as_bytes());
680 };
681 write_string(&mut buf, "general.architecture", "qwen3");
682 let name = "w";
684 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
685 buf.extend_from_slice(name.as_bytes());
686 buf.extend_from_slice(&1u32.to_le_bytes());
687 buf.extend_from_slice(&4u64.to_le_bytes());
688 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
689 buf.extend_from_slice(&0u64.to_le_bytes());
690 while !buf
691 .len()
692 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
693 {
694 buf.push(0);
695 }
696 for _ in 0..4 {
697 buf.extend_from_slice(&1.0f32.to_le_bytes());
698 }
699 let path = std::env::temp_dir().join("rlx_auto_dispatch_sniff.gguf");
700 std::fs::write(&path, &buf).unwrap();
701 let sniff = auto_sniff(&path).expect("sniff");
702 assert_eq!(sniff.runner_name, "qwen3");
703 match sniff.from {
704 SniffedFrom::GgufArch(a) => assert_eq!(a, "qwen3"),
705 other => panic!("wrong sniff source: {other:?}"),
706 }
707 std::fs::remove_file(&path).ok();
708 }
709
710 #[test]
713 fn run_auto_injects_weights_flag_when_missing() {
714 use crate::registry::{ModelRunner, register_runner};
715 use std::sync::{Mutex, OnceLock};
716
717 static CAPTURED: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
718 fn captured() -> &'static Mutex<Vec<String>> {
719 CAPTURED.get_or_init(|| Mutex::new(Vec::new()))
720 }
721
722 struct Capture;
723 impl ModelRunner for Capture {
724 fn name(&self) -> &'static str {
725 "qwen3"
726 }
727 fn description(&self) -> &'static str {
728 "test capture"
729 }
730 fn run(&self, args: &[String]) -> Result<()> {
731 *captured().lock().unwrap() = args.to_vec();
732 Ok(())
733 }
734 }
735 register_runner(Box::new(Capture));
736
737 let dir = std::env::temp_dir().join("rlx_auto_dispatch_run_auto");
739 std::fs::create_dir_all(&dir).unwrap();
740 let path = dir.join("model.gguf");
741 let mut buf: Vec<u8> = Vec::new();
742 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
743 buf.extend_from_slice(&3u32.to_le_bytes());
744 buf.extend_from_slice(&1u64.to_le_bytes());
745 buf.extend_from_slice(&1u64.to_le_bytes());
746 let k = "general.architecture";
747 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
748 buf.extend_from_slice(k.as_bytes());
749 buf.extend_from_slice(&8u32.to_le_bytes());
750 let v = "qwen3";
751 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
752 buf.extend_from_slice(v.as_bytes());
753 let name = "w";
754 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
755 buf.extend_from_slice(name.as_bytes());
756 buf.extend_from_slice(&1u32.to_le_bytes());
757 buf.extend_from_slice(&4u64.to_le_bytes());
758 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
759 buf.extend_from_slice(&0u64.to_le_bytes());
760 while !buf
761 .len()
762 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
763 {
764 buf.push(0);
765 }
766 for _ in 0..4 {
767 buf.extend_from_slice(&1.0f32.to_le_bytes());
768 }
769 std::fs::write(&path, &buf).unwrap();
770
771 run_auto(&[path.display().to_string(), "--prompt".into(), "hi".into()]).unwrap();
773 let got = captured().lock().unwrap().clone();
774 assert_eq!(
775 got,
776 vec![
777 "--weights".to_string(),
778 path.display().to_string(),
779 "--prompt".into(),
780 "hi".into()
781 ]
782 );
783
784 run_auto(&[
786 path.display().to_string(),
787 "--weights".into(),
788 "/other/path".into(),
789 "--prompt".into(),
790 "hi".into(),
791 ])
792 .unwrap();
793 let got = captured().lock().unwrap().clone();
794 assert_eq!(
795 got,
796 vec![
797 "--weights".to_string(),
798 "/other/path".into(),
799 "--prompt".into(),
800 "hi".into(),
801 ]
802 );
803
804 std::fs::remove_dir_all(&dir).ok();
805 }
806
807 #[test]
808 fn auto_sniff_reads_safetensors_sidecar() {
809 let dir = std::env::temp_dir().join("rlx_auto_dispatch_sidecar");
810 std::fs::create_dir_all(&dir).unwrap();
811 let cfg = dir.join("config.json");
812 std::fs::write(&cfg, br#"{"model_type":"llama"}"#).unwrap();
813 let st = dir.join("model.safetensors");
814 std::fs::write(&st, b"").unwrap();
816 let sniff = auto_sniff(&st).expect("sniff");
817 assert_eq!(sniff.runner_name, "llama32");
818 std::fs::remove_dir_all(&dir).ok();
819 }
820}