1pub mod channels;
13pub mod kernel;
14pub mod personality;
15pub mod policies;
16pub mod voice;
17
18pub use channels::*;
20pub use kernel::*;
21pub use personality::*;
22pub use policies::*;
23pub use voice::*;
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27
28use serde::{Deserialize, Serialize};
29
30use crate::delegation::DelegationConfig;
31use crate::routing::RoutingConfig;
32use crate::secret::SecretString;
33
34pub(crate) fn default_true() -> bool {
36 true
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct Config {
46 #[serde(default)]
48 pub agents: AgentsConfig,
49
50 #[serde(default)]
52 pub channels: ChannelsConfig,
53
54 #[serde(default)]
56 pub providers: ProvidersConfig,
57
58 #[serde(default)]
60 pub gateway: GatewayConfig,
61
62 #[serde(default)]
64 pub tools: ToolsConfig,
65
66 #[serde(default)]
68 pub delegation: DelegationConfig,
69
70 #[serde(default)]
72 pub routing: RoutingConfig,
73
74 #[serde(default)]
76 pub voice: VoiceConfig,
77
78 #[serde(default)]
80 pub kernel: KernelConfig,
81
82 #[serde(default)]
84 pub pipeline: PipelineConfig,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct PipelineConfig {
95 #[serde(default = "default_scorer")]
97 pub scorer: String,
98
99 #[serde(default = "default_learner")]
101 pub learner: String,
102}
103
104fn default_scorer() -> String {
105 "noop".into()
106}
107
108fn default_learner() -> String {
109 "noop".into()
110}
111
112impl Default for PipelineConfig {
113 fn default() -> Self {
114 Self {
115 scorer: default_scorer(),
116 learner: default_learner(),
117 }
118 }
119}
120
121impl Config {
122 pub fn workspace_path(&self) -> PathBuf {
128 let raw = &self.agents.defaults.workspace;
129 #[cfg(feature = "native")]
130 if let Some(rest) = raw.strip_prefix("~/")
131 && let Some(home) = dirs::home_dir()
132 {
133 return home.join(rest);
134 }
135 PathBuf::from(raw)
136 }
137
138 pub fn workspace_path_with_home(&self, home: Option<&std::path::Path>) -> PathBuf {
143 let raw = &self.agents.defaults.workspace;
144 if let Some(rest) = raw.strip_prefix("~/")
145 && let Some(home) = home
146 {
147 return home.join(rest);
148 }
149 PathBuf::from(raw)
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
157pub struct AgentsConfig {
158 #[serde(default)]
160 pub defaults: AgentDefaults,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct AgentDefaults {
166 #[serde(default = "default_workspace")]
168 pub workspace: String,
169
170 #[serde(default = "default_model")]
172 pub model: String,
173
174 #[serde(default = "default_max_tokens", alias = "maxTokens")]
176 pub max_tokens: i32,
177
178 #[serde(default = "default_temperature")]
180 pub temperature: f64,
181
182 #[serde(default = "default_max_tool_iterations", alias = "maxToolIterations")]
184 pub max_tool_iterations: i32,
185
186 #[serde(default = "default_memory_window", alias = "memoryWindow")]
188 pub memory_window: i32,
189}
190
191fn default_workspace() -> String {
192 "~/.nanobot/workspace".into()
193}
194fn default_model() -> String {
195 "deepseek/deepseek-chat".into()
196}
197fn default_max_tokens() -> i32 {
198 8192
199}
200fn default_temperature() -> f64 {
201 0.7
202}
203fn default_max_tool_iterations() -> i32 {
204 20
205}
206fn default_memory_window() -> i32 {
207 50
208}
209
210impl Default for AgentDefaults {
211 fn default() -> Self {
212 Self {
213 workspace: default_workspace(),
214 model: default_model(),
215 max_tokens: default_max_tokens(),
216 temperature: default_temperature(),
217 max_tool_iterations: default_max_tool_iterations(),
218 memory_window: default_memory_window(),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, Default)]
227pub struct ProviderConfig {
228 #[serde(default, alias = "apiKey")]
230 pub api_key: SecretString,
231
232 #[serde(default, alias = "apiBase", alias = "baseUrl")]
234 pub api_base: Option<String>,
235
236 #[serde(default, alias = "extraHeaders")]
238 pub extra_headers: Option<HashMap<String, String>>,
239
240 #[serde(default, alias = "browserDirect")]
242 pub browser_direct: bool,
243
244 #[serde(default, alias = "corsProxy")]
246 pub cors_proxy: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
251pub struct ProvidersConfig {
252 #[serde(default)]
254 pub custom: ProviderConfig,
255
256 #[serde(default)]
258 pub anthropic: ProviderConfig,
259
260 #[serde(default)]
262 pub openai: ProviderConfig,
263
264 #[serde(default)]
266 pub openrouter: ProviderConfig,
267
268 #[serde(default)]
270 pub deepseek: ProviderConfig,
271
272 #[serde(default)]
274 pub groq: ProviderConfig,
275
276 #[serde(default)]
278 pub zhipu: ProviderConfig,
279
280 #[serde(default)]
282 pub dashscope: ProviderConfig,
283
284 #[serde(default)]
286 pub vllm: ProviderConfig,
287
288 #[serde(default)]
290 pub gemini: ProviderConfig,
291
292 #[serde(default)]
294 pub moonshot: ProviderConfig,
295
296 #[serde(default)]
298 pub minimax: ProviderConfig,
299
300 #[serde(default)]
302 pub aihubmix: ProviderConfig,
303
304 #[serde(default)]
306 pub openai_codex: ProviderConfig,
307
308 #[serde(default)]
310 pub xai: ProviderConfig,
311
312 #[serde(default)]
314 pub elevenlabs: ProviderConfig,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct GatewayConfig {
322 #[serde(default = "default_gateway_host")]
324 pub host: String,
325
326 #[serde(default = "default_gateway_port")]
328 pub port: u16,
329
330 #[serde(default, alias = "heartbeatIntervalMinutes")]
332 pub heartbeat_interval_minutes: u64,
333
334 #[serde(default = "default_heartbeat_prompt", alias = "heartbeatPrompt")]
336 pub heartbeat_prompt: String,
337
338 #[serde(default = "default_api_port", alias = "apiPort")]
340 pub api_port: u16,
341
342 #[serde(default = "default_cors_origins", alias = "corsOrigins")]
344 pub cors_origins: Vec<String>,
345
346 #[serde(default, alias = "apiEnabled")]
348 pub api_enabled: bool,
349}
350
351fn default_gateway_host() -> String {
352 "0.0.0.0".into()
353}
354fn default_gateway_port() -> u16 {
355 18790
356}
357fn default_heartbeat_prompt() -> String {
358 "heartbeat".into()
359}
360fn default_api_port() -> u16 {
361 18789
362}
363fn default_cors_origins() -> Vec<String> {
364 vec!["http://localhost:5173".into()]
365}
366
367impl Default for GatewayConfig {
368 fn default() -> Self {
369 Self {
370 host: default_gateway_host(),
371 port: default_gateway_port(),
372 heartbeat_interval_minutes: 0,
373 heartbeat_prompt: default_heartbeat_prompt(),
374 api_port: default_api_port(),
375 cors_origins: default_cors_origins(),
376 api_enabled: false,
377 }
378 }
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, Default)]
385pub struct ToolsConfig {
386 #[serde(default)]
388 pub web: WebToolsConfig,
389
390 #[serde(default, rename = "exec")]
392 pub exec_tool: ExecToolConfig,
393
394 #[serde(default, alias = "restrictToWorkspace")]
396 pub restrict_to_workspace: bool,
397
398 #[serde(default, alias = "mcpServers")]
400 pub mcp_servers: HashMap<String, MCPServerConfig>,
401
402 #[serde(default, alias = "commandPolicy")]
404 pub command_policy: CommandPolicyConfig,
405
406 #[serde(default, alias = "urlPolicy")]
408 pub url_policy: UrlPolicyConfig,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, Default)]
413pub struct WebToolsConfig {
414 #[serde(default)]
416 pub search: WebSearchConfig,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct WebSearchConfig {
422 #[serde(default, alias = "apiKey")]
424 pub api_key: SecretString,
425
426 #[serde(default = "default_max_results", alias = "maxResults")]
428 pub max_results: u32,
429}
430
431fn default_max_results() -> u32 {
432 5
433}
434
435impl Default for WebSearchConfig {
436 fn default() -> Self {
437 Self {
438 api_key: SecretString::default(),
439 max_results: default_max_results(),
440 }
441 }
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct ExecToolConfig {
447 #[serde(default = "default_exec_timeout")]
449 pub timeout: u32,
450}
451
452fn default_exec_timeout() -> u32 {
453 60
454}
455
456impl Default for ExecToolConfig {
457 fn default() -> Self {
458 Self {
459 timeout: default_exec_timeout(),
460 }
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct MCPServerConfig {
467 #[serde(default)]
469 pub command: String,
470
471 #[serde(default)]
473 pub args: Vec<String>,
474
475 #[serde(default)]
477 pub env: HashMap<String, String>,
478
479 #[serde(default)]
481 pub url: String,
482
483 #[serde(default = "default_true", alias = "internalOnly")]
486 pub internal_only: bool,
487}
488
489impl Default for MCPServerConfig {
490 fn default() -> Self {
491 Self {
492 command: String::new(),
493 args: Vec::new(),
494 env: HashMap::new(),
495 url: String::new(),
496 internal_only: true,
497 }
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 const FIXTURE_PATH: &str = concat!(
507 env!("CARGO_MANIFEST_DIR"),
508 "/../../tests/fixtures/config.json"
509 );
510
511 fn load_fixture() -> Config {
512 let content =
513 std::fs::read_to_string(FIXTURE_PATH).expect("fixture config.json should exist");
514 serde_json::from_str(&content).expect("fixture should deserialize")
515 }
516
517 #[test]
518 fn deserialize_fixture() {
519 let cfg = load_fixture();
520
521 assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
523 assert_eq!(cfg.agents.defaults.max_tokens, 8192);
524 assert_eq!(cfg.agents.defaults.temperature, 0.7);
525 assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
526 assert_eq!(cfg.agents.defaults.memory_window, 50);
527
528 assert!(cfg.channels.telegram.enabled);
530 assert_eq!(cfg.channels.telegram.token.expose(), "test-bot-token-123");
531 assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]);
532 assert!(!cfg.channels.slack.enabled);
533 assert!(!cfg.channels.discord.enabled);
534
535 assert_eq!(cfg.providers.anthropic.api_key.expose(), "sk-ant-test-key");
537 assert_eq!(cfg.providers.openrouter.api_key.expose(), "sk-or-test-key");
538 assert_eq!(
539 cfg.providers.openrouter.api_base.as_deref(),
540 Some("https://openrouter.ai/api/v1")
541 );
542 assert!(cfg.providers.deepseek.api_key.is_empty());
543
544 assert_eq!(cfg.gateway.host, "0.0.0.0");
546 assert_eq!(cfg.gateway.port, 18790);
547
548 assert_eq!(cfg.tools.web.search.max_results, 5);
550 assert_eq!(cfg.tools.exec_tool.timeout, 60);
551 assert!(!cfg.tools.restrict_to_workspace);
552 assert!(cfg.tools.mcp_servers.contains_key("test-server"));
553 let mcp = &cfg.tools.mcp_servers["test-server"];
554 assert_eq!(mcp.command, "npx");
555 assert_eq!(mcp.args, vec!["-y", "test-mcp-server"]);
556 }
557
558 #[test]
559 fn camel_case_aliases() {
560 let cfg = load_fixture();
564 assert_eq!(cfg.agents.defaults.max_tokens, 8192); assert_eq!(cfg.agents.defaults.max_tool_iterations, 20); assert_eq!(cfg.agents.defaults.memory_window, 50); assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]); }
569
570 #[test]
571 fn default_values_for_missing_fields() {
572 let json = r#"{}"#;
573 let cfg: Config = serde_json::from_str(json).unwrap();
574
575 assert_eq!(cfg.agents.defaults.workspace, "~/.nanobot/workspace");
577 assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
578 assert_eq!(cfg.agents.defaults.max_tokens, 8192);
579 assert!((cfg.agents.defaults.temperature - 0.7).abs() < f64::EPSILON);
580 assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
581 assert_eq!(cfg.agents.defaults.memory_window, 50);
582
583 assert!(!cfg.channels.telegram.enabled);
585 assert!(cfg.channels.telegram.token.is_empty());
586 assert!(!cfg.channels.slack.enabled);
587 assert_eq!(cfg.channels.slack.mode, "socket");
588 assert!(!cfg.channels.discord.enabled);
589 assert_eq!(cfg.channels.discord.intents, 37377);
590
591 assert_eq!(cfg.gateway.host, "0.0.0.0");
593 assert_eq!(cfg.gateway.port, 18790);
594
595 assert_eq!(cfg.tools.exec_tool.timeout, 60);
597 assert_eq!(cfg.tools.web.search.max_results, 5);
598 }
599
600 #[test]
601 fn serde_roundtrip() {
602 let cfg = load_fixture();
603 let json = serde_json::to_string(&cfg).unwrap();
604 let restored: Config = serde_json::from_str(&json).unwrap();
605 assert_eq!(restored.agents.defaults.model, cfg.agents.defaults.model);
606 assert_eq!(restored.gateway.port, cfg.gateway.port);
607 assert!(restored.providers.anthropic.api_key.is_empty());
610 }
611
612 #[test]
613 fn unknown_fields_ignored() {
614 let json = r#"{
615 "agents": { "defaults": { "model": "test" } },
616 "unknown_top_level": true,
617 "channels": {
618 "telegram": { "enabled": false, "some_future_field": 42 }
619 },
620 "providers": {
621 "anthropic": { "apiKey": "k", "newField": "x" }
622 }
623 }"#;
624 let cfg: Config = serde_json::from_str(json).unwrap();
625 assert_eq!(cfg.agents.defaults.model, "test");
626 assert!(!cfg.channels.telegram.enabled);
627 assert_eq!(cfg.providers.anthropic.api_key.expose(), "k");
628 }
629
630 #[test]
631 fn unknown_channel_plugins_in_extra() {
632 let json = r#"{
633 "channels": {
634 "telegram": { "enabled": true },
635 "my_custom_channel": { "url": "wss://custom.io" }
636 }
637 }"#;
638 let cfg: Config = serde_json::from_str(json).unwrap();
639 assert!(cfg.channels.telegram.enabled);
640 assert!(cfg.channels.extra.contains_key("my_custom_channel"));
641 }
642
643 #[test]
644 fn workspace_path_expansion() {
645 let mut cfg = Config::default();
646 cfg.agents.defaults.workspace = "~/.clawft/workspace".into();
647 let path = cfg.workspace_path();
648 assert!(!path.to_string_lossy().starts_with('~'));
650 }
651
652 #[test]
653 fn provider_config_with_extra_headers() {
654 let json = r#"{
655 "apiKey": "test",
656 "extraHeaders": { "X-Custom": "value" }
657 }"#;
658 let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
659 assert_eq!(cfg.api_key.expose(), "test");
660 let headers = cfg.extra_headers.unwrap();
661 assert_eq!(headers["X-Custom"], "value");
662 }
663
664 #[test]
665 fn email_config_defaults() {
666 let cfg = EmailConfig::default();
667 assert_eq!(cfg.imap_port, 993);
668 assert!(cfg.imap_use_ssl);
669 assert_eq!(cfg.smtp_port, 587);
670 assert!(cfg.smtp_use_tls);
671 assert!(!cfg.smtp_use_ssl);
672 assert!(cfg.auto_reply_enabled);
673 assert_eq!(cfg.poll_interval_seconds, 30);
674 assert!(cfg.mark_seen);
675 assert_eq!(cfg.max_body_chars, 12000);
676 assert_eq!(cfg.subject_prefix, "Re: ");
677 }
678
679 #[test]
680 fn mochat_config_defaults() {
681 let cfg = MochatConfig::default();
682 assert_eq!(cfg.base_url, "https://mochat.io");
683 assert_eq!(cfg.socket_path, "/socket.io");
684 assert_eq!(cfg.socket_reconnect_delay_ms, 1000);
685 assert_eq!(cfg.socket_max_reconnect_delay_ms, 10000);
686 assert_eq!(cfg.socket_connect_timeout_ms, 10000);
687 assert_eq!(cfg.refresh_interval_ms, 30000);
688 assert_eq!(cfg.watch_timeout_ms, 25000);
689 assert_eq!(cfg.watch_limit, 100);
690 assert_eq!(cfg.retry_delay_ms, 500);
691 assert_eq!(cfg.max_retry_attempts, 0);
692 assert_eq!(cfg.reply_delay_mode, "non-mention");
693 assert_eq!(cfg.reply_delay_ms, 120000);
694 }
695
696 #[test]
697 fn slack_dm_config_defaults() {
698 let cfg = SlackDMConfig::default();
699 assert!(cfg.enabled);
700 assert_eq!(cfg.policy, "open");
701 }
702
703 #[test]
704 fn gateway_heartbeat_defaults() {
705 let cfg = GatewayConfig::default();
706 assert_eq!(cfg.heartbeat_interval_minutes, 0);
707 assert_eq!(cfg.heartbeat_prompt, "heartbeat");
708 }
709
710 #[test]
711 fn gateway_heartbeat_from_json() {
712 let json = r#"{
713 "host": "0.0.0.0",
714 "port": 8080,
715 "heartbeatIntervalMinutes": 15,
716 "heartbeatPrompt": "status check"
717 }"#;
718 let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
719 assert_eq!(cfg.heartbeat_interval_minutes, 15);
720 assert_eq!(cfg.heartbeat_prompt, "status check");
721 }
722
723 #[test]
724 fn gateway_heartbeat_disabled_by_default() {
725 let json = r#"{"host": "0.0.0.0"}"#;
726 let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
727 assert_eq!(cfg.heartbeat_interval_minutes, 0);
728 assert_eq!(cfg.heartbeat_prompt, "heartbeat");
729 }
730
731 #[test]
732 fn mcp_server_config_roundtrip() {
733 let cfg = MCPServerConfig {
734 command: "npx".into(),
735 args: vec!["-y".into(), "test-server".into()],
736 env: {
737 let mut m = HashMap::new();
738 m.insert("API_KEY".into(), "secret".into());
739 m
740 },
741 url: String::new(),
742 internal_only: false,
743 };
744 let json = serde_json::to_string(&cfg).unwrap();
745 let restored: MCPServerConfig = serde_json::from_str(&json).unwrap();
746 assert_eq!(restored.command, "npx");
747 assert_eq!(restored.args.len(), 2);
748 assert_eq!(restored.env["API_KEY"], "secret");
749 assert!(!restored.internal_only);
750 }
751
752 #[test]
753 fn command_policy_config_defaults() {
754 let config: CommandPolicyConfig = serde_json::from_str("{}").unwrap();
755 assert_eq!(config.mode, "allowlist");
756 assert!(config.allowlist.is_empty());
757 assert!(config.denylist.is_empty());
758 }
759
760 #[test]
761 fn command_policy_config_custom() {
762 let json = r#"{"mode": "denylist", "allowlist": ["echo", "ls"], "denylist": ["rm -rf /"]}"#;
763 let config: CommandPolicyConfig = serde_json::from_str(json).unwrap();
764 assert_eq!(config.mode, "denylist");
765 assert_eq!(config.allowlist, vec!["echo", "ls"]);
766 assert_eq!(config.denylist, vec!["rm -rf /"]);
767 }
768
769 #[test]
770 fn url_policy_config_defaults() {
771 let config: UrlPolicyConfig = serde_json::from_str("{}").unwrap();
772 assert!(config.enabled);
773 assert!(!config.allow_private);
774 assert!(config.allowed_domains.is_empty());
775 assert!(config.blocked_domains.is_empty());
776 }
777
778 #[test]
779 fn url_policy_config_custom() {
780 let json = r#"{"enabled": false, "allowPrivate": true, "allowedDomains": ["internal.corp"], "blockedDomains": ["evil.com"]}"#;
781 let config: UrlPolicyConfig = serde_json::from_str(json).unwrap();
782 assert!(!config.enabled);
783 assert!(config.allow_private);
784 assert_eq!(config.allowed_domains, vec!["internal.corp"]);
785 assert_eq!(config.blocked_domains, vec!["evil.com"]);
786 }
787
788 #[test]
789 fn tools_config_includes_policies() {
790 let json = r#"{"commandPolicy": {"mode": "denylist"}, "urlPolicy": {"enabled": false}}"#;
791 let config: ToolsConfig = serde_json::from_str(json).unwrap();
792 assert_eq!(config.command_policy.mode, "denylist");
793 assert!(!config.url_policy.enabled);
794 }
795
796 #[test]
797 fn tools_config_policies_default_when_absent() {
798 let config: ToolsConfig = serde_json::from_str("{}").unwrap();
799 assert_eq!(config.command_policy.mode, "allowlist");
800 assert!(config.url_policy.enabled);
801 }
802
803 #[test]
806 fn voice_config_defaults() {
807 let cfg: VoiceConfig = serde_json::from_str("{}").unwrap();
808 assert!(!cfg.enabled);
809 assert_eq!(cfg.audio.sample_rate, 16000);
810 assert_eq!(cfg.audio.chunk_size, 512);
811 assert_eq!(cfg.audio.channels, 1);
812 assert!(cfg.audio.input_device.is_none());
813 assert!(cfg.audio.output_device.is_none());
814 assert!(cfg.stt.enabled);
815 assert_eq!(cfg.stt.model, "sherpa-onnx-streaming-zipformer-en-20M");
816 assert!(cfg.stt.language.is_empty());
817 assert!(cfg.tts.enabled);
818 assert_eq!(cfg.tts.model, "vits-piper-en_US-amy-medium");
819 assert!(cfg.tts.voice.is_empty());
820 assert!((cfg.tts.speed - 1.0).abs() < f32::EPSILON);
821 assert!((cfg.vad.threshold - 0.5).abs() < f32::EPSILON);
822 assert_eq!(cfg.vad.silence_timeout_ms, 1500);
823 assert_eq!(cfg.vad.min_speech_ms, 250);
824 assert!(!cfg.wake.enabled);
825 assert_eq!(cfg.wake.phrase, "hey weft");
826 assert!((cfg.wake.sensitivity - 0.5).abs() < f32::EPSILON);
827 assert!(cfg.wake.model_path.is_none());
828 assert!(!cfg.cloud_fallback.enabled);
829 assert!(cfg.cloud_fallback.stt_provider.is_empty());
830 assert!(cfg.cloud_fallback.tts_provider.is_empty());
831 }
832
833 #[test]
834 fn gateway_api_fields_defaults() {
835 let cfg = GatewayConfig::default();
836 assert_eq!(cfg.api_port, 18789);
837 assert_eq!(cfg.cors_origins, vec!["http://localhost:5173"]);
838 assert!(!cfg.api_enabled);
839 }
840
841 #[test]
842 fn provider_browser_fields_defaults() {
843 let cfg: ProviderConfig = serde_json::from_str("{}").unwrap();
844 assert!(!cfg.browser_direct);
845 assert!(cfg.cors_proxy.is_none());
846 }
847
848 #[test]
849 fn provider_base_url_alias() {
850 let json = r#"{"baseUrl": "https://example.com"}"#;
851 let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
852 assert_eq!(cfg.api_base.as_deref(), Some("https://example.com"));
853 }
854
855 #[test]
856 fn config_with_voice_section() {
857 let json = r#"{"voice": {"enabled": true}}"#;
858 let cfg: Config = serde_json::from_str(json).unwrap();
859 assert!(cfg.voice.enabled);
860 assert_eq!(cfg.voice.audio.sample_rate, 16000);
862 assert!(cfg.voice.stt.enabled);
863 }
864
865 #[test]
866 fn config_with_all_new_fields() {
867 let json = r#"{
868 "voice": {
869 "enabled": true,
870 "audio": { "sampleRate": 48000, "chunkSize": 1024, "channels": 2 },
871 "stt": { "model": "custom-stt", "language": "zh" },
872 "tts": { "model": "custom-tts", "voice": "alloy", "speed": 1.5 },
873 "vad": { "threshold": 0.8, "silenceTimeoutMs": 2000, "minSpeechMs": 500 },
874 "wake": { "enabled": true, "phrase": "ok clawft", "sensitivity": 0.7 },
875 "cloudFallback": { "enabled": true, "sttProvider": "whisper", "ttsProvider": "elevenlabs" }
876 },
877 "gateway": {
878 "host": "127.0.0.1",
879 "port": 9000,
880 "apiPort": 9001,
881 "corsOrigins": ["http://localhost:3000", "https://app.example.com"],
882 "apiEnabled": true
883 },
884 "providers": {
885 "openai": {
886 "apiKey": "sk-test",
887 "baseUrl": "https://api.openai.com/v1",
888 "browserDirect": true,
889 "corsProxy": "https://proxy.example.com"
890 }
891 }
892 }"#;
893 let cfg: Config = serde_json::from_str(json).unwrap();
894
895 assert!(cfg.voice.enabled);
897 assert_eq!(cfg.voice.audio.sample_rate, 48000);
898 assert_eq!(cfg.voice.audio.chunk_size, 1024);
899 assert_eq!(cfg.voice.audio.channels, 2);
900 assert_eq!(cfg.voice.stt.model, "custom-stt");
901 assert_eq!(cfg.voice.stt.language, "zh");
902 assert_eq!(cfg.voice.tts.model, "custom-tts");
903 assert_eq!(cfg.voice.tts.voice, "alloy");
904 assert!((cfg.voice.tts.speed - 1.5).abs() < f32::EPSILON);
905 assert!((cfg.voice.vad.threshold - 0.8).abs() < f32::EPSILON);
906 assert_eq!(cfg.voice.vad.silence_timeout_ms, 2000);
907 assert_eq!(cfg.voice.vad.min_speech_ms, 500);
908 assert!(cfg.voice.wake.enabled);
909 assert_eq!(cfg.voice.wake.phrase, "ok clawft");
910 assert!((cfg.voice.wake.sensitivity - 0.7).abs() < f32::EPSILON);
911 assert!(cfg.voice.cloud_fallback.enabled);
912 assert_eq!(cfg.voice.cloud_fallback.stt_provider, "whisper");
913 assert_eq!(cfg.voice.cloud_fallback.tts_provider, "elevenlabs");
914
915 assert_eq!(cfg.gateway.api_port, 9001);
917 assert_eq!(
918 cfg.gateway.cors_origins,
919 vec!["http://localhost:3000", "https://app.example.com"]
920 );
921 assert!(cfg.gateway.api_enabled);
922
923 assert!(cfg.providers.openai.browser_direct);
925 assert_eq!(
926 cfg.providers.openai.cors_proxy.as_deref(),
927 Some("https://proxy.example.com")
928 );
929 assert_eq!(
930 cfg.providers.openai.api_base.as_deref(),
931 Some("https://api.openai.com/v1")
932 );
933 }
934}