Skip to main content

qualia_client_core/
ollama_harness.rs

1//! Optional Ollama HTTP harness for inference when the native Qualia GGUF path
2//! is unavailable or the principal explicitly selects Ollama.
3//!
4//! **Not the primary engine.** Qualia remains Local GGUF / wgpu in-process.
5//! This harness is opt-in so chat, ETL scaffolding, and later CML/logic gates
6//! can still run against a reachable Ollama (or OpenAI-compatible) endpoint
7//! while native inference is brought up.
8//!
9//! Wire format: Ollama REST (`/api/tags`, `/api/generate`, `/api/chat`).
10//! Network I/O is confined to this module; callers pass prompts already
11//! augmented by Qualia retrieval / ontology routing.
12
13use serde::{Deserialize, Serialize};
14
15use crate::inference_backend::InferenceBackendSettings;
16
17/// Default local Ollama base URL (override via settings or `OLLAMA_HOST`).
18pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434";
19
20/// Operator-visible probe of a configured Ollama endpoint.
21#[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/// One completed generation (non-streaming).
41#[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    /// GET `/api/tags` — reachability + model list for the options page.
152    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    /// Async variant of [`Self::probe_status`] for Tauri commands.
226    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    /// POST `/api/generate` — single-shot completion (blocking; chat/ETL cold path).
300    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    /// Async generate for desktop/Tauri.
332    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    /// POST `/api/chat` multi-turn (system + user messages).
372    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    /// Embedding vectors via `/api/embeddings` (ETL / retrieval prep).
404    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
424/// Convenience: probe using currently persisted settings.
425pub 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// ── Wire types ────────────────────────────────────────────────────────────────
436
437#[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}