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    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
369    // Callers in mur-core that need the real home dir should use `dirs` there.
370    let home = std::env::var("HOME")
371        .map(PathBuf::from)
372        .unwrap_or_else(|_| PathBuf::from("/tmp"));
373    home.join(".mur")
374}
375fn default_server_url() -> String {
376    "https://mur-server.fly.dev".to_string()
377}
378
379#[cfg(test)]
380mod backend_config_tests {
381    use super::*;
382
383    #[test]
384    fn default_is_ollama_qwen3() {
385        let cfg = BackendConfig::default();
386        assert_eq!(cfg.provider, "ollama");
387        assert_eq!(cfg.model, "qwen3.5:4b");
388        assert_eq!(cfg.endpoint, None);
389        assert_eq!(cfg.api_key_env, None);
390        assert_eq!(cfg.timeout_secs, None);
391    }
392
393    #[test]
394    fn deserializes_anthropic_full() {
395        let yaml = "\
396provider: anthropic
397model: claude-haiku-4-5
398api_key_env: ANTHROPIC_API_KEY
399timeout_secs: 60
400";
401        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
402        assert_eq!(cfg.provider, "anthropic");
403        assert_eq!(cfg.model, "claude-haiku-4-5");
404        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
405        assert_eq!(cfg.timeout_secs, Some(60));
406        assert_eq!(cfg.endpoint, None);
407    }
408
409    #[test]
410    fn deserializes_partial_fills_defaults() {
411        let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
412        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
413        assert_eq!(cfg.provider, "anthropic");
414        assert_eq!(cfg.model, "claude-sonnet-5");
415        assert_eq!(cfg.api_key_env, None);
416        assert_eq!(cfg.timeout_secs, None);
417    }
418
419    #[test]
420    fn round_trips_through_yaml() {
421        let original = BackendConfig {
422            provider: "anthropic".into(),
423            model: "claude-haiku-4-5".into(),
424            endpoint: Some("https://api.anthropic.com".into()),
425            api_key_env: Some("ANTHROPIC_API_KEY".into()),
426            api_key_ref: None,
427            timeout_secs: Some(60),
428        };
429        let yaml = serde_yaml::to_string(&original).unwrap();
430        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
431        assert_eq!(parsed, original);
432    }
433
434    #[test]
435    fn skills_config_curation_gate_defaults_on() {
436        let c = SkillsConfig::default();
437        assert!(c.require_human_curation_before_stable);
438    }
439}
440
441#[cfg(test)]
442mod per_stage_backend_tests {
443    use super::*;
444
445    #[test]
446    fn compact_extractive_backend_override_parses() {
447        let yaml = "\
448extractive_backend:
449  provider: anthropic
450  model: claude-haiku-4-5
451  api_key_env: ANTHROPIC_API_KEY
452";
453        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
454        let extractive = cfg
455            .extractive_backend
456            .as_ref()
457            .expect("override should parse");
458        assert_eq!(extractive.provider, "anthropic");
459        assert_eq!(extractive.model, "claude-haiku-4-5");
460        assert!(cfg.abstractive_backend.is_none());
461    }
462
463    #[test]
464    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
465        let yaml = "\
466backend:
467  provider: anthropic
468  model: claude-sonnet-5
469  api_key_env: ANTHROPIC_API_KEY
470rewriter_backend:
471  provider: ollama
472  model: llama3.2:3b
473";
474        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
475        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
476        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
477    }
478
479    #[test]
480    fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
481        // C1 regression test: effective_rewriter_backend must follow the
482        // answer stage's `backend` when `rewriter_backend` is unset, NOT
483        // fall straight through to the smart slot (`llm`) — the rewriter is
484        // not an independent pinning point, only an independent timeout.
485        // `llm` below uses a distinctly different provider ("omlx", which
486        // to_backend_config() maps to "openai") than `cfg.backend`
487        // ("anthropic"), so this test cannot pass by coincidentally landing
488        // on the same provider from either source: if the fix regresses to
489        // falling through to `llm`, `rewriter.provider` comes back
490        // "openai" and the first assertion fails.
491        let cfg = AskConfig {
492            backend: Some(BackendConfig {
493                provider: "anthropic".into(),
494                model: "claude-sonnet-5".into(),
495                endpoint: None,
496                api_key_env: Some("ANTHROPIC_API_KEY".into()),
497                api_key_ref: None,
498                timeout_secs: None,
499            }),
500            ..Default::default()
501        };
502        let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
503        assert_eq!(rewriter.provider, "anthropic");
504        assert_eq!(rewriter.model, "claude-sonnet-5");
505        assert_eq!(
506            rewriter.timeout_secs,
507            Some(cfg.rewriter_timeout_secs as u64),
508            "rewriter must keep its own tighter timeout even while following the answer stage's backend"
509        );
510    }
511
512    #[test]
513    fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
514        let mut cfg = AskConfig {
515            rewriter_timeout_secs: 8,
516            ..AskConfig::default()
517        };
518        cfg.rewriter_backend = Some(BackendConfig {
519            provider: "anthropic".into(),
520            model: "claude-haiku-4-5".into(),
521            endpoint: None,
522            api_key_env: Some("ANTHROPIC_API_KEY".into()),
523            api_key_ref: None,
524            timeout_secs: Some(30),
525        });
526        let b = cfg.effective_rewriter_backend(&omlx_llm());
527        assert_eq!(
528            b.timeout_secs,
529            Some(30),
530            "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
531        );
532    }
533
534    fn omlx_llm() -> LlmConfig {
535        LlmConfig {
536            provider: "omlx".into(),
537            model: "Qwen3.5-4B-MLX-4bit".into(),
538            api_key_env: None,
539            api_key_ref: Some("env:OMLX_API_KEY".into()),
540            openai_url: Some("http://127.0.0.1:8000/v1".into()),
541        }
542    }
543
544    #[test]
545    fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
546        let ask = AskConfig::default();
547        let b = ask.effective_backend(&omlx_llm());
548        assert_eq!(b.provider, "openai");
549        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
550        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
551        assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
552        // stage timeout is baked in, not left to the factory's 120s default
553        assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
554    }
555
556    #[test]
557    fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
558        let ask = AskConfig::default();
559        let b = ask.effective_rewriter_backend(&omlx_llm());
560        assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
561        assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
562    }
563
564    #[test]
565    fn explicit_override_wins_over_the_smart_slot() {
566        let ask = AskConfig {
567            backend: Some(BackendConfig {
568                provider: "anthropic".into(),
569                model: "claude-haiku-4-5".into(),
570                endpoint: None,
571                api_key_env: None,
572                api_key_ref: None,
573                timeout_secs: Some(42),
574            }),
575            ..Default::default()
576        };
577        let b = ask.effective_backend(&omlx_llm());
578        assert_eq!(b.provider, "anthropic");
579        assert_eq!(b.timeout_secs, Some(42));
580    }
581
582    #[test]
583    fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
584        let llm = omlx_llm();
585        for b in [
586            CompactConfig::default().effective_extractive_backend(&llm),
587            CompactConfig::default().effective_abstractive_backend(&llm),
588            RollupConfig::default().effective_extractive_backend(&llm),
589            RollupConfig::default().effective_abstractive_backend(&llm),
590        ] {
591            assert_eq!(b.provider, "openai");
592            assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
593            assert_eq!(b.timeout_secs, Some(120));
594        }
595    }
596
597    #[test]
598    fn rollup_override_is_honored() {
599        let r = RollupConfig {
600            abstractive_backend: Some(BackendConfig {
601                provider: "ollama".into(),
602                model: "qwen3:4b".into(),
603                endpoint: Some("http://box.local:11434".into()),
604                api_key_env: None,
605                api_key_ref: None,
606                timeout_secs: None,
607            }),
608            ..Default::default()
609        };
610        let b = r.effective_abstractive_backend(&omlx_llm());
611        assert_eq!(b.provider, "ollama");
612        assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
613    }
614}