1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4mod attribution;
5mod backend;
6mod conversations;
7mod ops;
8mod scratch;
9mod search;
10mod skills;
11mod turn_track;
12
13pub use attribution::*;
14pub use backend::*;
15pub use conversations::*;
16pub use ops::*;
17pub use scratch::*;
18pub use search::*;
19pub use skills::*;
20pub use turn_track::*;
21
22use ops::{
23 default_heartbeat_interval_secs, default_heartbeat_stale_after_intervals, default_rotate_at_mb,
24};
25
26pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
27
28pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
33
34pub const DEFAULT_MAX_RETRIES: u32 = 1;
35pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
36pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
37pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
38pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
39
40pub const DEFAULT_SMART_ENABLED: bool = false;
44
45pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
50
51fn default_max_retries() -> u32 {
52 DEFAULT_MAX_RETRIES
53}
54fn default_backoff_base_ms() -> u64 {
55 DEFAULT_BACKOFF_BASE_MS
56}
57fn default_cooldown_secs() -> u64 {
58 DEFAULT_COOLDOWN_SECS
59}
60fn default_smart_max_escalations() -> u32 {
61 DEFAULT_SMART_MAX_ESCALATIONS
62}
63fn default_smart_enabled() -> bool {
64 DEFAULT_SMART_ENABLED
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct SmartConfig {
74 #[serde(default = "default_smart_enabled")]
75 pub enabled: bool,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub cheap: Option<String>,
78 #[serde(default = "default_smart_max_escalations")]
79 pub max_escalations: u32,
80}
81
82impl Default for SmartConfig {
83 fn default() -> Self {
84 Self {
85 enabled: DEFAULT_SMART_ENABLED,
86 cheap: None,
87 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct ModelSwitchConfig {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub default: Option<String>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub fallback_chain: Vec<String>,
102 #[serde(default)]
103 pub retry: RetryConfig,
104 #[serde(default)]
105 pub routing: RoutingConfig,
106 #[serde(default)]
107 pub smart: SmartConfig,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RetryConfig {
112 #[serde(default = "default_max_retries")]
113 pub max_retries: u32,
114 #[serde(default = "default_backoff_base_ms")]
115 pub backoff_base_ms: u64,
116 #[serde(default = "default_cooldown_secs")]
117 pub cooldown_secs: u64,
118}
119
120impl Default for RetryConfig {
121 fn default() -> Self {
122 Self {
123 max_retries: DEFAULT_MAX_RETRIES,
124 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
125 cooldown_secs: DEFAULT_COOLDOWN_SECS,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
131pub struct RoutingConfig {
132 #[serde(default)]
133 pub enabled: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub cheap: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub frontier: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub threshold_input_tokens: Option<u32>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
147pub struct SmartOverride {
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub enabled: Option<bool>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub cheap: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub max_escalations: Option<u32>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
159pub struct RoutingOverride {
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub enabled: Option<bool>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub cheap: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub frontier: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub threshold_input_tokens: Option<u32>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub smart: Option<SmartOverride>,
174}
175
176impl SmartConfig {
177 pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
179 let Some(o) = ov else { return self.clone() };
180 SmartConfig {
181 enabled: o.enabled.unwrap_or(self.enabled),
182 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
183 max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
184 }
185 }
186}
187
188impl RoutingConfig {
189 pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
191 let Some(o) = ov else { return self.clone() };
192 RoutingConfig {
193 enabled: o.enabled.unwrap_or(self.enabled),
194 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
195 frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
196 threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, Default)]
203pub struct Config {
204 #[serde(default)]
205 pub embedding: EmbeddingConfig,
206
207 #[serde(default)]
208 pub llm: LlmConfig,
209
210 #[serde(default)]
211 pub models: ModelSwitchConfig,
212
213 #[serde(default)]
214 pub retrieval: RetrievalConfig,
215
216 #[serde(default)]
217 pub paths: PathConfig,
218
219 #[serde(default)]
220 pub server: ServerConfig,
221
222 #[serde(default)]
223 pub community: CommunityConfig,
224
225 #[serde(default)]
226 pub conversations: ConversationsConfig,
227
228 #[serde(default)]
229 pub sync: SyncConfig,
230
231 #[serde(default)]
233 pub storage: StorageConfig,
234
235 #[serde(default)]
236 pub sources_global: SourcesGlobalConfig,
237
238 #[serde(default)]
240 pub sleep_cycle: SleepCycleConfig,
241
242 #[serde(default)]
244 pub skills: SkillsConfig,
245
246 #[serde(default)]
248 pub skill_llm: SkillLlmConfig,
249
250 #[serde(default)]
252 pub cross_agent: CrossAgentConfig,
253
254 #[serde(default)]
256 pub nudge: NudgeConfig,
257
258 #[serde(default)]
260 pub mobile_relay: MobileRelayConfig,
261
262 #[serde(default)]
264 pub session: SessionCfg,
265
266 #[serde(default)]
267 pub harvest: HarvestCfg,
268
269 #[serde(default)]
271 pub cc_proxy: CcProxyConfig,
272
273 #[serde(default)]
275 pub cli: CliConfig,
276
277 #[serde(default)]
279 pub parallel_jobs: ParallelJobsConfig,
280
281 #[serde(default)]
283 pub federation_snapshot: SnapshotConfig,
284
285 #[serde(default)]
287 pub memory: MemoryConfig,
288
289 #[serde(default)]
291 pub fleet_run: FleetRunConfig,
292
293 #[serde(default)]
295 pub git_push: GitPushConfig,
296
297 #[serde(default)]
299 pub open_items: OpenItemsConfig,
300
301 #[serde(default)]
303 pub fleet: FleetConfig,
304
305 #[serde(default)]
307 pub update: UpdateConfig,
308
309 #[serde(default)]
311 pub runs: RunsConfig,
312
313 #[serde(default)]
315 pub capture: CaptureConfig,
316
317 #[serde(default)]
321 pub limits: crate::limits::Limits,
322
323 #[serde(default)]
326 pub notifications: NotificationsConfig,
327
328 #[serde(default)]
331 pub monitor_resolver: MonitorResolverConfig,
332
333 #[serde(default)]
336 pub triage: TriageConfig,
337
338 #[serde(default)]
340 pub attribution: AttributionConfig,
341
342 #[serde(default)]
344 pub scratch: ScratchConfig,
345
346 #[serde(default)]
348 pub turn_track: TurnTrackConfig,
349
350 #[serde(default)]
352 pub search: SearchConfig,
353}
354
355impl Config {
356 pub fn load_or_default(path: &std::path::Path) -> Self {
361 let Ok(text) = std::fs::read_to_string(path) else {
362 return Self::default();
363 };
364 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
365 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
366 cfg.sanitize();
367 cfg
368 }
369
370 pub fn try_load(path: &std::path::Path) -> Result<Self, String> {
375 let text = match std::fs::read_to_string(path) {
376 Ok(t) => t,
377 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
378 Err(e) => return Err(format!("{}: {e}", path.display())),
379 };
380 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
381 let mut cfg: Self =
382 serde_yaml_ng::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
383 cfg.sanitize();
384 Ok(cfg)
385 }
386
387 fn sanitize(&mut self) {
392 if self.runs.heartbeat_interval_secs == 0 {
396 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
397 }
398 if self.capture.rotate_at_mb == 0 {
400 self.capture.rotate_at_mb = default_rotate_at_mb();
401 }
402 if self.runs.heartbeat_stale_after_intervals == 0 {
403 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
404 }
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 #[test]
411 fn update_defaults_apply_when_block_absent_and_fields_parse() {
412 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
413 assert_eq!(c.update, super::UpdateConfig::default());
414 assert!(c.update.codesign_identity.is_none());
415 assert!(c.update.restart_exclude.is_empty());
416
417 let c: super::Config = serde_yaml_ng::from_str(
418 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
419 )
420 .unwrap();
421 assert_eq!(
422 c.update.codesign_identity.as_deref(),
423 Some("Developer ID Application: X (TEAM)")
424 );
425 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
426 }
427
428 #[test]
429 fn federation_snapshot_defaults_apply_when_block_absent() {
430 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
431 assert_eq!(c.federation_snapshot.poll_secs, 30);
432 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
433 assert_eq!(
434 c.federation_snapshot.min_lifecycle,
435 crate::skill::stats::LifecycleState::Stable
436 );
437 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
439 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
440 assert_eq!(c.memory.capture, super::CaptureMode::Off);
441 }
442
443 use super::*;
444
445 #[test]
446 fn default_bundled_model_id_is_qwen35_2b() {
447 assert_eq!(
448 crate::config::DEFAULT_BUNDLED_MODEL_ID,
449 "Qwen3.5-2B-MLX-4bit"
450 );
451 }
452
453 #[test]
454 fn nudge_config_defaults() {
455 let c = NudgeConfig::default();
456 assert!(c.enabled);
457 assert_eq!(c.daily_cap, 3);
458 assert_eq!(c.snooze_days, 7);
459 assert_eq!(c.threshold, 3);
460 }
461
462 #[test]
463 fn config_has_nudge_section_with_defaults() {
464 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
465 assert_eq!(c.nudge.daily_cap, 3);
466 }
467
468 #[test]
469 fn storage_config_default_is_lancedb() {
470 let c = StorageConfig::default();
471 assert_eq!(c.vector_backend, "lancedb");
472 assert_eq!(c.qdrant_url, None);
473 assert_eq!(c.qdrant_api_key_ref, None);
474 }
475
476 #[test]
477 fn sources_global_config_has_sensible_defaults() {
478 let c = SourcesGlobalConfig::default();
479 assert_eq!(c.poll_interval_secs, 600);
480 assert_eq!(c.max_chunks_per_sync, 10_000);
481 assert_eq!(c.max_parallel_sources, 3);
482 assert_eq!(c.default_weight, 1.0);
483 assert_eq!(c.embedding_batch_size, 32);
484 }
485
486 #[test]
487 fn config_default_has_storage_and_sources_global() {
488 let c = Config::default();
489 assert_eq!(c.storage.vector_backend, "lancedb");
490 assert_eq!(c.sources_global.default_weight, 1.0);
491 }
492
493 #[test]
494 fn config_loads_yaml_without_new_fields() {
495 let yaml = r#"
498embedding:
499 provider: ollama
500 model: test-model
501 dimensions: 512
502 ollama_endpoint: http://localhost:11434
503"#;
504 let c: Config = serde_yaml::from_str(yaml).expect("parses");
505 assert_eq!(c.storage.vector_backend, "lancedb");
506 assert_eq!(c.sources_global.max_parallel_sources, 3);
507 }
508
509 #[test]
510 fn llm_config_to_backend_config_anthropic_passthrough() {
511 let cfg = LlmConfig {
512 provider: "anthropic".into(),
513 model: "claude-haiku-4-5".into(),
514 api_key_env: Some("ANTHROPIC_API_KEY".into()),
515 api_key_ref: None,
516 openai_url: None,
517 };
518 let b = cfg.to_backend_config();
519 assert_eq!(b.provider, "anthropic");
520 assert_eq!(b.model, "claude-haiku-4-5");
521 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
522 assert_eq!(b.endpoint, None);
523 assert_eq!(b.timeout_secs, None);
524 }
525
526 #[test]
527 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
528 let cfg = LlmConfig {
529 provider: "openai".into(),
530 model: "gpt-4o-mini".into(),
531 api_key_env: None,
532 api_key_ref: None,
533 openai_url: Some("https://api.together.xyz/v1".into()),
534 };
535 let b = cfg.to_backend_config();
536 assert_eq!(b.provider, "openai");
537 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
538 assert_eq!(b.api_key_env, None); }
540
541 #[test]
542 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
543 let cfg = LlmConfig {
544 provider: "ollama".into(),
545 model: "qwen3:14b".into(),
546 api_key_env: None,
547 api_key_ref: None,
548 openai_url: Some("http://192.168.1.10:11434".into()),
549 };
550 let b = cfg.to_backend_config();
551 assert_eq!(b.provider, "ollama");
552 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
553 }
554
555 #[test]
556 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
557 let cfg = LlmConfig {
561 provider: "custom-name".into(),
562 model: "some-model".into(),
563 api_key_env: Some("CUSTOM_KEY".into()),
564 api_key_ref: None,
565 openai_url: Some("https://my-proxy.local/v1".into()),
566 };
567 let b = cfg.to_backend_config();
568 assert_eq!(
569 b.provider, "openai",
570 "unknown provider + openai_url should alias to openai"
571 );
572 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
573 }
574
575 #[test]
576 fn api_key_ref_roundtrips_and_defaults_none() {
577 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
579 assert_eq!(b.api_key_ref, None);
580 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
581 assert_eq!(l.api_key_ref, None);
582 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
583 assert_eq!(e.api_key_ref, None);
584
585 let l2 = LlmConfig {
587 api_key_ref: Some("keychain:mur/anthropic".into()),
588 ..Default::default()
589 };
590 let y = serde_yaml_ng::to_string(&l2).unwrap();
591 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
592 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
593 assert_eq!(
594 l3.to_backend_config().api_key_ref.as_deref(),
595 Some("keychain:mur/anthropic")
596 );
597 }
598
599 #[test]
600 fn open_items_muted_parses_and_defaults_empty() {
601 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
602 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
603
604 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
605 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
606 }
607
608 #[test]
611 fn unreadable_config_yields_no_mutes() {
612 let tmp = tempfile::tempdir().unwrap();
613 let path = tmp.path().join("config.yaml");
614 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
615 let cfg = Config::load_or_default(&path);
616 assert!(
617 cfg.open_items.muted.is_empty(),
618 "a broken config must hide nothing"
619 );
620
621 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
623 assert!(missing.open_items.muted.is_empty());
624 }
625
626 #[test]
627 fn rollup_config_accepts_backend_overrides() {
628 let yaml = r#"
629enabled: true
630extractive_backend:
631 provider: openai
632 model: Qwen3.5-4B-MLX-4bit
633 endpoint: http://127.0.0.1:8000/v1
634"#;
635 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
636 let b = c.extractive_backend.expect("override present");
637 assert_eq!(b.provider, "openai");
638 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
639 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
640 assert!(c.abstractive_backend.is_none());
641 }
642
643 #[test]
644 fn legacy_conversation_fields_are_gone_from_serialized_output() {
645 let cfg = Config::default();
646 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
652 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
653 assert!(
654 !yaml.contains(key),
655 "legacy key {key} still serialized:\n{yaml}"
656 );
657 }
658 }
659
660 #[test]
661 fn embedding_ollama_endpoint_is_omitted_when_unset() {
662 let mut cfg = Config::default();
663 cfg.embedding.provider = "omlx".into();
664 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
665 cfg.embedding.ollama_endpoint = None;
666 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
667 assert!(
668 !yaml.contains("ollama_endpoint"),
669 "dead field re-emitted:\n{yaml}"
670 );
671 }
672
673 #[test]
674 fn embedding_ollama_endpoint_still_round_trips_when_set() {
675 let yaml =
676 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
677 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
678 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
679 }
680}
681
682#[cfg(test)]
683mod model_switch_config_tests {
684 use super::*;
685
686 #[test]
687 fn model_switch_config_defaults_and_omitted_block() {
688 let cfg: Config = serde_yaml::from_str("{}").unwrap();
690 assert_eq!(cfg.models.default, None);
691 assert!(cfg.models.fallback_chain.is_empty());
692 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
693 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
694 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
695 assert!(!cfg.models.routing.enabled);
696
697 let yaml = "models:\n default: claude_sonnet\n fallback_chain: [claude_sonnet, deepseek_v4_pro]\n routing:\n enabled: true\n cheap: deepseek_v4_flash\n frontier: claude_opus\n threshold_input_tokens: 1500\n";
699 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
700 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
701 assert_eq!(
702 cfg.models.fallback_chain,
703 vec!["claude_sonnet", "deepseek_v4_pro"]
704 );
705 assert!(cfg.models.routing.enabled);
706 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
707 }
708
709 #[test]
710 fn smart_config_defaults_off_with_autopick() {
711 let cfg: Config = serde_yaml::from_str("{}").unwrap();
712 assert!(
713 !cfg.models.smart.enabled,
714 "Smart background routing is opt-in (capability-gate spec §2)"
715 );
716 assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
718 cfg.models.smart.max_escalations,
719 DEFAULT_SMART_MAX_ESCALATIONS
720 );
721 }
722
723 #[test]
724 fn smart_override_inherits_field_by_field() {
725 let global = SmartConfig {
726 enabled: true,
727 cheap: Some("g".into()),
728 max_escalations: 3,
729 };
730 assert_eq!(global.merged(None), global);
731 assert_eq!(global.merged(Some(&SmartOverride::default())), global);
732 let only_cheap = SmartOverride {
734 cheap: Some("a".into()),
735 ..Default::default()
736 };
737 let m = global.merged(Some(&only_cheap));
738 assert!(m.enabled, "overriding cheap must not disable Smart");
739 assert_eq!(m.cheap.as_deref(), Some("a"));
740 assert_eq!(m.max_escalations, 3);
741 let off = SmartOverride {
743 enabled: Some(false),
744 ..Default::default()
745 };
746 assert!(!global.merged(Some(&off)).enabled);
747 }
748
749 #[test]
750 fn routing_override_inherits_field_by_field() {
751 let global = RoutingConfig {
752 enabled: true,
753 cheap: Some("c".into()),
754 frontier: Some("f".into()),
755 threshold_input_tokens: Some(9),
756 };
757 let only_cheap = RoutingOverride {
758 cheap: Some("a".into()),
759 ..Default::default()
760 };
761 let m = global.merged(Some(&only_cheap));
762 assert!(m.enabled, "overriding cheap must not disable routing");
763 assert_eq!(m.cheap.as_deref(), Some("a"));
764 assert_eq!(m.frontier.as_deref(), Some("f"));
765 assert_eq!(m.threshold_input_tokens, Some(9));
766 }
767}