Skip to main content

mur_common/config/
backend.rs

1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4pub struct SyncConfig {
5    /// Sync method: "cloud", "git", or "local"
6    #[serde(default = "default_sync_method")]
7    pub method: String,
8
9    /// Git remote URL for git sync
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub git_remote: Option<String>,
12
13    /// Auto-sync on context pull / session stop
14    #[serde(default)]
15    pub auto: bool,
16
17    /// Default team ID for cloud sync (set on first successful sync)
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub team_id: Option<String>,
20}
21
22fn default_sync_method() -> String {
23    "local".to_string()
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ServerConfig {
28    /// Server URL (default: https://mur-server.fly.dev)
29    #[serde(default = "default_server_url")]
30    pub url: String,
31}
32
33impl Default for ServerConfig {
34    fn default() -> Self {
35        Self {
36            url: default_server_url(),
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct CommunityConfig {
43    /// Whether community pattern sharing is enabled
44    #[serde(default)]
45    pub enabled: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EmbeddingConfig {
50    /// "ollama", "openai", "gemini", or "anthropic"
51    #[serde(default = "default_embedding_provider")]
52    pub provider: String,
53
54    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
55    #[serde(default = "default_embedding_model")]
56    pub model: String,
57
58    /// Vector dimensions (fixed after first index build)
59    #[serde(default = "default_dimensions")]
60    pub dimensions: usize,
61
62    /// Ollama endpoint. `None` for every non-Ollama provider — the OpenAI
63    /// path uses `openai_url`. Kept out of the serialized document when
64    /// unset so it stops reappearing in configs that never use it.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub ollama_endpoint: Option<String>,
67
68    /// API key env var name (e.g. "OPENAI_API_KEY")
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub api_key_env: Option<String>,
71
72    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
73    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub api_key_ref: Option<String>,
76
77    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub openai_url: Option<String>,
80}
81
82impl Default for EmbeddingConfig {
83    fn default() -> Self {
84        Self {
85            provider: default_embedding_provider(),
86            model: default_embedding_model(),
87            dimensions: default_dimensions(),
88            ollama_endpoint: Some(default_ollama_endpoint()),
89            api_key_env: None,
90            api_key_ref: None,
91            openai_url: None,
92        }
93    }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct LlmConfig {
98    /// "anthropic", "openai", "gemini", or "ollama"
99    #[serde(default = "default_llm_provider")]
100    pub provider: String,
101
102    #[serde(default = "default_llm_model")]
103    pub model: String,
104
105    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub api_key_env: Option<String>,
108
109    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
110    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub api_key_ref: Option<String>,
113
114    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub openai_url: Option<String>,
117}
118
119impl Default for LlmConfig {
120    fn default() -> Self {
121        Self {
122            provider: default_llm_provider(),
123            model: default_llm_model(),
124            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
125            api_key_ref: None,
126            openai_url: None,
127        }
128    }
129}
130
131impl LlmConfig {
132    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
133    /// into a BackendConfig that the new ChatBackend factory consumes.
134    /// Mapping:
135    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
136    ///   (preserves the historical LlmConfig::llm_complete fall-through for
137    ///   OpenAI-compatible passthrough proxies).
138    /// - `model` 1:1.
139    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
140    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
141    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
142    /// - `timeout_secs` always None (factory defaults to 120s — matches
143    ///   the historical 60s reqwest default behavior closely enough).
144    pub fn to_backend_config(&self) -> BackendConfig {
145        let provider = match self.provider.as_str() {
146            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
147            _ if self.openai_url.is_some() => "openai".into(),
148            other => other.into(), // factory will reject with "unsupported provider"
149        };
150        BackendConfig {
151            provider,
152            model: self.model.clone(),
153            endpoint: self.openai_url.clone(),
154            api_key_env: self.api_key_env.clone(),
155            api_key_ref: self.api_key_ref.clone(),
156            timeout_secs: None,
157        }
158    }
159}
160
161/// Backend selection for a single chat-completion call site.
162///
163/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
164/// (per-stage) and `AskConfig` (per-stage) to override the legacy
165/// Ollama-only path. None of the `Option` fields are required;
166/// resolution falls back to provider defaults
167/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
168///
169/// Stays in mur-common (not mur-core) because it is pure data and
170/// will be reused by mur-agent-runtime in a future phase.
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(default)]
173pub struct BackendConfig {
174    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
175    pub provider: String,
176    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
177    pub model: String,
178    /// Provider endpoint. None = provider default
179    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
180    pub endpoint: Option<String>,
181    /// Env var holding the API key. None = no auth (ollama).
182    pub api_key_env: Option<String>,
183    /// SecretRef string for the API key. Takes precedence over `api_key_env`.
184    pub api_key_ref: Option<String>,
185    /// Per-call timeout in seconds. None = 120s.
186    pub timeout_secs: Option<u64>,
187}
188
189impl Default for BackendConfig {
190    fn default() -> Self {
191        Self {
192            provider: "ollama".into(),
193            model: DEFAULT_LOCAL_LLM_MODEL.into(),
194            endpoint: None,
195            api_key_env: None,
196            api_key_ref: None,
197            timeout_secs: None,
198        }
199    }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct RetrievalConfig {
204    /// Max patterns to inject per query
205    #[serde(default = "default_max_patterns")]
206    pub max_patterns: usize,
207
208    /// Max tokens for injected content
209    #[serde(default = "default_max_tokens")]
210    pub max_tokens: usize,
211
212    /// Minimum score threshold
213    #[serde(default = "default_min_score")]
214    pub min_score: f64,
215
216    /// MMR diversity threshold (cosine > this = too similar)
217    #[serde(default = "default_mmr_threshold")]
218    pub mmr_threshold: f64,
219
220    /// Injection slots reserved for notes when mature skills would otherwise
221    /// fill every seat (memory federation P1). 0 disables the reservation.
222    #[serde(default = "default_reserved_note_slots")]
223    pub reserved_note_slots: usize,
224}
225
226impl Default for RetrievalConfig {
227    fn default() -> Self {
228        Self {
229            max_patterns: default_max_patterns(),
230            max_tokens: default_max_tokens(),
231            min_score: default_min_score(),
232            mmr_threshold: default_mmr_threshold(),
233            reserved_note_slots: default_reserved_note_slots(),
234        }
235    }
236}
237
238fn default_reserved_note_slots() -> usize {
239    1
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct PathConfig {
244    /// Root MUR directory (default: ~/.mur)
245    #[serde(default = "default_mur_dir")]
246    pub mur_dir: PathBuf,
247}
248
249impl Default for PathConfig {
250    fn default() -> Self {
251        Self {
252            mur_dir: default_mur_dir(),
253        }
254    }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct StorageConfig {
259    /// Vector backend identifier: "lancedb" (default) or "qdrant".
260    #[serde(default = "default_vector_backend")]
261    pub vector_backend: String,
262
263    /// Qdrant connection URL (only used when vector_backend = "qdrant").
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub qdrant_url: Option<String>,
266
267    /// Keyring account name holding the Qdrant API key, if any.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub qdrant_api_key_ref: Option<String>,
270}
271
272impl Default for StorageConfig {
273    fn default() -> Self {
274        Self {
275            vector_backend: default_vector_backend(),
276            qdrant_url: None,
277            qdrant_api_key_ref: None,
278        }
279    }
280}
281
282fn default_vector_backend() -> String {
283    "lancedb".to_string()
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct SourcesGlobalConfig {
288    /// Polling interval for cloud sources (seconds).
289    #[serde(default = "default_poll_interval_secs")]
290    pub poll_interval_secs: u64,
291
292    /// Safety cap: do not sync more than this many chunks per run.
293    #[serde(default = "default_max_chunks_per_sync")]
294    pub max_chunks_per_sync: usize,
295
296    /// Upper bound on parallel source sync tasks.
297    #[serde(default = "default_max_parallel_sources")]
298    pub max_parallel_sources: usize,
299
300    /// Weight applied to new sources unless overridden.
301    #[serde(default = "default_source_weight")]
302    pub default_weight: f32,
303
304    /// Embedding request batch size.
305    #[serde(default = "default_embedding_batch_size")]
306    pub embedding_batch_size: usize,
307}
308
309impl Default for SourcesGlobalConfig {
310    fn default() -> Self {
311        Self {
312            poll_interval_secs: default_poll_interval_secs(),
313            max_chunks_per_sync: default_max_chunks_per_sync(),
314            max_parallel_sources: default_max_parallel_sources(),
315            default_weight: default_source_weight(),
316            embedding_batch_size: default_embedding_batch_size(),
317        }
318    }
319}
320
321fn default_poll_interval_secs() -> u64 {
322    600
323}
324fn default_max_chunks_per_sync() -> usize {
325    10_000
326}
327fn default_max_parallel_sources() -> usize {
328    3
329}
330fn default_source_weight() -> f32 {
331    1.0
332}
333fn default_embedding_batch_size() -> usize {
334    32
335}
336
337fn default_embedding_provider() -> String {
338    "ollama".to_string()
339}
340fn default_embedding_model() -> String {
341    "qwen3-embedding:0.6b".to_string()
342}
343fn default_dimensions() -> usize {
344    1024
345}
346fn default_ollama_endpoint() -> String {
347    DEFAULT_OLLAMA_ENDPOINT.to_string()
348}
349fn default_llm_provider() -> String {
350    "anthropic".to_string()
351}
352fn default_llm_model() -> String {
353    "claude-opus-5".to_string()
354}
355fn default_max_patterns() -> usize {
356    5
357}
358fn default_max_tokens() -> usize {
359    2000
360}
361fn default_min_score() -> f64 {
362    0.35
363}
364fn default_mmr_threshold() -> f64 {
365    0.85
366}
367fn default_mur_dir() -> PathBuf {
368    crate::home::mur_home_lossy()
369}
370fn default_server_url() -> String {
371    "https://mur-server.fly.dev".to_string()
372}
373
374#[cfg(test)]
375mod backend_config_tests {
376    use super::*;
377
378    #[test]
379    fn default_is_ollama_qwen3() {
380        let cfg = BackendConfig::default();
381        assert_eq!(cfg.provider, "ollama");
382        assert_eq!(cfg.model, "qwen3.5:4b");
383        assert_eq!(cfg.endpoint, None);
384        assert_eq!(cfg.api_key_env, None);
385        assert_eq!(cfg.timeout_secs, None);
386    }
387
388    #[test]
389    fn deserializes_anthropic_full() {
390        let yaml = "\
391provider: anthropic
392model: claude-haiku-4-5
393api_key_env: ANTHROPIC_API_KEY
394timeout_secs: 60
395";
396        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
397        assert_eq!(cfg.provider, "anthropic");
398        assert_eq!(cfg.model, "claude-haiku-4-5");
399        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
400        assert_eq!(cfg.timeout_secs, Some(60));
401        assert_eq!(cfg.endpoint, None);
402    }
403
404    #[test]
405    fn deserializes_partial_fills_defaults() {
406        let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
407        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
408        assert_eq!(cfg.provider, "anthropic");
409        assert_eq!(cfg.model, "claude-sonnet-5");
410        assert_eq!(cfg.api_key_env, None);
411        assert_eq!(cfg.timeout_secs, None);
412    }
413
414    #[test]
415    fn round_trips_through_yaml() {
416        let original = BackendConfig {
417            provider: "anthropic".into(),
418            model: "claude-haiku-4-5".into(),
419            endpoint: Some("https://api.anthropic.com".into()),
420            api_key_env: Some("ANTHROPIC_API_KEY".into()),
421            api_key_ref: None,
422            timeout_secs: Some(60),
423        };
424        let yaml = serde_yaml::to_string(&original).unwrap();
425        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
426        assert_eq!(parsed, original);
427    }
428
429    #[test]
430    fn skills_config_curation_gate_defaults_on() {
431        let c = SkillsConfig::default();
432        assert!(c.require_human_curation_before_stable);
433    }
434}
435
436#[cfg(test)]
437mod per_stage_backend_tests {
438    use super::*;
439
440    #[test]
441    fn compact_extractive_backend_override_parses() {
442        let yaml = "\
443extractive_backend:
444  provider: anthropic
445  model: claude-haiku-4-5
446  api_key_env: ANTHROPIC_API_KEY
447";
448        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
449        let extractive = cfg
450            .extractive_backend
451            .as_ref()
452            .expect("override should parse");
453        assert_eq!(extractive.provider, "anthropic");
454        assert_eq!(extractive.model, "claude-haiku-4-5");
455        assert!(cfg.abstractive_backend.is_none());
456    }
457
458    #[test]
459    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
460        let yaml = "\
461backend:
462  provider: anthropic
463  model: claude-sonnet-5
464  api_key_env: ANTHROPIC_API_KEY
465rewriter_backend:
466  provider: ollama
467  model: llama3.2:3b
468";
469        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
470        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
471        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
472    }
473
474    #[test]
475    fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
476        // C1 regression test: effective_rewriter_backend must follow the
477        // answer stage's `backend` when `rewriter_backend` is unset, NOT
478        // fall straight through to the smart slot (`llm`) — the rewriter is
479        // not an independent pinning point, only an independent timeout.
480        // `llm` below uses a distinctly different provider ("omlx", which
481        // to_backend_config() maps to "openai") than `cfg.backend`
482        // ("anthropic"), so this test cannot pass by coincidentally landing
483        // on the same provider from either source: if the fix regresses to
484        // falling through to `llm`, `rewriter.provider` comes back
485        // "openai" and the first assertion fails.
486        let cfg = AskConfig {
487            backend: Some(BackendConfig {
488                provider: "anthropic".into(),
489                model: "claude-sonnet-5".into(),
490                endpoint: None,
491                api_key_env: Some("ANTHROPIC_API_KEY".into()),
492                api_key_ref: None,
493                timeout_secs: None,
494            }),
495            ..Default::default()
496        };
497        let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
498        assert_eq!(rewriter.provider, "anthropic");
499        assert_eq!(rewriter.model, "claude-sonnet-5");
500        assert_eq!(
501            rewriter.timeout_secs,
502            Some(cfg.rewriter_timeout_secs as u64),
503            "rewriter must keep its own tighter timeout even while following the answer stage's backend"
504        );
505    }
506
507    #[test]
508    fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
509        let mut cfg = AskConfig {
510            rewriter_timeout_secs: 8,
511            ..AskConfig::default()
512        };
513        cfg.rewriter_backend = Some(BackendConfig {
514            provider: "anthropic".into(),
515            model: "claude-haiku-4-5".into(),
516            endpoint: None,
517            api_key_env: Some("ANTHROPIC_API_KEY".into()),
518            api_key_ref: None,
519            timeout_secs: Some(30),
520        });
521        let b = cfg.effective_rewriter_backend(&omlx_llm());
522        assert_eq!(
523            b.timeout_secs,
524            Some(30),
525            "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
526        );
527    }
528
529    fn omlx_llm() -> LlmConfig {
530        LlmConfig {
531            provider: "omlx".into(),
532            model: "Qwen3.5-4B-MLX-4bit".into(),
533            api_key_env: None,
534            api_key_ref: Some("env:OMLX_API_KEY".into()),
535            openai_url: Some("http://127.0.0.1:8000/v1".into()),
536        }
537    }
538
539    #[test]
540    fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
541        let ask = AskConfig::default();
542        let b = ask.effective_backend(&omlx_llm());
543        assert_eq!(b.provider, "openai");
544        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
545        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
546        assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
547        // stage timeout is baked in, not left to the factory's 120s default
548        assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
549    }
550
551    #[test]
552    fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
553        let ask = AskConfig::default();
554        let b = ask.effective_rewriter_backend(&omlx_llm());
555        assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
556        assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
557    }
558
559    #[test]
560    fn explicit_override_wins_over_the_smart_slot() {
561        let ask = AskConfig {
562            backend: Some(BackendConfig {
563                provider: "anthropic".into(),
564                model: "claude-haiku-4-5".into(),
565                endpoint: None,
566                api_key_env: None,
567                api_key_ref: None,
568                timeout_secs: Some(42),
569            }),
570            ..Default::default()
571        };
572        let b = ask.effective_backend(&omlx_llm());
573        assert_eq!(b.provider, "anthropic");
574        assert_eq!(b.timeout_secs, Some(42));
575    }
576
577    #[test]
578    fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
579        let llm = omlx_llm();
580        for b in [
581            CompactConfig::default().effective_extractive_backend(&llm),
582            CompactConfig::default().effective_abstractive_backend(&llm),
583            RollupConfig::default().effective_extractive_backend(&llm),
584            RollupConfig::default().effective_abstractive_backend(&llm),
585        ] {
586            assert_eq!(b.provider, "openai");
587            assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
588            assert_eq!(b.timeout_secs, Some(120));
589        }
590    }
591
592    #[test]
593    fn rollup_override_is_honored() {
594        let r = RollupConfig {
595            abstractive_backend: Some(BackendConfig {
596                provider: "ollama".into(),
597                model: "qwen3:4b".into(),
598                endpoint: Some("http://box.local:11434".into()),
599                api_key_env: None,
600                api_key_ref: None,
601                timeout_secs: None,
602            }),
603            ..Default::default()
604        };
605        let b = r.effective_abstractive_backend(&omlx_llm());
606        assert_eq!(b.provider, "ollama");
607        assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
608    }
609}