kernel/discovery/
ollama_scanner.rs1use 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
13pub struct OllamaStoreScanner {
15 root: PathBuf,
16}
17
18impl OllamaStoreScanner {
19 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 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 let parts: Vec<std::borrow::Cow<str>> = relative
80 .components()
81 .map(|component| component.as_os_str().to_string_lossy())
82 .collect();
83 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 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(¶ms.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
183fn 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 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}