Skip to main content

kernel/discovery/
ollama_scanner.rs

1//! Scans a local Ollama store (`~/.ollama/models`): walks `manifests/<registry>/
2//! <namespace>/<model>/<tag>`, reads each manifest's layer list, and resolves the
3//! weight/template/projector/params blobs into a [`DiscoveredModel`].
4
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::discovery::scanner::{DiscoveredModel, ScanResult, StoreScanner};
10use crate::records::{JsonValue, ModelSource, SourceKind};
11use crate::resolution::ollama_profile;
12
13/// A scanner over one Ollama models root.
14pub struct OllamaStoreScanner {
15    root: PathBuf,
16}
17
18impl OllamaStoreScanner {
19    /// A scanner rooted at an Ollama models directory (the one holding
20    /// `manifests/` and `blobs/`).
21    pub fn new(root: impl Into<PathBuf>) -> Self {
22        Self { root: root.into() }
23    }
24
25    fn blob_path(&self, digest: &str) -> PathBuf {
26        self.root.join("blobs").join(digest.replace(':', "-"))
27    }
28}
29
30#[derive(Debug, Deserialize)]
31struct Manifest {
32    #[serde(default)]
33    layers: Vec<Layer>,
34}
35
36#[derive(Debug, Deserialize)]
37struct Layer {
38    #[serde(rename = "mediaType", default)]
39    media_type: String,
40    #[serde(default)]
41    size: i64,
42    #[serde(default)]
43    digest: String,
44}
45
46impl StoreScanner for OllamaStoreScanner {
47    fn kinds(&self) -> Vec<SourceKind> {
48        vec![SourceKind::ollama()]
49    }
50
51    fn scan(&self) -> ScanResult {
52        let mut result = ScanResult::default();
53        if !self.root.exists() {
54            return result;
55        }
56        // Root exists but can't be listed (no search permission, or it isn't a
57        // directory) — that's a scan failure, not an empty store.
58        if std::fs::read_dir(&self.root).is_err() {
59            result.failed_kinds.push(SourceKind::ollama());
60            return result;
61        }
62        let manifests = self.root.join("manifests");
63        if !manifests.exists() {
64            return result;
65        }
66
67        let mut files = Vec::new();
68        if collect_files(&manifests, &mut files).is_err() {
69            result.failed_kinds.push(SourceKind::ollama());
70            return result;
71        }
72
73        for file in files {
74            let Ok(relative) = file.strip_prefix(&manifests) else {
75                continue;
76            };
77            // Map (don't drop) each component so a non-UTF-8 segment can't shift
78            // the count — the four-component check must see the true depth.
79            let parts: Vec<std::borrow::Cow<str>> = relative
80                .components()
81                .map(|component| component.as_os_str().to_string_lossy())
82                .collect();
83            // `<registry>/<namespace>/<model>/<tag>` — exactly four components.
84            let [_, namespace, model, tag] = parts.as_slice() else {
85                continue;
86            };
87
88            let bytes = match std::fs::read(&file) {
89                Ok(bytes) => bytes,
90                Err(_) => {
91                    result
92                        .issues
93                        .push(format!("ollama: unreadable manifest {}", display(&file)));
94                    continue;
95                }
96            };
97            let manifest = match serde_json::from_slice::<Manifest>(&bytes) {
98                Ok(manifest) => manifest,
99                Err(error) => {
100                    result.issues.push(format!(
101                        "ollama: unreadable manifest {}: {error}",
102                        display(&file)
103                    ));
104                    continue;
105                }
106            };
107
108            let name = if namespace.as_ref() == "library" {
109                format!("{model}:{tag}")
110            } else {
111                format!("{namespace}/{model}:{tag}")
112            };
113            let footprint: i64 = manifest.layers.iter().map(|layer| layer.size).sum();
114            let weight_blob = manifest
115                .layers
116                .iter()
117                .find(|layer| layer.media_type.ends_with(".model"))
118                .map(|layer| display(&self.blob_path(&layer.digest)));
119            let has_template = manifest
120                .layers
121                .iter()
122                .any(|layer| layer.media_type.ends_with(".template"));
123            let has_projector = manifest
124                .layers
125                .iter()
126                .any(|layer| layer.media_type.ends_with(".projector"));
127            let profile = ollama_profile(has_projector, weight_blob.as_deref());
128
129            let mut context_length_hint = None;
130            let mut stop_tokens_hint = None;
131            if let Some(params) = manifest
132                .layers
133                .iter()
134                .find(|layer| layer.media_type.ends_with(".params"))
135            {
136                match std::fs::read(self.blob_path(&params.digest))
137                    .ok()
138                    .and_then(|bytes| serde_json::from_slice::<JsonValue>(&bytes).ok())
139                {
140                    Some(JsonValue::Object(fields)) => {
141                        context_length_hint = fields
142                            .get("num_ctx")
143                            .and_then(JsonValue::as_i64)
144                            .filter(|value| *value > 0);
145                        stop_tokens_hint = fields.get("stop").and_then(string_array);
146                    }
147                    _ => result
148                        .issues
149                        .push(format!("ollama: unreadable params blob for {name}")),
150                }
151            }
152
153            let mut source = ModelSource::new(SourceKind::ollama(), &display(&file));
154            source.repo = Some(name.clone());
155            let mut discovered = DiscoveredModel::new(name, source);
156            discovered.modality_hint = Some(profile.modality);
157            discovered.capabilities_hint = profile.capabilities;
158            discovered.execution_hint = profile.execution;
159            discovered.footprint_bytes = footprint;
160            discovered.primary_weight_path = weight_blob;
161            discovered.context_length_hint = context_length_hint;
162            discovered.has_chat_template_hint = has_template.then_some(true);
163            discovered.stop_tokens_hint = stop_tokens_hint;
164            result.discovered.push(discovered);
165        }
166
167        result
168    }
169}
170
171/// Recursively collect the regular files under `dir` (skipping hidden entries).
172/// An error reading `dir` itself propagates; a subdirectory that can't be read is
173/// skipped so one bad directory doesn't abort the whole scan.
174fn collect_files(dir: &Path, into: &mut Vec<PathBuf>) -> std::io::Result<()> {
175    for entry in std::fs::read_dir(dir)? {
176        let entry = entry?;
177        let path = entry.path();
178        if path
179            .file_name()
180            .and_then(|name| name.to_str())
181            .is_some_and(|name| name.starts_with('.'))
182        {
183            continue;
184        }
185        let Ok(file_type) = entry.file_type() else {
186            continue;
187        };
188        if file_type.is_dir() {
189            let _ = collect_files(&path, into);
190        } else if file_type.is_file() {
191            into.push(path);
192        }
193    }
194    Ok(())
195}
196
197fn string_array(value: &JsonValue) -> Option<Vec<String>> {
198    let JsonValue::Array(items) = value else {
199        return None;
200    };
201    // All-or-nothing: a single non-string element voids the whole array rather
202    // than being silently dropped.
203    items
204        .iter()
205        .map(|item| item.as_str().map(str::to_owned))
206        .collect()
207}
208
209fn display(path: &Path) -> String {
210    path.to_string_lossy().into_owned()
211}