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
83impl Config {
84 pub fn workspace_path(&self) -> PathBuf {
90 let raw = &self.agents.defaults.workspace;
91 #[cfg(feature = "native")]
92 if let Some(rest) = raw.strip_prefix("~/")
93 && let Some(home) = dirs::home_dir()
94 {
95 return home.join(rest);
96 }
97 PathBuf::from(raw)
98 }
99
100 pub fn workspace_path_with_home(&self, home: Option<&std::path::Path>) -> PathBuf {
105 let raw = &self.agents.defaults.workspace;
106 if let Some(rest) = raw.strip_prefix("~/")
107 && let Some(home) = home
108 {
109 return home.join(rest);
110 }
111 PathBuf::from(raw)
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
119pub struct AgentsConfig {
120 #[serde(default)]
122 pub defaults: AgentDefaults,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct AgentDefaults {
128 #[serde(default = "default_workspace")]
130 pub workspace: String,
131
132 #[serde(default = "default_model")]
134 pub model: String,
135
136 #[serde(default = "default_max_tokens", alias = "maxTokens")]
138 pub max_tokens: i32,
139
140 #[serde(default = "default_temperature")]
142 pub temperature: f64,
143
144 #[serde(default = "default_max_tool_iterations", alias = "maxToolIterations")]
146 pub max_tool_iterations: i32,
147
148 #[serde(default = "default_memory_window", alias = "memoryWindow")]
150 pub memory_window: i32,
151}
152
153fn default_workspace() -> String {
154 "~/.nanobot/workspace".into()
155}
156fn default_model() -> String {
157 "deepseek/deepseek-chat".into()
158}
159fn default_max_tokens() -> i32 {
160 8192
161}
162fn default_temperature() -> f64 {
163 0.7
164}
165fn default_max_tool_iterations() -> i32 {
166 20
167}
168fn default_memory_window() -> i32 {
169 50
170}
171
172impl Default for AgentDefaults {
173 fn default() -> Self {
174 Self {
175 workspace: default_workspace(),
176 model: default_model(),
177 max_tokens: default_max_tokens(),
178 temperature: default_temperature(),
179 max_tool_iterations: default_max_tool_iterations(),
180 memory_window: default_memory_window(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189pub struct ProviderConfig {
190 #[serde(default, alias = "apiKey")]
192 pub api_key: SecretString,
193
194 #[serde(default, alias = "apiBase", alias = "baseUrl")]
196 pub api_base: Option<String>,
197
198 #[serde(default, alias = "extraHeaders")]
200 pub extra_headers: Option<HashMap<String, String>>,
201
202 #[serde(default, alias = "browserDirect")]
204 pub browser_direct: bool,
205
206 #[serde(default, alias = "corsProxy")]
208 pub cors_proxy: Option<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, Default)]
213pub struct ProvidersConfig {
214 #[serde(default)]
216 pub custom: ProviderConfig,
217
218 #[serde(default)]
220 pub anthropic: ProviderConfig,
221
222 #[serde(default)]
224 pub openai: ProviderConfig,
225
226 #[serde(default)]
228 pub openrouter: ProviderConfig,
229
230 #[serde(default)]
232 pub deepseek: ProviderConfig,
233
234 #[serde(default)]
236 pub groq: ProviderConfig,
237
238 #[serde(default)]
240 pub zhipu: ProviderConfig,
241
242 #[serde(default)]
244 pub dashscope: ProviderConfig,
245
246 #[serde(default)]
248 pub vllm: ProviderConfig,
249
250 #[serde(default)]
252 pub gemini: ProviderConfig,
253
254 #[serde(default)]
256 pub moonshot: ProviderConfig,
257
258 #[serde(default)]
260 pub minimax: ProviderConfig,
261
262 #[serde(default)]
264 pub aihubmix: ProviderConfig,
265
266 #[serde(default)]
268 pub openai_codex: ProviderConfig,
269
270 #[serde(default)]
272 pub xai: ProviderConfig,
273
274 #[serde(default)]
276 pub elevenlabs: ProviderConfig,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct GatewayConfig {
284 #[serde(default = "default_gateway_host")]
286 pub host: String,
287
288 #[serde(default = "default_gateway_port")]
290 pub port: u16,
291
292 #[serde(default, alias = "heartbeatIntervalMinutes")]
294 pub heartbeat_interval_minutes: u64,
295
296 #[serde(default = "default_heartbeat_prompt", alias = "heartbeatPrompt")]
298 pub heartbeat_prompt: String,
299
300 #[serde(default = "default_api_port", alias = "apiPort")]
302 pub api_port: u16,
303
304 #[serde(default = "default_cors_origins", alias = "corsOrigins")]
306 pub cors_origins: Vec<String>,
307
308 #[serde(default, alias = "apiEnabled")]
310 pub api_enabled: bool,
311}
312
313fn default_gateway_host() -> String {
314 "0.0.0.0".into()
315}
316fn default_gateway_port() -> u16 {
317 18790
318}
319fn default_heartbeat_prompt() -> String {
320 "heartbeat".into()
321}
322fn default_api_port() -> u16 {
323 18789
324}
325fn default_cors_origins() -> Vec<String> {
326 vec!["http://localhost:5173".into()]
327}
328
329impl Default for GatewayConfig {
330 fn default() -> Self {
331 Self {
332 host: default_gateway_host(),
333 port: default_gateway_port(),
334 heartbeat_interval_minutes: 0,
335 heartbeat_prompt: default_heartbeat_prompt(),
336 api_port: default_api_port(),
337 cors_origins: default_cors_origins(),
338 api_enabled: false,
339 }
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, Default)]
347pub struct ToolsConfig {
348 #[serde(default)]
350 pub web: WebToolsConfig,
351
352 #[serde(default, rename = "exec")]
354 pub exec_tool: ExecToolConfig,
355
356 #[serde(default, alias = "restrictToWorkspace")]
358 pub restrict_to_workspace: bool,
359
360 #[serde(default, alias = "mcpServers")]
362 pub mcp_servers: HashMap<String, MCPServerConfig>,
363
364 #[serde(default, alias = "commandPolicy")]
366 pub command_policy: CommandPolicyConfig,
367
368 #[serde(default, alias = "urlPolicy")]
370 pub url_policy: UrlPolicyConfig,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, Default)]
375pub struct WebToolsConfig {
376 #[serde(default)]
378 pub search: WebSearchConfig,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct WebSearchConfig {
384 #[serde(default, alias = "apiKey")]
386 pub api_key: SecretString,
387
388 #[serde(default = "default_max_results", alias = "maxResults")]
390 pub max_results: u32,
391}
392
393fn default_max_results() -> u32 {
394 5
395}
396
397impl Default for WebSearchConfig {
398 fn default() -> Self {
399 Self {
400 api_key: SecretString::default(),
401 max_results: default_max_results(),
402 }
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct ExecToolConfig {
409 #[serde(default = "default_exec_timeout")]
411 pub timeout: u32,
412}
413
414fn default_exec_timeout() -> u32 {
415 60
416}
417
418impl Default for ExecToolConfig {
419 fn default() -> Self {
420 Self {
421 timeout: default_exec_timeout(),
422 }
423 }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct MCPServerConfig {
429 #[serde(default)]
431 pub command: String,
432
433 #[serde(default)]
435 pub args: Vec<String>,
436
437 #[serde(default)]
439 pub env: HashMap<String, String>,
440
441 #[serde(default)]
443 pub url: String,
444
445 #[serde(default = "default_true", alias = "internalOnly")]
448 pub internal_only: bool,
449}
450
451impl Default for MCPServerConfig {
452 fn default() -> Self {
453 Self {
454 command: String::new(),
455 args: Vec::new(),
456 env: HashMap::new(),
457 url: String::new(),
458 internal_only: true,
459 }
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 const FIXTURE_PATH: &str = concat!(
469 env!("CARGO_MANIFEST_DIR"),
470 "/../../tests/fixtures/config.json"
471 );
472
473 fn load_fixture() -> Config {
474 let content =
475 std::fs::read_to_string(FIXTURE_PATH).expect("fixture config.json should exist");
476 serde_json::from_str(&content).expect("fixture should deserialize")
477 }
478
479 #[test]
480 fn deserialize_fixture() {
481 let cfg = load_fixture();
482
483 assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
485 assert_eq!(cfg.agents.defaults.max_tokens, 8192);
486 assert_eq!(cfg.agents.defaults.temperature, 0.7);
487 assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
488 assert_eq!(cfg.agents.defaults.memory_window, 50);
489
490 assert!(cfg.channels.telegram.enabled);
492 assert_eq!(cfg.channels.telegram.token.expose(), "test-bot-token-123");
493 assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]);
494 assert!(!cfg.channels.slack.enabled);
495 assert!(!cfg.channels.discord.enabled);
496
497 assert_eq!(cfg.providers.anthropic.api_key.expose(), "sk-ant-test-key");
499 assert_eq!(cfg.providers.openrouter.api_key.expose(), "sk-or-test-key");
500 assert_eq!(
501 cfg.providers.openrouter.api_base.as_deref(),
502 Some("https://openrouter.ai/api/v1")
503 );
504 assert!(cfg.providers.deepseek.api_key.is_empty());
505
506 assert_eq!(cfg.gateway.host, "0.0.0.0");
508 assert_eq!(cfg.gateway.port, 18790);
509
510 assert_eq!(cfg.tools.web.search.max_results, 5);
512 assert_eq!(cfg.tools.exec_tool.timeout, 60);
513 assert!(!cfg.tools.restrict_to_workspace);
514 assert!(cfg.tools.mcp_servers.contains_key("test-server"));
515 let mcp = &cfg.tools.mcp_servers["test-server"];
516 assert_eq!(mcp.command, "npx");
517 assert_eq!(mcp.args, vec!["-y", "test-mcp-server"]);
518 }
519
520 #[test]
521 fn camel_case_aliases() {
522 let cfg = load_fixture();
526 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"]); }
531
532 #[test]
533 fn default_values_for_missing_fields() {
534 let json = r#"{}"#;
535 let cfg: Config = serde_json::from_str(json).unwrap();
536
537 assert_eq!(cfg.agents.defaults.workspace, "~/.nanobot/workspace");
539 assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
540 assert_eq!(cfg.agents.defaults.max_tokens, 8192);
541 assert!((cfg.agents.defaults.temperature - 0.7).abs() < f64::EPSILON);
542 assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
543 assert_eq!(cfg.agents.defaults.memory_window, 50);
544
545 assert!(!cfg.channels.telegram.enabled);
547 assert!(cfg.channels.telegram.token.is_empty());
548 assert!(!cfg.channels.slack.enabled);
549 assert_eq!(cfg.channels.slack.mode, "socket");
550 assert!(!cfg.channels.discord.enabled);
551 assert_eq!(cfg.channels.discord.intents, 37377);
552
553 assert_eq!(cfg.gateway.host, "0.0.0.0");
555 assert_eq!(cfg.gateway.port, 18790);
556
557 assert_eq!(cfg.tools.exec_tool.timeout, 60);
559 assert_eq!(cfg.tools.web.search.max_results, 5);
560 }
561
562 #[test]
563 fn serde_roundtrip() {
564 let cfg = load_fixture();
565 let json = serde_json::to_string(&cfg).unwrap();
566 let restored: Config = serde_json::from_str(&json).unwrap();
567 assert_eq!(restored.agents.defaults.model, cfg.agents.defaults.model);
568 assert_eq!(restored.gateway.port, cfg.gateway.port);
569 assert!(restored.providers.anthropic.api_key.is_empty());
572 }
573
574 #[test]
575 fn unknown_fields_ignored() {
576 let json = r#"{
577 "agents": { "defaults": { "model": "test" } },
578 "unknown_top_level": true,
579 "channels": {
580 "telegram": { "enabled": false, "some_future_field": 42 }
581 },
582 "providers": {
583 "anthropic": { "apiKey": "k", "newField": "x" }
584 }
585 }"#;
586 let cfg: Config = serde_json::from_str(json).unwrap();
587 assert_eq!(cfg.agents.defaults.model, "test");
588 assert!(!cfg.channels.telegram.enabled);
589 assert_eq!(cfg.providers.anthropic.api_key.expose(), "k");
590 }
591
592 #[test]
593 fn unknown_channel_plugins_in_extra() {
594 let json = r#"{
595 "channels": {
596 "telegram": { "enabled": true },
597 "my_custom_channel": { "url": "wss://custom.io" }
598 }
599 }"#;
600 let cfg: Config = serde_json::from_str(json).unwrap();
601 assert!(cfg.channels.telegram.enabled);
602 assert!(cfg.channels.extra.contains_key("my_custom_channel"));
603 }
604
605 #[test]
606 fn workspace_path_expansion() {
607 let mut cfg = Config::default();
608 cfg.agents.defaults.workspace = "~/.clawft/workspace".into();
609 let path = cfg.workspace_path();
610 assert!(!path.to_string_lossy().starts_with('~'));
612 }
613
614 #[test]
615 fn provider_config_with_extra_headers() {
616 let json = r#"{
617 "apiKey": "test",
618 "extraHeaders": { "X-Custom": "value" }
619 }"#;
620 let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
621 assert_eq!(cfg.api_key.expose(), "test");
622 let headers = cfg.extra_headers.unwrap();
623 assert_eq!(headers["X-Custom"], "value");
624 }
625
626 #[test]
627 fn email_config_defaults() {
628 let cfg = EmailConfig::default();
629 assert_eq!(cfg.imap_port, 993);
630 assert!(cfg.imap_use_ssl);
631 assert_eq!(cfg.smtp_port, 587);
632 assert!(cfg.smtp_use_tls);
633 assert!(!cfg.smtp_use_ssl);
634 assert!(cfg.auto_reply_enabled);
635 assert_eq!(cfg.poll_interval_seconds, 30);
636 assert!(cfg.mark_seen);
637 assert_eq!(cfg.max_body_chars, 12000);
638 assert_eq!(cfg.subject_prefix, "Re: ");
639 }
640
641 #[test]
642 fn mochat_config_defaults() {
643 let cfg = MochatConfig::default();
644 assert_eq!(cfg.base_url, "https://mochat.io");
645 assert_eq!(cfg.socket_path, "/socket.io");
646 assert_eq!(cfg.socket_reconnect_delay_ms, 1000);
647 assert_eq!(cfg.socket_max_reconnect_delay_ms, 10000);
648 assert_eq!(cfg.socket_connect_timeout_ms, 10000);
649 assert_eq!(cfg.refresh_interval_ms, 30000);
650 assert_eq!(cfg.watch_timeout_ms, 25000);
651 assert_eq!(cfg.watch_limit, 100);
652 assert_eq!(cfg.retry_delay_ms, 500);
653 assert_eq!(cfg.max_retry_attempts, 0);
654 assert_eq!(cfg.reply_delay_mode, "non-mention");
655 assert_eq!(cfg.reply_delay_ms, 120000);
656 }
657
658 #[test]
659 fn slack_dm_config_defaults() {
660 let cfg = SlackDMConfig::default();
661 assert!(cfg.enabled);
662 assert_eq!(cfg.policy, "open");
663 }
664
665 #[test]
666 fn gateway_heartbeat_defaults() {
667 let cfg = GatewayConfig::default();
668 assert_eq!(cfg.heartbeat_interval_minutes, 0);
669 assert_eq!(cfg.heartbeat_prompt, "heartbeat");
670 }
671
672 #[test]
673 fn gateway_heartbeat_from_json() {
674 let json = r#"{
675 "host": "0.0.0.0",
676 "port": 8080,
677 "heartbeatIntervalMinutes": 15,
678 "heartbeatPrompt": "status check"
679 }"#;
680 let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
681 assert_eq!(cfg.heartbeat_interval_minutes, 15);
682 assert_eq!(cfg.heartbeat_prompt, "status check");
683 }
684
685 #[test]
686 fn gateway_heartbeat_disabled_by_default() {
687 let json = r#"{"host": "0.0.0.0"}"#;
688 let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
689 assert_eq!(cfg.heartbeat_interval_minutes, 0);
690 assert_eq!(cfg.heartbeat_prompt, "heartbeat");
691 }
692
693 #[test]
694 fn mcp_server_config_roundtrip() {
695 let cfg = MCPServerConfig {
696 command: "npx".into(),
697 args: vec!["-y".into(), "test-server".into()],
698 env: {
699 let mut m = HashMap::new();
700 m.insert("API_KEY".into(), "secret".into());
701 m
702 },
703 url: String::new(),
704 internal_only: false,
705 };
706 let json = serde_json::to_string(&cfg).unwrap();
707 let restored: MCPServerConfig = serde_json::from_str(&json).unwrap();
708 assert_eq!(restored.command, "npx");
709 assert_eq!(restored.args.len(), 2);
710 assert_eq!(restored.env["API_KEY"], "secret");
711 assert!(!restored.internal_only);
712 }
713
714 #[test]
715 fn command_policy_config_defaults() {
716 let config: CommandPolicyConfig = serde_json::from_str("{}").unwrap();
717 assert_eq!(config.mode, "allowlist");
718 assert!(config.allowlist.is_empty());
719 assert!(config.denylist.is_empty());
720 }
721
722 #[test]
723 fn command_policy_config_custom() {
724 let json = r#"{"mode": "denylist", "allowlist": ["echo", "ls"], "denylist": ["rm -rf /"]}"#;
725 let config: CommandPolicyConfig = serde_json::from_str(json).unwrap();
726 assert_eq!(config.mode, "denylist");
727 assert_eq!(config.allowlist, vec!["echo", "ls"]);
728 assert_eq!(config.denylist, vec!["rm -rf /"]);
729 }
730
731 #[test]
732 fn url_policy_config_defaults() {
733 let config: UrlPolicyConfig = serde_json::from_str("{}").unwrap();
734 assert!(config.enabled);
735 assert!(!config.allow_private);
736 assert!(config.allowed_domains.is_empty());
737 assert!(config.blocked_domains.is_empty());
738 }
739
740 #[test]
741 fn url_policy_config_custom() {
742 let json = r#"{"enabled": false, "allowPrivate": true, "allowedDomains": ["internal.corp"], "blockedDomains": ["evil.com"]}"#;
743 let config: UrlPolicyConfig = serde_json::from_str(json).unwrap();
744 assert!(!config.enabled);
745 assert!(config.allow_private);
746 assert_eq!(config.allowed_domains, vec!["internal.corp"]);
747 assert_eq!(config.blocked_domains, vec!["evil.com"]);
748 }
749
750 #[test]
751 fn tools_config_includes_policies() {
752 let json = r#"{"commandPolicy": {"mode": "denylist"}, "urlPolicy": {"enabled": false}}"#;
753 let config: ToolsConfig = serde_json::from_str(json).unwrap();
754 assert_eq!(config.command_policy.mode, "denylist");
755 assert!(!config.url_policy.enabled);
756 }
757
758 #[test]
759 fn tools_config_policies_default_when_absent() {
760 let config: ToolsConfig = serde_json::from_str("{}").unwrap();
761 assert_eq!(config.command_policy.mode, "allowlist");
762 assert!(config.url_policy.enabled);
763 }
764
765 #[test]
768 fn voice_config_defaults() {
769 let cfg: VoiceConfig = serde_json::from_str("{}").unwrap();
770 assert!(!cfg.enabled);
771 assert_eq!(cfg.audio.sample_rate, 16000);
772 assert_eq!(cfg.audio.chunk_size, 512);
773 assert_eq!(cfg.audio.channels, 1);
774 assert!(cfg.audio.input_device.is_none());
775 assert!(cfg.audio.output_device.is_none());
776 assert!(cfg.stt.enabled);
777 assert_eq!(cfg.stt.model, "sherpa-onnx-streaming-zipformer-en-20M");
778 assert!(cfg.stt.language.is_empty());
779 assert!(cfg.tts.enabled);
780 assert_eq!(cfg.tts.model, "vits-piper-en_US-amy-medium");
781 assert!(cfg.tts.voice.is_empty());
782 assert!((cfg.tts.speed - 1.0).abs() < f32::EPSILON);
783 assert!((cfg.vad.threshold - 0.5).abs() < f32::EPSILON);
784 assert_eq!(cfg.vad.silence_timeout_ms, 1500);
785 assert_eq!(cfg.vad.min_speech_ms, 250);
786 assert!(!cfg.wake.enabled);
787 assert_eq!(cfg.wake.phrase, "hey weft");
788 assert!((cfg.wake.sensitivity - 0.5).abs() < f32::EPSILON);
789 assert!(cfg.wake.model_path.is_none());
790 assert!(!cfg.cloud_fallback.enabled);
791 assert!(cfg.cloud_fallback.stt_provider.is_empty());
792 assert!(cfg.cloud_fallback.tts_provider.is_empty());
793 }
794
795 #[test]
796 fn gateway_api_fields_defaults() {
797 let cfg = GatewayConfig::default();
798 assert_eq!(cfg.api_port, 18789);
799 assert_eq!(cfg.cors_origins, vec!["http://localhost:5173"]);
800 assert!(!cfg.api_enabled);
801 }
802
803 #[test]
804 fn provider_browser_fields_defaults() {
805 let cfg: ProviderConfig = serde_json::from_str("{}").unwrap();
806 assert!(!cfg.browser_direct);
807 assert!(cfg.cors_proxy.is_none());
808 }
809
810 #[test]
811 fn provider_base_url_alias() {
812 let json = r#"{"baseUrl": "https://example.com"}"#;
813 let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
814 assert_eq!(cfg.api_base.as_deref(), Some("https://example.com"));
815 }
816
817 #[test]
818 fn config_with_voice_section() {
819 let json = r#"{"voice": {"enabled": true}}"#;
820 let cfg: Config = serde_json::from_str(json).unwrap();
821 assert!(cfg.voice.enabled);
822 assert_eq!(cfg.voice.audio.sample_rate, 16000);
824 assert!(cfg.voice.stt.enabled);
825 }
826
827 #[test]
828 fn config_with_all_new_fields() {
829 let json = r#"{
830 "voice": {
831 "enabled": true,
832 "audio": { "sampleRate": 48000, "chunkSize": 1024, "channels": 2 },
833 "stt": { "model": "custom-stt", "language": "zh" },
834 "tts": { "model": "custom-tts", "voice": "alloy", "speed": 1.5 },
835 "vad": { "threshold": 0.8, "silenceTimeoutMs": 2000, "minSpeechMs": 500 },
836 "wake": { "enabled": true, "phrase": "ok clawft", "sensitivity": 0.7 },
837 "cloudFallback": { "enabled": true, "sttProvider": "whisper", "ttsProvider": "elevenlabs" }
838 },
839 "gateway": {
840 "host": "127.0.0.1",
841 "port": 9000,
842 "apiPort": 9001,
843 "corsOrigins": ["http://localhost:3000", "https://app.example.com"],
844 "apiEnabled": true
845 },
846 "providers": {
847 "openai": {
848 "apiKey": "sk-test",
849 "baseUrl": "https://api.openai.com/v1",
850 "browserDirect": true,
851 "corsProxy": "https://proxy.example.com"
852 }
853 }
854 }"#;
855 let cfg: Config = serde_json::from_str(json).unwrap();
856
857 assert!(cfg.voice.enabled);
859 assert_eq!(cfg.voice.audio.sample_rate, 48000);
860 assert_eq!(cfg.voice.audio.chunk_size, 1024);
861 assert_eq!(cfg.voice.audio.channels, 2);
862 assert_eq!(cfg.voice.stt.model, "custom-stt");
863 assert_eq!(cfg.voice.stt.language, "zh");
864 assert_eq!(cfg.voice.tts.model, "custom-tts");
865 assert_eq!(cfg.voice.tts.voice, "alloy");
866 assert!((cfg.voice.tts.speed - 1.5).abs() < f32::EPSILON);
867 assert!((cfg.voice.vad.threshold - 0.8).abs() < f32::EPSILON);
868 assert_eq!(cfg.voice.vad.silence_timeout_ms, 2000);
869 assert_eq!(cfg.voice.vad.min_speech_ms, 500);
870 assert!(cfg.voice.wake.enabled);
871 assert_eq!(cfg.voice.wake.phrase, "ok clawft");
872 assert!((cfg.voice.wake.sensitivity - 0.7).abs() < f32::EPSILON);
873 assert!(cfg.voice.cloud_fallback.enabled);
874 assert_eq!(cfg.voice.cloud_fallback.stt_provider, "whisper");
875 assert_eq!(cfg.voice.cloud_fallback.tts_provider, "elevenlabs");
876
877 assert_eq!(cfg.gateway.api_port, 9001);
879 assert_eq!(
880 cfg.gateway.cors_origins,
881 vec!["http://localhost:3000", "https://app.example.com"]
882 );
883 assert!(cfg.gateway.api_enabled);
884
885 assert!(cfg.providers.openai.browser_direct);
887 assert_eq!(
888 cfg.providers.openai.cors_proxy.as_deref(),
889 Some("https://proxy.example.com")
890 );
891 assert_eq!(
892 cfg.providers.openai.api_base.as_deref(),
893 Some("https://api.openai.com/v1")
894 );
895 }
896}