Skip to main content

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}