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::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 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(¶ms.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
180fn 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 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}