Skip to main content

runtime/
ollama.rs

1//! Ollama Registry Client
2//!
3//! Downloads models from the Ollama registry (registry.ollama.ai).
4//! Models are cached locally in ~/.cache/unillm/ollama/
5
6use anyhow::{anyhow, Result};
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use tokio::io::AsyncWriteExt;
11
12const OLLAMA_REGISTRY: &str = "https://registry.ollama.ai";
13const DEFAULT_TAG: &str = "latest";
14
15/// Ollama registry client for downloading models
16pub struct OllamaRegistry {
17    client: Client,
18    cache_dir: PathBuf,
19}
20
21/// Ollama manifest format (Docker registry v2 compatible)
22#[derive(Debug, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Manifest {
25    pub schema_version: i32,
26    pub media_type: String,
27    pub config: LayerInfo,
28    pub layers: Vec<LayerInfo>,
29}
30
31#[derive(Debug, Deserialize, Serialize)]
32pub struct LayerInfo {
33    pub digest: String,
34    pub size: u64,
35    #[serde(rename = "mediaType")]
36    pub media_type: String,
37}
38
39/// Model info extracted from manifest
40#[derive(Debug, Clone)]
41pub struct ModelInfo {
42    pub name: String,
43    pub tag: String,
44    pub digest: String,
45    pub size: u64,
46}
47
48impl OllamaRegistry {
49    /// Create a new Ollama registry client with default cache directory
50    pub fn new() -> Result<Self> {
51        let cache_dir = dirs::cache_dir()
52            .unwrap_or_else(|| PathBuf::from("."))
53            .join("unillm")
54            .join("ollama");
55
56        Self::with_cache_dir(cache_dir)
57    }
58
59    /// Create a new Ollama registry client with custom cache directory
60    pub fn with_cache_dir(cache_dir: PathBuf) -> Result<Self> {
61        std::fs::create_dir_all(&cache_dir)?;
62        std::fs::create_dir_all(cache_dir.join("manifests"))?;
63        std::fs::create_dir_all(cache_dir.join("blobs"))?;
64
65        Ok(Self {
66            client: Client::new(),
67            cache_dir,
68        })
69    }
70
71    /// Get cache directory
72    pub fn cache_dir(&self) -> &Path {
73        &self.cache_dir
74    }
75
76    /// Parse model string into name and tag
77    /// Examples: "tinyllama" -> ("tinyllama", "latest")
78    ///           "qwen2.5:0.5b" -> ("qwen2.5", "0.5b")
79    fn parse_model_string(model: &str) -> (&str, &str) {
80        if let Some((name, tag)) = model.split_once(':') {
81            (name, tag)
82        } else {
83            (model, DEFAULT_TAG)
84        }
85    }
86
87    /// Fetch manifest for a model
88    pub async fn get_manifest(&self, model: &str) -> Result<Manifest> {
89        let (name, tag) = Self::parse_model_string(model);
90        let url = format!("{}/v2/library/{}/manifests/{}", OLLAMA_REGISTRY, name, tag);
91
92        println!("Fetching manifest from: {}", url);
93
94        let response = self
95            .client
96            .get(&url)
97            .header("Accept", "application/vnd.docker.distribution.manifest.v2+json")
98            .send()
99            .await?;
100
101        if !response.status().is_success() {
102            return Err(anyhow!(
103                "Failed to fetch manifest: {} - {}",
104                response.status(),
105                response.text().await.unwrap_or_default()
106            ));
107        }
108
109        let manifest: Manifest = response.json().await?;
110        Ok(manifest)
111    }
112
113    /// Get model info from manifest
114    pub async fn get_model_info(&self, model: &str) -> Result<ModelInfo> {
115        let (name, tag) = Self::parse_model_string(model);
116        let manifest = self.get_manifest(model).await?;
117
118        // Find the model layer (GGUF file)
119        let model_layer = manifest
120            .layers
121            .iter()
122            .find(|l| l.media_type.contains("model"))
123            .ok_or_else(|| anyhow!("No model layer found in manifest"))?;
124
125        Ok(ModelInfo {
126            name: name.to_string(),
127            tag: tag.to_string(),
128            digest: model_layer.digest.clone(),
129            size: model_layer.size,
130        })
131    }
132
133    /// Check if a model is already cached
134    pub fn is_cached(&self, model: &str) -> bool {
135        if let Ok(info) = tokio::runtime::Handle::current().block_on(self.get_model_info(model)) {
136            let blob_path = self.blob_path(&info.digest);
137            blob_path.exists()
138        } else {
139            false
140        }
141    }
142
143    /// Get path where a blob would be cached
144    fn blob_path(&self, digest: &str) -> PathBuf {
145        // digest is like "sha256:abc123..."
146        let filename = digest.replace(':', "_") + ".gguf";
147        self.cache_dir.join("blobs").join(filename)
148    }
149
150    /// Pull (download) a model from the registry
151    pub async fn pull(&self, model: &str) -> Result<PathBuf> {
152        let info = self.get_model_info(model).await?;
153        let blob_path = self.blob_path(&info.digest);
154
155        // Check if already cached
156        if blob_path.exists() {
157            println!("Model already cached at: {}", blob_path.display());
158            return Ok(blob_path);
159        }
160
161        println!(
162            "Downloading {} ({:.2} MB)...",
163            model,
164            info.size as f64 / 1_000_000.0
165        );
166
167        // Download the blob
168        let (name, _tag) = Self::parse_model_string(model);
169        let url = format!(
170            "{}/v2/library/{}/blobs/{}",
171            OLLAMA_REGISTRY, name, info.digest
172        );
173
174        let response = self.client.get(&url).send().await?;
175
176        if !response.status().is_success() {
177            return Err(anyhow!(
178                "Failed to download blob: {} - {}",
179                response.status(),
180                response.text().await.unwrap_or_default()
181            ));
182        }
183
184        // Stream to file with progress
185        let total_size = info.size;
186        let mut downloaded: u64 = 0;
187        let mut file = tokio::fs::File::create(&blob_path).await?;
188
189        let mut stream = response.bytes_stream();
190        use futures_util::StreamExt;
191
192        while let Some(chunk) = stream.next().await {
193            let chunk = chunk?;
194            file.write_all(&chunk).await?;
195            downloaded += chunk.len() as u64;
196
197            // Print progress every 10MB
198            if downloaded % (10 * 1024 * 1024) < chunk.len() as u64 {
199                println!(
200                    "  Progress: {:.1}%",
201                    (downloaded as f64 / total_size as f64) * 100.0
202                );
203            }
204        }
205
206        file.flush().await?;
207        println!("Download complete: {}", blob_path.display());
208
209        Ok(blob_path)
210    }
211
212    /// List all cached models
213    pub fn list_cached(&self) -> Vec<String> {
214        let blobs_dir = self.cache_dir.join("blobs");
215        let mut models = Vec::new();
216
217        if let Ok(entries) = std::fs::read_dir(blobs_dir) {
218            for entry in entries.flatten() {
219                if let Some(name) = entry.file_name().to_str() {
220                    if name.ends_with(".gguf") {
221                        models.push(name.to_string());
222                    }
223                }
224            }
225        }
226
227        models
228    }
229
230    /// Delete a cached model
231    pub fn delete_cached(&self, digest: &str) -> Result<()> {
232        let blob_path = self.blob_path(digest);
233        if blob_path.exists() {
234            std::fs::remove_file(blob_path)?;
235        }
236        Ok(())
237    }
238
239    /// Get the path to a cached model (if it exists)
240    pub fn get_cached_path(&self, model: &str) -> Option<PathBuf> {
241        if let Ok(info) = tokio::runtime::Handle::current().block_on(self.get_model_info(model)) {
242            let blob_path = self.blob_path(&info.digest);
243            if blob_path.exists() {
244                return Some(blob_path);
245            }
246        }
247        None
248    }
249}
250
251impl Default for OllamaRegistry {
252    fn default() -> Self {
253        Self::new().expect("Failed to create OllamaRegistry")
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_parse_model_string() {
263        assert_eq!(
264            OllamaRegistry::parse_model_string("tinyllama"),
265            ("tinyllama", "latest")
266        );
267        assert_eq!(
268            OllamaRegistry::parse_model_string("qwen2.5:0.5b"),
269            ("qwen2.5", "0.5b")
270        );
271        assert_eq!(
272            OllamaRegistry::parse_model_string("llama3.2:1b"),
273            ("llama3.2", "1b")
274        );
275    }
276
277    #[tokio::test]
278    async fn test_get_manifest() {
279        // This test requires network access
280        let registry = OllamaRegistry::new().unwrap();
281
282        // Try to get manifest for a small model
283        match registry.get_manifest("qwen2.5:0.5b").await {
284            Ok(manifest) => {
285                assert_eq!(manifest.schema_version, 2);
286                assert!(!manifest.layers.is_empty());
287                println!("Manifest layers: {:?}", manifest.layers.len());
288            }
289            Err(e) => {
290                // Network errors are ok in CI
291                println!("Skipping manifest test (network error): {}", e);
292            }
293        }
294    }
295
296    #[tokio::test]
297    async fn test_get_model_info() {
298        let registry = OllamaRegistry::new().unwrap();
299
300        match registry.get_model_info("qwen2.5:0.5b").await {
301            Ok(info) => {
302                assert_eq!(info.name, "qwen2.5");
303                assert_eq!(info.tag, "0.5b");
304                assert!(info.digest.starts_with("sha256:"));
305                println!("Model size: {} bytes", info.size);
306            }
307            Err(e) => {
308                println!("Skipping model info test (network error): {}", e);
309            }
310        }
311    }
312}