qualia_client_core/
inference_backend.rs1use 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 #[serde(default)]
20 pub remote_endpoint: String,
21
22 #[serde(default = "default_ollama_base_url")]
25 pub ollama_base_url: String,
26 #[serde(default = "default_ollama_model")]
28 pub ollama_model: String,
29 #[serde(default = "default_ollama_embed_model")]
31 pub ollama_embed_model: String,
32 #[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 #[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 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 }
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 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 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
169pub 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}