1use super::*;
2
3pub fn normalize_model_name(value: &str) -> Option<String> {
4 let trimmed = value.trim();
5 if trimmed.is_empty() || trimmed == "*" {
6 None
7 } else {
8 Some(trimmed.to_string())
9 }
10}
11
12pub(crate) fn normalize_string_list(values: &mut Vec<String>, lowercase: bool) {
13 *values = values
14 .drain(..)
15 .map(|value| {
16 let value = value.trim();
17 if lowercase {
18 value.to_ascii_lowercase()
19 } else {
20 value.to_string()
21 }
22 })
23 .filter(|value| !value.is_empty())
24 .collect();
25 values.sort();
26 values.dedup();
27}
28
29pub fn resolve_model_name_with_fallback(
30 provider: &str,
31 candidate: Option<&str>,
32 fallback: Option<&str>,
33) -> String {
34 candidate
35 .and_then(normalize_model_name)
36 .or_else(|| fallback.and_then(normalize_model_name))
37 .unwrap_or_else(|| default_model_for_provider_name(provider).to_string())
38}
39
40pub(crate) fn expand_path(path: &str) -> PathBuf {
42 match shellexpand::tilde(path) {
43 std::borrow::Cow::Borrowed(p) => PathBuf::from(p),
44 std::borrow::Cow::Owned(p) => PathBuf::from(p),
45 }
46}
47
48pub(crate) fn detect_system_timezone() -> String {
49 match iana_time_zone::get_timezone() {
50 Ok(tz_name) => tz_name,
51 Err(e) => {
52 warn!("Failed to detect system timezone automatically: {e}. Falling back to UTC.");
53 "UTC".into()
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum UserMessageLanguage {
64 #[default]
65 En,
66 Zh,
67 Bilingual,
68}
69
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum SystemPromptTimeDetail {
77 None,
80 #[default]
83 Date,
84 Full,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize)]
90pub struct Config {
91 #[serde(default = "default_llm_provider")]
93 pub llm_provider: String,
94 #[serde(default = "default_api_key")]
95 pub api_key: String,
96 #[serde(default)]
100 pub api_keys: Vec<String>,
101 #[serde(default = "default_model")]
102 pub model: String,
103 #[serde(default)]
104 pub provider_presets: HashMap<String, LlmProviderProfile>,
105 #[serde(default)]
106 pub llm_providers: HashMap<String, LlmProviderProfile>,
107 #[serde(default)]
108 pub llm_base_url: Option<String>,
109 #[serde(default = "default_llm_user_agent")]
110 pub llm_user_agent: String,
111 #[serde(default = "default_max_tokens")]
112 pub max_tokens: u32,
113 #[serde(default = "default_max_tool_iterations")]
114 pub max_tool_iterations: usize,
115 #[serde(default = "default_compaction_timeout_secs")]
116 pub compaction_timeout_secs: u64,
117 #[serde(default)]
120 pub aux_models: AuxModels,
121 #[serde(default)]
126 pub fallback_model: Option<String>,
127 #[serde(default = "default_max_history_messages")]
128 pub max_history_messages: usize,
129 #[serde(default = "default_max_document_size_mb")]
130 pub max_document_size_mb: u64,
131 #[serde(default = "default_memory_token_budget")]
132 pub memory_token_budget: usize,
133 #[serde(default = "default_memory_l0_identity_pct")]
135 pub memory_l0_identity_pct: usize,
136 #[serde(default = "default_memory_l1_essential_pct")]
138 pub memory_l1_essential_pct: usize,
139 #[serde(default = "default_memory_max_entries_per_chat")]
140 pub memory_max_entries_per_chat: usize,
141 #[serde(default = "default_memory_max_global_entries")]
142 pub memory_max_global_entries: usize,
143 #[serde(default = "default_kg_max_triples_per_chat")]
145 pub kg_max_triples_per_chat: usize,
146 #[serde(default = "default_tool_result_truncation_threshold_chars")]
150 pub tool_result_truncation_threshold_chars: usize,
151 #[serde(default = "default_tool_result_truncation_head_chars")]
154 pub tool_result_truncation_head_chars: usize,
155 #[serde(default = "default_tool_result_truncation_tail_chars")]
158 pub tool_result_truncation_tail_chars: usize,
159 #[serde(default = "default_tool_result_artifact_ttl_hours")]
162 pub tool_result_artifact_ttl_hours: u64,
163 #[serde(default = "default_memory_recency_half_life_days")]
168 pub memory_recency_half_life_days: f64,
169 #[serde(default = "default_memory_graph_recall_enabled")]
175 pub memory_graph_recall_enabled: bool,
176 #[serde(default = "default_memory_graph_max_hops")]
179 pub memory_graph_max_hops: usize,
180 #[serde(default = "default_memory_graph_max_triples")]
184 pub memory_graph_max_triples: usize,
185 #[serde(default = "default_tool_repeat_window")]
191 pub tool_repeat_window: usize,
192 #[serde(default = "default_tool_repeat_limit")]
194 pub tool_repeat_limit: usize,
195 #[serde(default = "default_anthropic_prompt_cache_enabled")]
199 pub anthropic_prompt_cache_enabled: bool,
200 #[serde(default = "default_anthropic_prompt_cache_ttl")]
203 pub anthropic_prompt_cache_ttl: String,
204 #[serde(default = "default_checkpoints_enabled")]
210 pub checkpoints_enabled: bool,
211 #[serde(default = "default_skill_archive_after_days")]
216 pub skill_archive_after_days: u64,
217 #[serde(default = "default_skills_catalog_top_k")]
223 pub skills_catalog_top_k: usize,
224 #[serde(default)]
229 pub system_prompt_time_detail: SystemPromptTimeDetail,
230 #[serde(default = "default_max_session_messages")]
231 pub max_session_messages: usize,
232 #[serde(default = "default_diff_max_lines")]
235 pub diff_max_lines: usize,
236 #[serde(default = "default_file_diffs_in_chat")]
239 pub file_diffs_in_chat: bool,
240 #[serde(default = "default_model_context_window")]
243 pub model_context_window: usize,
244 #[serde(default = "default_context_pressure_compact_pct")]
248 pub context_pressure_compact_pct: usize,
249 #[serde(default)]
253 pub self_recheck: SelfRecheckConfig,
254 #[serde(default = "default_compact_keep_recent")]
255 pub compact_keep_recent: usize,
256 #[serde(default = "default_tool_timeout_secs")]
257 pub default_tool_timeout_secs: u64,
258 #[serde(default)]
259 pub tool_timeout_overrides: HashMap<String, u64>,
260 #[serde(default = "default_mcp_request_timeout_secs")]
261 pub default_mcp_request_timeout_secs: u64,
262 #[serde(default)]
263 pub show_thinking: bool,
264 #[serde(default)]
265 pub subagents: SubagentConfig,
266 #[serde(default)]
267 pub idle_checkin: IdleCheckinConfig,
268 #[serde(default)]
269 pub heartbeat: HeartbeatConfig,
270 #[serde(default)]
271 pub token_budget: TokenBudgetConfig,
272 #[serde(default)]
273 pub alerts: AlertsConfig,
274 #[serde(default)]
275 pub trust_report: TrustReportConfig,
276 #[serde(default)]
278 pub user_message_language: UserMessageLanguage,
279 #[serde(default)]
280 pub sleep_time: SleepTimeConfig,
281 #[serde(default)]
282 pub interjection: InterjectionConfig,
283 #[serde(default)]
284 pub a2a: A2AConfig,
285
286 #[serde(default = "default_chat_turn_queue_max_pending")]
289 pub chat_turn_queue_max_pending: usize,
290 #[serde(default = "default_true")]
293 pub enable_mid_turn_injection: bool,
294 #[serde(default = "default_true")]
298 pub mid_turn_injection_echo: bool,
299 #[serde(default = "default_parallel_tool_max_concurrency")]
301 pub parallel_tool_max_concurrency: usize,
302 #[serde(default)]
305 pub tool_concurrency_overrides: HashMap<String, String>,
306 #[serde(default)]
309 pub openai_compat_body_overrides: HashMap<String, serde_json::Value>,
310 #[serde(default)]
313 pub openai_compat_body_overrides_by_provider:
314 HashMap<String, HashMap<String, serde_json::Value>>,
315 #[serde(default)]
317 pub openai_compat_body_overrides_by_model: HashMap<String, HashMap<String, serde_json::Value>>,
318
319 #[serde(default = "default_data_dir")]
321 pub data_dir: String,
322 #[serde(default)]
323 pub skills_dir: Option<String>,
324 #[serde(default = "default_working_dir")]
325 pub working_dir: String,
326 #[serde(default = "default_working_dir_isolation")]
327 pub working_dir_isolation: WorkingDirIsolation,
328 #[serde(default = "default_high_risk_tool_user_confirmation_required")]
329 pub high_risk_tool_user_confirmation_required: bool,
330 #[serde(default = "default_bash_dangerous_patterns")]
337 pub bash_dangerous_patterns: Vec<String>,
338 #[serde(default)]
340 pub rtk: RtkConfig,
341 #[serde(default)]
342 pub sandbox: SandboxConfig,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub override_timezone: Option<String>,
345 #[serde(default = "default_timezone", skip_serializing)]
346 pub timezone: String,
347 #[serde(default = "default_control_chat_ids")]
348 pub control_chat_ids: Vec<i64>,
349 #[serde(default)]
350 pub discord_bot_token: Option<String>,
351 #[serde(default)]
352 pub discord_allowed_channels: Vec<u64>,
353 #[serde(default)]
354 pub discord_no_mention: bool,
355 #[serde(default = "default_allow_group_slash_without_mention")]
356 pub allow_group_slash_without_mention: bool,
357
358 #[serde(default = "default_web_enabled")]
360 pub web_enabled: bool,
361 #[serde(default = "default_web_host")]
362 pub web_host: String,
363 #[serde(default = "default_web_port")]
364 pub web_port: u16,
365 #[serde(default = "default_web_max_inflight_per_session")]
366 pub web_max_inflight_per_session: usize,
367 #[serde(default = "default_web_max_requests_per_window")]
368 pub web_max_requests_per_window: usize,
369 #[serde(default = "default_web_rate_window_seconds")]
370 pub web_rate_window_seconds: u64,
371 #[serde(default = "default_web_run_history_limit")]
372 pub web_run_history_limit: usize,
373 #[serde(default = "default_web_session_idle_ttl_seconds")]
374 pub web_session_idle_ttl_seconds: u64,
375 #[serde(default)]
376 pub web_fetch_validation: WebContentValidationConfig,
377 #[serde(default)]
378 pub web_fetch_url_validation: WebFetchUrlValidationConfig,
379 #[serde(default)]
382 pub web_search: SearchProviderConfig,
383 #[serde(default)]
386 pub output_guardrail: OutputGuardrailConfig,
387 #[serde(default)]
390 pub tool_policy: crate::tool_guardrails::ToolPolicyConfig,
391 #[serde(default)]
394 pub egress_policy: EgressPolicyConfig,
395
396 #[serde(default)]
398 pub embedding_provider: Option<String>,
399 #[serde(default)]
400 pub embedding_api_key: Option<String>,
401 #[serde(default)]
402 pub embedding_base_url: Option<String>,
403 #[serde(default)]
404 pub embedding_model: Option<String>,
405 #[serde(default)]
406 pub embedding_dim: Option<usize>,
407 #[serde(default)]
408 pub openai_api_key: Option<String>,
409
410 #[serde(default = "default_model_prices")]
412 pub model_prices: Vec<ModelPrice>,
413
414 #[serde(default = "default_reflector_enabled")]
416 pub reflector_enabled: bool,
417 #[serde(default = "default_reflector_interval_mins")]
418 pub reflector_interval_mins: u64,
419 #[serde(default = "default_dlq_replay_enabled")]
424 pub dlq_replay_enabled: bool,
425 #[serde(default = "default_dlq_replay_interval_secs")]
427 pub dlq_replay_interval_secs: u64,
428 #[serde(default = "default_dlq_max_replay_attempts")]
431 pub dlq_max_replay_attempts: u32,
432 #[serde(default = "default_skill_review_min_tool_calls")]
436 pub skill_review_min_tool_calls: usize,
437
438 #[serde(default = "default_soul_path")]
442 pub soul_path: Option<String>,
443 #[serde(default = "default_souls_dir")]
445 pub souls_dir: Option<String>,
446
447 #[serde(default)]
454 pub context_dir: Option<String>,
455 #[serde(default = "default_context_max_chars")]
459 pub context_max_chars: usize,
460
461 #[serde(default)]
468 pub voice_round_trip: bool,
469
470 #[serde(default = "default_user_model_max_chars")]
478 pub user_model_max_chars: usize,
479
480 #[serde(flatten)]
482 pub clawhub: ClawHubConfig,
483
484 #[serde(default)]
486 pub plugins: PluginsConfig,
487
488 #[serde(default)]
494 pub media: MediaConfig,
495
496 #[serde(default)]
499 pub openai_base_url: Option<String>,
500
501 #[serde(default = "default_voice_provider", rename = "voice_provider")]
504 pub voice_provider: String,
505 #[serde(default, rename = "voice_transcription_command")]
508 pub voice_transcription_command: Option<String>,
509
510 #[serde(default)]
512 pub observability: Option<serde_yaml::Value>,
513
514 #[serde(default)]
519 pub channels: HashMap<String, serde_yaml::Value>,
520
521 #[serde(default = "default_telegram_bot_token")]
523 pub telegram_bot_token: String,
524 #[serde(default = "default_bot_username")]
525 pub bot_username: String,
526 #[serde(default)]
527 pub allowed_groups: Vec<i64>,
528}
529
530pub(crate) fn collect_enabled_channel_endpoints(value: &serde_yaml::Value, out: &mut Vec<String>) {
531 let Some(mapping) = value.as_mapping() else {
532 return;
533 };
534 if mapping
535 .get(serde_yaml::Value::String("enabled".into()))
536 .and_then(serde_yaml::Value::as_bool)
537 == Some(false)
538 {
539 return;
540 }
541
542 for (key, value) in mapping {
543 let Some(key) = key.as_str() else {
544 continue;
545 };
546 let key = key.to_ascii_lowercase();
547 let is_endpoint_key = key.contains("url")
548 || key.contains("endpoint")
549 || key == "homeserver"
550 || key == "homeserver_url";
551 if is_endpoint_key {
552 if let Some(endpoint) = value
553 .as_str()
554 .map(str::trim)
555 .filter(|endpoint| !endpoint.is_empty())
556 {
557 out.push(endpoint.to_string());
558 }
559 }
560 match value {
561 serde_yaml::Value::Mapping(_) => collect_enabled_channel_endpoints(value, out),
562 serde_yaml::Value::Sequence(values) => {
563 for value in values {
564 collect_enabled_channel_endpoints(value, out);
565 }
566 }
567 _ => {}
568 }
569 }
570}
571
572impl Config {
573 pub fn is_control_chat(&self, chat_id: i64) -> bool {
578 self.control_chat_ids.contains(&chat_id)
579 }
580
581 fn ensure_mapping_mut(value: &mut serde_yaml::Value) -> &mut serde_yaml::Mapping {
582 if !matches!(value, serde_yaml::Value::Mapping(_)) {
583 *value = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
584 }
585 value
586 .as_mapping_mut()
587 .expect("value should be a mapping after initialization")
588 }
589
590 fn channel_default_account_id(&self, channel: &str) -> Option<String> {
591 let channel_cfg = self.channels.get(channel)?;
592 let mut account_ids: Vec<String> = channel_cfg
593 .get("accounts")
594 .and_then(|v| v.as_mapping())
595 .map(|m| {
596 m.keys()
597 .filter_map(|k| k.as_str().map(ToOwned::to_owned))
598 .collect()
599 })
600 .unwrap_or_default();
601 account_ids.sort();
602 channel_cfg
603 .get("default_account")
604 .and_then(|v| v.as_str())
605 .map(str::trim)
606 .filter(|v| !v.is_empty())
607 .map(ToOwned::to_owned)
608 .or_else(|| {
609 if channel_cfg
610 .get("accounts")
611 .and_then(|v| v.get("default"))
612 .is_some()
613 {
614 Some("default".to_string())
615 } else {
616 account_ids.first().cloned()
617 }
618 })
619 }
620
621 fn llm_override_target(&self, channel: &str) -> Option<(String, Option<String>)> {
622 let channel = channel.trim();
623 if channel.is_empty() {
624 return None;
625 }
626 if let Some((base_channel, account_id)) = channel.split_once('.') {
627 let base_channel = base_channel.trim();
628 let account_id = account_id.trim();
629 if base_channel.is_empty() || account_id.is_empty() {
630 return None;
631 }
632 return Some((base_channel.to_string(), Some(account_id.to_string())));
633 }
634
635 Some((
636 channel.to_string(),
637 self.channel_default_account_id(channel),
638 ))
639 }
640
641 fn llm_override_mapping_mut(&mut self, channel: &str) -> Option<&mut serde_yaml::Mapping> {
642 let (base_channel, account_id) = self.llm_override_target(channel)?;
643 let channel_value = self
644 .channels
645 .entry(base_channel)
646 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
647 let channel_map = Self::ensure_mapping_mut(channel_value);
648 if let Some(account_id) = account_id {
649 let accounts_key = serde_yaml::Value::String("accounts".to_string());
650 let accounts_value = channel_map
651 .entry(accounts_key)
652 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
653 let accounts_map = Self::ensure_mapping_mut(accounts_value);
654 let account_value = accounts_map
655 .entry(serde_yaml::Value::String(account_id))
656 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
657 return Some(Self::ensure_mapping_mut(account_value));
658 }
659 Some(channel_map)
660 }
661
662 pub fn set_provider_override_for_channel(&mut self, channel: &str, provider: Option<&str>) {
663 let Some(target) = self.llm_override_mapping_mut(channel) else {
664 return;
665 };
666 let provider_preset_key = serde_yaml::Value::String("provider_preset".to_string());
667 let llm_provider_key = serde_yaml::Value::String("llm_provider".to_string());
668 target.remove(&provider_preset_key);
669 target.remove(&llm_provider_key);
670 if let Some(provider) = provider
671 .map(str::trim)
672 .filter(|provider| !provider.is_empty())
673 .map(|provider| provider.to_ascii_lowercase())
674 {
675 target.insert(
676 provider_preset_key,
677 serde_yaml::Value::String(provider.to_string()),
678 );
679 }
680 }
681
682 pub fn set_model_override_for_channel(&mut self, channel: &str, model: Option<&str>) {
683 let Some(target) = self.llm_override_mapping_mut(channel) else {
684 return;
685 };
686 let model_key = serde_yaml::Value::String("model".to_string());
687 target.remove(&model_key);
688 if let Some(model) = model.and_then(normalize_model_name) {
689 target.insert(model_key, serde_yaml::Value::String(model.to_string()));
690 }
691 }
692
693 fn channel_account_bot_username(&self, channel: &str, account_id: &str) -> Option<String> {
694 self.channels
695 .get(channel)
696 .and_then(|v| v.get("accounts"))
697 .and_then(|v| v.get(account_id))
698 .and_then(|v| v.get("bot_username"))
699 .and_then(|v| v.as_str())
700 .map(str::trim)
701 .filter(|v| !v.is_empty())
702 .map(ToOwned::to_owned)
703 }
704
705 fn channel_account_soul_path(&self, channel: &str, account_id: &str) -> Option<String> {
706 self.channels
707 .get(channel)
708 .and_then(|v| v.get("accounts"))
709 .and_then(|v| v.get(account_id))
710 .and_then(|v| v.get("soul_path"))
711 .and_then(|v| v.as_str())
712 .map(str::trim)
713 .filter(|v| !v.is_empty())
714 .map(ToOwned::to_owned)
715 }
716
717 fn provider_override_from_value(value: &serde_yaml::Value) -> Option<String> {
718 value
719 .get("provider_preset")
720 .and_then(|v| v.as_str())
721 .or_else(|| value.get("llm_provider").and_then(|v| v.as_str()))
722 .map(str::trim)
723 .filter(|v| !v.is_empty())
724 .map(|v| v.to_ascii_lowercase())
725 }
726
727 fn channel_account_provider_override(&self, channel: &str, account_id: &str) -> Option<String> {
728 self.channels
729 .get(channel)
730 .and_then(|v| v.get("accounts"))
731 .and_then(|v| v.get(account_id))
732 .and_then(Self::provider_override_from_value)
733 }
734
735 fn model_override_from_value(value: &serde_yaml::Value) -> Option<String> {
736 value
737 .get("model")
738 .and_then(|v| v.as_str())
739 .and_then(normalize_model_name)
740 }
741
742 fn channel_account_model_override(&self, channel: &str, account_id: &str) -> Option<String> {
743 self.channels
744 .get(channel)
745 .and_then(|v| v.get("accounts"))
746 .and_then(|v| v.get(account_id))
747 .and_then(Self::model_override_from_value)
748 }
749
750 pub fn provider_override_for_channel(&self, channel: &str) -> Option<String> {
751 if let Some((base_channel, account_id)) = channel.split_once('.') {
752 if let Some(v) = self.channel_account_provider_override(base_channel, account_id) {
753 return Some(v);
754 }
755 return self
756 .channels
757 .get(base_channel)
758 .and_then(Self::provider_override_from_value);
759 }
760
761 if let Some(default_account) = self.channel_default_account_id(channel) {
762 if let Some(v) = self.channel_account_provider_override(channel, &default_account) {
763 return Some(v);
764 }
765 }
766
767 self.channels
768 .get(channel)
769 .and_then(Self::provider_override_from_value)
770 }
771
772 pub fn model_override_for_channel(&self, channel: &str) -> Option<String> {
773 if let Some((base_channel, account_id)) = channel.split_once('.') {
774 if let Some(v) = self.channel_account_model_override(base_channel, account_id) {
775 return Some(v);
776 }
777 return self
778 .channels
779 .get(base_channel)
780 .and_then(Self::model_override_from_value);
781 }
782
783 if let Some(default_account) = self.channel_default_account_id(channel) {
784 if let Some(v) = self.channel_account_model_override(channel, &default_account) {
785 return Some(v);
786 }
787 }
788
789 self.channels
790 .get(channel)
791 .and_then(Self::model_override_from_value)
792 }
793
794 pub fn soul_path_for_channel(&self, channel: &str) -> Option<String> {
795 let channel_override = self
796 .channels
797 .get(channel)
798 .and_then(|v| v.get("soul_path"))
799 .and_then(|v| v.as_str())
800 .map(str::trim)
801 .filter(|v| !v.is_empty())
802 .map(ToOwned::to_owned);
803 if channel_override.is_some() {
804 return channel_override;
805 }
806
807 if let Some((base_channel, account_id)) = channel.split_once('.') {
808 if let Some(v) = self.channel_account_soul_path(base_channel, account_id) {
809 return Some(v);
810 }
811 return self
812 .channels
813 .get(base_channel)
814 .and_then(|v| v.get("soul_path"))
815 .and_then(|v| v.as_str())
816 .map(str::trim)
817 .filter(|v| !v.is_empty())
818 .map(ToOwned::to_owned);
819 }
820
821 if let Some(default_account) = self.channel_default_account_id(channel) {
822 if let Some(v) = self.channel_account_soul_path(channel, &default_account) {
823 return Some(v);
824 }
825 }
826
827 self.channels
828 .get(channel)
829 .and_then(|v| v.get("soul_path"))
830 .and_then(|v| v.as_str())
831 .map(str::trim)
832 .filter(|v| !v.is_empty())
833 .map(ToOwned::to_owned)
834 }
835
836 pub fn bot_username_for_channel(&self, channel: &str) -> String {
837 let channel_override = self
838 .channels
839 .get(channel)
840 .and_then(|v| v.get("bot_username"))
841 .and_then(|v| v.as_str())
842 .map(str::trim)
843 .filter(|v| !v.is_empty());
844 if let Some(v) = channel_override {
845 return v.to_string();
846 }
847
848 if let Some((base_channel, account_id)) = channel.split_once('.') {
849 if let Some(v) = self.channel_account_bot_username(base_channel, account_id) {
850 return v;
851 }
852 } else if let Some(default_account) = self.channel_default_account_id(channel) {
853 if let Some(v) = self.channel_account_bot_username(channel, &default_account) {
854 return v;
855 }
856 }
857
858 let global = self.bot_username.trim();
859 if !global.is_empty() {
860 global.to_string()
861 } else {
862 default_bot_username()
863 }
864 }
865
866 pub fn bot_username_overrides(&self) -> HashMap<String, String> {
867 let mut overrides: HashMap<String, String> = self
868 .channels
869 .iter()
870 .filter_map(|(channel, cfg)| {
871 cfg.get("bot_username")
872 .and_then(|v| v.as_str())
873 .map(str::trim)
874 .filter(|v| !v.is_empty())
875 .map(|v| (channel.clone(), v.to_string()))
876 })
877 .collect();
878
879 for (channel, channel_cfg) in &self.channels {
880 let accounts = channel_cfg.get("accounts").and_then(|v| v.as_mapping());
881 let Some(accounts) = accounts else {
882 continue;
883 };
884 let default_account = self.channel_default_account_id(channel);
885 for (key, value) in accounts {
886 let Some(account_id) = key.as_str() else {
887 continue;
888 };
889 let username = value
890 .get("bot_username")
891 .and_then(|v| v.as_str())
892 .map(str::trim)
893 .filter(|v| !v.is_empty());
894 let Some(username) = username else {
895 continue;
896 };
897 if default_account
898 .as_deref()
899 .map(|v| v == account_id)
900 .unwrap_or(false)
901 {
902 overrides.insert(channel.clone(), username.to_string());
903 } else {
904 overrides.insert(format!("{channel}.{account_id}"), username.to_string());
905 }
906 }
907 }
908
909 overrides
910 }
911
912 pub fn llm_provider_overrides(&self) -> HashMap<String, String> {
913 let mut overrides: HashMap<String, String> = self
914 .channels
915 .iter()
916 .filter_map(|(channel, cfg)| {
917 Self::provider_override_from_value(cfg).map(|provider| (channel.clone(), provider))
918 })
919 .collect();
920
921 for (channel, channel_cfg) in &self.channels {
922 let accounts = channel_cfg.get("accounts").and_then(|v| v.as_mapping());
923 let Some(accounts) = accounts else {
924 continue;
925 };
926 let default_account = self.channel_default_account_id(channel);
927 for (key, value) in accounts {
928 let Some(account_id) = key.as_str() else {
929 continue;
930 };
931 let Some(provider) = Self::provider_override_from_value(value) else {
932 continue;
933 };
934 if default_account
935 .as_deref()
936 .map(|v| v == account_id)
937 .unwrap_or(false)
938 {
939 overrides.insert(channel.clone(), provider);
940 } else {
941 overrides.insert(format!("{channel}.{account_id}"), provider);
942 }
943 }
944 }
945
946 overrides
947 }
948
949 #[cfg(any(test, feature = "test-support"))]
950 pub fn test_defaults() -> Self {
951 Self {
952 telegram_bot_token: "tok".into(),
953 bot_username: "bot".into(),
954 llm_provider: "anthropic".into(),
955 api_key: "key".into(),
956 api_keys: Vec::new(),
957 model: "claude-sonnet-4-5-20250929".into(),
958 provider_presets: HashMap::new(),
959 llm_providers: HashMap::new(),
960 llm_base_url: None,
961 llm_user_agent: default_llm_user_agent(),
962 max_tokens: 8192,
963 max_tool_iterations: 100,
964 compaction_timeout_secs: 180,
965 aux_models: AuxModels::default(),
966 fallback_model: None,
967 max_history_messages: 50,
968 max_document_size_mb: 100,
969 memory_token_budget: 1500,
970 memory_l0_identity_pct: 20,
971 memory_l1_essential_pct: 30,
972 memory_max_entries_per_chat: 200,
973 memory_max_global_entries: 500,
974 kg_max_triples_per_chat: 1000,
975 tool_result_truncation_threshold_chars: 4000,
976 tool_result_truncation_head_chars: 1500,
977 tool_result_truncation_tail_chars: 500,
978 tool_result_artifact_ttl_hours: 24,
979 memory_recency_half_life_days: 30.0,
980 memory_graph_recall_enabled: true,
981 memory_graph_max_hops: 2,
982 memory_graph_max_triples: 10,
983 tool_repeat_window: 10,
984 tool_repeat_limit: 3,
985 anthropic_prompt_cache_enabled: true,
986 anthropic_prompt_cache_ttl: "5m".into(),
987 checkpoints_enabled: false,
988 skill_archive_after_days: 30,
989 skills_catalog_top_k: 3,
990 system_prompt_time_detail: SystemPromptTimeDetail::default(),
991 data_dir: default_data_dir(),
992 skills_dir: None,
993 working_dir: default_working_dir(),
994 working_dir_isolation: WorkingDirIsolation::Chat,
995 high_risk_tool_user_confirmation_required: true,
996 bash_dangerous_patterns: default_bash_dangerous_patterns(),
997 rtk: RtkConfig::default(),
998 sandbox: SandboxConfig::default(),
999 openai_api_key: None,
1000 override_timezone: None,
1001 timezone: "UTC".into(),
1002 allowed_groups: vec![],
1003 control_chat_ids: vec![],
1004 max_session_messages: 40,
1005 diff_max_lines: default_diff_max_lines(),
1006 file_diffs_in_chat: default_file_diffs_in_chat(),
1007 model_context_window: default_model_context_window(),
1008 context_pressure_compact_pct: default_context_pressure_compact_pct(),
1009 self_recheck: SelfRecheckConfig::default(),
1010 compact_keep_recent: 20,
1011 default_tool_timeout_secs: default_tool_timeout_secs(),
1012 tool_timeout_overrides: HashMap::new(),
1013 default_mcp_request_timeout_secs: default_mcp_request_timeout_secs(),
1014 discord_bot_token: None,
1015 discord_allowed_channels: vec![],
1016 discord_no_mention: false,
1017 allow_group_slash_without_mention: false,
1018 show_thinking: false,
1019 subagents: SubagentConfig::default(),
1020 idle_checkin: IdleCheckinConfig::default(),
1021 heartbeat: HeartbeatConfig::default(),
1022 token_budget: TokenBudgetConfig::default(),
1023 alerts: AlertsConfig::default(),
1024 trust_report: TrustReportConfig::default(),
1025 user_message_language: UserMessageLanguage::default(),
1026 sleep_time: SleepTimeConfig::default(),
1027 interjection: InterjectionConfig::default(),
1028 a2a: A2AConfig::default(),
1029 openai_compat_body_overrides: HashMap::new(),
1030 openai_compat_body_overrides_by_provider: HashMap::new(),
1031 openai_compat_body_overrides_by_model: HashMap::new(),
1032 web_enabled: true,
1033 web_host: "127.0.0.1".into(),
1034 web_port: 10961,
1035 web_max_inflight_per_session: 10,
1036 web_max_requests_per_window: 8,
1037 web_rate_window_seconds: 10,
1038 web_run_history_limit: 512,
1039 web_session_idle_ttl_seconds: 300,
1040 web_fetch_validation: WebContentValidationConfig::default(),
1041 web_fetch_url_validation: WebFetchUrlValidationConfig::default(),
1042 web_search: SearchProviderConfig::default(),
1043 output_guardrail: OutputGuardrailConfig::default(),
1044 tool_policy: crate::tool_guardrails::ToolPolicyConfig::default(),
1045 egress_policy: EgressPolicyConfig::default(),
1046 model_prices: vec![],
1047 embedding_provider: None,
1048 embedding_api_key: None,
1049 embedding_base_url: None,
1050 embedding_model: None,
1051 embedding_dim: None,
1052 reflector_enabled: true,
1053 reflector_interval_mins: 15,
1054 dlq_replay_enabled: true,
1055 dlq_replay_interval_secs: 300,
1056 dlq_max_replay_attempts: 3,
1057 skill_review_min_tool_calls: 5,
1058 soul_path: None,
1059 souls_dir: None,
1060 context_dir: None,
1061 context_max_chars: 8000,
1062 user_model_max_chars: 1500,
1063 voice_round_trip: false,
1064 clawhub: ClawHubConfig::default(),
1065 plugins: PluginsConfig::default(),
1066 media: MediaConfig::default(),
1067 openai_base_url: None,
1068 voice_provider: "openai".into(),
1069 voice_transcription_command: None,
1070 observability: None,
1071 channels: HashMap::new(),
1072 chat_turn_queue_max_pending: 20,
1073 enable_mid_turn_injection: true,
1074 mid_turn_injection_echo: true,
1075 parallel_tool_max_concurrency: 8,
1076 tool_concurrency_overrides: HashMap::new(),
1077 }
1078 }
1079
1080 pub fn data_root_dir(&self) -> PathBuf {
1082 expand_path(&self.data_dir)
1083 }
1084
1085 pub fn runtime_data_dir(&self) -> String {
1087 let root = self.data_root_dir();
1088 if root.file_name().and_then(|n| n.to_str()) == Some("runtime") {
1090 return root.to_string_lossy().to_string();
1091 }
1092 root.join("runtime").to_string_lossy().to_string()
1093 }
1094
1095 pub fn skills_data_dir(&self) -> String {
1097 if let Ok(explicit_dir) = std::env::var("MICROCLAW_SKILLS_DIR") {
1099 let trimmed = explicit_dir.trim();
1100 if !trimmed.is_empty() {
1101 return expand_path(trimmed).to_string_lossy().to_string();
1102 }
1103 }
1104 if let Some(configured) = &self.skills_dir {
1106 let trimmed = configured.trim();
1107 if !trimmed.is_empty() {
1108 return expand_path(trimmed).to_string_lossy().to_string();
1109 }
1110 }
1111 self.data_root_dir()
1113 .join("skills")
1114 .to_string_lossy()
1115 .to_string()
1116 }
1117
1118 pub fn souls_data_dir(&self) -> String {
1120 if let Some(configured) = &self.souls_dir {
1121 let trimmed = configured.trim();
1122 if !trimmed.is_empty() {
1123 return expand_path(trimmed).to_string_lossy().to_string();
1124 }
1125 }
1126 self.data_root_dir()
1127 .join("souls")
1128 .to_string_lossy()
1129 .to_string()
1130 }
1131
1132 pub fn clawhub_lockfile_path(&self) -> PathBuf {
1133 self.data_root_dir().join("clawhub.lock.json")
1134 }
1135
1136 pub fn clawhub_load_verification(&self) -> Option<(PathBuf, bool)> {
1139 match self.clawhub.verify_on_load {
1140 ClawHubVerifyMode::Off => None,
1141 ClawHubVerifyMode::Warn => Some((self.clawhub_lockfile_path(), false)),
1142 ClawHubVerifyMode::Block => Some((self.clawhub_lockfile_path(), true)),
1143 }
1144 }
1145
1146 pub fn config_path_for_setup() -> PathBuf {
1147 if let Ok(custom) = std::env::var("MICROCLAW_CONFIG") {
1148 return expand_path(&custom);
1149 }
1150 if std::path::Path::new("./microclaw.config.yaml").exists() {
1151 return PathBuf::from("./microclaw.config.yaml");
1152 }
1153 if std::path::Path::new("./microclaw.config.yml").exists() {
1154 return PathBuf::from("./microclaw.config.yml");
1155 }
1156 if std::env::var("SNAP").is_ok() {
1157 if let Ok(snap_user_common) = std::env::var("SNAP_USER_COMMON") {
1158 return PathBuf::from(snap_user_common).join("config.yaml");
1159 }
1160 }
1161 PathBuf::from("microclaw.config.yaml")
1162 }
1163
1164 pub fn resolve_config_path() -> Result<Option<PathBuf>, MicroClawError> {
1165 if let Ok(custom) = std::env::var("MICROCLAW_CONFIG") {
1167 let expanded = expand_path(&custom);
1168 if expanded.exists() {
1169 return Ok(Some(expanded));
1170 }
1171 return Err(MicroClawError::Config(format!(
1172 "MICROCLAW_CONFIG points to non-existent file: {custom}"
1173 )));
1174 }
1175
1176 if std::path::Path::new("./microclaw.config.yaml").exists() {
1177 return Ok(Some(PathBuf::from("./microclaw.config.yaml")));
1178 }
1179 if std::path::Path::new("./microclaw.config.yml").exists() {
1180 return Ok(Some(PathBuf::from("./microclaw.config.yml")));
1181 }
1182 Ok(None)
1183 }
1184
1185 fn inferred_channel_enabled(&self, channel: &str) -> bool {
1186 match channel {
1187 "telegram" => {
1188 !self.telegram_bot_token.trim().is_empty() || self.channels.contains_key("telegram")
1189 }
1190 "discord" => {
1191 self.discord_bot_token
1192 .as_deref()
1193 .map(|v| !v.trim().is_empty())
1194 .unwrap_or(false)
1195 || self.channels.contains_key("discord")
1196 }
1197 "web" => self.web_enabled || self.channels.contains_key("web"),
1198 _ => self.channels.contains_key(channel),
1199 }
1200 }
1201
1202 fn explicit_channel_enabled(&self, channel: &str) -> Option<bool> {
1203 self.channels
1204 .get(channel)
1205 .and_then(|v| v.get("enabled"))
1206 .and_then(|v| v.as_bool())
1207 }
1208
1209 pub fn channel_enabled(&self, channel: &str) -> bool {
1210 let needle = channel.trim().to_lowercase();
1211 if let Some(explicit) = self.explicit_channel_enabled(&needle) {
1212 return explicit;
1213 }
1214 self.inferred_channel_enabled(&needle)
1215 }
1216
1217 pub fn channel_status(&self) -> (Vec<&'static str>, Vec<&'static str>) {
1223 let configured = [
1224 (
1225 "telegram",
1226 !self.telegram_bot_token.trim().is_empty()
1227 || self.channels.contains_key("telegram"),
1228 ),
1229 (
1230 "discord",
1231 self.discord_bot_token
1232 .as_deref()
1233 .map(|v| !v.trim().is_empty())
1234 .unwrap_or(false)
1235 || self.channels.contains_key("discord"),
1236 ),
1237 ("slack", self.channels.contains_key("slack")),
1238 ("feishu", self.channels.contains_key("feishu")),
1239 ("matrix", self.channels.contains_key("matrix")),
1240 ("irc", self.channels.contains_key("irc")),
1241 ("web", self.web_enabled || self.channels.contains_key("web")),
1242 ];
1243 let mut enabled = Vec::new();
1244 let mut configured_but_disabled = Vec::new();
1245 for (name, is_configured) in configured {
1246 if !is_configured {
1247 continue;
1248 }
1249 if self.channel_enabled(name) {
1250 enabled.push(name);
1251 } else {
1252 configured_but_disabled.push(name);
1253 }
1254 }
1255 (enabled, configured_but_disabled)
1256 }
1257
1258 pub fn load() -> Result<Self, MicroClawError> {
1260 let yaml_path = Self::resolve_config_path()?;
1261
1262 if let Some(path) = yaml_path {
1263 return Self::load_from_path(&path);
1264 }
1265
1266 Err(MicroClawError::Config(
1268 "No microclaw.config.yaml found. Run `microclaw setup` to create one.".into(),
1269 ))
1270 }
1271
1272 pub fn load_from_path(path: &Path) -> Result<Self, MicroClawError> {
1274 Self::load_from_path_inner(path, false)
1275 }
1276
1277 pub fn load_from_path_for_headless(path: &Path) -> Result<Self, MicroClawError> {
1284 Self::load_from_path_inner(path, true)
1285 }
1286
1287 pub fn for_embedded(
1289 provider: impl Into<String>,
1290 model: impl Into<String>,
1291 api_key: impl Into<String>,
1292 base_url: Option<String>,
1293 ) -> Result<Self, MicroClawError> {
1294 let mut config: Config = serde_yaml::from_str("{}")
1295 .map_err(|error| MicroClawError::Config(error.to_string()))?;
1296 config.llm_provider = provider.into();
1297 config.model = model.into();
1298 config.api_key = api_key.into();
1299 config.llm_base_url = base_url;
1300 config.web_enabled = true;
1301 config.post_deserialize()?;
1302 config.web_enabled = false;
1303 config.channels.remove("web");
1304 Ok(config)
1305 }
1306
1307 fn load_from_path_inner(path: &Path, allow_no_channels: bool) -> Result<Self, MicroClawError> {
1308 let path_str = path.to_string_lossy().to_string();
1309 let content = std::fs::read_to_string(path)
1310 .map_err(|e| MicroClawError::Config(format!("Failed to read {path_str}: {e}")))?;
1311 if let Ok(raw) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
1312 if let Some(map) = raw.as_mapping() {
1313 let old_top_level = map
1314 .get(serde_yaml::Value::String("web_auth_token".to_string()))
1315 .is_some();
1316 let old_channel_level = map
1317 .get(serde_yaml::Value::String("channels".to_string()))
1318 .and_then(|v| v.as_mapping())
1319 .and_then(|channels| channels.get(serde_yaml::Value::String("web".to_string())))
1320 .and_then(|v| v.as_mapping())
1321 .map(|web| {
1322 web.contains_key(serde_yaml::Value::String("auth_token".to_string()))
1323 })
1324 .unwrap_or(false);
1325 if old_top_level || old_channel_level {
1326 warn!(
1327 "Deprecated web auth token config detected in {}. \
1328`web_auth_token` / `channels.web.auth_token` are ignored; migrate to operator password + API keys.",
1329 path_str
1330 );
1331 }
1332 for warning in unknown_top_level_key_warnings(map) {
1333 warn!("{warning} (in {path_str})");
1334 }
1335 }
1336 }
1337 let mut config: Config = serde_yaml::from_str(&content)
1338 .map_err(|error| MicroClawError::Config(friendly_yaml_error(&path_str, &error)))?;
1339 let synthesized_channel = allow_no_channels && config.channel_status().0.is_empty();
1340 if synthesized_channel {
1341 config.web_enabled = true;
1342 }
1343 config.post_deserialize()?;
1344 if synthesized_channel {
1345 config.web_enabled = false;
1346 config.channels.remove("web");
1347 }
1348 Ok(config)
1349 }
1350
1351 pub fn post_deserialize(&mut self) -> Result<(), MicroClawError> {
1353 self.llm_provider = self.llm_provider.trim().to_lowercase();
1354
1355 self.model = resolve_model_name_with_fallback(&self.llm_provider, Some(&self.model), None);
1356 self.provider_presets =
1357 normalize_provider_profiles(std::mem::take(&mut self.provider_presets));
1358 self.llm_providers = normalize_provider_profiles(std::mem::take(&mut self.llm_providers));
1359 for (alias, preset) in self.provider_presets.clone() {
1360 self.llm_providers
1361 .entry(alias)
1362 .and_modify(|existing| {
1363 *existing = merge_provider_profile(preset.clone(), existing.clone());
1364 })
1365 .or_insert(preset);
1366 }
1367 for channel_cfg in self.channels.values_mut() {
1368 migrate_model_override_alias_to_provider_preset(channel_cfg, &self.provider_presets);
1369 }
1370
1371 self.override_timezone = self
1372 .override_timezone
1373 .as_ref()
1374 .map(|v| v.trim().to_string())
1375 .filter(|v| !v.is_empty());
1376 if self
1377 .override_timezone
1378 .as_deref()
1379 .is_some_and(|v| v.eq_ignore_ascii_case("auto"))
1380 {
1381 self.override_timezone = None;
1382 }
1383
1384 let tz_raw = self
1387 .override_timezone
1388 .clone()
1389 .unwrap_or_else(|| "auto".to_string());
1390
1391 if tz_raw.eq_ignore_ascii_case("auto") {
1392 let detected = detect_system_timezone();
1393 if detected.parse::<chrono_tz::Tz>().is_ok() {
1394 self.timezone = detected;
1395 } else {
1396 warn!(
1397 "Detected system timezone '{}' is not recognized by chrono-tz. Falling back to UTC.",
1398 detected
1399 );
1400 self.timezone = "UTC".into();
1401 }
1402 } else {
1403 tz_raw
1404 .parse::<chrono_tz::Tz>()
1405 .map_err(|_| MicroClawError::Config(format!("Invalid timezone: {tz_raw}")))?;
1406 self.timezone = tz_raw;
1407 }
1408
1409 if let Some(ref url) = self.llm_base_url {
1411 if url.trim().is_empty() {
1412 self.llm_base_url = None;
1413 }
1414 }
1415 self.llm_user_agent = self.llm_user_agent.trim().to_string();
1416 if self.llm_user_agent.is_empty() {
1417 self.llm_user_agent = default_llm_user_agent();
1418 }
1419 if let Some(dir) = &self.skills_dir {
1420 let trimmed = dir.trim().to_string();
1421 self.skills_dir = if trimmed.is_empty() {
1422 None
1423 } else {
1424 Some(expand_path(&trimmed).to_string_lossy().to_string())
1425 };
1426 }
1427 if let Some(dir) = &self.souls_dir {
1428 let trimmed = dir.trim().to_string();
1429 self.souls_dir = if trimmed.is_empty() {
1430 None
1431 } else {
1432 Some(expand_path(&trimmed).to_string_lossy().to_string())
1433 };
1434 }
1435 if let Some(dir) = &self.plugins.dir {
1436 let trimmed = dir.trim().to_string();
1437 self.plugins.dir = if trimmed.is_empty() {
1438 None
1439 } else {
1440 Some(expand_path(&trimmed).to_string_lossy().to_string())
1441 };
1442 }
1443 if self.working_dir.trim().is_empty() {
1444 self.working_dir = default_working_dir();
1445 } else {
1446 self.working_dir = expand_path(&self.working_dir).to_string_lossy().to_string();
1447 }
1448 self.data_dir = expand_path(&self.data_dir).to_string_lossy().to_string();
1449 self.sandbox.image = self.sandbox.image.trim().to_string();
1450 if self.sandbox.image.is_empty() {
1451 self.sandbox.image = default_sandbox_image();
1452 }
1453 self.sandbox.container_prefix = self.sandbox.container_prefix.trim().to_string();
1454 if self.sandbox.container_prefix.is_empty() {
1455 self.sandbox.container_prefix = default_sandbox_container_prefix();
1456 }
1457 normalize_string_list(&mut self.sandbox.credential_env_allowlist, false);
1458 normalize_string_list(&mut self.egress_policy.allow_hosts, true);
1459 normalize_string_list(&mut self.egress_policy.deny_hosts, true);
1460 normalize_string_list(&mut self.tool_policy.allow_tools, false);
1461 normalize_string_list(&mut self.tool_policy.deny_tools, false);
1462 for grant in &mut self.tool_policy.grants {
1463 grant.channel = grant
1464 .channel
1465 .take()
1466 .map(|value| value.trim().to_ascii_lowercase())
1467 .filter(|value| !value.is_empty());
1468 grant.principal = grant
1469 .principal
1470 .take()
1471 .map(|value| value.trim().to_ascii_lowercase())
1472 .filter(|value| !value.is_empty());
1473 normalize_string_list(&mut grant.allow_tools, false);
1474 normalize_string_list(&mut grant.deny_tools, false);
1475 }
1476 if self.web_host.trim().is_empty() {
1477 self.web_host = default_web_host();
1478 }
1479 self.a2a.public_base_url = self
1480 .a2a
1481 .public_base_url
1482 .as_ref()
1483 .map(|v| v.trim().trim_end_matches('/').to_string())
1484 .filter(|v| !v.is_empty());
1485 self.a2a.agent_name = self
1486 .a2a
1487 .agent_name
1488 .as_ref()
1489 .map(|v| v.trim().to_string())
1490 .filter(|v| !v.is_empty());
1491 self.a2a.agent_description = self
1492 .a2a
1493 .agent_description
1494 .as_ref()
1495 .map(|v| v.trim().to_string())
1496 .filter(|v| !v.is_empty());
1497 self.a2a.shared_tokens = self
1498 .a2a
1499 .shared_tokens
1500 .drain(..)
1501 .map(|v| v.trim().to_string())
1502 .filter(|v| !v.is_empty())
1503 .collect();
1504 self.a2a.peers = self
1505 .a2a
1506 .peers
1507 .drain()
1508 .filter_map(|(name, mut peer)| {
1509 let normalized = name.trim().to_ascii_lowercase();
1510 if normalized.is_empty() {
1511 return None;
1512 }
1513 peer.base_url = peer.base_url.trim().trim_end_matches('/').to_string();
1514 if peer.base_url.is_empty() {
1515 return None;
1516 }
1517 peer.bearer_token = peer
1518 .bearer_token
1519 .as_ref()
1520 .map(|v| v.trim().to_string())
1521 .filter(|v| !v.is_empty());
1522 peer.description = peer
1523 .description
1524 .as_ref()
1525 .map(|v| v.trim().to_string())
1526 .filter(|v| !v.is_empty());
1527 peer.default_session_key = peer
1528 .default_session_key
1529 .as_ref()
1530 .map(|v| v.trim().to_string())
1531 .filter(|v| !v.is_empty());
1532 Some((normalized, peer))
1533 })
1534 .collect();
1535 if let Some(provider) = &self.embedding_provider {
1536 let p = provider.trim().to_lowercase();
1537 self.embedding_provider = if p.is_empty() { None } else { Some(p) };
1538 }
1539 if let Some(v) = &self.embedding_api_key {
1540 if v.trim().is_empty() {
1541 self.embedding_api_key = None;
1542 }
1543 }
1544 if let Some(v) = &self.embedding_base_url {
1545 if v.trim().is_empty() {
1546 self.embedding_base_url = None;
1547 }
1548 }
1549 if let Some(v) = &self.embedding_model {
1550 let m = v.trim().to_string();
1551 self.embedding_model = if m.is_empty() { None } else { Some(m) };
1552 }
1553 if let Some(v) = self.embedding_dim {
1554 if v == 0 {
1555 self.embedding_dim = None;
1556 }
1557 }
1558 if let Some(web_cfg) = self
1559 .channels
1560 .get_mut("web")
1561 .and_then(|v| v.as_mapping_mut())
1562 {
1563 if web_cfg
1564 .remove(serde_yaml::Value::String("auth_token".to_string()))
1565 .is_some()
1566 {
1567 warn!(
1568 "Deprecated `channels.web.auth_token` detected and ignored. \
1569Use operator password + API keys for Web auth."
1570 );
1571 }
1572 }
1573 if self.web_max_inflight_per_session == 0 {
1574 self.web_max_inflight_per_session = default_web_max_inflight_per_session();
1575 }
1576 if self.web_max_requests_per_window == 0 {
1577 self.web_max_requests_per_window = default_web_max_requests_per_window();
1578 }
1579 if self.web_rate_window_seconds == 0 {
1580 self.web_rate_window_seconds = default_web_rate_window_seconds();
1581 }
1582 if self.web_run_history_limit == 0 {
1583 self.web_run_history_limit = default_web_run_history_limit();
1584 }
1585 if self.web_session_idle_ttl_seconds == 0 {
1586 self.web_session_idle_ttl_seconds = default_web_session_idle_ttl_seconds();
1587 }
1588 self.web_fetch_validation.normalize();
1589 self.web_fetch_url_validation.normalize();
1590 self.web_search.normalize();
1591 if self.max_document_size_mb == 0 {
1592 self.max_document_size_mb = default_max_document_size_mb();
1593 }
1594 if self.default_tool_timeout_secs == 0 {
1595 self.default_tool_timeout_secs = default_tool_timeout_secs();
1596 }
1597 if self.default_mcp_request_timeout_secs == 0 {
1598 self.default_mcp_request_timeout_secs = default_mcp_request_timeout_secs();
1599 }
1600 if self.subagents.max_concurrent == 0 {
1601 self.subagents.max_concurrent = default_subagent_max_concurrent();
1602 }
1603 if self.subagents.max_active_per_chat == 0 {
1604 self.subagents.max_active_per_chat = default_subagent_max_active_per_chat();
1605 }
1606 if self.subagents.run_timeout_secs == 0 {
1607 self.subagents.run_timeout_secs = default_subagent_run_timeout_secs();
1608 }
1609 if self.subagents.max_spawn_depth == 0 {
1610 self.subagents.max_spawn_depth = default_subagent_max_spawn_depth();
1611 }
1612 self.subagents.max_spawn_depth = self.subagents.max_spawn_depth.min(5);
1613 if self.subagents.max_children_per_run == 0 {
1614 self.subagents.max_children_per_run = default_subagent_max_children_per_run();
1615 }
1616 if self.subagents.announce_relay_interval_secs == 0 {
1617 self.subagents.announce_relay_interval_secs =
1618 default_subagent_announce_relay_interval_secs();
1619 }
1620 self.subagents.announce_relay_interval_secs =
1621 self.subagents.announce_relay_interval_secs.clamp(1, 300);
1622 if self.subagents.max_tokens_per_run <= 0 {
1623 self.subagents.max_tokens_per_run = default_subagent_max_tokens_per_run();
1624 }
1625 self.subagents.max_tokens_per_run =
1626 self.subagents.max_tokens_per_run.clamp(2_000, 2_000_000);
1627 if self.subagents.orchestrate_max_workers == 0 {
1628 self.subagents.orchestrate_max_workers = default_subagent_orchestrate_max_workers();
1629 }
1630 self.subagents.orchestrate_max_workers =
1631 self.subagents.orchestrate_max_workers.clamp(1, 12);
1632 self.subagents.acp.normalize();
1633 self.tool_timeout_overrides = self
1634 .tool_timeout_overrides
1635 .drain()
1636 .filter_map(|(name, timeout_secs)| {
1637 let normalized = name.trim().to_ascii_lowercase();
1638 if normalized.is_empty() || timeout_secs == 0 {
1639 None
1640 } else {
1641 Some((normalized, timeout_secs))
1642 }
1643 })
1644 .collect();
1645 self.openai_compat_body_overrides =
1646 normalize_body_override_params(std::mem::take(&mut self.openai_compat_body_overrides));
1647 self.openai_compat_body_overrides_by_provider = self
1648 .openai_compat_body_overrides_by_provider
1649 .drain()
1650 .filter_map(|(provider, params)| {
1651 let provider = provider.trim().to_ascii_lowercase();
1652 if provider.is_empty() {
1653 return None;
1654 }
1655 let params = normalize_body_override_params(params);
1656 if params.is_empty() {
1657 None
1658 } else {
1659 Some((provider, params))
1660 }
1661 })
1662 .collect();
1663 self.openai_compat_body_overrides_by_model = self
1664 .openai_compat_body_overrides_by_model
1665 .drain()
1666 .filter_map(|(model, params)| {
1667 let model = model.trim().to_string();
1668 if model.is_empty() {
1669 return None;
1670 }
1671 let params = normalize_body_override_params(params);
1672 if params.is_empty() {
1673 None
1674 } else {
1675 Some((model, params))
1676 }
1677 })
1678 .collect();
1679 if self.memory_token_budget == 0 {
1680 self.memory_token_budget = default_memory_token_budget();
1681 }
1682 for price in &mut self.model_prices {
1683 price.model = price.model.trim().to_string();
1684 if price.model.is_empty() {
1685 return Err(MicroClawError::Config(
1686 "model_prices entries must include non-empty model".into(),
1687 ));
1688 }
1689 if !(price.input_per_million_usd.is_finite() && price.input_per_million_usd >= 0.0) {
1690 return Err(MicroClawError::Config(format!(
1691 "model_prices[{}].input_per_million_usd must be >= 0",
1692 price.model
1693 )));
1694 }
1695 if !(price.output_per_million_usd.is_finite() && price.output_per_million_usd >= 0.0) {
1696 return Err(MicroClawError::Config(format!(
1697 "model_prices[{}].output_per_million_usd must be >= 0",
1698 price.model
1699 )));
1700 }
1701 }
1702
1703 if self.channels.is_empty() {
1705 if !self.telegram_bot_token.trim().is_empty() {
1706 self.channels.insert(
1707 "telegram".into(),
1708 serde_yaml::to_value(serde_json::json!({
1709 "enabled": true,
1710 "bot_token": self.telegram_bot_token,
1711 "bot_username": self.bot_username,
1712 "allowed_groups": self.allowed_groups,
1713 }))
1714 .unwrap(),
1715 );
1716 }
1717 if let Some(ref token) = self.discord_bot_token {
1718 if !token.trim().is_empty() {
1719 self.channels.insert(
1720 "discord".into(),
1721 serde_yaml::to_value(serde_json::json!({
1722 "enabled": true,
1723 "bot_token": token,
1724 "allowed_channels": self.discord_allowed_channels,
1725 "no_mention": self.discord_no_mention,
1726 }))
1727 .unwrap(),
1728 );
1729 }
1730 }
1731 if self.web_enabled {
1732 self.channels.insert(
1733 "web".into(),
1734 serde_yaml::to_value(serde_json::json!({
1735 "enabled": true,
1736 "host": self.web_host,
1737 "port": self.web_port,
1738 }))
1739 .unwrap(),
1740 );
1741 }
1742 }
1743
1744 let (enabled_channels, configured_but_disabled) = self.channel_status();
1746 if enabled_channels.is_empty() {
1747 let hint = if configured_but_disabled.is_empty() {
1748 String::new()
1749 } else {
1750 format!(
1751 " These channels are configured but not enabled: {}. \
1752 Set `channels.<name>.enabled: true` (or run `microclaw setup`).",
1753 configured_but_disabled.join(", ")
1754 )
1755 };
1756 return Err(MicroClawError::Config(format!(
1757 "At least one channel must be enabled and configured (via channels.<name>.enabled or legacy channel settings).{hint}"
1758 )));
1759 }
1760 if self.api_key.is_empty() && !provider_allows_empty_api_key(&self.llm_provider) {
1761 return Err(MicroClawError::Config("api_key is required".into()));
1762 }
1763 if is_openai_codex_provider(&self.llm_provider) {
1764 if !self.api_key.trim().is_empty() {
1765 return Err(MicroClawError::Config(
1766 "openai-codex ignores microclaw.config.yaml api_key. Configure ~/.codex/auth.json or run `codex login` instead.".into(),
1767 ));
1768 }
1769 if self
1770 .llm_base_url
1771 .as_ref()
1772 .map(|v| !v.trim().is_empty())
1773 .unwrap_or(false)
1774 {
1775 return Err(MicroClawError::Config(
1776 "openai-codex ignores microclaw.config.yaml llm_base_url. Configure ~/.codex/config.toml instead.".into(),
1777 ));
1778 }
1779 let has_codex_auth = codex_auth_file_has_access_token()?;
1780 if !has_codex_auth {
1781 return Err(MicroClawError::Config(
1782 "openai-codex requires ~/.codex/auth.json (access token or OPENAI_API_KEY), or OPENAI_CODEX_ACCESS_TOKEN. Run `codex login` or update Codex config files.".into(),
1783 ));
1784 }
1785 }
1786 if is_qwen_portal_provider(&self.llm_provider) && self.api_key.trim().is_empty() {
1787 let has_qwen_auth = qwen_oauth_file_has_access_token()?;
1788 if !has_qwen_auth {
1789 return Err(MicroClawError::Config(
1790 "qwen-portal requires api_key, or ~/.qwen/oauth_creds.json (access_token), or QWEN_PORTAL_ACCESS_TOKEN.".into(),
1791 ));
1792 }
1793 }
1794
1795 self.validate_configured_egress()?;
1796
1797 Ok(())
1798 }
1799
1800 fn validate_configured_egress(&self) -> Result<(), MicroClawError> {
1801 if self.egress_policy.mode == EgressPolicyMode::Off {
1802 return Ok(());
1803 }
1804 for url in self.configured_egress_endpoints() {
1805 let decision =
1806 crate::internal::tool_runtime::egress::evaluate_url(&self.egress_policy, &url);
1807 match decision {
1808 crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
1809 warn!(url = %url, reason = %reason, "configured endpoint violates egress policy");
1810 }
1811 crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
1812 return Err(MicroClawError::Config(format!(
1813 "configured endpoint `{url}` blocked by egress policy: {reason}"
1814 )));
1815 }
1816 crate::internal::tool_runtime::egress::EgressDecision::Allow => {}
1817 }
1818 }
1819 Ok(())
1820 }
1821
1822 fn configured_egress_endpoints(&self) -> Vec<String> {
1823 let mut urls = Vec::new();
1824 {
1825 let mut add = |value: Option<&str>| {
1826 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1827 urls.push(value.to_string());
1828 }
1829 };
1830
1831 add(self.llm_base_url.as_deref());
1832 for profile in self
1833 .provider_presets
1834 .values()
1835 .chain(self.llm_providers.values())
1836 {
1837 add(profile.llm_base_url.as_deref());
1838 }
1839 add(self.embedding_base_url.as_deref());
1840 add(self.openai_base_url.as_deref());
1841
1842 let media_enabled = self.media.image_gen.enabled
1843 || self.media.vision.enabled
1844 || self.media.tts.enabled
1845 || self.media.stt.enabled
1846 || self.media.podcast.enabled;
1847 if media_enabled {
1848 add(self.media.base_url.as_deref());
1849 }
1850 if self.media.tts.enabled || self.media.podcast.enabled {
1851 add(self.media.tts.base_url.as_deref());
1852 }
1853 if self.media.podcast.enabled {
1854 add(self.media.podcast.base_url.as_deref());
1855 }
1856
1857 if self.web_search.effective_backend()
1858 == crate::internal::tool_runtime::web_search::SearchBackend::Searxng
1859 {
1860 add(Some(&self.web_search.searxng_base_url));
1861 }
1862 for peer in self.a2a.peers.values().filter(|peer| peer.enabled) {
1863 add(Some(&peer.base_url));
1864 }
1865 if self.clawhub.agent_tools_enabled {
1866 add(Some(&self.clawhub.registry));
1867 }
1868 if self.alerts.enabled {
1869 add(Some(&self.alerts.webhook_url));
1870 }
1871 }
1872
1873 for channel in self.channels.values() {
1874 collect_enabled_channel_endpoints(channel, &mut urls);
1875 }
1876
1877 urls.sort();
1878 urls.dedup();
1879 urls
1880 }
1881
1882 fn merged_profile_from_alias(&self, alias: &str) -> Option<ResolvedLlmProviderProfile> {
1883 let alias = alias.trim().to_ascii_lowercase();
1884 if alias.is_empty() {
1885 return None;
1886 }
1887 if alias == self.llm_provider {
1888 let mut provider = self.llm_provider.clone();
1889 let mut api_key = self.api_key.clone();
1890 let mut api_keys = self.api_keys.clone();
1891 let mut llm_base_url = self.llm_base_url.clone();
1892 let mut llm_user_agent = self.llm_user_agent.clone();
1893 let mut default_model = self.model.clone();
1894 let mut models = vec![default_model.clone()];
1895 let mut show_thinking = self.show_thinking;
1896 if let Some(profile) = self.llm_providers.get(&alias) {
1897 if let Some(v) = &profile.provider {
1898 provider = v.clone();
1899 }
1900 if let Some(v) = &profile.api_key {
1901 api_key = v.clone();
1902 }
1903 if !profile.api_keys.is_empty() {
1904 api_keys = profile.api_keys.clone();
1905 }
1906 if let Some(v) = &profile.llm_base_url {
1907 llm_base_url = Some(v.clone());
1908 }
1909 if let Some(v) = &profile.llm_user_agent {
1910 llm_user_agent = v.clone();
1911 }
1912 if let Some(v) = &profile.default_model {
1913 default_model = v.clone();
1914 }
1915 if !profile.models.is_empty() {
1916 models = profile.models.clone();
1917 }
1918 if let Some(v) = profile.show_thinking {
1919 show_thinking = v;
1920 }
1921 }
1922 default_model = resolve_model_name_with_fallback(
1923 &provider,
1924 Some(&default_model),
1925 Some(&self.model),
1926 );
1927 models = models
1928 .into_iter()
1929 .filter_map(|model| normalize_model_name(&model))
1930 .collect();
1931 if !models.iter().any(|m| m == &default_model) {
1932 models.push(default_model.clone());
1933 }
1934 models.sort();
1935 models.dedup();
1936 return Some(ResolvedLlmProviderProfile {
1937 alias,
1938 provider,
1939 api_key,
1940 api_keys,
1941 llm_base_url,
1942 llm_user_agent,
1943 default_model,
1944 models,
1945 show_thinking,
1946 });
1947 }
1948
1949 let profile = self.llm_providers.get(&alias)?;
1950 let provider = profile.provider.clone().unwrap_or_else(|| alias.clone());
1951 let api_key = profile
1952 .api_key
1953 .clone()
1954 .unwrap_or_else(|| self.api_key.clone());
1955 let api_keys = if !profile.api_keys.is_empty() {
1957 profile.api_keys.clone()
1958 } else if profile.api_key.is_none() {
1959 self.api_keys.clone()
1960 } else {
1961 Vec::new()
1962 };
1963 let llm_base_url = profile
1964 .llm_base_url
1965 .clone()
1966 .or_else(|| self.llm_base_url.clone());
1967 let llm_user_agent = profile
1968 .llm_user_agent
1969 .clone()
1970 .unwrap_or_else(|| self.llm_user_agent.clone());
1971 let default_model = resolve_model_name_with_fallback(
1972 &provider,
1973 profile.default_model.as_deref(),
1974 Some(&self.model),
1975 );
1976 let mut models = if profile.models.is_empty() {
1977 vec![default_model.clone()]
1978 } else {
1979 profile
1980 .models
1981 .clone()
1982 .into_iter()
1983 .filter_map(|model| normalize_model_name(&model))
1984 .collect()
1985 };
1986 let show_thinking = profile.show_thinking.unwrap_or(self.show_thinking);
1987 if !models.iter().any(|m| m == &default_model) {
1988 models.push(default_model.clone());
1989 }
1990 models.sort();
1991 models.dedup();
1992 Some(ResolvedLlmProviderProfile {
1993 alias,
1994 provider,
1995 api_key,
1996 api_keys,
1997 llm_base_url,
1998 llm_user_agent,
1999 default_model,
2000 models,
2001 show_thinking,
2002 })
2003 }
2004
2005 pub fn resolve_llm_provider_profile(&self, alias: &str) -> Option<ResolvedLlmProviderProfile> {
2006 self.merged_profile_from_alias(alias)
2007 }
2008
2009 pub fn list_llm_provider_profiles(&self) -> Vec<ResolvedLlmProviderProfile> {
2010 let mut out = Vec::new();
2011 if let Some(default_profile) = self.resolve_llm_provider_profile(&self.llm_provider) {
2012 out.push(default_profile);
2013 }
2014 let mut aliases: Vec<String> = self.llm_providers.keys().cloned().collect();
2015 aliases.sort();
2016 aliases.dedup();
2017 for alias in aliases {
2018 if alias == self.llm_provider {
2019 continue;
2020 }
2021 if let Some(profile) = self.resolve_llm_provider_profile(&alias) {
2022 out.push(profile);
2023 }
2024 }
2025 out
2026 }
2027
2028 pub fn channel_config<T: DeserializeOwned>(&self, name: &str) -> Option<T> {
2030 self.channels
2031 .get(name)
2032 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
2033 }
2034
2035 pub fn model_price(&self, model: &str) -> Option<&ModelPrice> {
2036 let needle = model.trim();
2037 self.model_prices
2038 .iter()
2039 .find(|p| p.model.eq_ignore_ascii_case(needle))
2040 .or_else(|| self.model_prices.iter().find(|p| p.model == "*"))
2041 }
2042
2043 pub fn estimate_cost_usd(
2044 &self,
2045 model: &str,
2046 input_tokens: i64,
2047 output_tokens: i64,
2048 ) -> Option<f64> {
2049 let price = self.model_price(model)?;
2050 let in_tok = input_tokens.max(0) as f64;
2051 let out_tok = output_tokens.max(0) as f64;
2052 Some(
2053 (in_tok / 1_000_000.0) * price.input_per_million_usd
2054 + (out_tok / 1_000_000.0) * price.output_per_million_usd,
2055 )
2056 }
2057
2058 pub fn tool_timeout_secs(&self, tool_name: &str, fallback: u64) -> u64 {
2059 let normalized = tool_name.trim().to_ascii_lowercase();
2060 if let Some(timeout_secs) = self.tool_timeout_overrides.get(&normalized) {
2061 return *timeout_secs;
2062 }
2063 if self.default_tool_timeout_secs == 0 {
2064 fallback
2065 } else {
2066 self.default_tool_timeout_secs
2067 }
2068 }
2069
2070 pub fn mcp_request_timeout_secs(&self) -> u64 {
2071 if self.default_mcp_request_timeout_secs == 0 {
2072 default_mcp_request_timeout_secs()
2073 } else {
2074 self.default_mcp_request_timeout_secs
2075 }
2076 }
2077
2078 #[allow(dead_code)]
2080 pub fn save_yaml(&self, path: &str) -> Result<(), MicroClawError> {
2081 let content = serde_yaml::to_string(self)
2082 .map_err(|e| MicroClawError::Config(format!("Failed to serialize config: {e}")))?;
2083 std::fs::write(path, content)?;
2084 Ok(())
2085 }
2086}
2087
2088pub(crate) fn normalize_body_override_params(
2089 params: HashMap<String, serde_json::Value>,
2090) -> HashMap<String, serde_json::Value> {
2091 params
2092 .into_iter()
2093 .filter_map(|(key, value)| {
2094 let key = key.trim().to_string();
2095 if key.is_empty() {
2096 None
2097 } else {
2098 Some((key, value))
2099 }
2100 })
2101 .collect()
2102}
2103
2104pub(crate) fn normalize_provider_profiles(
2105 profiles: HashMap<String, LlmProviderProfile>,
2106) -> HashMap<String, LlmProviderProfile> {
2107 profiles
2108 .into_iter()
2109 .filter_map(|(alias, mut profile)| {
2110 let alias = alias.trim().to_ascii_lowercase();
2111 if alias.is_empty() {
2112 return None;
2113 }
2114 profile.provider = profile
2115 .provider
2116 .as_ref()
2117 .map(|v| v.trim().to_ascii_lowercase())
2118 .filter(|v| !v.is_empty());
2119 profile.api_key = profile
2120 .api_key
2121 .as_ref()
2122 .map(|v| v.trim().to_string())
2123 .filter(|v| !v.is_empty());
2124 profile.llm_base_url = profile
2125 .llm_base_url
2126 .as_ref()
2127 .map(|v| v.trim().to_string())
2128 .filter(|v| !v.is_empty());
2129 profile.llm_user_agent = profile
2130 .llm_user_agent
2131 .as_ref()
2132 .map(|v| v.trim().to_string())
2133 .filter(|v| !v.is_empty());
2134 profile.default_model = profile
2135 .default_model
2136 .as_ref()
2137 .and_then(|v| normalize_model_name(v));
2138 profile.models = profile
2139 .models
2140 .into_iter()
2141 .filter_map(|m| normalize_model_name(&m))
2142 .collect::<Vec<_>>();
2143 profile.models.sort();
2144 profile.models.dedup();
2145 Some((alias, profile))
2146 })
2147 .collect()
2148}
2149
2150pub(crate) fn migrate_model_override_alias_to_provider_preset(
2151 value: &mut serde_yaml::Value,
2152 known_profiles: &HashMap<String, LlmProviderProfile>,
2153) {
2154 let Some(map) = value.as_mapping_mut() else {
2155 return;
2156 };
2157
2158 let provider_preset_key = serde_yaml::Value::String("provider_preset".to_string());
2159 let llm_provider_key = serde_yaml::Value::String("llm_provider".to_string());
2160 let model_key = serde_yaml::Value::String("model".to_string());
2161
2162 let has_provider_override =
2163 map.contains_key(&provider_preset_key) || map.contains_key(&llm_provider_key);
2164 if !has_provider_override {
2165 let maybe_profile_alias = map
2166 .get(&model_key)
2167 .and_then(|v| v.as_str())
2168 .map(str::trim)
2169 .filter(|v| !v.is_empty())
2170 .map(|v| v.to_ascii_lowercase());
2171 if let Some(alias) = maybe_profile_alias {
2172 if known_profiles.contains_key(&alias) {
2173 map.insert(
2174 provider_preset_key.clone(),
2175 serde_yaml::Value::String(alias),
2176 );
2177 map.remove(&model_key);
2178 }
2179 }
2180 }
2181
2182 let accounts_key = serde_yaml::Value::String("accounts".to_string());
2183 if let Some(accounts) = map.get_mut(&accounts_key).and_then(|v| v.as_mapping_mut()) {
2184 for (_, account) in accounts.iter_mut() {
2185 migrate_model_override_alias_to_provider_preset(account, known_profiles);
2186 }
2187 }
2188}
2189
2190pub(crate) fn merge_provider_profile(
2191 base: LlmProviderProfile,
2192 override_profile: LlmProviderProfile,
2193) -> LlmProviderProfile {
2194 let mut models = if override_profile.models.is_empty() {
2195 base.models
2196 } else {
2197 override_profile.models
2198 };
2199 models.sort();
2200 models.dedup();
2201
2202 LlmProviderProfile {
2203 provider: override_profile.provider.or(base.provider),
2204 api_key: override_profile.api_key.or(base.api_key),
2205 api_keys: if override_profile.api_keys.is_empty() {
2206 base.api_keys
2207 } else {
2208 override_profile.api_keys
2209 },
2210 llm_base_url: override_profile.llm_base_url.or(base.llm_base_url),
2211 llm_user_agent: override_profile.llm_user_agent.or(base.llm_user_agent),
2212 default_model: override_profile.default_model.or(base.default_model),
2213 models,
2214 show_thinking: override_profile.show_thinking.or(base.show_thinking),
2215 }
2216}
2217
2218#[derive(Debug, Default)]
2221pub struct ConfigCheckReport {
2222 pub errors: Vec<String>,
2223 pub warnings: Vec<String>,
2224 pub summary: Option<(Vec<&'static str>, Vec<&'static str>, String, String)>,
2227}
2228
2229impl ConfigCheckReport {
2230 pub fn ok(&self) -> bool {
2231 self.errors.is_empty()
2232 }
2233}
2234
2235pub fn check_config_content(path_str: &str, content: &str) -> ConfigCheckReport {
2240 let mut report = ConfigCheckReport::default();
2241
2242 let raw: serde_yaml::Value = match serde_yaml::from_str(content) {
2244 Ok(v) => v,
2245 Err(e) => {
2246 report.errors.push(format!("YAML syntax error: {e}"));
2247 return report;
2248 }
2249 };
2250
2251 if let Some(map) = raw.as_mapping() {
2253 report.warnings.extend(unknown_top_level_key_warnings(map));
2254 } else {
2255 report
2256 .errors
2257 .push("Config root must be a YAML mapping (key: value pairs).".to_string());
2258 return report;
2259 }
2260
2261 match serde_yaml::from_str::<Config>(content) {
2264 Err(e) => {
2265 report.errors.push(friendly_yaml_error(path_str, &e));
2266 }
2267 Ok(mut config) => match config.post_deserialize() {
2268 Err(e) => report.errors.push(e.to_string()),
2270 Ok(()) => {
2271 let (enabled, disabled) = config.channel_status();
2272 report.summary = Some((
2273 enabled,
2274 disabled,
2275 config.llm_provider.clone(),
2276 config.model.clone(),
2277 ));
2278 }
2279 },
2280 }
2281 report
2282}
2283
2284pub fn run_config_check() -> i32 {
2288 let path = match Config::resolve_config_path() {
2289 Ok(Some(p)) => p,
2290 Ok(None) => {
2291 eprintln!("✗ No microclaw.config.yaml found. Run `microclaw setup` to create one.");
2292 return 2;
2293 }
2294 Err(e) => {
2295 eprintln!("✗ {e}");
2296 return 2;
2297 }
2298 };
2299 let path_str = path.to_string_lossy().to_string();
2300 let content = match std::fs::read_to_string(&path) {
2301 Ok(c) => c,
2302 Err(e) => {
2303 eprintln!("✗ Failed to read {path_str}: {e}");
2304 return 2;
2305 }
2306 };
2307
2308 println!("Checking {path_str}");
2309 let report = check_config_content(&path_str, &content);
2310 for w in &report.warnings {
2311 println!(" ⚠ {w}");
2312 }
2313 for e in &report.errors {
2314 println!(" ✗ {e}");
2315 }
2316 if let Some((enabled, disabled, provider, model)) = &report.summary {
2317 let enabled_str = if enabled.is_empty() {
2318 "none".to_string()
2319 } else {
2320 enabled.join(", ")
2321 };
2322 println!(" provider: {provider} · model: {model}");
2323 println!(" channels enabled: {enabled_str}");
2324 if !disabled.is_empty() {
2325 println!(" configured but disabled: {}", disabled.join(", "));
2326 }
2327 }
2328 if report.ok() {
2329 println!(
2330 "✓ Config OK{}",
2331 if report.warnings.is_empty() {
2332 ""
2333 } else {
2334 " (with warnings)"
2335 }
2336 );
2337 0
2338 } else {
2339 println!("✗ Config has errors — fix them and re-run, or run `microclaw setup`.");
2340 1
2341 }
2342}
2343
2344pub(crate) fn friendly_yaml_error(path_str: &str, err: &serde_yaml::Error) -> String {
2349 let raw = err.to_string();
2350 let mut hint = String::new();
2351 if let Some((kind, token)) = extract_unknown_token(&raw) {
2352 match suggest_closest(token, &extract_backtick_options(&raw)) {
2353 Some(best) => {
2354 hint = format!("\n\n Unknown {kind} `{token}` — did you mean `{best}`?");
2355 }
2356 None => hint = format!("\n\n Unknown {kind} `{token}`."),
2357 }
2358 }
2359 format!(
2360 "Failed to parse {path_str}: {raw}{hint}\n\n \
2361 Edit the config and re-run, or run `microclaw setup`.\n \
2362 Reference: https://github.com/microclaw/microclaw/blob/main/microclaw.config.example.yaml"
2363 )
2364}
2365
2366pub(crate) fn known_top_level_config_keys() -> Option<std::collections::BTreeSet<String>> {
2372 let base: Config = serde_yaml::from_str("{}").ok()?;
2373 let serde_yaml::Value::Mapping(serialized) = serde_yaml::to_value(&base).ok()? else {
2374 return None;
2375 };
2376 let mut known: std::collections::BTreeSet<String> = serialized
2377 .keys()
2378 .filter_map(|k| k.as_str().map(str::to_string))
2379 .collect();
2380 for k in ["timezone", "override_timezone", "web_auth_token"] {
2383 known.insert(k.to_string());
2384 }
2385 if known.len() < 5 {
2388 return None;
2389 }
2390 Some(known)
2391}
2392
2393pub(crate) fn unknown_top_level_key_warnings(map: &serde_yaml::Mapping) -> Vec<String> {
2398 let Some(known) = known_top_level_config_keys() else {
2399 return Vec::new();
2400 };
2401 let candidates: Vec<String> = known.iter().cloned().collect();
2402 let mut warnings = Vec::new();
2403 for key in map.keys() {
2404 let Some(name) = key.as_str() else { continue };
2405 if known.contains(name) {
2406 continue;
2407 }
2408 match suggest_closest(name, &candidates) {
2409 Some(best) => warnings.push(format!(
2410 "Unknown config key `{name}` (ignored) — did you mean `{best}`?"
2411 )),
2412 None => warnings.push(format!("Unknown config key `{name}` (ignored).")),
2413 }
2414 }
2415 warnings
2416}
2417
2418pub(crate) fn extract_unknown_token(raw: &str) -> Option<(&'static str, &str)> {
2420 for (needle, kind) in [
2421 ("unknown variant `", "variant"),
2422 ("unknown field `", "field"),
2423 ] {
2424 if let Some(start) = raw.find(needle) {
2425 let rest = &raw[start + needle.len()..];
2426 if let Some(end) = rest.find('`') {
2427 return Some((kind, &rest[..end]));
2428 }
2429 }
2430 }
2431 None
2432}
2433
2434pub(crate) fn extract_backtick_options(raw: &str) -> Vec<String> {
2436 let Some(exp) = raw.find("expected") else {
2437 return Vec::new();
2438 };
2439 let mut out = Vec::new();
2440 let mut rest = &raw[exp..];
2441 while let Some(open) = rest.find('`') {
2442 rest = &rest[open + 1..];
2443 if let Some(close) = rest.find('`') {
2444 out.push(rest[..close].to_string());
2445 rest = &rest[close + 1..];
2446 } else {
2447 break;
2448 }
2449 }
2450 out
2451}
2452
2453pub(crate) fn suggest_closest(token: &str, options: &[String]) -> Option<String> {
2456 let threshold = (token.chars().count() / 3).max(2);
2457 options
2458 .iter()
2459 .map(|o| (levenshtein(token, o), o))
2460 .filter(|(d, _)| *d <= threshold)
2461 .min_by_key(|(d, _)| *d)
2462 .map(|(_, o)| o.clone())
2463}
2464
2465pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
2467 let a: Vec<char> = a.chars().collect();
2468 let b: Vec<char> = b.chars().collect();
2469 let mut prev: Vec<usize> = (0..=b.len()).collect();
2470 let mut cur = vec![0usize; b.len() + 1];
2471 for (i, ca) in a.iter().enumerate() {
2472 cur[0] = i + 1;
2473 for (j, cb) in b.iter().enumerate() {
2474 let cost = if ca == cb { 0 } else { 1 };
2475 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
2476 }
2477 std::mem::swap(&mut prev, &mut cur);
2478 }
2479 prev[b.len()]
2480}
2481
2482#[cfg(test)]
2483mod tests {
2484 use super::*;
2485 #[allow(unused_imports)]
2486 use crate::config::test_prelude::*;
2487
2488 #[test]
2489 fn config_check_reports_syntax_errors_with_location() {
2490 let report = check_config_content("t.yaml", "telegram_bot_token: [unclosed");
2491 assert!(!report.ok());
2492 assert!(report.errors[0].contains("YAML syntax error"));
2493 assert!(report.summary.is_none());
2494 }
2495
2496 #[test]
2497 fn config_check_warns_on_unknown_key_with_suggestion() {
2498 let report = check_config_content(
2499 "t.yaml",
2500 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nmax_tool_iterationz: 5\n",
2501 );
2502 assert!(report.ok(), "unknown keys warn but do not fail the check");
2503 assert!(
2504 report
2505 .warnings
2506 .iter()
2507 .any(|w| w.contains("max_tool_iterationz") && w.contains("max_tool_iterations")),
2508 "expected did-you-mean warning, got: {:?}",
2509 report.warnings
2510 );
2511 let (enabled, _, provider, _) = report.summary.as_ref().unwrap();
2512 assert!(enabled.contains(&"telegram"));
2513 assert_eq!(provider, "anthropic");
2514 }
2515
2516 #[test]
2517 fn config_check_rejects_bad_enum_value_with_suggestion() {
2518 let report = check_config_content("t.yaml", "api_key: key\ntool_policy:\n mode: blok\n");
2519 assert!(!report.ok());
2520 assert!(
2521 report.errors[0].contains("blok") && report.errors[0].contains("block"),
2522 "expected did-you-mean on enum typo, got: {:?}",
2523 report.errors
2524 );
2525 }
2526
2527 #[test]
2528 fn config_check_flags_no_enabled_channel() {
2529 let report = check_config_content("t.yaml", "api_key: key\n");
2531 assert!(report.ok(), "errors: {:?}", report.errors);
2532 let (enabled, ..) = report.summary.as_ref().unwrap();
2533 assert!(enabled.contains(&"web"));
2534
2535 let report = check_config_content(
2538 "t.yaml",
2539 "api_key: key\nchannels:\n web:\n enabled: false\n",
2540 );
2541 assert!(
2542 !report.ok(),
2543 "disabling all channels should be an error, got warnings: {:?}",
2544 report.warnings
2545 );
2546 }
2547
2548 #[test]
2549 fn config_check_rejects_non_mapping_root() {
2550 let report = check_config_content("t.yaml", "- just\n- a\n- list\n");
2551 assert!(!report.ok());
2552 assert!(report.errors[0].contains("mapping"));
2553 }
2554
2555 #[test]
2556 fn friendly_error_suggests_closest_field() {
2557 #[derive(Debug, serde::Deserialize)]
2559 #[serde(deny_unknown_fields)]
2560 struct Mini {
2561 #[allow(dead_code)]
2562 discord: bool,
2563 }
2564 let err = serde_yaml::from_str::<Mini>("discrod: true\n").unwrap_err();
2565 let msg = friendly_yaml_error("cfg.yaml", &err);
2566 assert!(msg.contains("did you mean `discord`?"), "got: {msg}");
2567 assert!(msg.contains("microclaw setup"));
2568 }
2569
2570 #[test]
2571 fn extract_and_suggest_helpers() {
2572 let raw = "unknown variant `discrod`, expected one of `telegram`, `discord`, `slack`";
2573 assert_eq!(extract_unknown_token(raw), Some(("variant", "discrod")));
2574 let opts = extract_backtick_options(raw);
2575 assert_eq!(opts, vec!["telegram", "discord", "slack"]);
2576 assert_eq!(
2577 suggest_closest("discrod", &opts).as_deref(),
2578 Some("discord")
2579 );
2580 assert_eq!(suggest_closest("zzzzzz", &opts), None);
2582 }
2583
2584 #[test]
2585 fn levenshtein_basics() {
2586 assert_eq!(levenshtein("kitten", "sitting"), 3);
2587 assert_eq!(levenshtein("discord", "discord"), 0);
2588 assert_eq!(levenshtein("", "abc"), 3);
2589 }
2590
2591 #[test]
2592 fn channel_status_splits_enabled_and_disabled() {
2593 let cfg: Config = serde_yaml::from_str(
2594 "api_key: k\nweb_enabled: false\nchannels:\n telegram:\n enabled: true\n discord:\n enabled: false\n",
2595 )
2596 .unwrap();
2597 let (enabled, disabled) = cfg.channel_status();
2598 assert_eq!(enabled, vec!["telegram"]);
2599 assert_eq!(disabled, vec!["discord"]);
2600 }
2601
2602 #[test]
2603 fn no_enabled_channel_error_names_disabled_channel() {
2604 let mut cfg: Config = serde_yaml::from_str(
2605 "api_key: k\nweb_enabled: false\nchannels:\n telegram:\n enabled: false\n",
2606 )
2607 .unwrap();
2608 let err = cfg.post_deserialize().unwrap_err().to_string();
2609 assert!(err.contains("configured but not enabled"), "got: {err}");
2610 assert!(err.contains("telegram"), "got: {err}");
2611 }
2612
2613 #[test]
2614 fn aux_models_parse_from_yaml() {
2615 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\naux_models:\n compaction: claude-haiku-4-5\n reflector: claude-haiku-4-5\n";
2616 let config: Config = serde_yaml::from_str(yaml).unwrap();
2617 assert_eq!(
2618 config.aux_models.compaction.as_deref(),
2619 Some("claude-haiku-4-5")
2620 );
2621 assert_eq!(
2622 config.aux_models.reflector_model(),
2623 Some("claude-haiku-4-5")
2624 );
2625 }
2626
2627 #[test]
2628 fn aux_models_absent_yaml_defaults_to_none() {
2629 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2630 let config: Config = serde_yaml::from_str(yaml).unwrap();
2631 assert!(config.aux_models.compaction.is_none());
2632 }
2633
2634 #[test]
2635 fn aux_models_title_vision_parse_from_yaml() {
2636 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\naux_models:\n title: claude-haiku-4-5\n vision: claude-haiku-4-5\n";
2637 let config: Config = serde_yaml::from_str(yaml).unwrap();
2638 assert_eq!(config.aux_models.title_model(), Some("claude-haiku-4-5"));
2639 assert_eq!(config.aux_models.vision_model(), Some("claude-haiku-4-5"));
2640 }
2641
2642 #[test]
2643 fn test_clawhub_config_defaults() {
2644 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2645 let config: Config = serde_yaml::from_str(yaml).unwrap();
2646 assert_eq!(config.clawhub.registry, "https://clawhub.ai");
2647 assert!(config.clawhub.agent_tools_enabled);
2648 }
2649
2650 #[test]
2651 fn test_voice_config_defaults() {
2652 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2653 let config: Config = serde_yaml::from_str(yaml).unwrap();
2654 assert_eq!(config.voice_provider, "openai");
2655 assert!(config.voice_transcription_command.is_none());
2656 }
2657
2658 #[test]
2659 fn test_voice_config_local_provider() {
2660 let yaml = r#"
2661telegram_bot_token: tok
2662bot_username: bot
2663api_key: key
2664voice_provider: "local"
2665voice_transcription_command: "whisper-mlx --file {file}"
2666"#;
2667 let config: Config = serde_yaml::from_str(yaml).unwrap();
2668 assert_eq!(config.voice_provider, "local");
2669 assert_eq!(
2670 config.voice_transcription_command,
2671 Some("whisper-mlx --file {file}".into())
2672 );
2673 }
2674
2675 #[test]
2676 fn test_config_yaml_roundtrip() {
2677 let config = test_config();
2678 let yaml = serde_yaml::to_string(&config).unwrap();
2679 let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
2680 assert_eq!(parsed.telegram_bot_token, "tok");
2681 assert_eq!(parsed.max_tokens, 8192);
2682 assert_eq!(parsed.llm_provider, "anthropic");
2683 }
2684
2685 #[test]
2686 fn test_default_data_dir_uses_microclaw_home() {
2687 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2688 let config: Config = serde_yaml::from_str(yaml).unwrap();
2689 assert!(config.data_dir.ends_with(".microclaw"));
2690 }
2691
2692 #[test]
2693 fn test_config_sandbox_defaults_to_off() {
2694 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2695 let config: Config = serde_yaml::from_str(yaml).unwrap();
2696 assert!(matches!(config.sandbox.mode, SandboxMode::Off));
2697 assert!(matches!(config.sandbox.backend, SandboxBackend::Auto));
2698 assert!(config.sandbox.require_runtime);
2699 assert_eq!(config.sandbox.image, "ubuntu:25.10");
2700 }
2701
2702 #[test]
2703 fn test_post_deserialize_empty_working_dir_uses_default() {
2704 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir: ' '\n";
2705 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2706 config.post_deserialize().unwrap();
2707 assert!(std::path::PathBuf::from(&config.working_dir)
2708 .ends_with(std::path::Path::new(".microclaw").join("working_dir")));
2709 }
2710
2711 #[test]
2712 fn test_post_deserialize_zero_memory_budget_uses_default() {
2713 let yaml =
2714 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nmemory_token_budget: 0\n";
2715 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2716 config.post_deserialize().unwrap();
2717 assert_eq!(config.memory_token_budget, 1500);
2718 }
2719
2720 #[test]
2721 fn test_config_working_dir_isolation_defaults_to_chat() {
2722 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2723 let config: Config = serde_yaml::from_str(yaml).unwrap();
2724 assert!(matches!(
2725 config.working_dir_isolation,
2726 WorkingDirIsolation::Chat
2727 ));
2728 }
2729
2730 #[test]
2731 fn test_config_working_dir_isolation_accepts_chat() {
2732 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: chat\n";
2733 let config: Config = serde_yaml::from_str(yaml).unwrap();
2734 assert!(matches!(
2735 config.working_dir_isolation,
2736 WorkingDirIsolation::Chat
2737 ));
2738 }
2739
2740 #[test]
2741 fn test_config_working_dir_isolation_accepts_true() {
2742 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: true\n";
2743 let config: Config = serde_yaml::from_str(yaml).unwrap();
2744 assert!(matches!(
2745 config.working_dir_isolation,
2746 WorkingDirIsolation::Chat
2747 ));
2748 }
2749
2750 #[test]
2751 fn test_config_working_dir_isolation_accepts_false() {
2752 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: false\n";
2753 let config: Config = serde_yaml::from_str(yaml).unwrap();
2754 assert!(matches!(
2755 config.working_dir_isolation,
2756 WorkingDirIsolation::Shared
2757 ));
2758 }
2759
2760 #[test]
2761 fn test_high_risk_tool_user_confirmation_required_defaults_true() {
2762 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2763 let config: Config = serde_yaml::from_str(yaml).unwrap();
2764 assert!(config.high_risk_tool_user_confirmation_required);
2765 }
2766
2767 #[test]
2768 fn test_high_risk_tool_user_confirmation_required_accepts_false() {
2769 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nhigh_risk_tool_user_confirmation_required: false\n";
2770 let config: Config = serde_yaml::from_str(yaml).unwrap();
2771 assert!(!config.high_risk_tool_user_confirmation_required);
2772 }
2773
2774 #[test]
2775 fn test_config_post_deserialize() {
2776 let yaml =
2777 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: ANTHROPIC\n";
2778 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2779 config.post_deserialize().unwrap();
2780 assert_eq!(config.llm_provider, "anthropic");
2781 assert_eq!(config.model, "claude-sonnet-4-5-20250929");
2782 }
2783
2784 #[test]
2785 fn test_post_deserialize_invalid_timezone() {
2786 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\noverride_timezone: Mars/Olympus\n";
2787 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2788 let err = config.post_deserialize().unwrap_err();
2789 let msg = err.to_string();
2790 assert!(msg.contains("Invalid timezone"));
2791 }
2792
2793 #[test]
2794 fn test_post_deserialize_auto_timezone_resolves_to_valid_tz() {
2795 let yaml =
2796 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\noverride_timezone: auto\n";
2797 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2798 config.post_deserialize().unwrap();
2799 assert!(config.timezone.parse::<chrono_tz::Tz>().is_ok());
2800 }
2801
2802 #[test]
2803 fn test_post_deserialize_missing_api_key() {
2804 let yaml = "telegram_bot_token: tok\nbot_username: bot\n";
2805 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2806 let err = config.post_deserialize().unwrap_err();
2807 let msg = err.to_string();
2808 assert!(msg.contains("api_key is required"));
2809 }
2810
2811 #[test]
2812 fn test_post_deserialize_openai_codex_allows_empty_api_key() {
2813 let _guard = env_lock();
2814 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2815 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2816 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2817
2818 let auth_dir = std::env::temp_dir().join(format!(
2819 "microclaw-codex-auth-{}",
2820 std::time::SystemTime::now()
2821 .duration_since(std::time::UNIX_EPOCH)
2822 .unwrap_or_default()
2823 .as_nanos()
2824 ));
2825 std::fs::create_dir_all(&auth_dir).unwrap();
2826 std::fs::write(
2827 auth_dir.join("auth.json"),
2828 r#"{"tokens":{"access_token":"tok"}}"#,
2829 )
2830 .unwrap();
2831 std::env::set_var("CODEX_HOME", &auth_dir);
2832
2833 let yaml =
2834 "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\nmodel: gpt-5.3-codex\n";
2835 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2836 config.post_deserialize().unwrap();
2837
2838 if let Some(prev) = prev_codex_home {
2839 std::env::set_var("CODEX_HOME", prev);
2840 } else {
2841 std::env::remove_var("CODEX_HOME");
2842 }
2843 if let Some(prev) = prev_access {
2844 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2845 } else {
2846 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2847 }
2848 let _ = std::fs::remove_file(auth_dir.join("auth.json"));
2849 let _ = std::fs::remove_dir(auth_dir);
2850 assert_eq!(config.llm_provider, "openai-codex");
2851 }
2852
2853 #[test]
2854 fn test_post_deserialize_missing_bot_tokens() {
2855 let yaml = "bot_username: bot\napi_key: key\nweb_enabled: false\n";
2856 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2857 let err = config.post_deserialize().unwrap_err();
2858 let msg = err.to_string();
2859 assert!(msg.contains("channel must be enabled"));
2860 }
2861
2862 #[test]
2863 fn test_post_deserialize_discord_only() {
2864 let yaml = "bot_username: bot\napi_key: key\ndiscord_bot_token: discord_tok\n";
2865 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2866 config.post_deserialize().unwrap();
2868 }
2869
2870 #[test]
2871 fn test_post_deserialize_irc_only() {
2872 let yaml = r##"
2873api_key: key
2874channels:
2875 irc:
2876 enabled: true
2877 server: "irc.example.com"
2878 nick: "microclaw"
2879 channels: "#microclaw"
2880"##;
2881 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2882 config.post_deserialize().unwrap();
2883 assert!(config.channel_enabled("irc"));
2884 }
2885
2886 #[test]
2887 fn test_post_deserialize_matrix_only() {
2888 let yaml = r##"
2889api_key: key
2890channels:
2891 matrix:
2892 enabled: true
2893 homeserver_url: "https://matrix.example.com"
2894 access_token: "syt_xxx"
2895 bot_user_id: "@microclaw:example.com"
2896"##;
2897 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2898 config.post_deserialize().unwrap();
2899 assert!(config.channel_enabled("matrix"));
2900 }
2901
2902 #[test]
2903 fn test_post_deserialize_channel_enabled_flag_overrides_legacy_inference() {
2904 let yaml = "telegram_bot_token: tok\nbot_username: bot\ndiscord_bot_token: discord_tok\napi_key: key\nchannels:\n telegram:\n enabled: false\n discord:\n enabled: true\n web:\n enabled: false\n";
2905 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2906 config.post_deserialize().unwrap();
2907
2908 assert!(!config.channel_enabled("telegram"));
2909 assert!(config.channel_enabled("discord"));
2910 assert!(!config.channel_enabled("web"));
2911 }
2912
2913 #[test]
2914 fn test_post_deserialize_channel_enabled_flag_controls_web() {
2915 let yaml =
2916 "api_key: key\ndiscord_bot_token: discord_tok\nchannels:\n web:\n enabled: false\n";
2917 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2918 config.post_deserialize().unwrap();
2919
2920 assert!(!config.channel_enabled("web"));
2921 }
2922
2923 #[test]
2924 fn test_post_deserialize_openai_default_model() {
2925 let yaml =
2926 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: openai\n";
2927 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2928 config.post_deserialize().unwrap();
2929 assert_eq!(config.model, "gpt-5.2");
2930 }
2931
2932 #[test]
2933 fn test_post_deserialize_wildcard_model_falls_back_to_provider_default() {
2934 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: openai\nmodel: '*'\n";
2935 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2936 config.post_deserialize().unwrap();
2937 assert_eq!(config.model, "gpt-5.2");
2938 }
2939
2940 #[test]
2941 fn test_post_deserialize_openai_codex_default_model() {
2942 let _guard = env_lock();
2943 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2944 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2945 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2946
2947 let auth_dir = std::env::temp_dir().join(format!(
2948 "microclaw-codex-auth-{}",
2949 std::time::SystemTime::now()
2950 .duration_since(std::time::UNIX_EPOCH)
2951 .unwrap_or_default()
2952 .as_nanos()
2953 ));
2954 std::fs::create_dir_all(&auth_dir).unwrap();
2955 std::fs::write(
2956 auth_dir.join("auth.json"),
2957 r#"{"tokens":{"access_token":"tok"}}"#,
2958 )
2959 .unwrap();
2960 std::env::set_var("CODEX_HOME", &auth_dir);
2961
2962 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
2963 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2964 config.post_deserialize().unwrap();
2965
2966 if let Some(prev) = prev_codex_home {
2967 std::env::set_var("CODEX_HOME", prev);
2968 } else {
2969 std::env::remove_var("CODEX_HOME");
2970 }
2971 if let Some(prev) = prev_access {
2972 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2973 } else {
2974 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2975 }
2976 let _ = std::fs::remove_file(auth_dir.join("auth.json"));
2977 let _ = std::fs::remove_dir(auth_dir);
2978 assert_eq!(config.model, "gpt-5.3-codex");
2979 }
2980
2981 #[test]
2982 fn test_post_deserialize_openai_codex_missing_oauth_token() {
2983 let _guard = env_lock();
2984 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2985 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2986 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2987
2988 let auth_dir = std::env::temp_dir().join(format!(
2989 "microclaw-codex-auth-missing-{}",
2990 std::time::SystemTime::now()
2991 .duration_since(std::time::UNIX_EPOCH)
2992 .unwrap_or_default()
2993 .as_nanos()
2994 ));
2995 std::fs::create_dir_all(&auth_dir).unwrap();
2996 std::env::set_var("CODEX_HOME", &auth_dir);
2997
2998 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
2999 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3000 let err = config.post_deserialize().unwrap_err();
3001 let msg = err.to_string();
3002
3003 if let Some(prev) = prev_codex_home {
3004 std::env::set_var("CODEX_HOME", prev);
3005 } else {
3006 std::env::remove_var("CODEX_HOME");
3007 }
3008 if let Some(prev) = prev_access {
3009 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
3010 } else {
3011 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
3012 }
3013 let _ = std::fs::remove_dir(auth_dir);
3014
3015 assert!(msg.contains("openai-codex requires ~/.codex/auth.json"));
3016 }
3017
3018 #[test]
3019 fn test_post_deserialize_openai_codex_rejects_plain_api_key_without_oauth() {
3020 let _guard = env_lock();
3021 let prev_codex_home = std::env::var("CODEX_HOME").ok();
3022 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
3023 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
3024
3025 let auth_dir = std::env::temp_dir().join(format!(
3026 "microclaw-codex-auth-plain-key-{}",
3027 std::time::SystemTime::now()
3028 .duration_since(std::time::UNIX_EPOCH)
3029 .unwrap_or_default()
3030 .as_nanos()
3031 ));
3032 std::fs::create_dir_all(&auth_dir).unwrap();
3033 std::env::set_var("CODEX_HOME", &auth_dir);
3034
3035 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\napi_key: sk-user-stale\n";
3036 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3037 let err = config.post_deserialize().unwrap_err();
3038 let msg = err.to_string();
3039
3040 if let Some(prev) = prev_codex_home {
3041 std::env::set_var("CODEX_HOME", prev);
3042 } else {
3043 std::env::remove_var("CODEX_HOME");
3044 }
3045 if let Some(prev) = prev_access {
3046 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
3047 } else {
3048 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
3049 }
3050 let _ = std::fs::remove_dir(auth_dir);
3051
3052 assert!(msg.contains("ignores microclaw.config.yaml api_key"));
3053 }
3054
3055 #[test]
3056 fn test_post_deserialize_openai_codex_allows_env_access_token() {
3057 let _guard = env_lock();
3058 let prev_codex_home = std::env::var("CODEX_HOME").ok();
3059 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
3060 std::env::remove_var("CODEX_HOME");
3061 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", "env-token");
3062
3063 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
3064 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3065 config.post_deserialize().unwrap();
3066
3067 if let Some(prev) = prev_codex_home {
3068 std::env::set_var("CODEX_HOME", prev);
3069 } else {
3070 std::env::remove_var("CODEX_HOME");
3071 }
3072 if let Some(prev) = prev_access {
3073 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
3074 } else {
3075 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
3076 }
3077
3078 assert_eq!(config.llm_provider, "openai-codex");
3079 }
3080
3081 #[test]
3082 fn test_post_deserialize_qwen_code_allows_oauth_without_api_key() {
3083 let _guard = env_lock();
3084 let prev_qwen_home = std::env::var("QWEN_HOME").ok();
3085 let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
3086 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3087
3088 let qwen_dir = std::env::temp_dir().join(format!(
3089 "microclaw-qwen-auth-{}",
3090 std::time::SystemTime::now()
3091 .duration_since(std::time::UNIX_EPOCH)
3092 .unwrap_or_default()
3093 .as_nanos()
3094 ));
3095 std::fs::create_dir_all(&qwen_dir).unwrap();
3096 std::fs::write(
3097 qwen_dir.join("oauth_creds.json"),
3098 r#"{"access_token":"qwen-oauth-token"}"#,
3099 )
3100 .unwrap();
3101 std::env::set_var("QWEN_HOME", &qwen_dir);
3102
3103 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: qwen-portal\n";
3104 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3105 config.post_deserialize().unwrap();
3106
3107 if let Some(prev) = prev_qwen_home {
3108 std::env::set_var("QWEN_HOME", prev);
3109 } else {
3110 std::env::remove_var("QWEN_HOME");
3111 }
3112 if let Some(prev) = prev_qwen_access {
3113 std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
3114 } else {
3115 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3116 }
3117 let _ = std::fs::remove_file(qwen_dir.join("oauth_creds.json"));
3118 let _ = std::fs::remove_dir(qwen_dir);
3119
3120 assert_eq!(config.llm_provider, "qwen-portal");
3121 }
3122
3123 #[test]
3124 fn test_post_deserialize_qwen_code_missing_oauth_and_api_key() {
3125 let _guard = env_lock();
3126 let prev_qwen_home = std::env::var("QWEN_HOME").ok();
3127 let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
3128 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3129
3130 let qwen_dir = std::env::temp_dir().join(format!(
3131 "microclaw-qwen-auth-missing-{}",
3132 std::time::SystemTime::now()
3133 .duration_since(std::time::UNIX_EPOCH)
3134 .unwrap_or_default()
3135 .as_nanos()
3136 ));
3137 std::fs::create_dir_all(&qwen_dir).unwrap();
3138 std::env::set_var("QWEN_HOME", &qwen_dir);
3139
3140 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: qwen-portal\n";
3141 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3142 let err = config.post_deserialize().unwrap_err();
3143
3144 if let Some(prev) = prev_qwen_home {
3145 std::env::set_var("QWEN_HOME", prev);
3146 } else {
3147 std::env::remove_var("QWEN_HOME");
3148 }
3149 if let Some(prev) = prev_qwen_access {
3150 std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
3151 } else {
3152 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3153 }
3154 let _ = std::fs::remove_dir(qwen_dir);
3155
3156 assert!(err
3157 .to_string()
3158 .contains("qwen-portal requires api_key, or ~/.qwen/oauth_creds.json"));
3159 }
3160
3161 #[test]
3162 fn test_post_deserialize_ollama_default_model_and_empty_key() {
3163 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: ollama\n";
3164 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3165 config.post_deserialize().unwrap();
3166 assert_eq!(config.model, "llama3.2");
3167 }
3168
3169 #[test]
3170 fn test_post_deserialize_empty_base_url_becomes_none() {
3171 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_base_url: ' '\n";
3172 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3173 config.post_deserialize().unwrap();
3174 assert!(config.llm_base_url.is_none());
3175 }
3176
3177 #[test]
3178 fn test_post_deserialize_empty_llm_user_agent_uses_default() {
3179 let yaml =
3180 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_user_agent: ' '\n";
3181 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3182 config.post_deserialize().unwrap();
3183 assert_eq!(
3184 config.llm_user_agent,
3185 crate::http_client::default_llm_user_agent()
3186 );
3187 }
3188
3189 #[test]
3190 fn test_post_deserialize_provider_case_insensitive() {
3191 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: ' ANTHROPIC '\n";
3192 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3193 config.post_deserialize().unwrap();
3194 assert_eq!(config.llm_provider, "anthropic");
3195 assert_eq!(config.model, "claude-sonnet-4-5-20250929");
3196 }
3197
3198 #[test]
3199 fn test_post_deserialize_normalizes_openai_compat_body_overrides() {
3200 let yaml = r#"
3201telegram_bot_token: tok
3202bot_username: bot
3203api_key: key
3204openai_compat_body_overrides:
3205 " temperature ": 0.2
3206 " ": 1
3207openai_compat_body_overrides_by_provider:
3208 " OPENAI ":
3209 " top_p ": 0.95
3210 "": 1
3211 " ":
3212 seed: 7
3213openai_compat_body_overrides_by_model:
3214 " gpt-5.2 ":
3215 " frequency_penalty ": 0.1
3216 "": 1
3217 " ":
3218 seed: 7
3219"#;
3220 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3221 config.post_deserialize().unwrap();
3222
3223 assert_eq!(
3224 config.openai_compat_body_overrides.get("temperature"),
3225 Some(&serde_json::json!(0.2))
3226 );
3227 assert!(!config.openai_compat_body_overrides.contains_key(" "));
3228
3229 let provider_params = config
3230 .openai_compat_body_overrides_by_provider
3231 .get("openai")
3232 .unwrap();
3233 assert_eq!(provider_params.get("top_p"), Some(&serde_json::json!(0.95)));
3234 assert!(!provider_params.contains_key(""));
3235 assert!(!config
3236 .openai_compat_body_overrides_by_provider
3237 .contains_key(" OPENAI "));
3238
3239 let model_params = config
3240 .openai_compat_body_overrides_by_model
3241 .get("gpt-5.2")
3242 .unwrap();
3243 assert_eq!(
3244 model_params.get("frequency_penalty"),
3245 Some(&serde_json::json!(0.1))
3246 );
3247 assert!(!model_params.contains_key(""));
3248 }
3249
3250 #[test]
3251 fn test_post_deserialize_normalizes_subagent_acp_config() {
3252 let yaml = r#"
3253telegram_bot_token: tok
3254bot_username: bot
3255api_key: key
3256subagents:
3257 acp:
3258 enabled: true
3259 command: " codex "
3260 args: [" --model ", " ", "gpt-5.4"]
3261 env:
3262 " OPENAI_API_KEY ": abc
3263 " ": ignored
3264 default_target: " worker "
3265 targets:
3266 " worker ":
3267 enabled: true
3268 command: " codex-worker "
3269 args: [" --fast "]
3270 env:
3271 " TOKEN ": xyz
3272"#;
3273 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3274 config.post_deserialize().unwrap();
3275
3276 assert!(config.subagents.acp.default_target.enabled);
3277 assert_eq!(config.subagents.acp.default_target.command, "codex");
3278 assert_eq!(
3279 config.subagents.acp.default_target.args,
3280 vec!["--model".to_string(), "gpt-5.4".to_string()]
3281 );
3282 assert_eq!(
3283 config
3284 .subagents
3285 .acp
3286 .default_target
3287 .env
3288 .get("OPENAI_API_KEY"),
3289 Some(&"abc".to_string())
3290 );
3291 assert_eq!(
3292 config.subagents.acp.default_target_name.as_deref(),
3293 Some("worker")
3294 );
3295 assert!(!config.subagents.acp.default_target.env.contains_key(" "));
3296 let target = config.subagents.acp.targets.get("worker").unwrap();
3297 assert!(target.enabled);
3298 assert_eq!(target.command, "codex-worker");
3299 assert_eq!(target.args, vec!["--fast".to_string()]);
3300 assert_eq!(target.env.get("TOKEN"), Some(&"xyz".to_string()));
3301 }
3302
3303 #[test]
3304 fn test_post_deserialize_web_non_local_no_token_required() {
3305 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nweb_enabled: true\nweb_host: 0.0.0.0\n";
3306 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3307 config.post_deserialize().unwrap();
3308 }
3309
3310 #[test]
3311 fn test_post_deserialize_web_channel_auth_token_is_removed() {
3312 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nweb_enabled: true\nweb_host: 0.0.0.0\nchannels:\n web:\n enabled: true\n auth_token: token123\n";
3313 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3314 config.post_deserialize().unwrap();
3315 let has_auth_token = config
3316 .channels
3317 .get("web")
3318 .and_then(|v| v.as_mapping())
3319 .map(|map| map.contains_key(serde_yaml::Value::String("auth_token".to_string())))
3320 .unwrap_or(false);
3321 assert!(!has_auth_token);
3322 }
3323
3324 #[test]
3325 fn test_model_prices_parse_and_estimate() {
3326 let yaml = r#"
3327telegram_bot_token: tok
3328bot_username: bot
3329api_key: key
3330model_prices:
3331 - model: claude-sonnet-4-5-20250929
3332 input_per_million_usd: 3.0
3333 output_per_million_usd: 15.0
3334"#;
3335 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3336 config.post_deserialize().unwrap();
3337 let est = config
3338 .estimate_cost_usd("claude-sonnet-4-5-20250929", 1000, 2000)
3339 .unwrap();
3340 assert!((est - 0.033).abs() < 1e-9);
3341 }
3342
3343 #[test]
3344 fn test_model_prices_invalid_rejected() {
3345 let yaml = r#"
3346telegram_bot_token: tok
3347bot_username: bot
3348api_key: key
3349model_prices:
3350 - model: ""
3351 input_per_million_usd: 1.0
3352 output_per_million_usd: 1.0
3353"#;
3354 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3355 let err = config.post_deserialize().unwrap_err();
3356 assert!(err
3357 .to_string()
3358 .contains("model_prices entries must include non-empty model"));
3359 }
3360
3361 #[test]
3362 fn test_config_yaml_with_all_optional_fields() {
3363 let yaml = r#"
3364telegram_bot_token: tok
3365bot_username: bot
3366api_key: key
3367openai_api_key: sk-test
3368override_timezone: US/Eastern
3369allowed_groups: [123, 456]
3370control_chat_ids: [999]
3371max_session_messages: 60
3372compact_keep_recent: 30
3373discord_bot_token: discord_tok
3374discord_allowed_channels: [111, 222]
3375"#;
3376 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3377 config.post_deserialize().unwrap();
3378 assert_eq!(config.openai_api_key.as_deref(), Some("sk-test"));
3379 assert_eq!(config.timezone, "US/Eastern");
3380 assert_eq!(config.allowed_groups, vec![123, 456]);
3381 assert_eq!(config.control_chat_ids, vec![999]);
3382 assert_eq!(config.max_session_messages, 60);
3383 assert_eq!(config.compact_keep_recent, 30);
3384 assert_eq!(config.discord_allowed_channels, vec![111, 222]);
3385 }
3386
3387 #[test]
3388 fn test_expand_path_with_tilde() {
3389 let home = PathBuf::from(shellexpand::tilde("~").as_ref());
3390 let path = "~/foo/bar";
3391 let expanded = expand_path(path);
3392 assert_eq!(expanded, home.join("foo/bar"));
3393
3394 let path = "~";
3395 let expanded = expand_path(path);
3396 assert_eq!(expanded, home);
3397
3398 let path = "/absolute/path";
3399 let expanded = expand_path(path);
3400 assert_eq!(expanded, std::path::PathBuf::from("/absolute/path"));
3401 }
3402
3403 #[test]
3404 fn unknown_top_level_key_is_flagged() {
3405 let map = mapping_of("definitely_not_a_key: true\napi_key: k\n");
3406 let warnings = unknown_top_level_key_warnings(&map);
3407 assert!(
3408 warnings.iter().any(|w| w.contains("definitely_not_a_key")),
3409 "expected an unknown-key warning, got: {warnings:?}"
3410 );
3411 assert!(
3413 !warnings.iter().any(|w| w.contains("`api_key`")),
3414 "api_key should be recognized, got: {warnings:?}"
3415 );
3416 }
3417
3418 #[test]
3419 fn unknown_key_gets_did_you_mean() {
3420 let map = mapping_of("api_ky: k\n");
3422 let warnings = unknown_top_level_key_warnings(&map);
3423 assert!(
3424 warnings
3425 .iter()
3426 .any(|w| w.contains("api_ky") && w.contains("did you mean `api_key`")),
3427 "expected a did-you-mean suggestion, got: {warnings:?}"
3428 );
3429 }
3430
3431 #[test]
3432 fn known_top_level_keys_produce_no_warnings() {
3433 let yaml = "api_key: k\ntelegram_bot_token: t\nclawhub_registry: https://x\n\
3436timezone: UTC\noverride_timezone: UTC\nsandbox:\n enabled: false\n";
3437 let warnings = unknown_top_level_key_warnings(&mapping_of(yaml));
3438 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3439 }
3440}