1use anyhow::{Context, Result, anyhow};
41use rlx_core::gguf_support::resolve_weights_file;
42use rlx_gguf::{GgufFile, MetaValue};
43use std::path::{Path, PathBuf};
44
45use crate::auto_dispatch::{
46 UnimplementedArch, arch_runner_name, known_unimplemented_arch, model_type_runner_name,
47};
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum CompatSource {
52 GgufArch(String),
54 SafetensorsConfig(String),
56}
57
58impl CompatSource {
59 pub fn arch(&self) -> &str {
60 match self {
61 Self::GgufArch(s) | Self::SafetensorsConfig(s) => s.as_str(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Default)]
69pub struct GgufRequiredFields {
70 pub context_length: Option<u64>,
71 pub embedding_length: Option<u64>,
72 pub block_count: Option<u64>,
73 pub tokenizer_model: Option<String>,
74 pub has_tokens: bool,
75}
76
77impl GgufRequiredFields {
78 pub fn missing(&self) -> Vec<&'static str> {
81 let mut out = Vec::new();
82 if self.context_length.is_none() {
83 out.push("<arch>.context_length");
84 }
85 if self.embedding_length.is_none() {
86 out.push("<arch>.embedding_length");
87 }
88 if self.block_count.is_none() {
89 out.push("<arch>.block_count");
90 }
91 if self.tokenizer_model.is_none() {
92 out.push("tokenizer.ggml.model");
93 }
94 if !self.has_tokens {
95 out.push("tokenizer.ggml.tokens");
96 }
97 out
98 }
99
100 pub fn is_complete(&self) -> bool {
101 self.missing().is_empty()
102 }
103}
104
105#[derive(Debug, Clone)]
107pub enum CompatibilityStatus {
108 Supported { runner: &'static str },
112 MissingMetadata { missing: Vec<&'static str> },
115 KnownUnimplemented(UnimplementedArch),
117 Unknown,
119}
120
121impl CompatibilityStatus {
122 pub fn is_runnable(&self) -> bool {
123 matches!(self, Self::Supported { .. })
124 }
125}
126
127#[derive(Debug, Clone)]
130pub struct CompatibilityReport {
131 pub path: PathBuf,
132 pub source: CompatSource,
133 pub status: CompatibilityStatus,
134 pub gguf_fields: Option<GgufRequiredFields>,
136}
137
138impl CompatibilityReport {
139 pub fn to_json(&self) -> String {
142 let (status_tag, status_detail) = match &self.status {
143 CompatibilityStatus::Supported { runner } => {
144 ("supported", serde_json::json!({ "runner": runner }))
145 }
146 CompatibilityStatus::MissingMetadata { missing } => (
147 "missing_metadata",
148 serde_json::json!({ "missing": missing }),
149 ),
150 CompatibilityStatus::KnownUnimplemented(u) => (
151 "known_unimplemented",
152 serde_json::json!({
153 "family": u.family,
154 "milestone": u.milestone,
155 "note": u.note,
156 }),
157 ),
158 CompatibilityStatus::Unknown => ("unknown", serde_json::Value::Null),
159 };
160 let (source_kind, arch) = match &self.source {
161 CompatSource::GgufArch(s) => ("gguf", s.as_str()),
162 CompatSource::SafetensorsConfig(s) => ("safetensors_config", s.as_str()),
163 };
164 let gguf_fields = self.gguf_fields.as_ref().map(|f| {
165 serde_json::json!({
166 "context_length": f.context_length,
167 "embedding_length": f.embedding_length,
168 "block_count": f.block_count,
169 "tokenizer_model": f.tokenizer_model,
170 "has_tokens": f.has_tokens,
171 })
172 });
173 serde_json::json!({
174 "path": self.path.display().to_string(),
175 "source": source_kind,
176 "arch": arch,
177 "status": status_tag,
178 "detail": status_detail,
179 "gguf_fields": gguf_fields,
180 })
181 .to_string()
182 }
183}
184
185impl std::fmt::Display for CompatibilityReport {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 writeln!(f, "path: {}", self.path.display())?;
188 match &self.source {
189 CompatSource::GgufArch(a) => {
190 writeln!(f, "source: GGUF general.architecture = `{a}`")?;
191 }
192 CompatSource::SafetensorsConfig(a) => {
193 writeln!(f, "source: safetensors config.json model_type = `{a}`")?;
194 }
195 }
196 if let Some(fields) = &self.gguf_fields {
197 writeln!(f, "fields:")?;
198 writeln!(
199 f,
200 " context_length: {}",
201 opt_display(fields.context_length)
202 )?;
203 writeln!(
204 f,
205 " embedding_length: {}",
206 opt_display(fields.embedding_length)
207 )?;
208 writeln!(f, " block_count: {}", opt_display(fields.block_count))?;
209 writeln!(
210 f,
211 " tokenizer.model: {}",
212 fields.tokenizer_model.as_deref().unwrap_or("<missing>")
213 )?;
214 writeln!(
215 f,
216 " tokens: {}",
217 if fields.has_tokens {
218 "present"
219 } else {
220 "<missing>"
221 }
222 )?;
223 }
224 match &self.status {
225 CompatibilityStatus::Supported { runner } => {
226 writeln!(f, "status: SUPPORTED")?;
227 writeln!(f, "runner: {runner}")?;
228 writeln!(
229 f,
230 " rlx-run {runner} --weights {}",
231 self.path.display()
232 )?;
233 }
234 CompatibilityStatus::MissingMetadata { missing } => {
235 writeln!(f, "status: INCOMPATIBLE (missing required GGUF metadata)")?;
236 writeln!(f, "missing: {}", missing.join(", "))?;
237 writeln!(
238 f,
239 "note: llama.cpp would also reject this file at load time"
240 )?;
241 }
242 CompatibilityStatus::KnownUnimplemented(u) => {
243 writeln!(f, "status: NOT YET IMPLEMENTED")?;
244 writeln!(f, "family: {}", u.family)?;
245 writeln!(f, "blocked by: PLAN.md {}", u.milestone)?;
246 writeln!(f, "note: {}", u.note)?;
247 }
248 CompatibilityStatus::Unknown => {
249 writeln!(f, "status: UNKNOWN ARCH")?;
250 writeln!(
251 f,
252 "note: arch `{}` is not in rlx-models's recognized set or on PLAN.md",
253 self.source.arch()
254 )?;
255 }
256 }
257 Ok(())
258 }
259}
260
261fn opt_display<T: std::fmt::Display>(v: Option<T>) -> String {
262 match v {
263 Some(v) => v.to_string(),
264 None => "<missing>".to_string(),
265 }
266}
267
268fn meta_arch_u64(raw: &GgufFile, arch: &str, suffix: &str) -> Option<u64> {
270 let k = format!("{arch}.{suffix}");
271 raw.metadata.get(&k).and_then(MetaValue::as_u64)
272}
273
274fn extract_gguf_fields(raw: &GgufFile, arch: &str) -> GgufRequiredFields {
275 let tokenizer_model = raw
276 .metadata
277 .get("tokenizer.ggml.model")
278 .and_then(MetaValue::as_str)
279 .map(str::to_owned);
280 let has_tokens = matches!(
281 raw.metadata.get("tokenizer.ggml.tokens"),
282 Some(MetaValue::Array(arr)) if !arr.is_empty()
283 );
284 GgufRequiredFields {
285 context_length: meta_arch_u64(raw, arch, "context_length"),
286 embedding_length: meta_arch_u64(raw, arch, "embedding_length"),
287 block_count: meta_arch_u64(raw, arch, "block_count"),
288 tokenizer_model,
289 has_tokens,
290 }
291}
292
293fn classify(source: &CompatSource, fields: Option<&GgufRequiredFields>) -> CompatibilityStatus {
294 let arch = source.arch();
295 if let Some(runner) = match source {
296 CompatSource::GgufArch(_) => arch_runner_name(arch),
297 CompatSource::SafetensorsConfig(_) => model_type_runner_name(arch),
298 } {
299 if let Some(f) = fields {
301 let missing = f.missing();
302 if !missing.is_empty() {
303 return CompatibilityStatus::MissingMetadata { missing };
304 }
305 }
306 return CompatibilityStatus::Supported { runner };
307 }
308 if let Some(u) = known_unimplemented_arch(arch) {
309 return CompatibilityStatus::KnownUnimplemented(u);
310 }
311 CompatibilityStatus::Unknown
312}
313
314pub fn check_path(path: &Path) -> Result<CompatibilityReport> {
316 let file = resolve_weights_file(path)?;
317 let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("");
318 match ext {
319 "gguf" => {
320 let raw =
321 GgufFile::from_path(&file).with_context(|| format!("opening GGUF {file:?}"))?;
322 let arch = raw
323 .metadata
324 .get("general.architecture")
325 .and_then(MetaValue::as_str)
326 .ok_or_else(|| anyhow!("{file:?}: GGUF has no general.architecture"))?
327 .to_string();
328 let fields = extract_gguf_fields(&raw, &arch);
329 let source = CompatSource::GgufArch(arch);
330 let status = classify(&source, Some(&fields));
331 Ok(CompatibilityReport {
332 path: file,
333 source,
334 status,
335 gguf_fields: Some(fields),
336 })
337 }
338 "safetensors" => {
339 let dir = file
340 .parent()
341 .ok_or_else(|| anyhow!("safetensors path {file:?} has no parent dir"))?;
342 let cfg = dir.join("config.json");
343 if !cfg.is_file() {
344 return Err(anyhow!(
345 "{file:?}: no sidecar config.json — cannot determine model_type"
346 ));
347 }
348 let bytes = std::fs::read(&cfg).with_context(|| format!("reading {cfg:?}"))?;
349 let v: serde_json::Value =
350 serde_json::from_slice(&bytes).with_context(|| format!("parsing {cfg:?}"))?;
351 let model_type = v
352 .get("model_type")
353 .and_then(serde_json::Value::as_str)
354 .ok_or_else(|| anyhow!("{cfg:?}: missing `model_type`"))?
355 .to_string();
356 let source = CompatSource::SafetensorsConfig(model_type);
357 let status = classify(&source, None);
358 Ok(CompatibilityReport {
359 path: file,
360 source,
361 status,
362 gguf_fields: None,
363 })
364 }
365 other => Err(anyhow!(
366 "{file:?}: unsupported extension `.{other}` (expected .gguf or .safetensors)"
367 )),
368 }
369}
370
371pub fn looks_like_hf_repo(s: &str) -> bool {
376 if s.starts_with('/') || s.starts_with('.') || s.starts_with('~') {
377 return false;
378 }
379 let slashes = s.bytes().filter(|b| *b == b'/').count();
380 if slashes != 1 {
381 return false;
382 }
383 let last = s.rsplit_once('/').map(|(_, t)| t).unwrap_or("");
384 !matches!(
387 last.rsplit_once('.').map(|(_, ext)| ext),
388 Some("gguf") | Some("safetensors") | Some("bin") | Some("pt") | Some("onnx"),
389 )
390}
391
392pub fn run_check(args: &[String]) -> Result<()> {
394 let mut json = false;
395 let mut input: Option<&str> = None;
396 for a in args {
397 match a.as_str() {
398 "--json" => json = true,
399 "-h" | "--help" | "help" => {
400 println!(
401 "rlx-run check — report whether rlx-models can run a model\n\
402 \n\
403 USAGE:\n rlx-run check <path-or-repo> [--json]\n\
404 \n\
405 Accepts a local weights path or a HuggingFace repo id\n\
406 (e.g. `unsloth/Qwen3-7B-GGUF`). Mirrors llama.cpp's load-time\n\
407 GGUF field check + HuggingFace's compatibility predicate, so\n\
408 the verdict matches what users see upstream.\n\
409 \n\
410 HF-repo checks require the `compat-net` cargo feature."
411 );
412 return Ok(());
413 }
414 other => {
415 if input.is_some() {
416 return Err(anyhow!("check: unexpected extra arg `{other}`"));
417 }
418 input = Some(other);
419 }
420 }
421 }
422 let input = input.ok_or_else(|| {
423 anyhow!("check: expected a weights path or HF repo id\nusage: rlx-run check <path-or-repo> [--json]")
424 })?;
425 let report = if looks_like_hf_repo(input) {
426 check_hf_repo(input)?
427 } else {
428 check_path(Path::new(input))?
429 };
430 if json {
431 println!("{}", report.to_json());
432 } else {
433 print!("{report}");
434 }
435 if !report.status.is_runnable() {
436 return Err(anyhow!("model is not runnable by rlx-models"));
437 }
438 Ok(())
439}
440
441#[cfg(not(feature = "compat-net"))]
443pub fn check_hf_repo(repo: &str) -> Result<CompatibilityReport> {
444 Err(anyhow!(
445 "{repo}: HF-repo checks require building rlx-cli with the `compat-net` feature \
446 (`cargo build -p rlx-cli --features compat-net`)"
447 ))
448}
449
450#[cfg(feature = "compat-net")]
451mod hf_fetch {
452 use super::*;
453 use std::io::Read;
454
455 pub fn resolve_url(repo: &str, file: &str) -> String {
458 format!("https://huggingface.co/{repo}/resolve/main/{file}")
459 }
460
461 pub fn tree_api_url(repo: &str) -> String {
463 format!("https://huggingface.co/api/models/{repo}/tree/main")
464 }
465
466 pub const GGUF_HEADER_FETCH_BYTES: usize = 4 * 1024 * 1024;
470
471 fn get(url: &str) -> Result<ureq::Response> {
472 ureq::get(url)
473 .timeout(std::time::Duration::from_secs(30))
474 .call()
475 .with_context(|| format!("GET {url}"))
476 }
477
478 fn get_range(url: &str, end_inclusive: usize) -> Result<Vec<u8>> {
479 let resp = ureq::get(url)
480 .timeout(std::time::Duration::from_secs(60))
481 .set("Range", &format!("bytes=0-{end_inclusive}"))
482 .call()
483 .with_context(|| format!("GET (range) {url}"))?;
484 let mut buf = Vec::with_capacity(end_inclusive + 1);
485 resp.into_reader()
486 .take((end_inclusive + 1) as u64)
487 .read_to_end(&mut buf)
488 .with_context(|| format!("reading range body from {url}"))?;
489 Ok(buf)
490 }
491
492 pub fn check(repo: &str) -> Result<CompatibilityReport> {
495 let cfg_url = resolve_url(repo, "config.json");
496 if let Ok(resp) = get(&cfg_url) {
497 if resp.status() == 200 {
498 let mut bytes = Vec::new();
499 resp.into_reader().read_to_end(&mut bytes).ok();
500 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
501 if let Some(model_type) =
502 v.get("model_type").and_then(serde_json::Value::as_str)
503 {
504 let source = CompatSource::SafetensorsConfig(model_type.to_string());
505 let status = classify(&source, None);
506 return Ok(CompatibilityReport {
507 path: PathBuf::from(format!("hf://{repo}/config.json")),
508 source,
509 status,
510 gguf_fields: None,
511 });
512 }
513 }
514 }
515 }
516
517 let tree_url = tree_api_url(repo);
519 let resp = get(&tree_url)?;
520 if resp.status() != 200 {
521 return Err(anyhow!(
522 "{repo}: HF tree API returned status {} (is the repo public?)",
523 resp.status()
524 ));
525 }
526 let listing: serde_json::Value = resp
527 .into_json()
528 .with_context(|| format!("parsing HF tree JSON for {repo}"))?;
529 let arr = listing
530 .as_array()
531 .ok_or_else(|| anyhow!("{repo}: HF tree API did not return a JSON array"))?;
532 let gguf_path = arr
533 .iter()
534 .filter_map(|v| v.get("path").and_then(serde_json::Value::as_str))
535 .find(|p| p.ends_with(".gguf"))
536 .ok_or_else(|| {
537 anyhow!(
538 "{repo}: no config.json with model_type and no .gguf file at root — \
539 cannot determine architecture"
540 )
541 })?
542 .to_owned();
543
544 let gguf_url = resolve_url(repo, &gguf_path);
545 let bytes = get_range(&gguf_url, GGUF_HEADER_FETCH_BYTES - 1)?;
546 let mut cursor = std::io::Cursor::new(bytes);
547 let raw = GgufFile::from_reader(&mut cursor)
548 .with_context(|| format!("parsing GGUF header from {gguf_url}"))?;
549 let arch = raw
550 .metadata
551 .get("general.architecture")
552 .and_then(MetaValue::as_str)
553 .ok_or_else(|| anyhow!("{gguf_url}: GGUF has no general.architecture"))?
554 .to_string();
555 let fields = extract_gguf_fields(&raw, &arch);
556 let source = CompatSource::GgufArch(arch);
557 let status = classify(&source, Some(&fields));
558 Ok(CompatibilityReport {
559 path: PathBuf::from(format!("hf://{repo}/{gguf_path}")),
560 source,
561 status,
562 gguf_fields: Some(fields),
563 })
564 }
565}
566
567#[cfg(feature = "compat-net")]
576pub fn check_hf_repo(repo: &str) -> Result<CompatibilityReport> {
577 hf_fetch::check(repo)
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn write_test_gguf(arch: &str, fields: &[(&str, MetaValueOwned)]) -> PathBuf {
588 let mut buf: Vec<u8> = Vec::new();
589 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
590 buf.extend_from_slice(&3u32.to_le_bytes());
591 buf.extend_from_slice(&1u64.to_le_bytes()); let kv_count = 1 + fields.len(); buf.extend_from_slice(&(kv_count as u64).to_le_bytes());
594
595 write_string_kv(&mut buf, "general.architecture", arch);
596 for (k, v) in fields {
597 match v {
598 MetaValueOwned::Str(s) => write_string_kv(&mut buf, k, s),
599 MetaValueOwned::U64(n) => {
600 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
601 buf.extend_from_slice(k.as_bytes());
602 buf.extend_from_slice(&10u32.to_le_bytes()); buf.extend_from_slice(&n.to_le_bytes());
604 }
605 MetaValueOwned::StringArray(items) => {
606 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
607 buf.extend_from_slice(k.as_bytes());
608 buf.extend_from_slice(&9u32.to_le_bytes()); buf.extend_from_slice(&8u32.to_le_bytes()); buf.extend_from_slice(&(items.len() as u64).to_le_bytes());
611 for s in items {
612 buf.extend_from_slice(&(s.len() as u64).to_le_bytes());
613 buf.extend_from_slice(s.as_bytes());
614 }
615 }
616 }
617 }
618 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 use std::sync::atomic::{AtomicU64, Ordering};
636 static SEQ: AtomicU64 = AtomicU64::new(0);
637 let path = std::env::temp_dir().join(format!(
638 "rlx_compat_{}_{}_{}.gguf",
639 arch,
640 std::process::id(),
641 SEQ.fetch_add(1, Ordering::Relaxed),
642 ));
643 std::fs::write(&path, &buf).unwrap();
644 path
645 }
646
647 enum MetaValueOwned {
648 Str(String),
649 U64(u64),
650 StringArray(Vec<String>),
651 }
652
653 fn write_string_kv(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
661 #[test]
662 fn supported_when_arch_known_and_all_required_fields_present() {
663 let path = write_test_gguf(
664 "qwen3",
665 &[
666 ("qwen3.context_length", MetaValueOwned::U64(8192)),
667 ("qwen3.embedding_length", MetaValueOwned::U64(4096)),
668 ("qwen3.block_count", MetaValueOwned::U64(32)),
669 ("tokenizer.ggml.model", MetaValueOwned::Str("gpt2".into())),
670 (
671 "tokenizer.ggml.tokens",
672 MetaValueOwned::StringArray(vec!["a".into(), "b".into()]),
673 ),
674 ],
675 );
676 let r = check_path(&path).unwrap();
677 match r.status {
678 CompatibilityStatus::Supported { runner } => assert_eq!(runner, "qwen3"),
679 other => panic!("expected Supported, got {other:?}"),
680 }
681 assert!(r.status.is_runnable());
682 assert_eq!(r.gguf_fields.as_ref().unwrap().context_length, Some(8192));
683 std::fs::remove_file(&path).ok();
684 }
685
686 #[test]
687 fn missing_metadata_when_required_field_absent() {
688 let path = write_test_gguf(
690 "qwen3",
691 &[
692 ("qwen3.context_length", MetaValueOwned::U64(8192)),
693 ("qwen3.embedding_length", MetaValueOwned::U64(4096)),
694 ("tokenizer.ggml.model", MetaValueOwned::Str("gpt2".into())),
695 (
696 "tokenizer.ggml.tokens",
697 MetaValueOwned::StringArray(vec!["a".into()]),
698 ),
699 ],
700 );
701 let r = check_path(&path).unwrap();
702 match &r.status {
703 CompatibilityStatus::MissingMetadata { missing } => {
704 assert!(missing.contains(&"<arch>.block_count"));
705 }
706 other => panic!("expected MissingMetadata, got {other:?}"),
707 }
708 assert!(!r.status.is_runnable());
709 std::fs::remove_file(&r.path).ok();
710 }
711
712 #[test]
713 fn known_unimplemented_when_arch_in_plan_but_not_implemented() {
714 let path = write_test_gguf(
715 "minimax-m2",
716 &[
717 ("minimax-m2.context_length", MetaValueOwned::U64(8192)),
718 ("minimax-m2.embedding_length", MetaValueOwned::U64(4096)),
719 ("minimax-m2.block_count", MetaValueOwned::U64(32)),
720 ("tokenizer.ggml.model", MetaValueOwned::Str("gpt2".into())),
721 (
722 "tokenizer.ggml.tokens",
723 MetaValueOwned::StringArray(vec!["a".into()]),
724 ),
725 ],
726 );
727 let r = check_path(&path).unwrap();
728 match &r.status {
729 CompatibilityStatus::KnownUnimplemented(u) => {
730 assert_eq!(u.milestone, "M5");
731 assert!(u.family.contains("MiniMax"));
732 }
733 other => panic!("expected KnownUnimplemented, got {other:?}"),
734 }
735 assert!(!r.status.is_runnable());
736 std::fs::remove_file(&r.path).ok();
737 }
738
739 #[test]
740 fn unknown_when_arch_not_recognized() {
741 let path = write_test_gguf("totally-fake-arch", &[]);
742 let r = check_path(&path).unwrap();
743 assert!(matches!(r.status, CompatibilityStatus::Unknown));
744 std::fs::remove_file(&r.path).ok();
745 }
746
747 #[test]
748 fn json_round_trip_emits_status_tag() {
749 let path = write_test_gguf("totally-fake-arch", &[]);
750 let r = check_path(&path).unwrap();
751 let j = r.to_json();
752 let v: serde_json::Value = serde_json::from_str(&j).unwrap();
753 assert_eq!(v["status"], "unknown");
754 assert_eq!(v["source"], "gguf");
755 assert_eq!(v["arch"], "totally-fake-arch");
756 std::fs::remove_file(&r.path).ok();
757 }
758
759 #[test]
760 fn looks_like_hf_repo_distinguishes_repos_from_paths() {
761 assert!(looks_like_hf_repo("unsloth/Qwen3-7B-GGUF"));
762 assert!(looks_like_hf_repo("bartowski/something"));
763 assert!(!looks_like_hf_repo("/Users/me/model.gguf"));
765 assert!(!looks_like_hf_repo("./model.gguf"));
766 assert!(!looks_like_hf_repo("~/models/qwen3"));
767 assert!(!looks_like_hf_repo("model.gguf"));
768 assert!(!looks_like_hf_repo("models/qwen3/file.gguf"));
770 assert!(!looks_like_hf_repo("org/file.safetensors"));
772 assert!(!looks_like_hf_repo("org/file.gguf"));
773 }
774
775 #[cfg(feature = "compat-net")]
776 #[test]
777 fn hf_url_construction() {
778 use super::hf_fetch::{resolve_url, tree_api_url};
779 assert_eq!(
780 resolve_url("unsloth/Qwen3-7B-GGUF", "config.json"),
781 "https://huggingface.co/unsloth/Qwen3-7B-GGUF/resolve/main/config.json"
782 );
783 assert_eq!(
784 tree_api_url("unsloth/Qwen3-7B-GGUF"),
785 "https://huggingface.co/api/models/unsloth/Qwen3-7B-GGUF/tree/main"
786 );
787 }
788
789 #[test]
790 fn safetensors_uses_sidecar_model_type() {
791 let dir = std::env::temp_dir().join("rlx_compat_st_sidecar");
792 std::fs::create_dir_all(&dir).unwrap();
793 std::fs::write(dir.join("config.json"), br#"{"model_type":"llama"}"#).unwrap();
794 let st = dir.join("model.safetensors");
795 std::fs::write(&st, b"").unwrap();
796 let r = check_path(&st).unwrap();
797 match r.status {
798 CompatibilityStatus::Supported { runner } => assert_eq!(runner, "llama32"),
799 other => panic!("expected Supported, got {other:?}"),
800 }
801 std::fs::remove_dir_all(&dir).ok();
802 }
803}