trusty_memory/service/user_config.rs
1//! User config (`~/.trusty-memory/config.toml`) loading + `DreamConfig`
2//! derivation.
3//!
4//! Why: split out of `helpers.rs` (issue #2593 follow-up, code review on
5//! #2977) to keep that file under the 500-SLOC production cap after the
6//! `dream_config_from_user_config` addition pushed it over. This is also a
7//! cohesive unit on its own: "read config.toml" and "translate it into the
8//! shapes downstream consumers need" belong together, separate from the
9//! unrelated preview/snippet/palace-info transforms that fill the rest of
10//! `helpers.rs`.
11//! What: `UserConfigMin`/`OpenRouterMin`/`LocalModelMin` (the minimal TOML
12//! mirror), `LoadedUserConfig` (the public, normalised shape), `load_user_config`
13//! (file → `LoadedUserConfig`), and `dream_config_from_user_config`
14//! (`LoadedUserConfig` → `DreamConfig`, used by both the idle dream scheduler
15//! and the on-demand `dream_consolidate_room`/`palace_dream` tools). Re-exported
16//! from `service::mod` unchanged so `crate::service::{load_user_config,
17//! dream_config_from_user_config, LoadedUserConfig}` keeps resolving exactly as
18//! before this split — no public API change.
19//! Test: `dream_config_from_user_config_prefers_local_model_when_resolved`,
20//! `dream_config_from_user_config_prefers_openrouter_model_with_key`,
21//! `dream_config_from_user_config_prefers_openrouter_model_with_env_key`.
22
23use serde::Deserialize;
24use trusty_common::memory_core::dream::DreamConfig;
25use trusty_common::memory_core::semantic_consolidation::SemanticConsolidationConfig;
26
27/// Minimal mirror of the user-config schema.
28#[derive(Deserialize, Default, Clone)]
29struct UserConfigMin {
30 #[serde(default)]
31 openrouter: OpenRouterMin,
32 #[serde(default)]
33 local_model: LocalModelMin,
34}
35
36#[derive(Deserialize, Default, Clone)]
37struct OpenRouterMin {
38 #[serde(default)]
39 api_key: String,
40 #[serde(default)]
41 model: String,
42}
43
44#[derive(Deserialize, Clone)]
45struct LocalModelMin {
46 #[serde(default = "default_local_enabled")]
47 enabled: bool,
48 #[serde(default = "default_local_base_url")]
49 base_url: String,
50 #[serde(default = "default_local_model")]
51 model: String,
52}
53
54fn default_local_enabled() -> bool {
55 true
56}
57fn default_local_base_url() -> String {
58 "http://localhost:11434".to_string()
59}
60fn default_local_model() -> String {
61 "llama3.2".to_string()
62}
63
64impl Default for LocalModelMin {
65 fn default() -> Self {
66 Self {
67 enabled: default_local_enabled(),
68 base_url: default_local_base_url(),
69 model: default_local_model(),
70 }
71 }
72}
73
74/// Loaded user config (mirrors the public `LoadedUserConfig` from `web.rs`).
75#[derive(Clone)]
76pub struct LoadedUserConfig {
77 pub openrouter_api_key: String,
78 pub openrouter_model: String,
79 pub local_model: trusty_common::LocalModelConfig,
80}
81
82impl Default for LoadedUserConfig {
83 fn default() -> Self {
84 Self {
85 openrouter_api_key: String::new(),
86 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
87 local_model: trusty_common::LocalModelConfig::default(),
88 }
89 }
90}
91
92/// Read the user's `~/.trusty-memory/config.toml`, falling back to defaults.
93///
94/// Why: shared between HTTP config endpoint, chat tool dispatch, and
95/// provider auto-detection.
96/// What: returns `Some(LoadedUserConfig)` even when the file is missing
97/// (so callers see defaults consistently); `None` only when the home
98/// directory itself can't be resolved.
99/// Test: indirectly via `config_endpoint_returns_payload`.
100pub fn load_user_config() -> Option<LoadedUserConfig> {
101 let home = dirs::home_dir()?;
102 let path = home.join(".trusty-memory").join("config.toml");
103 if !path.exists() {
104 return Some(LoadedUserConfig::default());
105 }
106 let raw = std::fs::read_to_string(&path).ok()?;
107 let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
108 let model = if parsed.openrouter.model.is_empty() {
109 "anthropic/claude-3-5-sonnet".to_string()
110 } else {
111 parsed.openrouter.model
112 };
113 Some(LoadedUserConfig {
114 openrouter_api_key: parsed.openrouter.api_key,
115 openrouter_model: model,
116 local_model: trusty_common::LocalModelConfig {
117 enabled: parsed.local_model.enabled,
118 base_url: parsed.local_model.base_url,
119 model: parsed.local_model.model,
120 },
121 })
122}
123
124/// Derive a `DreamConfig` seed from the user's loaded config (OpenRouter key,
125/// local-model flag, and local-model id).
126///
127/// Why (issue #2593): the idle dream scheduler and the on-demand
128/// `dream_consolidate_room`/`palace_dream` tools both need to translate
129/// `LoadedUserConfig` into `DreamConfig` identically, or the two paths
130/// silently diverge — the idle scheduler previously used
131/// `DreamConfig::default()` outright (ignoring the user's config.toml
132/// entirely), and the on-demand path forwarded the OpenRouter key and the
133/// local-model flag but dropped `local_model.model`, leaving
134/// `semantic.model` on the OpenRouter-style default even when consolidation
135/// resolved to a local Ollama backend — the exact misconfiguration
136/// `validate_ollama_model` now rejects. Centralising the derivation also
137/// pins the "which model string goes with which backend" decision: it
138/// mirrors `build_consolidator_from_config`'s own branch
139/// (`local_model_enabled && api_key.is_empty()` => Ollama) so the model
140/// forwarded here always matches the backend the dream cycle will actually
141/// select. The key-presence half of that branch is resolved via the SAME
142/// `trusty_common::memory_core::semantic_consolidation::resolve_openrouter_api_key`
143/// that `build_consolidator_from_config` itself calls — an earlier version
144/// of this function checked only `cfg.openrouter_api_key` (config.toml),
145/// which diverged from `build_consolidator_from_config`'s env-var-fallback
146/// resolution whenever `OPENROUTER_API_KEY` was set in the daemon's process
147/// environment but absent from config.toml: this function picked the
148/// local-model id while the consolidator built the OpenRouter backend,
149/// silently sending an Ollama tag to OpenRouter every cycle.
150/// What: sets `openrouter_api_key`, `local_model_enabled`, and
151/// `semantic.model` (the local-model id when the local path resolves, the
152/// OpenRouter model id otherwise); every other `DreamConfig` field keeps its
153/// default.
154/// Test: `dream_config_from_user_config_prefers_local_model_when_resolved`,
155/// `dream_config_from_user_config_prefers_openrouter_model_with_key`,
156/// `dream_config_from_user_config_prefers_openrouter_model_with_env_key`.
157pub fn dream_config_from_user_config(cfg: &LoadedUserConfig) -> DreamConfig {
158 let resolved_api_key =
159 trusty_common::memory_core::semantic_consolidation::resolve_openrouter_api_key(
160 &cfg.openrouter_api_key,
161 );
162 let resolves_local = cfg.local_model.enabled && resolved_api_key.is_empty();
163 let model = if resolves_local {
164 cfg.local_model.model.clone()
165 } else {
166 cfg.openrouter_model.clone()
167 };
168
169 DreamConfig {
170 openrouter_api_key: cfg.openrouter_api_key.clone(),
171 local_model_enabled: cfg.local_model.enabled,
172 semantic: SemanticConsolidationConfig {
173 model,
174 ..SemanticConsolidationConfig::default()
175 },
176 ..DreamConfig::default()
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 /// Why (issue #2593): the exact production gap — the idle scheduler and
185 /// the on-demand tool both need `local_model.model` to reach
186 /// `DreamConfig.semantic.model` when the local Ollama path is what will
187 /// actually resolve (local model enabled, no OpenRouter key ANYWHERE —
188 /// neither config.toml nor the real process environment; without the
189 /// `EnvVarGuard::remove` this test is order-dependent on whatever the
190 /// ambient shell happens to export, since `dream_config_from_user_config`
191 /// now resolves the env-var fallback too — code review follow-up on
192 /// #2977). `#[serial]` avoids racing other tests that read/write the
193 /// same real env var.
194 /// What: builds a `LoadedUserConfig` with a configured local model id and
195 /// no OpenRouter key; asserts the derived `DreamConfig.semantic.model`
196 /// equals the configured local model, not the OpenRouter-style default.
197 #[test]
198 #[serial_test::serial]
199 fn dream_config_from_user_config_prefers_local_model_when_resolved() {
200 let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
201 let cfg = LoadedUserConfig {
202 openrouter_api_key: String::new(),
203 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
204 local_model: trusty_common::LocalModelConfig {
205 enabled: true,
206 base_url: "http://localhost:11434".to_string(),
207 model: "llama3.2".to_string(),
208 },
209 };
210
211 let dream_cfg = dream_config_from_user_config(&cfg);
212
213 assert_eq!(dream_cfg.semantic.model, "llama3.2");
214 assert!(dream_cfg.local_model_enabled);
215 assert!(dream_cfg.openrouter_api_key.is_empty());
216 }
217
218 /// Why: when an OpenRouter key is configured, consolidation resolves to
219 /// the OpenRouter backend regardless of `local_model.enabled` (mirrors
220 /// `build_consolidator_from_config`'s branch), so the OpenRouter model
221 /// id must be forwarded instead of the local-model id.
222 /// What: builds a `LoadedUserConfig` with both a local model AND an
223 /// OpenRouter key configured; asserts the derived `semantic.model` is
224 /// the OpenRouter model, not the local one.
225 #[test]
226 fn dream_config_from_user_config_prefers_openrouter_model_with_key() {
227 let cfg = LoadedUserConfig {
228 openrouter_api_key: "sk-test-key".to_string(),
229 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
230 local_model: trusty_common::LocalModelConfig {
231 enabled: true,
232 base_url: "http://localhost:11434".to_string(),
233 model: "llama3.2".to_string(),
234 },
235 };
236
237 let dream_cfg = dream_config_from_user_config(&cfg);
238
239 assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
240 assert_eq!(dream_cfg.openrouter_api_key, "sk-test-key");
241 }
242
243 /// Why (code review follow-up on #2977): `config.toml` may have no
244 /// OpenRouter key while the daemon's process environment carries
245 /// `OPENROUTER_API_KEY` — `build_consolidator_from_config` (trusty-common)
246 /// resolves that env-var fallback before choosing a backend, so this
247 /// helper must resolve it identically or it picks the local-model id
248 /// while the consolidator builds the OpenRouter backend, silently
249 /// sending an Ollama tag to OpenRouter every cycle. `#[serial]` +
250 /// `EnvVarGuard` (mirroring the pattern in
251 /// `trusty_common::memory_core::dream::tests`) avoid racing other tests
252 /// that read/write the same real env var.
253 /// What: empty `openrouter_api_key` in config, `OPENROUTER_API_KEY` set
254 /// in the real environment; asserts the OpenRouter model is chosen, not
255 /// the local one.
256 #[test]
257 #[serial_test::serial]
258 fn dream_config_from_user_config_prefers_openrouter_model_with_env_key() {
259 let _guard = EnvVarGuard::set("OPENROUTER_API_KEY", "sk-from-env");
260
261 let cfg = LoadedUserConfig {
262 openrouter_api_key: String::new(),
263 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
264 local_model: trusty_common::LocalModelConfig {
265 enabled: true,
266 base_url: "http://localhost:11434".to_string(),
267 model: "llama3.2".to_string(),
268 },
269 };
270
271 let dream_cfg = dream_config_from_user_config(&cfg);
272
273 assert_eq!(
274 dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet",
275 "an env-supplied OpenRouter key must resolve the OpenRouter \
276 model, not the local one, even though config.toml has no key"
277 );
278 }
279
280 // ─── RAII env-var guard for tests (mirrors
281 // trusty_common::memory_core::dream::tests::EnvVarGuard) ───────────────
282 //
283 // Safety: test-only; `#[serial_test::serial]` on every caller serialises
284 // access to the real process environment across test threads.
285
286 struct EnvVarGuard {
287 key: &'static str,
288 previous: Option<String>,
289 }
290
291 impl EnvVarGuard {
292 fn set(key: &'static str, value: &str) -> Self {
293 let previous = std::env::var(key).ok();
294 // Safety: test-only; caller is `#[serial]`.
295 unsafe { std::env::set_var(key, value) };
296 Self { key, previous }
297 }
298
299 fn remove(key: &'static str) -> Self {
300 let previous = std::env::var(key).ok();
301 // Safety: test-only; caller is `#[serial]`.
302 unsafe { std::env::remove_var(key) };
303 Self { key, previous }
304 }
305 }
306
307 impl Drop for EnvVarGuard {
308 fn drop(&mut self) {
309 // Safety: test-only; caller is `#[serial]`.
310 match &self.previous {
311 Some(v) => unsafe { std::env::set_var(self.key, v) },
312 None => unsafe { std::env::remove_var(self.key) },
313 }
314 }
315 }
316}