Skip to main content

qualia_client_core/
inference_backend.rs

1//! Persisted inference backend preference (Local / Remote / Hybrid / Ollama).
2//!
3//! **Local** (GGUF in-process) is the primary Qualia engine.
4//! **Ollama** is an explicit opt-in harness for when native inference is not
5//! ready or the principal wants a local Ollama endpoint for chat / ETL prep.
6
7use serde::{Deserialize, Serialize};
8
9use crate::chat_agents::AgentBackendKind;
10use crate::state::app_meta_dir;
11
12const SETTINGS_FILE: &str = "inference_backend.json";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct InferenceBackendSettings {
16    #[serde(default)]
17    pub backend: AgentBackendKind,
18    /// Remote (Nym / MCP) endpoint label or URL when backend is Remote/Hybrid.
19    #[serde(default)]
20    pub remote_endpoint: String,
21
22    // ── Optional Ollama harness (ignored unless backend == Ollama) ──────────
23    /// e.g. `http://127.0.0.1:11434`
24    #[serde(default = "default_ollama_base_url")]
25    pub ollama_base_url: String,
26    /// Generation model tag (e.g. `llama3.2`, `qwen2.5:7b`).
27    #[serde(default = "default_ollama_model")]
28    pub ollama_model: String,
29    /// Embedding model for ETL / retrieval prep.
30    #[serde(default = "default_ollama_embed_model")]
31    pub ollama_embed_model: String,
32    /// Optional bearer token for hosted/proxied Ollama-compatible APIs.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub ollama_api_key: Option<String>,
35    #[serde(default = "default_ollama_timeout")]
36    pub ollama_timeout_secs: u64,
37    #[serde(default = "default_ollama_num_ctx")]
38    pub ollama_num_ctx: u32,
39    #[serde(default = "default_ollama_temperature")]
40    pub ollama_temperature: f32,
41    /// When true and backend is Hybrid, try Ollama after local fails (not Remote).
42    #[serde(default)]
43    pub ollama_as_hybrid_fallback: bool,
44}
45
46fn default_ollama_base_url() -> String {
47    crate::ollama_harness::DEFAULT_OLLAMA_BASE_URL.to_string()
48}
49fn default_ollama_model() -> String {
50    "llama3.2".to_string()
51}
52fn default_ollama_embed_model() -> String {
53    "nomic-embed-text".to_string()
54}
55fn default_ollama_timeout() -> u64 {
56    120
57}
58fn default_ollama_num_ctx() -> u32 {
59    8192
60}
61fn default_ollama_temperature() -> f32 {
62    0.2
63}
64
65impl Default for InferenceBackendSettings {
66    fn default() -> Self {
67        Self {
68            backend: AgentBackendKind::Local,
69            remote_endpoint: String::new(),
70            ollama_base_url: default_ollama_base_url(),
71            ollama_model: default_ollama_model(),
72            ollama_embed_model: default_ollama_embed_model(),
73            ollama_api_key: None,
74            ollama_timeout_secs: default_ollama_timeout(),
75            ollama_num_ctx: default_ollama_num_ctx(),
76            ollama_temperature: default_ollama_temperature(),
77            ollama_as_hybrid_fallback: false,
78        }
79    }
80}
81
82impl InferenceBackendSettings {
83    /// Mirror `AgentConfig.inference_backend` string into structured settings.
84    pub fn apply_agent_config_backend_string(&mut self, s: &str) {
85        self.backend = AgentBackendKind::from_str(s);
86    }
87
88    pub fn as_agent_config_string(&self) -> String {
89        self.backend.as_str().to_string()
90    }
91
92    pub fn validate(&self) -> Result<(), String> {
93        match self.backend {
94            AgentBackendKind::Ollama => {
95                if self.ollama_base_url.trim().is_empty() {
96                    return Err("Ollama base URL is required when backend is Ollama".into());
97                }
98                if self.ollama_model.trim().is_empty() {
99                    return Err("Ollama model tag is required when backend is Ollama".into());
100                }
101                if !(self.ollama_base_url.starts_with("http://")
102                    || self.ollama_base_url.starts_with("https://"))
103                {
104                    return Err("Ollama base URL must start with http:// or https://".into());
105                }
106            }
107            AgentBackendKind::Remote => {
108                // remote_endpoint optional for now (MCP path may supply later)
109            }
110            _ => {}
111        }
112        Ok(())
113    }
114}
115
116fn settings_path() -> std::path::PathBuf {
117    app_meta_dir().join(SETTINGS_FILE)
118}
119
120pub fn load_inference_backend_settings() -> InferenceBackendSettings {
121    let path = settings_path();
122    if !path.is_file() {
123        // Seed from AgentConfig.inference_backend if present.
124        if let Some(state) = crate::state::APP_STATE.get() {
125            if let Ok(cfg) = state.config.lock() {
126                let mut s = InferenceBackendSettings::default();
127                s.apply_agent_config_backend_string(&cfg.inference_backend);
128                return s;
129            }
130        }
131        return InferenceBackendSettings::default();
132    }
133    std::fs::read_to_string(&path)
134        .ok()
135        .and_then(|text| serde_json::from_str(&text).ok())
136        .unwrap_or_default()
137}
138
139pub fn save_inference_backend_settings(settings: &InferenceBackendSettings) -> Result<(), String> {
140    settings.validate()?;
141    let path = settings_path();
142    if let Some(parent) = path.parent() {
143        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
144    }
145    let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
146    std::fs::write(&path, json).map_err(|e| e.to_string())?;
147
148    // Keep AgentConfig.inference_backend in sync for legacy UI that only stores a string.
149    if let Some(state) = crate::state::APP_STATE.get() {
150        if let Ok(mut cfg) = state.config.lock() {
151            cfg.inference_backend = settings.as_agent_config_string();
152            if let Ok(json) = serde_json::to_string_pretty(&*cfg) {
153                let _ = std::fs::write(crate::state::config_file_path(), json);
154            }
155        }
156    }
157    Ok(())
158}
159
160pub fn backend_label(settings: &InferenceBackendSettings) -> &'static str {
161    match settings.backend {
162        AgentBackendKind::Local => "Local GGUF (in-process Qualia)",
163        AgentBackendKind::Remote => "Remote (Nym mixnet, consent required)",
164        AgentBackendKind::Hybrid => "Hybrid (local first, remote/ollama fallback)",
165        AgentBackendKind::Ollama => "Ollama (optional HTTP harness)",
166    }
167}
168
169/// True when chat should use the Ollama harness instead of LocalLlmAgent.
170pub fn use_ollama_harness() -> bool {
171    matches!(
172        load_inference_backend_settings().backend,
173        AgentBackendKind::Ollama
174    )
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn default_is_local() {
183        let settings = InferenceBackendSettings::default();
184        assert_eq!(settings.backend, AgentBackendKind::Local);
185        assert!(!use_ollama_harness() || settings.backend == AgentBackendKind::Ollama);
186    }
187
188    #[test]
189    fn ollama_validate_requires_url_and_model() {
190        let mut s = InferenceBackendSettings {
191            backend: AgentBackendKind::Ollama,
192            ollama_base_url: String::new(),
193            ollama_model: String::new(),
194            ..Default::default()
195        };
196        assert!(s.validate().is_err());
197        s.ollama_base_url = "http://127.0.0.1:11434".into();
198        s.ollama_model = "llama3.2".into();
199        assert!(s.validate().is_ok());
200    }
201
202    #[test]
203    fn from_str_roundtrip_ollama() {
204        assert_eq!(
205            AgentBackendKind::from_str("ollama"),
206            AgentBackendKind::Ollama
207        );
208        assert_eq!(AgentBackendKind::Ollama.as_str(), "ollama");
209    }
210}