pub mod config;
pub mod convert;
pub mod gguf_emit;
use std::path::{Path, PathBuf};
pub use config::{VisionConfig, VisionConfigError};
#[derive(Debug, thiserror::Error)]
pub enum VitConvertError {
#[error("vision config parse error: {0}")]
Config(#[from] VisionConfigError),
#[error("safetensors read error: {0}")]
Safetensors(String),
#[error("GGUF emit error: {0}")]
GgufEmit(String),
#[error("tensor {name}: expected shape {expected:?}, got {actual:?}")]
ShapeMismatch {
name: String,
expected: Vec<usize>,
actual: Vec<usize>,
},
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
}
pub fn convert_vision_tower(
hf_repo_dir: &Path,
output_dir: &Path,
) -> Result<Option<PathBuf>, VitConvertError> {
let config_path = hf_repo_dir.join("config.json");
if !config_path.exists() {
return Err(VitConvertError::Config(VisionConfigError::NoConfigJson));
}
let raw = std::fs::read_to_string(&config_path)
.map_err(|e| VitConvertError::Config(VisionConfigError::Io(e.to_string())))?;
let root: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| VitConvertError::Config(VisionConfigError::BadJson(e.to_string())))?;
if root.get("vision_config").is_none() {
return Ok(None);
}
let slug = compute_slug(&root, hf_repo_dir);
let output = output_dir.join(format!("mmproj-{}-F16.gguf", slug));
convert_vision_tower_to_path(hf_repo_dir, &output)?;
Ok(Some(output))
}
pub fn convert_vision_tower_to_path(
hf_repo_dir: &Path,
output: &Path,
) -> Result<(), VitConvertError> {
convert_vision_tower_to_path_with_source(hf_repo_dir, output, None)
}
pub fn convert_vision_tower_to_path_with_source(
hf_repo_dir: &Path,
output: &Path,
source_sha256: Option<&str>,
) -> Result<(), VitConvertError> {
convert_vision_tower_to_path_with_source_and_pair(hf_repo_dir, output, source_sha256, None)
}
pub fn convert_vision_tower_to_path_with_source_and_pair(
hf_repo_dir: &Path,
output: &Path,
source_sha256: Option<&str>,
pair_generation: Option<&str>,
) -> Result<(), VitConvertError> {
let config_path = hf_repo_dir.join("config.json");
let raw = std::fs::read_to_string(&config_path)
.map_err(|e| VitConvertError::Config(VisionConfigError::Io(e.to_string())))?;
let root: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| VitConvertError::Config(VisionConfigError::BadJson(e.to_string())))?;
let mut vision_config = VisionConfig::from_hf_config(&root)?;
let processor_path = hf_repo_dir.join("preprocessor_config.json");
let requires_processor_config = vision_config.is_qwen3vl();
if processor_path.exists() {
let processor_raw = std::fs::read_to_string(&processor_path)?;
let processor: serde_json::Value = serde_json::from_str(&processor_raw)
.map_err(|e| VitConvertError::Config(VisionConfigError::BadJson(e.to_string())))?;
vision_config.apply_preprocessor_config(&processor)?;
if requires_processor_config
&& (vision_config.image_min_pixels.is_none()
|| vision_config.image_max_pixels.is_none())
{
return Err(VitConvertError::Config(VisionConfigError::InvalidField {
field: "processor.size",
value: "Qwen vision conversion requires positive shortest_edge and longest_edge pixel bounds"
.to_string(),
}));
}
} else if requires_processor_config {
return Err(VitConvertError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"Qwen vision conversion requires {}",
processor_path.display()
),
)));
}
let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(output_dir)
.map_err(|e| VitConvertError::GgufEmit(format!("mkdir output_dir: {e}")))?;
let tensors = convert::load_vision_tensors(hf_repo_dir, &vision_config)?;
let temporary = tempfile::NamedTempFile::new_in(output_dir)?;
let temporary_path = temporary.into_temp_path();
gguf_emit::write_mmproj_gguf_with_provenance_and_pair(
&temporary_path,
&vision_config,
&tensors,
source_sha256,
pair_generation,
)?;
std::fs::File::open(&temporary_path)?.sync_all()?;
temporary_path
.persist(output)
.map_err(|error| VitConvertError::Io(error.error))?;
Ok(())
}
pub fn compute_slug(config_root: &serde_json::Value, hf_repo_dir: &Path) -> String {
if let Some(name) = config_root.get("_name_or_path").and_then(|v| v.as_str()) {
return sanitize_slug(name);
}
hf_repo_dir
.file_name()
.and_then(|s| s.to_str())
.map(sanitize_slug)
.unwrap_or_else(|| "model".to_string())
}
fn sanitize_slug(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn no_vision_config_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("no-vision");
fs::create_dir_all(&input).unwrap();
fs::write(
input.join("config.json"),
r#"{"architectures":["Qwen3_5MoeForCausalLM"],"hidden_size":64}"#,
)
.unwrap();
let out_dir = tmp.path().join("out");
let result = convert_vision_tower(&input, &out_dir).expect("no error");
assert!(result.is_none(), "no vision_config → Ok(None)");
}
#[test]
fn missing_config_json_errors() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("no-config");
fs::create_dir_all(&input).unwrap();
let out_dir = tmp.path().join("out");
let err = convert_vision_tower(&input, &out_dir).unwrap_err();
assert!(matches!(
err,
VitConvertError::Config(VisionConfigError::NoConfigJson)
));
}
#[test]
fn malformed_json_errors() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("bad-json");
fs::create_dir_all(&input).unwrap();
fs::write(input.join("config.json"), "not json").unwrap();
let out_dir = tmp.path().join("out");
let err = convert_vision_tower(&input, &out_dir).unwrap_err();
assert!(matches!(
err,
VitConvertError::Config(VisionConfigError::BadJson(_))
));
}
#[test]
fn compute_slug_from_name_or_path() {
let root = serde_json::json!({"_name_or_path": "Qwen/Qwen3.6-27B"});
let slug = compute_slug(&root, Path::new("/tmp/ignored"));
assert_eq!(slug, "qwen-qwen3.6-27b");
}
#[test]
fn compute_slug_from_directory_when_no_name() {
let root = serde_json::json!({});
let slug = compute_slug(&root, Path::new("/tmp/qwen3.6-27B-apex"));
assert_eq!(slug, "qwen3.6-27b-apex");
}
#[test]
fn sanitize_slug_strips_bad_chars() {
assert_eq!(sanitize_slug("Foo/Bar_Baz.V2"), "foo-bar-baz.v2");
assert_eq!(sanitize_slug("---leading"), "leading");
}
#[test]
fn gemma4_config_returns_none_silent_regression_gate() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("gemma4-fixture");
fs::create_dir_all(&input).unwrap();
fs::write(
input.join("config.json"),
r#"{
"architectures": ["Gemma4ForCausalLM"],
"hidden_size": 2048,
"num_hidden_layers": 26
}"#,
)
.unwrap();
let out_dir = tmp.path().join("out");
let result = convert_vision_tower(&input, &out_dir).expect("gemma4 must not error");
assert!(
result.is_none(),
"gemma4 has no vision_config — must silently skip"
);
assert!(!out_dir.exists(), "no output dir created on silent-skip");
}
#[test]
fn qwen35moe_without_vision_config_silently_skips() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("qwen35moe-no-vc");
fs::create_dir_all(&input).unwrap();
fs::write(
input.join("config.json"),
r#"{
"architectures": ["Qwen3_5MoeForCausalLM"],
"hidden_size": 2048,
"num_hidden_layers": 40,
"num_experts": 256
}"#,
)
.unwrap();
let out_dir = tmp.path().join("out");
let result = convert_vision_tower(&input, &out_dir).unwrap();
assert!(
result.is_none(),
"MoE without vision_config must silent-skip"
);
}
#[test]
fn qwen_vision_conversion_requires_processor_config() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("qwen-vision");
fs::create_dir_all(&input).unwrap();
fs::write(
input.join("config.json"),
include_str!("../../../tests/fixtures/qwen38/config.json"),
)
.unwrap();
let err = convert_vision_tower_to_path(&input, &tmp.path().join("out.gguf"))
.expect_err("missing processor config must fail before tensor loading");
assert!(format!("{err}").contains("preprocessor_config.json"));
}
#[test]
fn qwen_vision_conversion_rejects_processor_without_pixel_bounds() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("qwen-vision");
fs::create_dir_all(&input).unwrap();
fs::write(
input.join("config.json"),
include_str!("../../../tests/fixtures/qwen38/config.json"),
)
.unwrap();
fs::write(input.join("preprocessor_config.json"), r#"{"size":{}}"#).unwrap();
let err = convert_vision_tower_to_path(&input, &tmp.path().join("out.gguf"))
.expect_err("missing pixel bounds must fail before tensor loading");
assert!(format!("{err}").contains("processor.size"));
}
}