use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
const OLLAMA_REGISTRY: &str = "https://registry.ollama.ai";
const DEFAULT_TAG: &str = "latest";
pub struct OllamaRegistry {
client: Client,
cache_dir: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub schema_version: i32,
pub media_type: String,
pub config: LayerInfo,
pub layers: Vec<LayerInfo>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LayerInfo {
pub digest: String,
pub size: u64,
#[serde(rename = "mediaType")]
pub media_type: String,
}
#[derive(Debug, Clone)]
pub struct ModelInfo {
pub name: String,
pub tag: String,
pub digest: String,
pub size: u64,
}
impl OllamaRegistry {
pub fn new() -> Result<Self> {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("unillm")
.join("ollama");
Self::with_cache_dir(cache_dir)
}
pub fn with_cache_dir(cache_dir: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&cache_dir)?;
std::fs::create_dir_all(cache_dir.join("manifests"))?;
std::fs::create_dir_all(cache_dir.join("blobs"))?;
Ok(Self {
client: Client::new(),
cache_dir,
})
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn parse_model_string(model: &str) -> (&str, &str) {
if let Some((name, tag)) = model.split_once(':') {
(name, tag)
} else {
(model, DEFAULT_TAG)
}
}
pub async fn get_manifest(&self, model: &str) -> Result<Manifest> {
let (name, tag) = Self::parse_model_string(model);
let url = format!("{}/v2/library/{}/manifests/{}", OLLAMA_REGISTRY, name, tag);
println!("Fetching manifest from: {}", url);
let response = self
.client
.get(&url)
.header("Accept", "application/vnd.docker.distribution.manifest.v2+json")
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow!(
"Failed to fetch manifest: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
));
}
let manifest: Manifest = response.json().await?;
Ok(manifest)
}
pub async fn get_model_info(&self, model: &str) -> Result<ModelInfo> {
let (name, tag) = Self::parse_model_string(model);
let manifest = self.get_manifest(model).await?;
let model_layer = manifest
.layers
.iter()
.find(|l| l.media_type.contains("model"))
.ok_or_else(|| anyhow!("No model layer found in manifest"))?;
Ok(ModelInfo {
name: name.to_string(),
tag: tag.to_string(),
digest: model_layer.digest.clone(),
size: model_layer.size,
})
}
pub fn is_cached(&self, model: &str) -> bool {
if let Ok(info) = tokio::runtime::Handle::current().block_on(self.get_model_info(model)) {
let blob_path = self.blob_path(&info.digest);
blob_path.exists()
} else {
false
}
}
fn blob_path(&self, digest: &str) -> PathBuf {
let filename = digest.replace(':', "_") + ".gguf";
self.cache_dir.join("blobs").join(filename)
}
pub async fn pull(&self, model: &str) -> Result<PathBuf> {
let info = self.get_model_info(model).await?;
let blob_path = self.blob_path(&info.digest);
if blob_path.exists() {
println!("Model already cached at: {}", blob_path.display());
return Ok(blob_path);
}
println!(
"Downloading {} ({:.2} MB)...",
model,
info.size as f64 / 1_000_000.0
);
let (name, _tag) = Self::parse_model_string(model);
let url = format!(
"{}/v2/library/{}/blobs/{}",
OLLAMA_REGISTRY, name, info.digest
);
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow!(
"Failed to download blob: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
));
}
let total_size = info.size;
let mut downloaded: u64 = 0;
let mut file = tokio::fs::File::create(&blob_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
downloaded += chunk.len() as u64;
if downloaded % (10 * 1024 * 1024) < chunk.len() as u64 {
println!(
" Progress: {:.1}%",
(downloaded as f64 / total_size as f64) * 100.0
);
}
}
file.flush().await?;
println!("Download complete: {}", blob_path.display());
Ok(blob_path)
}
pub fn list_cached(&self) -> Vec<String> {
let blobs_dir = self.cache_dir.join("blobs");
let mut models = Vec::new();
if let Ok(entries) = std::fs::read_dir(blobs_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".gguf") {
models.push(name.to_string());
}
}
}
}
models
}
pub fn delete_cached(&self, digest: &str) -> Result<()> {
let blob_path = self.blob_path(digest);
if blob_path.exists() {
std::fs::remove_file(blob_path)?;
}
Ok(())
}
pub fn get_cached_path(&self, model: &str) -> Option<PathBuf> {
if let Ok(info) = tokio::runtime::Handle::current().block_on(self.get_model_info(model)) {
let blob_path = self.blob_path(&info.digest);
if blob_path.exists() {
return Some(blob_path);
}
}
None
}
}
impl Default for OllamaRegistry {
fn default() -> Self {
Self::new().expect("Failed to create OllamaRegistry")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_model_string() {
assert_eq!(
OllamaRegistry::parse_model_string("tinyllama"),
("tinyllama", "latest")
);
assert_eq!(
OllamaRegistry::parse_model_string("qwen2.5:0.5b"),
("qwen2.5", "0.5b")
);
assert_eq!(
OllamaRegistry::parse_model_string("llama3.2:1b"),
("llama3.2", "1b")
);
}
#[tokio::test]
async fn test_get_manifest() {
let registry = OllamaRegistry::new().unwrap();
match registry.get_manifest("qwen2.5:0.5b").await {
Ok(manifest) => {
assert_eq!(manifest.schema_version, 2);
assert!(!manifest.layers.is_empty());
println!("Manifest layers: {:?}", manifest.layers.len());
}
Err(e) => {
println!("Skipping manifest test (network error): {}", e);
}
}
}
#[tokio::test]
async fn test_get_model_info() {
let registry = OllamaRegistry::new().unwrap();
match registry.get_model_info("qwen2.5:0.5b").await {
Ok(info) => {
assert_eq!(info.name, "qwen2.5");
assert_eq!(info.tag, "0.5b");
assert!(info.digest.starts_with("sha256:"));
println!("Model size: {} bytes", info.size);
}
Err(e) => {
println!("Skipping model info test (network error): {}", e);
}
}
}
}