Skip to main content

ferrox_models/
mmproj.rs

1//! Detect `mmproj-*.gguf` vision towers next to a main GGUF (P7).
2//!
3//! Ferrox does not yet run VL e2e — this helper only discovers companion
4//! projector files the way OpenAI-style multimodal loaders expect, so
5//! serve/CLI can fail closed with a clear path once `vl_engine` lands.
6
7use std::path::{Path, PathBuf};
8
9use crate::capability::{resolve_profile, ArchScope};
10
11/// Look for `mmproj*.gguf` (case-insensitive) in the same directory as
12/// `main_gguf`. Returns the first match by name, if any.
13pub fn find_mmproj_beside(main_gguf: &Path) -> Option<PathBuf> {
14    let dir = main_gguf.parent()?;
15    let mut matches: Vec<PathBuf> = std::fs::read_dir(dir)
16        .ok()?
17        .filter_map(|e| e.ok())
18        .map(|e| e.path())
19        .filter(|p| {
20            p.extension()
21                .and_then(|e| e.to_str())
22                .map(|e| e.eq_ignore_ascii_case("gguf"))
23                .unwrap_or(false)
24                && p.file_name()
25                    .and_then(|n| n.to_str())
26                    .map(|n| {
27                        let lower = n.to_ascii_lowercase();
28                        lower.starts_with("mmproj")
29                    })
30                    .unwrap_or(false)
31        })
32        .collect();
33    matches.sort();
34    matches.into_iter().next()
35}
36
37/// Log a clear warning when a companion mmproj exists but VL is not implemented.
38/// Known VL architectures get a stronger message (fail-closed hint for operators).
39pub fn warn_mmproj_if_present(main_gguf: &Path, arch: Option<&str>) {
40    let Some(mmproj) = find_mmproj_beside(main_gguf) else {
41        return;
42    };
43    let vl_arch = arch.is_some_and(|a| {
44        resolve_profile(a).is_some_and(|p| p.scope == ArchScope::DeferredMultimodal)
45    });
46    let arch_note = arch.map(|a| format!(" (arch={a})")).unwrap_or_default();
47    if vl_arch {
48        eprintln!(
49            "ferrox: warning: mmproj {} beside {}{} — VL architecture but multimodal \
50             generation not implemented (see docs/MODELS.md P7)",
51            mmproj.display(),
52            main_gguf.display(),
53            arch_note
54        );
55    } else {
56        eprintln!(
57            "ferrox: warning: mmproj companion {} found beside {}{} — vision projector not \
58             loaded; text-only path continues (see docs/MODELS.md P7)",
59            mmproj.display(),
60            main_gguf.display(),
61            arch_note
62        );
63    }
64}
65
66/// CLI-facing stderr variant of [`warn_mmproj_if_present`].
67pub fn eprint_mmproj_if_present(main_gguf: &Path, arch: Option<&str>) {
68    let Some(mmproj) = find_mmproj_beside(main_gguf) else {
69        return;
70    };
71    let arch_note = arch.map(|a| format!(" (arch={a})")).unwrap_or_default();
72    eprintln!(
73        "ferrox: warning: mmproj companion {} found beside {}{} — VL/projector not \
74         implemented; text-only load continues (see docs/MODELS.md)",
75        mmproj.display(),
76        main_gguf.display(),
77        arch_note
78    );
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::fs;
85    use std::io::Write;
86
87    #[test]
88    fn finds_mmproj_next_to_main() {
89        let dir = std::env::temp_dir().join(format!("ferrox_mmproj_{}", std::process::id()));
90        let _ = fs::remove_dir_all(&dir);
91        fs::create_dir_all(&dir).unwrap();
92        let main = dir.join("model-Q4_K_M.gguf");
93        let mm = dir.join("mmproj-f16.gguf");
94        fs::File::create(&main).unwrap().write_all(b"x").unwrap();
95        fs::File::create(&mm).unwrap().write_all(b"y").unwrap();
96        let found = find_mmproj_beside(&main).expect("mmproj");
97        assert_eq!(found, mm);
98        let _ = fs::remove_dir_all(&dir);
99    }
100
101    #[test]
102    fn none_when_no_mmproj() {
103        let dir = std::env::temp_dir().join(format!("ferrox_nomm_{}", std::process::id()));
104        let _ = fs::remove_dir_all(&dir);
105        fs::create_dir_all(&dir).unwrap();
106        let main = dir.join("only.gguf");
107        fs::File::create(&main).unwrap().write_all(b"x").unwrap();
108        assert!(find_mmproj_beside(&main).is_none());
109        let _ = fs::remove_dir_all(&dir);
110    }
111}