use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
MacosArm64,
Standard,
}
impl Platform {
#[must_use]
pub fn host() -> Self {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Self::MacosArm64
} else {
Self::Standard
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::MacosArm64 => "macos-arm64",
Self::Standard => "standard",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ModelFile {
pub name: &'static str,
pub url: &'static str,
pub sha256: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct ModelVariant {
pub platform: Platform,
pub files: &'static [ModelFile],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelKind {
Embedding,
Generative,
Ocr,
Vision,
Audio,
}
impl ModelKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Embedding => "embedding",
Self::Generative => "generative",
Self::Ocr => "ocr",
Self::Vision => "vision",
Self::Audio => "audio",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceTier {
Low,
Mid,
High,
}
impl ResourceTier {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Mid => "mid",
Self::High => "high",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelRole {
None,
Instruct,
Coding,
Reasoning,
}
impl ModelRole {
#[must_use]
pub fn as_str(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Instruct => Some("instruct"),
Self::Coding => Some("coding"),
Self::Reasoning => Some("reasoning"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ModelSpec {
pub name: &'static str,
pub kind: ModelKind,
pub role: ModelRole,
pub tier: ResourceTier,
pub dim: usize,
pub licence: &'static str,
pub description: &'static str,
pub size_mib: u32,
pub variants: &'static [ModelVariant],
}
impl ModelSpec {
#[must_use]
pub fn variant_for(&self, platform: Platform) -> Option<&ModelVariant> {
self.variants
.iter()
.find(|v| v.platform == platform)
.or_else(|| {
self.variants
.iter()
.find(|v| v.platform == Platform::Standard)
})
}
}
pub const REGISTRY: &[ModelSpec] = &[
ModelSpec {
name: "bge-base-en-v1.5",
kind: ModelKind::Embedding,
role: ModelRole::None,
tier: ResourceTier::Mid,
dim: 768,
licence: "MIT",
description: "BAAI/bge-base-en-v1.5 (F16 GGUF) — stronger English embeddings (768-d)",
size_mib: 209,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/CompendiumLabs/bge-base-en-v1.5-gguf/resolve/main/bge-base-en-v1.5-f16.gguf",
sha256: "88360fdf8521af0ac08d43818bd272da679ab97c685d9b273c48efd01a4187c2",
}],
}],
},
ModelSpec {
name: "bge-large-en-v1.5",
kind: ModelKind::Embedding,
role: ModelRole::None,
tier: ResourceTier::High,
dim: 1024,
licence: "MIT",
description: "BAAI/bge-large-en-v1.5 (F16 GGUF) — strongest English embeddings (1024-d)",
size_mib: 639,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/CompendiumLabs/bge-large-en-v1.5-gguf/resolve/main/bge-large-en-v1.5-f16.gguf",
sha256: "3379a0e9cea28fc6d7136df8ea7a88ef99ccce5963b9a6f7af9609997be762e3",
}],
}],
},
ModelSpec {
name: "bge-small-en-v1.5-gguf",
kind: ModelKind::Embedding,
role: ModelRole::None,
tier: ResourceTier::Low,
dim: 384,
licence: "MIT",
description: "BAAI/bge-small-en-v1.5 (F16 GGUF) — small English embeddings (384-d), served via llama.cpp",
size_mib: 65,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/CompendiumLabs/bge-small-en-v1.5-gguf/resolve/main/bge-small-en-v1.5-f16.gguf",
sha256: "f0b2fef971e8366438bfd2d9aefea1b0115919389448806d290237f638bae999",
}],
}],
},
ModelSpec {
name: "qwen3-0.6b",
kind: ModelKind::Generative,
role: ModelRole::Instruct,
tier: ResourceTier::Low,
dim: 0,
licence: "Apache-2.0",
description: "Qwen3-0.6B (Q4_K_M GGUF) — tiny offline instruct model, the `spec draft` default",
size_mib: 380,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/unsloth/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q4_K_M.gguf",
sha256: "ac2d97712095a558e31573f62f466a3f9d93990898b0ec79d7c974c1780d524a",
}],
}],
},
ModelSpec {
name: "qwen3-8b",
kind: ModelKind::Generative,
role: ModelRole::Instruct,
tier: ResourceTier::Mid,
dim: 0,
licence: "Apache-2.0",
description: "Qwen3-8B (Q4_K_M GGUF) — stronger offline drafting on a ~16 GB machine",
size_mib: 4795,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/Qwen/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q4_K_M.gguf",
sha256: "d98cdcbd03e17ce47681435b5150e34c1417f50b5c0019dd560e4882c5745785",
}],
}],
},
ModelSpec {
name: "qwen3-32b",
kind: ModelKind::Generative,
role: ModelRole::Instruct,
tier: ResourceTier::High,
dim: 0,
licence: "Apache-2.0",
description: "Qwen3-32B (Q4_K_M GGUF) — best offline drafting, for a workstation",
size_mib: 18845,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/Qwen/Qwen3-32B-GGUF/resolve/main/Qwen3-32B-Q4_K_M.gguf",
sha256: "efd971561896866f0e910cce52761ca77b1b138090c7f15fe284676d57d1f689",
}],
}],
},
ModelSpec {
name: "qwen2.5-coder-3b",
kind: ModelKind::Generative,
role: ModelRole::Coding,
tier: ResourceTier::Mid,
dim: 0,
licence: "Apache-2.0",
description: "Qwen2.5-Coder-3B-Instruct (Q4_K_M GGUF) — code completion/Q&A, served via llama.cpp",
size_mib: 1841,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/bartowski/Qwen2.5-Coder-3B-Instruct-GGUF/resolve/main/Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
sha256: "3da3afe6cf5c674ac195803ea0dd6fee7e1c228c2105c1ce8c66890d1d4ab460",
}],
}],
},
ModelSpec {
name: "qwen3-coder-30b-a3b",
kind: ModelKind::Generative,
role: ModelRole::Coding,
tier: ResourceTier::High,
dim: 0,
licence: "Apache-2.0",
description: "Qwen3-Coder-30B-A3B-Instruct (Q4_K_M GGUF) — 30B MoE coder (3B active), for a workstation",
size_mib: 17697,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF/resolve/main/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
sha256: "fadc3e5f8d42bf7e894a785b05082e47daee4df26680389817e2093056f088ad",
}],
}],
},
ModelSpec {
name: "deepseek-r1-distill-qwen-1.5b",
kind: ModelKind::Generative,
role: ModelRole::Reasoning,
tier: ResourceTier::Low,
dim: 0,
licence: "MIT",
description: "DeepSeek-R1-Distill-Qwen-1.5B (Q4_K_M GGUF) — small reasoning model, served via llama.cpp",
size_mib: 1066,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[ModelFile {
name: "model.gguf",
url: "https://huggingface.co/bartowski/DeepSeek-R1-Distill-Qwen-1.5B-GGUF/resolve/main/DeepSeek-R1-Distill-Qwen-1.5B-Q4_K_M.gguf",
sha256: "1741e5b2d062b07acf048bf0d2c514dadf2a48f94e2b4aa0cfe069af3838ee2f",
}],
}],
},
ModelSpec {
name: "ocrs-text",
kind: ModelKind::Ocr,
role: ModelRole::None,
tier: ResourceTier::Low,
dim: 0,
licence: "CC-BY-SA-4.0",
description: "ocrs text detection + recognition (pure-Rust OCR for `image-ocr`)",
size_mib: 12,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "text-detection.rten",
url: "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten",
sha256: "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca",
},
ModelFile {
name: "text-recognition.rten",
url: "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten",
sha256: "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e",
},
],
}],
},
ModelSpec {
name: "smolvlm-500m-gguf",
kind: ModelKind::Vision,
role: ModelRole::None,
tier: ResourceTier::Low,
dim: 0,
licence: "Apache-2.0",
description: "SmolVLM-500M-Instruct (Q8_0 GGUF + mmproj) — small vision-language model served via llama.cpp",
size_mib: 520,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "model.gguf",
url: "https://huggingface.co/ggml-org/SmolVLM-500M-Instruct-GGUF/resolve/main/SmolVLM-500M-Instruct-Q8_0.gguf",
sha256: "9d4612de6a42214499e301494a3ecc2be0abdd9de44e663bda63f1152fad1bf4",
},
ModelFile {
name: "mmproj.gguf",
url: "https://huggingface.co/ggml-org/SmolVLM-500M-Instruct-GGUF/resolve/main/mmproj-SmolVLM-500M-Instruct-Q8_0.gguf",
sha256: "d1eb8b6b23979205fdf63703ed10f788131a3f812c7b1f72e0119d5d81295150",
},
],
}],
},
ModelSpec {
name: "voxtral-mini-3b",
kind: ModelKind::Audio,
role: ModelRole::None,
tier: ResourceTier::Mid,
dim: 0,
licence: "Apache-2.0",
description: "Voxtral-Mini-3B (Mistral, Q4_K_M GGUF + Q8_0 audio mmproj) — speech transcription via llama.cpp mtmd",
size_mib: 3041,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "model.gguf",
url: "https://huggingface.co/ggml-org/Voxtral-Mini-3B-2507-GGUF/resolve/main/Voxtral-Mini-3B-2507-Q4_K_M.gguf",
sha256: "4705be8ec22ca23d12632f4b4a3691faa95917d90a06d3cf3c3ec0e91958f1a8",
},
ModelFile {
name: "mmproj.gguf",
url: "https://huggingface.co/ggml-org/Voxtral-Mini-3B-2507-GGUF/resolve/main/mmproj-Voxtral-Mini-3B-2507-Q8_0.gguf",
sha256: "4f24c4ef3ce929d02ed9d1cfb050ae9a7365f057c0ddec0d489580982ebe0d02",
},
],
}],
},
];
#[must_use]
pub fn find(name: &str) -> Option<&'static ModelSpec> {
REGISTRY.iter().find(|m| m.name == name)
}
fn store_root_from(
model_store: Option<PathBuf>,
roteiro_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> PathBuf {
if let Some(dir) = model_store {
return dir;
}
if let Some(dir) = roteiro_home {
return dir.join("models");
}
home.unwrap_or_else(|| PathBuf::from("."))
.join(".roteiro")
.join("models")
}
static MODEL_STORE_OVERRIDE: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
pub fn set_model_store(dir: PathBuf) {
let _ = MODEL_STORE_OVERRIDE.set(dir);
}
#[must_use]
pub fn store_root() -> PathBuf {
if let Some(dir) = MODEL_STORE_OVERRIDE.get() {
return dir.clone();
}
store_root_from(
std::env::var_os("ROTEIRO_MODEL_STORE").map(PathBuf::from),
std::env::var_os("ROTEIRO_HOME").map(PathBuf::from),
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from),
)
}
#[must_use]
pub fn model_dir(name: &str) -> PathBuf {
store_root().join(name)
}
#[must_use]
pub fn is_installed(name: &str, variant: &ModelVariant) -> bool {
let dir = model_dir(name);
variant.files.iter().all(|f| dir.join(f.name).exists())
}
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
#[must_use]
pub fn verify_sha256(bytes: &[u8], expected: &str) -> bool {
expected.is_empty() || sha256_hex(bytes).eq_ignore_ascii_case(expected)
}
pub fn ensure_model_dir(name: &str) -> std::io::Result<PathBuf> {
let dir = model_dir(name);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
#[derive(Debug, thiserror::Error)]
pub enum DownloadError {
#[error("download io error: {0}")]
Io(#[from] std::io::Error),
#[error("checksum mismatch: expected {expected}, got {got}")]
Checksum {
expected: String,
got: String,
},
}
pub fn download_verified(
mut reader: impl std::io::Read,
dest: &Path,
expected_sha256: &str,
) -> Result<(), DownloadError> {
use sha2::{Digest, Sha256};
struct PartialGuard<'a> {
path: &'a Path,
armed: bool,
}
impl Drop for PartialGuard<'_> {
fn drop(&mut self) {
if self.armed {
std::fs::remove_file(self.path).ok();
}
}
}
let tmp = dest.with_extension("partial");
let mut guard = PartialGuard {
path: &tmp,
armed: true,
};
let mut writer = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1 << 16]; loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
std::io::Write::write_all(&mut writer, &buf[..n])?;
}
writer
.into_inner()
.map_err(std::io::IntoInnerError::into_error)?
.sync_all()?;
if !expected_sha256.is_empty() {
let mut got = String::with_capacity(64);
for byte in hasher.finalize() {
use std::fmt::Write as _;
let _ = write!(got, "{byte:02x}");
}
if !got.eq_ignore_ascii_case(expected_sha256) {
return Err(DownloadError::Checksum {
expected: expected_sha256.to_owned(),
got,
});
}
}
if dest.exists() {
std::fs::remove_file(dest)?;
}
std::fs::rename(&tmp, dest)?;
guard.armed = false; Ok(())
}
#[cfg(test)]
mod tests {
use super::{
DownloadError, ModelKind, Platform, REGISTRY, ResourceTier, download_verified, find,
sha256_hex, store_root, verify_sha256,
};
use std::path::Path;
#[test]
fn download_verified_streams_and_checks() {
struct FailReader(usize);
impl std::io::Read for FailReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.0 == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "boom"));
}
let n = buf.len().min(self.0);
buf[..n].fill(b'x');
self.0 -= n;
Ok(n)
}
}
let dir = std::env::temp_dir().join(format!("roteiro-dl-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let payload = b"the streamed model bytes";
let sha = sha256_hex(payload);
let good = dir.join("good.bin");
download_verified(&payload[..], &good, &sha).expect("verified");
assert_eq!(std::fs::read(&good).expect("read"), payload);
let bad = dir.join("bad.bin");
let err = download_verified(&payload[..], &bad, &"0".repeat(64)).unwrap_err();
assert!(matches!(err, DownloadError::Checksum { .. }));
assert!(!bad.exists());
assert!(!bad.with_extension("partial").exists());
let dropped = dir.join("dropped.bin");
let err = download_verified(FailReader(100), &dropped, "").unwrap_err();
assert!(matches!(err, DownloadError::Io(_)));
assert!(!dropped.exists());
assert!(!dropped.with_extension("partial").exists());
let unpinned = dir.join("unpinned.bin");
download_verified(&payload[..], &unpinned, "").expect("unpinned");
assert!(unpinned.exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn registry_entries_are_well_formed() {
assert!(!REGISTRY.is_empty());
for spec in REGISTRY {
assert!(!spec.name.is_empty());
assert_eq!(
spec.dim > 0,
spec.kind == ModelKind::Embedding,
"{}",
spec.name
);
assert!(!spec.variants.is_empty());
assert!(
spec.variants
.iter()
.any(|v| v.platform == Platform::Standard),
"{} needs a Standard variant",
spec.name,
);
let v = spec.variant_for(Platform::host()).expect("host variant");
assert!(!v.files.is_empty());
assert!(!spec.tier.as_str().is_empty());
}
}
#[test]
fn every_section_has_a_low_tier_floor() {
for kind in [
ModelKind::Embedding,
ModelKind::Generative,
ModelKind::Ocr,
ModelKind::Vision,
] {
assert!(
REGISTRY
.iter()
.any(|s| s.kind == kind && s.tier == ResourceTier::Low),
"section {} needs a Low-tier entry",
kind.as_str(),
);
}
}
#[test]
fn variant_selection_falls_back_to_standard() {
let spec = find("bge-base-en-v1.5").expect("registered");
let mac = spec.variant_for(Platform::MacosArm64).expect("mac");
let std = spec.variant_for(Platform::Standard).expect("std");
assert_eq!(mac.platform, Platform::Standard);
assert_eq!(std.platform, Platform::Standard);
}
#[test]
fn platform_host_is_stable() {
let p = Platform::host();
assert!(matches!(p, Platform::MacosArm64 | Platform::Standard));
assert!(!p.as_str().is_empty());
}
#[test]
fn store_root_resolution() {
use super::store_root_from;
use std::path::PathBuf;
assert_eq!(
store_root_from(
Some(PathBuf::from("/data/models")),
Some(PathBuf::from("/opt/rt")),
Some(PathBuf::from("/home/u"))
),
Path::new("/data/models"),
);
assert_eq!(
store_root_from(
None,
Some(PathBuf::from("/opt/rt")),
Some(PathBuf::from("/home/u"))
),
Path::new("/opt/rt/models"),
);
assert_eq!(
store_root_from(None, None, Some(PathBuf::from("/home/u"))),
Path::new("/home/u/.roteiro/models"),
);
assert!(store_root().ends_with("models"));
}
#[test]
fn sha256_and_verify() {
let want = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
assert_eq!(sha256_hex(b"abc"), want);
assert!(verify_sha256(b"abc", want));
assert!(verify_sha256(b"abc", &want.to_uppercase()));
assert!(!verify_sha256(b"abc", "00"));
assert!(verify_sha256(b"anything", ""));
}
}