1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4pub struct SyncConfig {
5 #[serde(default = "default_sync_method")]
7 pub method: String,
8
9 #[serde(default, skip_serializing_if = "Option::is_none")]
11 pub git_remote: Option<String>,
12
13 #[serde(default)]
15 pub auto: bool,
16
17 #[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 #[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 #[serde(default)]
45 pub enabled: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EmbeddingConfig {
50 #[serde(default = "default_embedding_provider")]
52 pub provider: String,
53
54 #[serde(default = "default_embedding_model")]
56 pub model: String,
57
58 #[serde(default = "default_dimensions")]
60 pub dimensions: usize,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub ollama_endpoint: Option<String>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub api_key_env: Option<String>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub api_key_ref: Option<String>,
76
77 #[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 #[serde(default = "default_llm_provider")]
100 pub provider: String,
101
102 #[serde(default = "default_llm_model")]
103 pub model: String,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub api_key_env: Option<String>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub api_key_ref: Option<String>,
113
114 #[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 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(), };
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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(default)]
173pub struct BackendConfig {
174 pub provider: String,
176 pub model: String,
178 pub endpoint: Option<String>,
181 pub api_key_env: Option<String>,
183 pub api_key_ref: Option<String>,
185 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 #[serde(default = "default_max_patterns")]
206 pub max_patterns: usize,
207
208 #[serde(default = "default_max_tokens")]
210 pub max_tokens: usize,
211
212 #[serde(default = "default_min_score")]
214 pub min_score: f64,
215
216 #[serde(default = "default_mmr_threshold")]
218 pub mmr_threshold: f64,
219
220 #[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 #[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 #[serde(default = "default_vector_backend")]
261 pub vector_backend: String,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub qdrant_url: Option<String>,
266
267 #[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 #[serde(default = "default_poll_interval_secs")]
290 pub poll_interval_secs: u64,
291
292 #[serde(default = "default_max_chunks_per_sync")]
294 pub max_chunks_per_sync: usize,
295
296 #[serde(default = "default_max_parallel_sources")]
298 pub max_parallel_sources: usize,
299
300 #[serde(default = "default_source_weight")]
302 pub default_weight: f32,
303
304 #[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 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 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}