1use 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
15pub struct OllamaRegistry {
17 client: Client,
18 cache_dir: PathBuf,
19}
20
21#[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#[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 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 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 pub fn cache_dir(&self) -> &Path {
73 &self.cache_dir
74 }
75
76 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 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 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 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 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 fn blob_path(&self, digest: &str) -> PathBuf {
145 let filename = digest.replace(':', "_") + ".gguf";
147 self.cache_dir.join("blobs").join(filename)
148 }
149
150 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 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 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 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 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 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 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 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 let registry = OllamaRegistry::new().unwrap();
281
282 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 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}