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::{gguf_general_architecture, 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 template_layer = manifest
120                .layers
121                .iter()
122                .find(|layer| layer.media_type.ends_with(".template"));
123            let has_template = template_layer.is_some();
124            // Ollama decides tool support from this Go template: a tool-capable
125            // model gates its output on `.Tools`. Reading it is authoritative —
126            // the same signal `/api/show` reports — and needs no daemon. A model
127            // whose template we can't read stays undetermined (`None`).
128            let tool_capable_hint = template_layer
129                .and_then(|layer| std::fs::read_to_string(self.blob_path(&layer.digest)).ok())
130                .map(|template| template.contains(".Tools"));
131            let has_projector = manifest
132                .layers
133                .iter()
134                .any(|layer| layer.media_type.ends_with(".projector"));
135            let architecture = weight_blob
136                .as_deref()
137                .and_then(|path| gguf_general_architecture(Path::new(path)));
138            let profile = ollama_profile(has_projector, architecture.as_deref());
139
140            let mut context_length_hint = None;
141            let mut stop_tokens_hint = None;
142            if let Some(params) = manifest
143                .layers
144                .iter()
145                .find(|layer| layer.media_type.ends_with(".params"))
146            {
147                match std::fs::read(self.blob_path(&params.digest))
148                    .ok()
149                    .and_then(|bytes| serde_json::from_slice::<JsonValue>(&bytes).ok())
150                {
151                    Some(JsonValue::Object(fields)) => {
152                        context_length_hint = fields
153                            .get("num_ctx")
154                            .and_then(JsonValue::as_i64)
155                            .filter(|value| *value > 0);
156                        stop_tokens_hint = fields.get("stop").and_then(string_array);
157                    }
158                    _ => result
159                        .issues
160                        .push(format!("ollama: unreadable params blob for {name}")),
161                }
162            }
163
164            let mut source = ModelSource::new(SourceKind::ollama(), &display(&file));
165            source.repo = Some(name.clone());
166            let mut discovered = DiscoveredModel::new(name, source);
167            discovered.modality_hint = Some(profile.modality);
168            discovered.capabilities_hint = profile.capabilities;
169            discovered.execution_hint = profile.execution;
170            discovered.footprint_bytes = footprint;
171            discovered.primary_weight_path = weight_blob;
172            discovered.context_length_hint = context_length_hint;
173            discovered.has_chat_template_hint = has_template.then_some(true);
174            discovered.tool_capable_hint = tool_capable_hint;
175            discovered.stop_tokens_hint = stop_tokens_hint;
176            result.discovered.push(discovered);
177        }
178
179        result
180    }
181}
182
183/// Recursively collect the regular files under `dir` (skipping hidden entries).
184/// An error reading `dir` itself propagates; a subdirectory that can't be read is
185/// skipped so one bad directory doesn't abort the whole scan.
186fn collect_files(dir: &Path, into: &mut Vec<PathBuf>) -> std::io::Result<()> {
187    for entry in std::fs::read_dir(dir)? {
188        let entry = entry?;
189        let path = entry.path();
190        if path
191            .file_name()
192            .and_then(|name| name.to_str())
193            .is_some_and(|name| name.starts_with('.'))
194        {
195            continue;
196        }
197        let Ok(file_type) = entry.file_type() else {
198            continue;
199        };
200        if file_type.is_dir() {
201            let _ = collect_files(&path, into);
202        } else if file_type.is_file() {
203            into.push(path);
204        }
205    }
206    Ok(())
207}
208
209fn string_array(value: &JsonValue) -> Option<Vec<String>> {
210    let JsonValue::Array(items) = value else {
211        return None;
212    };
213    // All-or-nothing: a single non-string element voids the whole array rather
214    // than being silently dropped.
215    items
216        .iter()
217        .map(|item| item.as_str().map(str::to_owned))
218        .collect()
219}
220
221fn display(path: &Path) -> String {
222    path.to_string_lossy().into_owned()
223}