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