1use serde::{Deserialize, Serialize};
14
15use crate::inference_backend::InferenceBackendSettings;
16
17pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434";
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct OllamaStatus {
23 pub reachable: bool,
24 pub base_url: String,
25 pub version_hint: Option<String>,
26 pub models: Vec<OllamaModelInfo>,
27 pub error: Option<String>,
28 pub latency_ms: u64,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct OllamaModelInfo {
33 pub name: String,
34 pub size_bytes: Option<u64>,
35 pub parameter_size: Option<String>,
36 pub quantization: Option<String>,
37 pub family: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OllamaGenerateResult {
43 pub text: String,
44 pub model: String,
45 pub total_duration_ns: Option<u64>,
46 pub eval_count: Option<u32>,
47 pub prompt_eval_count: Option<u32>,
48}
49
50#[derive(Debug, Clone)]
51pub struct OllamaHarness {
52 pub base_url: String,
53 pub gen_model: String,
54 pub embed_model: String,
55 pub api_key: Option<String>,
56 pub num_ctx: u32,
57 pub timeout_secs: u64,
58 pub temperature: f32,
59}
60
61impl Default for OllamaHarness {
62 fn default() -> Self {
63 Self::from_settings(&InferenceBackendSettings::default())
64 }
65}
66
67impl OllamaHarness {
68 pub fn from_settings(settings: &InferenceBackendSettings) -> Self {
69 let base = settings
70 .ollama_base_url
71 .trim()
72 .trim_end_matches('/')
73 .to_string();
74 let base = if base.is_empty() {
75 std::env::var("OLLAMA_HOST").unwrap_or_else(|_| DEFAULT_OLLAMA_BASE_URL.to_string())
76 } else {
77 base
78 };
79 let gen = if settings.ollama_model.trim().is_empty() {
80 std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.2".to_string())
81 } else {
82 settings.ollama_model.trim().to_string()
83 };
84 let embed = if settings.ollama_embed_model.trim().is_empty() {
85 "nomic-embed-text".to_string()
86 } else {
87 settings.ollama_embed_model.trim().to_string()
88 };
89 Self {
90 base_url: base.trim_end_matches('/').to_string(),
91 gen_model: gen,
92 embed_model: embed,
93 api_key: settings
94 .ollama_api_key
95 .as_ref()
96 .map(|s| s.trim().to_string())
97 .filter(|s| !s.is_empty()),
98 num_ctx: settings.ollama_num_ctx.max(512),
99 timeout_secs: settings.ollama_timeout_secs.clamp(5, 3600),
100 temperature: settings.ollama_temperature.clamp(0.0, 2.0),
101 }
102 }
103
104 pub fn from_loaded_settings() -> Self {
105 Self::from_settings(&crate::inference_backend::load_inference_backend_settings())
106 }
107
108 fn url(&self, path: &str) -> String {
109 format!(
110 "{}{}",
111 self.base_url.trim_end_matches('/'),
112 if path.starts_with('/') {
113 path.to_string()
114 } else {
115 format!("/{path}")
116 }
117 )
118 }
119
120 fn apply_auth(
121 &self,
122 mut req: reqwest::blocking::RequestBuilder,
123 ) -> reqwest::blocking::RequestBuilder {
124 if let Some(key) = &self.api_key {
125 req = req.bearer_auth(key);
126 }
127 req
128 }
129
130 fn apply_auth_async(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
131 if let Some(key) = &self.api_key {
132 req = req.bearer_auth(key);
133 }
134 req
135 }
136
137 fn blocking_client(&self) -> Result<reqwest::blocking::Client, String> {
138 reqwest::blocking::Client::builder()
139 .timeout(std::time::Duration::from_secs(self.timeout_secs))
140 .build()
141 .map_err(|e| format!("ollama client: {e}"))
142 }
143
144 async fn async_client(&self) -> Result<reqwest::Client, String> {
145 reqwest::Client::builder()
146 .timeout(std::time::Duration::from_secs(self.timeout_secs))
147 .build()
148 .map_err(|e| format!("ollama client: {e}"))
149 }
150
151 pub fn probe_status(&self) -> OllamaStatus {
153 let t0 = std::time::Instant::now();
154 let client = match self.blocking_client() {
155 Ok(c) => c,
156 Err(e) => {
157 return OllamaStatus {
158 reachable: false,
159 base_url: self.base_url.clone(),
160 version_hint: None,
161 models: vec![],
162 error: Some(e),
163 latency_ms: 0,
164 };
165 }
166 };
167 let req = self.apply_auth(client.get(self.url("/api/tags")));
168 match req.send() {
169 Ok(resp) if resp.status().is_success() => {
170 let latency_ms = t0.elapsed().as_millis() as u64;
171 match resp.json::<TagsResponse>() {
172 Ok(tags) => OllamaStatus {
173 reachable: true,
174 base_url: self.base_url.clone(),
175 version_hint: Some("ollama".into()),
176 models: tags
177 .models
178 .into_iter()
179 .map(|m| OllamaModelInfo {
180 name: m.name,
181 size_bytes: m.size,
182 parameter_size: m
183 .details
184 .as_ref()
185 .and_then(|d| d.parameter_size.clone()),
186 quantization: m
187 .details
188 .as_ref()
189 .and_then(|d| d.quantization_level.clone()),
190 family: m.details.as_ref().and_then(|d| d.family.clone()),
191 })
192 .collect(),
193 error: None,
194 latency_ms,
195 },
196 Err(e) => OllamaStatus {
197 reachable: true,
198 base_url: self.base_url.clone(),
199 version_hint: None,
200 models: vec![],
201 error: Some(format!("parse /api/tags: {e}")),
202 latency_ms,
203 },
204 }
205 }
206 Ok(resp) => OllamaStatus {
207 reachable: false,
208 base_url: self.base_url.clone(),
209 version_hint: None,
210 models: vec![],
211 error: Some(format!("HTTP {}", resp.status())),
212 latency_ms: t0.elapsed().as_millis() as u64,
213 },
214 Err(e) => OllamaStatus {
215 reachable: false,
216 base_url: self.base_url.clone(),
217 version_hint: None,
218 models: vec![],
219 error: Some(e.to_string()),
220 latency_ms: t0.elapsed().as_millis() as u64,
221 },
222 }
223 }
224
225 pub async fn probe_status_async(&self) -> OllamaStatus {
227 let t0 = std::time::Instant::now();
228 let client = match self.async_client().await {
229 Ok(c) => c,
230 Err(e) => {
231 return OllamaStatus {
232 reachable: false,
233 base_url: self.base_url.clone(),
234 version_hint: None,
235 models: vec![],
236 error: Some(e),
237 latency_ms: 0,
238 };
239 }
240 };
241 let req = self.apply_auth_async(client.get(self.url("/api/tags")));
242 match req.send().await {
243 Ok(resp) if resp.status().is_success() => {
244 let latency_ms = t0.elapsed().as_millis() as u64;
245 match resp.json::<TagsResponse>().await {
246 Ok(tags) => OllamaStatus {
247 reachable: true,
248 base_url: self.base_url.clone(),
249 version_hint: Some("ollama".into()),
250 models: tags
251 .models
252 .into_iter()
253 .map(|m| OllamaModelInfo {
254 name: m.name,
255 size_bytes: m.size,
256 parameter_size: m
257 .details
258 .as_ref()
259 .and_then(|d| d.parameter_size.clone()),
260 quantization: m
261 .details
262 .as_ref()
263 .and_then(|d| d.quantization_level.clone()),
264 family: m.details.as_ref().and_then(|d| d.family.clone()),
265 })
266 .collect(),
267 error: None,
268 latency_ms,
269 },
270 Err(e) => OllamaStatus {
271 reachable: true,
272 base_url: self.base_url.clone(),
273 version_hint: None,
274 models: vec![],
275 error: Some(format!("parse /api/tags: {e}")),
276 latency_ms,
277 },
278 }
279 }
280 Ok(resp) => OllamaStatus {
281 reachable: false,
282 base_url: self.base_url.clone(),
283 version_hint: None,
284 models: vec![],
285 error: Some(format!("HTTP {}", resp.status())),
286 latency_ms: t0.elapsed().as_millis() as u64,
287 },
288 Err(e) => OllamaStatus {
289 reachable: false,
290 base_url: self.base_url.clone(),
291 version_hint: None,
292 models: vec![],
293 error: Some(e.to_string()),
294 latency_ms: t0.elapsed().as_millis() as u64,
295 },
296 }
297 }
298
299 pub fn generate(&self, system: &str, prompt: &str) -> Result<OllamaGenerateResult, String> {
301 let client = self.blocking_client()?;
302 let body = serde_json::json!({
303 "model": self.gen_model,
304 "system": system,
305 "prompt": prompt,
306 "stream": false,
307 "options": {
308 "temperature": self.temperature,
309 "num_ctx": self.num_ctx,
310 }
311 });
312 let req = self.apply_auth(client.post(self.url("/api/generate")).json(&body));
313 let resp = req.send().map_err(|e| format!("ollama generate: {e}"))?;
314 if !resp.status().is_success() {
315 let status = resp.status();
316 let body = resp.text().unwrap_or_default();
317 return Err(format!("ollama generate HTTP {status}: {body}"));
318 }
319 let parsed: GenerateResponse = resp
320 .json()
321 .map_err(|e| format!("ollama generate parse: {e}"))?;
322 Ok(OllamaGenerateResult {
323 text: parsed.response,
324 model: parsed.model.unwrap_or_else(|| self.gen_model.clone()),
325 total_duration_ns: parsed.total_duration,
326 eval_count: parsed.eval_count,
327 prompt_eval_count: parsed.prompt_eval_count,
328 })
329 }
330
331 pub async fn generate_async(
333 &self,
334 system: &str,
335 prompt: &str,
336 ) -> Result<OllamaGenerateResult, String> {
337 let client = self.async_client().await?;
338 let body = serde_json::json!({
339 "model": self.gen_model,
340 "system": system,
341 "prompt": prompt,
342 "stream": false,
343 "options": {
344 "temperature": self.temperature,
345 "num_ctx": self.num_ctx,
346 }
347 });
348 let req = self.apply_auth_async(client.post(self.url("/api/generate")).json(&body));
349 let resp = req
350 .send()
351 .await
352 .map_err(|e| format!("ollama generate: {e}"))?;
353 if !resp.status().is_success() {
354 let status = resp.status();
355 let body = resp.text().await.unwrap_or_default();
356 return Err(format!("ollama generate HTTP {status}: {body}"));
357 }
358 let parsed: GenerateResponse = resp
359 .json()
360 .await
361 .map_err(|e| format!("ollama generate parse: {e}"))?;
362 Ok(OllamaGenerateResult {
363 text: parsed.response,
364 model: parsed.model.unwrap_or_else(|| self.gen_model.clone()),
365 total_duration_ns: parsed.total_duration,
366 eval_count: parsed.eval_count,
367 prompt_eval_count: parsed.prompt_eval_count,
368 })
369 }
370
371 pub fn chat(&self, system: &str, user: &str) -> Result<OllamaGenerateResult, String> {
373 let client = self.blocking_client()?;
374 let body = serde_json::json!({
375 "model": self.gen_model,
376 "stream": false,
377 "messages": [
378 { "role": "system", "content": system },
379 { "role": "user", "content": user },
380 ],
381 "options": {
382 "temperature": self.temperature,
383 "num_ctx": self.num_ctx,
384 }
385 });
386 let req = self.apply_auth(client.post(self.url("/api/chat")).json(&body));
387 let resp = req.send().map_err(|e| format!("ollama chat: {e}"))?;
388 if !resp.status().is_success() {
389 let status = resp.status();
390 let body = resp.text().unwrap_or_default();
391 return Err(format!("ollama chat HTTP {status}: {body}"));
392 }
393 let parsed: ChatResponse = resp.json().map_err(|e| format!("ollama chat parse: {e}"))?;
394 Ok(OllamaGenerateResult {
395 text: parsed.message.map(|m| m.content).unwrap_or_default(),
396 model: parsed.model.unwrap_or_else(|| self.gen_model.clone()),
397 total_duration_ns: parsed.total_duration,
398 eval_count: parsed.eval_count,
399 prompt_eval_count: parsed.prompt_eval_count,
400 })
401 }
402
403 pub fn embed(&self, text: &str) -> Result<Vec<f32>, String> {
405 let client = self.blocking_client()?;
406 let body = serde_json::json!({
407 "model": self.embed_model,
408 "prompt": text,
409 });
410 let req = self.apply_auth(client.post(self.url("/api/embeddings")).json(&body));
411 let resp = req.send().map_err(|e| format!("ollama embed: {e}"))?;
412 if !resp.status().is_success() {
413 let status = resp.status();
414 let body = resp.text().unwrap_or_default();
415 return Err(format!("ollama embed HTTP {status}: {body}"));
416 }
417 let parsed: EmbeddingResponse = resp
418 .json()
419 .map_err(|e| format!("ollama embed parse: {e}"))?;
420 Ok(parsed.embedding)
421 }
422}
423
424pub fn probe_configured_ollama() -> OllamaStatus {
426 OllamaHarness::from_loaded_settings().probe_status()
427}
428
429pub async fn probe_configured_ollama_async() -> OllamaStatus {
430 OllamaHarness::from_loaded_settings()
431 .probe_status_async()
432 .await
433}
434
435#[derive(Debug, Deserialize)]
438struct TagsResponse {
439 #[serde(default)]
440 models: Vec<TagModel>,
441}
442
443#[derive(Debug, Deserialize)]
444struct TagModel {
445 name: String,
446 #[serde(default)]
447 size: Option<u64>,
448 #[serde(default)]
449 details: Option<TagDetails>,
450}
451
452#[derive(Debug, Deserialize)]
453struct TagDetails {
454 #[serde(default)]
455 parameter_size: Option<String>,
456 #[serde(default)]
457 quantization_level: Option<String>,
458 #[serde(default)]
459 family: Option<String>,
460}
461
462#[derive(Debug, Deserialize)]
463struct GenerateResponse {
464 response: String,
465 #[serde(default)]
466 model: Option<String>,
467 #[serde(default)]
468 total_duration: Option<u64>,
469 #[serde(default)]
470 eval_count: Option<u32>,
471 #[serde(default)]
472 prompt_eval_count: Option<u32>,
473}
474
475#[derive(Debug, Deserialize)]
476struct ChatResponse {
477 #[serde(default)]
478 message: Option<ChatMessage>,
479 #[serde(default)]
480 model: Option<String>,
481 #[serde(default)]
482 total_duration: Option<u64>,
483 #[serde(default)]
484 eval_count: Option<u32>,
485 #[serde(default)]
486 prompt_eval_count: Option<u32>,
487}
488
489#[derive(Debug, Deserialize)]
490struct ChatMessage {
491 #[serde(default)]
492 content: String,
493}
494
495#[derive(Debug, Deserialize)]
496struct EmbeddingResponse {
497 embedding: Vec<f32>,
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::chat_agents::AgentBackendKind;
504 use crate::inference_backend::InferenceBackendSettings;
505
506 #[test]
507 fn harness_from_settings_defaults() {
508 let s = InferenceBackendSettings {
509 backend: AgentBackendKind::Ollama,
510 ollama_base_url: "http://127.0.0.1:11434/".into(),
511 ollama_model: "qwen2.5:7b".into(),
512 ollama_embed_model: "nomic-embed-text".into(),
513 ollama_timeout_secs: 120,
514 ollama_num_ctx: 8192,
515 ollama_temperature: 0.2,
516 ..Default::default()
517 };
518 let h = OllamaHarness::from_settings(&s);
519 assert_eq!(h.base_url, "http://127.0.0.1:11434");
520 assert_eq!(h.gen_model, "qwen2.5:7b");
521 assert_eq!(h.num_ctx, 8192);
522 }
523
524 #[test]
525 fn unreachable_probe_is_fail_closed() {
526 let h = OllamaHarness {
527 base_url: "http://127.0.0.1:1".into(),
528 gen_model: "x".into(),
529 embed_model: "y".into(),
530 api_key: None,
531 num_ctx: 2048,
532 timeout_secs: 1,
533 temperature: 0.1,
534 };
535 let st = h.probe_status();
536 assert!(!st.reachable);
537 assert!(st.error.is_some());
538 }
539}