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,
}
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",
}
}
}
#[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)]
pub struct ModelSpec {
pub name: &'static str,
pub kind: ModelKind,
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: "all-minilm-l6-v2",
kind: ModelKind::Embedding,
tier: ResourceTier::Low,
dim: 384,
licence: "Apache-2.0",
description: "sentence-transformers/all-MiniLM-L6-v2 — small, fast general-purpose embeddings",
size_mib: 90,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "config.json",
url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/config.json",
sha256: "",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json",
sha256: "",
},
ModelFile {
name: "model.safetensors",
url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/model.safetensors",
sha256: "",
},
],
}],
},
ModelSpec {
name: "bge-base-en-v1.5",
kind: ModelKind::Embedding,
tier: ResourceTier::Mid,
dim: 768,
licence: "MIT",
description: "BAAI/bge-base-en-v1.5 — stronger English embeddings (768-d)",
size_mib: 420,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "config.json",
url: "https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/config.json",
sha256: "bc00af31a4a31b74040d73370aa83b62da34c90b75eb77bfa7db039d90abd591",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/tokenizer.json",
sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66",
},
ModelFile {
name: "model.safetensors",
url: "https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/model.safetensors",
sha256: "c7c1988aae201f80cf91a5dbbd5866409503b89dcaba877ca6dba7dd0a5167d7",
},
],
}],
},
ModelSpec {
name: "bge-large-en-v1.5",
kind: ModelKind::Embedding,
tier: ResourceTier::High,
dim: 1024,
licence: "MIT",
description: "BAAI/bge-large-en-v1.5 — strongest BERT embeddings we load (1024-d)",
size_mib: 1340,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "config.json",
url: "https://huggingface.co/BAAI/bge-large-en-v1.5/resolve/main/config.json",
sha256: "446712fac367857b4b1302762fe1cd7bfa8b3c4b77b4dc5d77c4025407660896",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/BAAI/bge-large-en-v1.5/resolve/main/tokenizer.json",
sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66",
},
ModelFile {
name: "model.safetensors",
url: "https://huggingface.co/BAAI/bge-large-en-v1.5/resolve/main/model.safetensors",
sha256: "45e1954914e29bd74080e6c1510165274ff5279421c89f76c418878732f64ae7",
},
],
}],
},
ModelSpec {
name: "bge-small-en-v1.5-gguf",
kind: ModelKind::Embedding,
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,
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",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/Qwen/Qwen3-0.6B/resolve/main/tokenizer.json",
sha256: "aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4",
},
],
}],
},
ModelSpec {
name: "qwen3-8b",
kind: ModelKind::Generative,
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",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/Qwen/Qwen3-8B/resolve/main/tokenizer.json",
sha256: "aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4",
},
],
}],
},
ModelSpec {
name: "qwen3-32b",
kind: ModelKind::Generative,
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",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/Qwen/Qwen3-32B/resolve/main/tokenizer.json",
sha256: "aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4",
},
],
}],
},
ModelSpec {
name: "ocrs-text",
kind: ModelKind::Ocr,
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: "moondream2",
kind: ModelKind::Vision,
tier: ResourceTier::Low,
dim: 0,
licence: "Apache-2.0",
description: "Moondream2 (Q4_0 GGUF) — tiny offline vision-language model for `image-vision`",
size_mib: 1445,
variants: &[ModelVariant {
platform: Platform::Standard,
files: &[
ModelFile {
name: "model.gguf",
url: "https://huggingface.co/santiagomed/candle-moondream/resolve/main/model-q4_0.gguf",
sha256: "cdde43dcf5f4249111ad36cadd8810ea88bf3aabb33de2bab0146e50a31d78c0",
},
ModelFile {
name: "tokenizer.json",
url: "https://huggingface.co/santiagomed/candle-moondream/resolve/main/tokenizer.json",
sha256: "337da36be7a71a6e88aa9148967a7bc8736f4b47c7de8e19ba92b89e80734cfc",
},
],
}],
},
ModelSpec {
name: "smolvlm-500m-gguf",
kind: ModelKind::Vision,
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",
},
],
}],
},
];
#[must_use]
pub fn find(name: &str) -> Option<&'static ModelSpec> {
REGISTRY.iter().find(|m| m.name == name)
}
fn store_root_from(roteiro_home: Option<PathBuf>, home: Option<PathBuf>) -> PathBuf {
if let Some(dir) = roteiro_home {
return dir.join("models");
}
home.unwrap_or_else(|| PathBuf::from("."))
.join(".roteiro")
.join("models")
}
#[must_use]
pub fn store_root() -> PathBuf {
store_root_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("all-minilm-l6-v2").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("/opt/rt")),
Some(PathBuf::from("/home/u"))
),
Path::new("/opt/rt/models"),
);
assert_eq!(
store_root_from(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", ""));
}
}