1use leviath_mcp::MCPServerConfig;
4use leviath_providers::ModelCapabilities;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ToolPolicy {
16 Allow,
18 #[default]
20 Ask,
21 Deny,
23}
24
25pub use leviath_core::config::TitleConfig;
29
30pub use leviath_core::config::{ObservabilityConfig, TelemetryExporterKind};
33
34#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum ScriptPermission {
44 Allow,
46 Deny,
48 #[default]
55 Inherit,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ScriptToolPermissions {
65 #[serde(default)]
67 pub http_get: ScriptPermission,
68 #[serde(default)]
70 pub http_post: ScriptPermission,
71 #[serde(default)]
73 pub shell: ScriptPermission,
74 #[serde(default)]
76 pub read_file: ScriptPermission,
77 #[serde(default)]
79 pub write_file: ScriptPermission,
80 #[serde(default)]
82 pub env_var: ScriptPermission,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Config {
88 pub default_provider: String,
90
91 pub providers: ProviderConfig,
93
94 pub agent_paths: Vec<PathBuf>,
96
97 pub openrouter_api_key: Option<String>,
99
100 pub ollama_base_url: Option<String>,
102
103 #[serde(default)]
105 pub mcp_servers: Vec<MCPServerConfig>,
106
107 pub default_model: Option<String>,
109
110 #[serde(default)]
113 pub model_capabilities: HashMap<String, ModelCapabilities>,
114
115 #[serde(default)]
122 pub model_providers: HashMap<String, ModelProviderConfig>,
123
124 #[serde(default)]
132 pub tool_permissions: HashMap<String, ToolPolicy>,
133
134 #[serde(default)]
149 pub agent_tool_permissions: HashMap<String, HashMap<String, ToolPolicy>>,
150
151 #[serde(default)]
156 pub title: TitleConfig,
157
158 pub request_timeout_secs: Option<u64>,
165
166 #[serde(default)]
178 pub rate_limits: HashMap<String, leviath_providers::RateLimitConfig>,
179
180 #[serde(default)]
187 pub taint_tracking: bool,
188
189 #[serde(default)]
191 pub limits: LimitsConfig,
192
193 #[serde(default = "default_true")]
203 pub batch_tool_hint: bool,
204
205 #[serde(default)]
207 pub webhook: WebhookConfig,
208
209 #[serde(default)]
215 pub observability: ObservabilityConfig,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub sandbox: Option<leviath_core::ToolSandboxConfig>,
223
224 #[serde(default)]
227 pub tool_script_permissions: ScriptToolPermissions,
228
229 #[serde(default)]
233 pub security: SecurityConfig,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241pub struct SecurityConfig {
242 #[serde(default = "default_true")]
253 pub allow_seed_commands: bool,
254
255 #[serde(default)]
271 pub allow_local_network: bool,
272
273 #[serde(default)]
293 pub allow_env_vars: Vec<String>,
294
295 #[serde(default)]
319 pub credential_store: leviath_core::CredentialStoreKind,
320}
321
322impl Default for SecurityConfig {
323 fn default() -> Self {
324 Self {
325 allow_seed_commands: true,
326 allow_local_network: false,
327 allow_env_vars: Vec::new(),
328 credential_store: leviath_core::CredentialStoreKind::File,
329 }
330 }
331}
332
333fn default_true() -> bool {
334 true
335}
336
337fn default_max_concurrent_inferences() -> Option<usize> {
338 Some(8)
339}
340
341fn default_default_max_iterations() -> Option<usize> {
342 Some(50)
343}
344
345fn default_max_concurrent_tools() -> usize {
346 8
347}
348
349fn default_script_shell_timeout_secs() -> u64 {
350 60
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct LimitsConfig {
360 #[serde(default = "default_max_concurrent_inferences")]
364 pub max_concurrent_inferences: Option<usize>,
365
366 #[serde(default = "default_max_concurrent_tools")]
371 pub max_concurrent_tools: usize,
372
373 #[serde(default = "default_default_max_iterations")]
377 pub default_max_iterations: Option<usize>,
378
379 #[serde(default)]
387 pub exact_token_counting: bool,
388
389 #[serde(default = "default_script_shell_timeout_secs")]
393 pub script_shell_timeout_secs: u64,
394}
395
396impl Default for LimitsConfig {
397 fn default() -> Self {
398 Self {
399 max_concurrent_inferences: default_max_concurrent_inferences(),
400 max_concurrent_tools: default_max_concurrent_tools(),
401 default_max_iterations: default_default_max_iterations(),
402 exact_token_counting: false,
403 script_shell_timeout_secs: default_script_shell_timeout_secs(),
404 }
405 }
406}
407
408fn default_webhook_max_retries() -> u32 {
409 3
410}
411
412fn default_webhook_base_delay_ms() -> u64 {
413 500
414}
415
416fn default_webhook_max_delay_ms() -> u64 {
417 30_000
418}
419
420fn default_webhook_timeout_secs() -> u64 {
421 10
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct WebhookConfig {
432 #[serde(default = "default_webhook_max_retries")]
435 pub max_retries: u32,
436
437 #[serde(default = "default_webhook_base_delay_ms")]
440 pub base_delay_ms: u64,
441
442 #[serde(default = "default_webhook_max_delay_ms")]
445 pub max_delay_ms: u64,
446
447 #[serde(default = "default_webhook_timeout_secs")]
449 pub timeout_secs: u64,
450}
451
452impl Default for WebhookConfig {
453 fn default() -> Self {
454 Self {
455 max_retries: default_webhook_max_retries(),
456 base_delay_ms: default_webhook_base_delay_ms(),
457 max_delay_ms: default_webhook_max_delay_ms(),
458 timeout_secs: default_webhook_timeout_secs(),
459 }
460 }
461}
462
463#[derive(Clone, Serialize, Deserialize)]
467pub struct ProviderConfig {
468 pub anthropic_api_key: Option<String>,
470
471 pub openai_api_key: Option<String>,
473
474 pub google_api_key: Option<String>,
476
477 #[serde(default)]
485 pub claude_code_enabled: bool,
486
487 #[serde(default)]
489 pub claude_code_binary: Option<String>,
490
491 #[serde(default)]
498 pub claude_code_effort: Option<String>,
499}
500
501impl std::fmt::Debug for ProviderConfig {
512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513 f.debug_struct("ProviderConfig")
514 .field("anthropic_api_key", &redacted(&self.anthropic_api_key))
515 .field("openai_api_key", &redacted(&self.openai_api_key))
516 .field("google_api_key", &redacted(&self.google_api_key))
517 .field("claude_code_enabled", &self.claude_code_enabled)
518 .field("claude_code_binary", &self.claude_code_binary)
519 .field("claude_code_effort", &self.claude_code_effort)
520 .finish()
521 }
522}
523
524fn redacted(value: &Option<String>) -> &'static str {
526 match value {
527 Some(_) => "<set>",
528 None => "<unset>",
529 }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, Default)]
538pub struct ModelProviderConfig {
539 #[serde(default)]
542 pub script: Option<String>,
543
544 #[serde(default)]
547 pub api_key: Option<String>,
548
549 #[serde(default)]
551 pub base_url: Option<String>,
552
553 #[serde(default)]
555 pub rate_limit: Option<leviath_providers::RateLimitConfig>,
556
557 #[serde(flatten)]
559 pub extra: HashMap<String, toml::Value>,
560}
561
562impl Default for Config {
563 fn default() -> Self {
564 Self {
565 default_provider: "anthropic".to_string(),
566 providers: ProviderConfig {
567 anthropic_api_key: None,
568 openai_api_key: None,
569 google_api_key: None,
570 claude_code_enabled: false,
571 claude_code_binary: None,
572 claude_code_effort: None,
573 },
574 agent_paths: Vec::new(),
575 openrouter_api_key: None,
576 ollama_base_url: None,
577 mcp_servers: Vec::new(),
578 default_model: None,
579 model_capabilities: HashMap::new(),
580 model_providers: HashMap::new(),
581 tool_permissions: HashMap::new(),
582 agent_tool_permissions: HashMap::new(),
583 title: TitleConfig::default(),
584 request_timeout_secs: None,
585 rate_limits: HashMap::new(),
586 taint_tracking: false,
587 limits: LimitsConfig::default(),
588 batch_tool_hint: true,
589 webhook: WebhookConfig::default(),
590 observability: ObservabilityConfig::default(),
591 sandbox: None,
592 tool_script_permissions: ScriptToolPermissions::default(),
593 security: SecurityConfig::default(),
594 }
595 }
596}
597
598impl Config {
599 pub fn permissions_for_agent(&self, agent_name: &str) -> HashMap<String, ToolPolicy> {
607 let mut merged = self.tool_permissions.clone();
608 if let Some(per_agent) = self.agent_tool_permissions.get(agent_name) {
609 merged.extend(per_agent.iter().map(|(k, v)| (k.clone(), *v)));
610 }
611 merged
612 }
613
614 pub fn load() -> anyhow::Result<Self> {
619 #[cfg(test)]
635 assert!(
636 std::env::var_os("LEVIATH_CONFIG_PATH").is_some(),
637 "Config::load() reached from a test that has not isolated the \
638 environment. Wrap the test in `config::with_isolated_config_path` \
639 (or `..._async`), which both points this at a scratch config and \
640 takes the same process-wide lock every other env-touching test \
641 holds. Without it this test races them and fails intermittently, \
642 somewhere else."
643 );
644
645 if std::env::var_os("LEVIATH_SKIP_DOTENV").is_none() {
663 let _ = dotenvy::from_filename(".env");
664 }
665
666 let config = Self::load_from_path(&Self::config_path())?;
667
668 check_permissions();
670
671 Ok(config)
672 }
673
674 fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
677 let mut config = if !path.exists() {
678 let path_display = path.display();
679 tracing::debug!("No config file found at {}, using defaults", path_display);
680 Self::default()
681 } else {
682 let content = std::fs::read_to_string(path).map_err(|e| {
683 anyhow::anyhow!("Failed to read config from '{}': {}", path.display(), e)
684 })?;
685
686 let c: Self = toml::from_str(&content)
687 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
688
689 for server in &c.mcp_servers {
693 server.validate()?;
694 }
695
696 let path_display = path.display();
697 tracing::debug!("Loaded config from {}", path_display);
698 c
699 };
700
701 if config.providers.anthropic_api_key.is_none() {
703 config.providers.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
704 }
705 if config.providers.openai_api_key.is_none() {
706 config.providers.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
707 }
708 if config.providers.google_api_key.is_none() {
709 config.providers.google_api_key = std::env::var("GOOGLE_API_KEY").ok();
710 }
711 if config.openrouter_api_key.is_none() {
712 config.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
713 }
714 if config.ollama_base_url.is_none() {
716 config.ollama_base_url = std::env::var("OLLAMA_HOST").ok();
717 }
718
719 config.fill_from_credential_store();
720
721 Ok(config)
722 }
723
724 fn fill_from_credential_store(&mut self) {
726 let resolved = crate::credentials::store_for(self.security.credential_store);
727 self.fill_from_credential_store_with(resolved);
728 }
729
730 fn fill_from_credential_store_with(&mut self, resolved: crate::credentials::Resolved) {
748 match resolved {
749 Ok(Some(store)) => self.apply_credential_store(store.as_ref()),
750 Ok(None) => {}
752 Err(e) => {
753 tracing::warn!("{e}. Falling back to keys from the config file and environment.");
754 }
755 }
756 }
757
758 fn apply_credential_store(&mut self, store: &dyn leviath_core::CredentialStore) {
760 let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
761 .iter()
762 .map(|p| leviath_core::provider_account(p))
763 .collect();
764 let mut found = store.read_all(&accounts);
765 let mut take = |provider: &str| found.remove(&leviath_core::provider_account(provider));
766
767 let anthropic = take("anthropic");
768 let openai = take("openai");
769 let google = take("google");
770 let openrouter = take("openrouter");
771
772 self.providers.anthropic_api_key = self.providers.anthropic_api_key.take().or(anthropic);
773 self.providers.openai_api_key = self.providers.openai_api_key.take().or(openai);
774 self.providers.google_api_key = self.providers.google_api_key.take().or(google);
775 self.openrouter_api_key = self.openrouter_api_key.take().or(openrouter);
776 }
777
778 fn without_secrets(&self) -> Self {
786 let mut copy = self.clone();
787 copy.providers.anthropic_api_key = None;
788 copy.providers.openai_api_key = None;
789 copy.providers.google_api_key = None;
790 copy.openrouter_api_key = None;
791 copy
792 }
793
794 pub(crate) fn provider_secrets(&self) -> Vec<(String, String)> {
796 [
797 ("anthropic", self.providers.anthropic_api_key.as_deref()),
798 ("openai", self.providers.openai_api_key.as_deref()),
799 ("google", self.providers.google_api_key.as_deref()),
800 ("openrouter", self.openrouter_api_key.as_deref()),
801 ]
802 .into_iter()
803 .filter_map(|(name, key)| {
804 key.map(|k| (leviath_core::provider_account(name), k.to_string()))
805 })
806 .collect()
807 }
808
809 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> anyhow::Result<()> {
814 if let Some(parent) = path.parent() {
816 create_config_dir(parent)?;
817 }
818
819 let resolved = crate::credentials::store_for(self.security.credential_store);
827 self.write_to(path, resolved)
828 }
829
830 fn write_to(
835 &self,
836 path: &std::path::Path,
837 resolved: crate::credentials::Resolved,
838 ) -> anyhow::Result<()> {
839 let to_write = match resolved.map_err(|e| anyhow::anyhow!("{e}"))? {
840 Some(store) => {
841 for (account, secret) in self.provider_secrets() {
842 store
843 .set(&account, &secret)
844 .map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
845 }
846 self.without_secrets()
847 }
848 None => self.clone(),
849 };
850
851 let content =
853 toml::to_string_pretty(&to_write).expect("Config serialization is infallible");
854
855 leviath_sys::write_private(path, content.as_bytes()).map_err(|e| {
860 anyhow::anyhow!("Failed to write config to '{}': {}", path.display(), e)
861 })?;
862
863 let path_display = path.display();
864 tracing::debug!("Saved config to {}", path_display);
865 Ok(())
866 }
867
868 pub fn load_from_path_public(path: &std::path::Path) -> anyhow::Result<Self> {
871 Self::load_from_path(path)
872 }
873
874 pub fn save_to_path_public(&self, path: &std::path::Path) -> anyhow::Result<()> {
876 self.save_to_path(path)
877 }
878
879 pub fn config_path() -> PathBuf {
893 if let Ok(override_path) = std::env::var("LEVIATH_CONFIG_PATH") {
894 return PathBuf::from(override_path);
895 }
896 leviath_core::data_dir()
897 .unwrap_or_default()
898 .join("config.toml")
899 }
900
901 pub fn validate_keys(&self) -> Vec<String> {
906 let mut warnings = Vec::new();
911 if let Some(key) = self.providers.anthropic_api_key.as_deref()
912 && !key.trim().is_empty()
913 && !key.starts_with("sk-ant-")
914 {
915 warnings.push(
916 "Anthropic API key doesn't start with 'sk-ant-' - verify it's correct".to_string(),
917 );
918 }
919 if let Some(key) = self.providers.openai_api_key.as_deref()
920 && !key.trim().is_empty()
921 && !key.starts_with("sk-")
922 {
923 warnings
924 .push("OpenAI API key doesn't start with 'sk-' - verify it's correct".to_string());
925 }
926 warnings
927 }
928}
929
930pub use leviath_core::paths::home_dir as leviath_home_dir;
937pub use leviath_core::paths::providers_dir;
938
939fn create_config_dir(dir: &std::path::Path) -> anyhow::Result<()> {
941 std::fs::create_dir_all(dir)
942 .map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
943 set_dir_permissions(dir);
944 Ok(())
945}
946
947fn check_permissions() {
951 check_permissions_at(&Config::config_path());
952}
953
954fn check_permissions_at(path: &std::path::Path) {
960 check_permissions_at_with(path, leviath_sys::ensure_file_private);
961}
962
963fn check_permissions_at_with(
971 path: &std::path::Path,
972 ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
973) {
974 match ensure(path) {
975 Ok(Some(old_mode)) => {
976 let masked_mode = old_mode & 0o777;
977 tracing::warn!(
978 "Config file has overly permissive permissions ({:o}), fixing to 600",
979 masked_mode
980 );
981 }
982 Ok(None) => {}
983 Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
984 }
985}
986
987fn set_dir_permissions(path: &std::path::Path) {
989 set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
990}
991
992fn set_dir_permissions_with(
995 path: &std::path::Path,
996 secure: fn(&std::path::Path) -> std::io::Result<()>,
997) {
998 if let Err(e) = secure(path) {
999 tracing::warn!("Failed to set config directory permissions: {}", e);
1000 }
1001}
1002
1003#[cfg(test)]
1012pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1013
1014#[cfg(test)]
1034pub(crate) struct CwdTestGuard {
1035 original_cwd: std::path::PathBuf,
1036 _lock: std::sync::MutexGuard<'static, ()>,
1037}
1038
1039#[cfg(test)]
1040impl Drop for CwdTestGuard {
1041 fn drop(&mut self) {
1042 let _ = std::env::set_current_dir(&self.original_cwd);
1043 }
1044}
1045
1046#[cfg(test)]
1049pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1050 let lock = CWD_LOCK
1051 .lock()
1052 .unwrap_or_else(std::sync::PoisonError::into_inner);
1053 let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1054 CwdTestGuard {
1055 original_cwd,
1056 _lock: lock,
1057 }
1058}
1059
1060#[cfg(test)]
1065const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1066 "ANTHROPIC_API_KEY",
1067 "OPENAI_API_KEY",
1068 "GOOGLE_API_KEY",
1069 "OPENROUTER_API_KEY",
1070];
1071
1072#[cfg(test)]
1074fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1075 let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1076 let _ = std::fs::create_dir_all(&fake_dir);
1077 fake_dir
1078}
1079
1080#[cfg(test)]
1086fn config_isolation_vars(
1087 fake_dir: &std::path::Path,
1088) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1089 let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1090 (
1091 "LEVIATH_CONFIG_PATH",
1092 Some(fake_dir.join("config.toml").into_os_string()),
1093 ),
1094 ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1095 ];
1096 for &key in PROVIDER_KEY_ENV_VARS {
1097 vars.push((key, None));
1098 }
1099 vars
1100}
1101
1102#[cfg(test)]
1110pub(crate) fn with_isolated_config_path<R>(
1111 unique: &str,
1112 f: impl FnOnce(&std::path::Path) -> R,
1113) -> R {
1114 let fake_dir = make_fake_config_dir(unique);
1115 let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1116 let _ = std::fs::remove_dir_all(&fake_dir);
1117 result
1118}
1119
1120#[cfg(test)]
1123pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1124 unique: &str,
1125 f: impl FnOnce(std::path::PathBuf) -> Fut,
1126) -> R
1127where
1128 Fut: std::future::Future<Output = R>,
1129{
1130 let fake_dir = make_fake_config_dir(unique);
1131 let result =
1132 temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1133 let _ = std::fs::remove_dir_all(&fake_dir);
1134 result
1135}
1136
1137#[cfg(test)]
1138mod dotenv_tests {
1139 use super::*;
1140
1141 #[test]
1152 fn a_dot_env_in_the_working_directory_is_read() {
1153 let dir = make_fake_config_dir("dotenv-read");
1154 std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1155
1156 {
1160 let _cwd = isolate_cwd_for_test();
1161 std::env::set_current_dir(&dir).unwrap();
1162
1163 temp_env::with_vars(
1164 [
1165 (
1166 "LEVIATH_CONFIG_PATH",
1167 Some(dir.join("config.toml").into_os_string()),
1168 ),
1169 ("LEVIATH_SKIP_DOTENV", None),
1170 ("LEV_DOTENV_PROBE", None),
1171 ],
1172 || {
1173 let loaded = Config::load();
1174 assert!(loaded.is_ok(), "a missing config file is not an error");
1175 assert_eq!(
1176 std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1177 Some("seen"),
1178 "the .env beside the working directory was read"
1179 );
1180 },
1181 );
1182 }
1183 let _ = std::fs::remove_dir_all(&dir);
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 #[test]
1193 fn saving_with_an_unreachable_keychain_writes_nothing() {
1194 let dir = tempfile::tempdir().unwrap();
1195 let path = dir.path().join("config.toml");
1196 let mut config = Config::default();
1197 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1198 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1199
1200 assert!(
1201 config
1202 .write_to(&path, Err("no keychain".to_string()))
1203 .is_err()
1204 );
1205 assert!(!path.exists(), "no file may be written at all");
1206 }
1207
1208 #[test]
1210 fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1211 use leviath_core::CredentialStore as _;
1212
1213 struct Refuses;
1214 impl leviath_core::CredentialStore for Refuses {
1215 fn get(&self, _: &str) -> Result<Option<String>, String> {
1216 Ok(None)
1217 }
1218 fn set(&self, _: &str, _: &str) -> Result<(), String> {
1219 Err("read-only keychain".to_string())
1220 }
1221 fn delete(&self, _: &str) -> Result<bool, String> {
1222 Err("read-only keychain".to_string())
1223 }
1224 }
1225
1226 let dir = tempfile::tempdir().unwrap();
1227 let path = dir.path().join("config.toml");
1228 let mut config = Config::default();
1229 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1230 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1231
1232 assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1235 assert!(Refuses.delete("provider/anthropic").is_err());
1236
1237 let err = config
1238 .write_to(&path, Ok(Some(Box::new(Refuses))))
1239 .expect_err("a refused write is not a save");
1240 assert!(err.to_string().contains("failed to store"), "{err}");
1241 assert!(!path.exists(), "no file may be written at all");
1242 }
1243
1244 #[test]
1247 fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1248 use leviath_core::CredentialStore;
1249
1250 let dir = tempfile::tempdir().unwrap();
1251 let path = dir.path().join("config.toml");
1252 let mut config = Config::default();
1253 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1254 config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1255 config.default_model = Some("some-model".to_string());
1256
1257 let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1258 struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1259 impl CredentialStore for Shared {
1260 fn get(&self, a: &str) -> Result<Option<String>, String> {
1261 self.0.get(a)
1262 }
1263 fn set(&self, a: &str, s: &str) -> Result<(), String> {
1264 self.0.set(a, s)
1265 }
1266 fn delete(&self, a: &str) -> Result<bool, String> {
1267 self.0.delete(a)
1268 }
1269 }
1270
1271 config
1272 .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1273 .unwrap();
1274
1275 assert!(
1277 Shared(store.clone())
1278 .delete(&leviath_core::provider_account("anthropic"))
1279 .unwrap()
1280 );
1281 store
1282 .set(
1283 &leviath_core::provider_account("anthropic"),
1284 "sk-ant-secret",
1285 )
1286 .unwrap();
1287
1288 let written = std::fs::read_to_string(&path).unwrap();
1289 assert!(!written.contains("sk-ant-secret"), "{written}");
1290 assert!(
1291 written.contains("some-model"),
1292 "settings survive: {written}"
1293 );
1294 assert_eq!(
1297 Shared(store.clone())
1298 .get(&leviath_core::provider_account("anthropic"))
1299 .unwrap()
1300 .as_deref(),
1301 Some("sk-ant-secret")
1302 );
1303 }
1304
1305 #[test]
1308 fn the_credential_store_fills_only_the_keys_that_are_unset() {
1309 use leviath_core::{CredentialStore, MemoryStore};
1310
1311 let store = MemoryStore::new();
1312 store
1313 .set(
1314 &leviath_core::provider_account("anthropic"),
1315 "from-keychain",
1316 )
1317 .unwrap();
1318 store
1319 .set(&leviath_core::provider_account("openai"), "openai-keychain")
1320 .unwrap();
1321 store
1322 .set(&leviath_core::provider_account("google"), "google-keychain")
1323 .unwrap();
1324 store
1325 .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1326 .unwrap();
1327
1328 let mut config = Config::default();
1329 config.providers.anthropic_api_key = Some("from-file".to_string());
1331 config.apply_credential_store(&store);
1332
1333 assert_eq!(
1334 config.providers.anthropic_api_key.as_deref(),
1335 Some("from-file"),
1336 "an existing key wins over the keychain"
1337 );
1338 assert_eq!(
1339 config.providers.openai_api_key.as_deref(),
1340 Some("openai-keychain")
1341 );
1342 assert_eq!(
1343 config.providers.google_api_key.as_deref(),
1344 Some("google-keychain")
1345 );
1346 assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1347 }
1348
1349 #[test]
1351 fn an_empty_credential_store_changes_nothing() {
1352 let mut config = Config::default();
1353 config.providers.openai_api_key = Some("keep-me".to_string());
1354 config.apply_credential_store(&leviath_core::MemoryStore::new());
1355 assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1356 assert!(config.providers.anthropic_api_key.is_none());
1357 }
1358
1359 #[test]
1364 fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1365 use leviath_core::{CredentialStore, MemoryStore};
1366
1367 let mut config = Config::default();
1368 config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1369 assert!(config.providers.anthropic_api_key.is_none());
1370
1371 let mut config = Config::default();
1373 config.providers.openai_api_key = Some("k".to_string());
1374 config.fill_from_credential_store_with(Ok(None));
1375 assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1376
1377 let store = MemoryStore::new();
1379 store
1380 .set(&leviath_core::provider_account("anthropic"), "filled")
1381 .unwrap();
1382 let mut config = Config::default();
1383 config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1384 assert_eq!(
1385 config.providers.anthropic_api_key.as_deref(),
1386 Some("filled")
1387 );
1388 }
1389
1390 #[test]
1391 fn provider_secrets_lists_every_set_key_and_nothing_else() {
1392 let mut config = Config::default();
1393 assert!(config.provider_secrets().is_empty());
1394
1395 config.providers.anthropic_api_key = Some("a".to_string());
1396 config.openrouter_api_key = Some("o".to_string());
1397 let secrets = config.provider_secrets();
1398 assert_eq!(secrets.len(), 2);
1399 assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1400 assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1401 }
1402
1403 #[test]
1407 fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1408 let mut config = Config::default();
1409 config.providers.anthropic_api_key = Some("a".to_string());
1410 config.providers.openai_api_key = Some("b".to_string());
1411 config.providers.google_api_key = Some("c".to_string());
1412 config.openrouter_api_key = Some("d".to_string());
1413 config.default_model = Some("m".to_string());
1414
1415 let stripped = config.without_secrets();
1416 assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1417 assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1418 assert_eq!(
1419 config.providers.anthropic_api_key.as_deref(),
1420 Some("a"),
1421 "the original is untouched"
1422 );
1423 }
1424
1425 use super::*;
1426 use crate::test_support::with_tracing;
1427
1428 #[test]
1431 fn leviath_home_dir_uses_override_when_set() {
1432 temp_env::with_var(
1433 "LEVIATH_HOME",
1434 Some("/tmp/leviath-home-override-test"),
1435 || {
1436 assert_eq!(
1437 leviath_home_dir(),
1438 Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1439 );
1440 },
1441 );
1442 }
1443
1444 #[test]
1445 fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1446 temp_env::with_var_unset("LEVIATH_HOME", || {
1447 assert_eq!(leviath_home_dir(), dirs::home_dir());
1448 });
1449 }
1450
1451 #[test]
1454 fn load_from_path_missing_file_returns_defaults() {
1455 let dir = tempfile::tempdir().unwrap();
1456 let path = dir.path().join("config.toml");
1457 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1458 assert_eq!(config.default_provider, "anthropic");
1459 }
1460
1461 #[test]
1462 fn load_from_path_valid_toml_is_parsed() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let path = dir.path().join("config.toml");
1465 let original = Config {
1466 default_provider: "openai".to_string(),
1467 ..Config::default()
1468 };
1469 std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1470 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1471 assert_eq!(config.default_provider, "openai");
1472 }
1473
1474 #[test]
1475 fn limits_default_to_bounded_values() {
1476 let limits = LimitsConfig::default();
1477 assert_eq!(limits.max_concurrent_inferences, Some(8));
1478 assert_eq!(limits.default_max_iterations, Some(50));
1479 assert!(!limits.exact_token_counting);
1481 assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1483 }
1484
1485 #[test]
1486 fn exact_token_counting_parses_when_set() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let path = dir.path().join("config.toml");
1489 let body = format!(
1490 "{}\n[limits]\nexact_token_counting = true\n",
1491 config_toml_without_limits()
1492 );
1493 std::fs::write(&path, body).unwrap();
1494 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1495 assert!(config.limits.exact_token_counting);
1496 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1498 }
1499
1500 #[cfg(test)]
1504 fn config_toml_without_limits() -> String {
1505 let full = toml::to_string_pretty(&Config::default()).unwrap();
1506 format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1507 }
1508
1509 #[test]
1510 fn limits_absent_section_uses_defaults() {
1511 let dir = tempfile::tempdir().unwrap();
1513 let path = dir.path().join("config.toml");
1514 std::fs::write(&path, config_toml_without_limits()).unwrap();
1515 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1516 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1517 assert_eq!(config.limits.default_max_iterations, Some(50));
1518 }
1519
1520 #[test]
1521 fn limits_partial_section_fills_the_other_default() {
1522 let dir = tempfile::tempdir().unwrap();
1524 let path = dir.path().join("config.toml");
1525 let body = format!(
1526 "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1527 config_toml_without_limits()
1528 );
1529 std::fs::write(&path, body).unwrap();
1530 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1531 assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1532 assert_eq!(config.limits.default_max_iterations, Some(50));
1533 }
1534
1535 #[test]
1536 fn load_from_path_existing_provider_keys_skip_env_fallback() {
1537 let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1545 .iter()
1546 .chain(["OLLAMA_HOST"].iter())
1547 .map(|&key| (key, None))
1548 .collect();
1549 temp_env::with_vars(unset, || {
1550 let dir = tempfile::tempdir().unwrap();
1551 let path = dir.path().join("config.toml");
1552 std::fs::write(
1553 &path,
1554 r#"
1555default_provider = "anthropic"
1556openrouter_api_key = "sk-or-existing"
1557ollama_base_url = "http://existing-ollama:11434"
1558agent_paths = []
1559
1560[providers]
1561anthropic_api_key = "sk-ant-existing"
1562openai_api_key = "sk-openai-existing"
1563google_api_key = "AIza-existing"
1564"#,
1565 )
1566 .unwrap();
1567
1568 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1569
1570 assert_eq!(
1571 config.providers.anthropic_api_key.as_deref(),
1572 Some("sk-ant-existing")
1573 );
1574 assert_eq!(
1575 config.providers.openai_api_key.as_deref(),
1576 Some("sk-openai-existing")
1577 );
1578 assert_eq!(
1579 config.providers.google_api_key.as_deref(),
1580 Some("AIza-existing")
1581 );
1582 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
1583 assert_eq!(
1584 config.ollama_base_url.as_deref(),
1585 Some("http://existing-ollama:11434")
1586 );
1587 });
1588 }
1589
1590 #[test]
1591 fn load_from_path_malformed_toml_returns_error() {
1592 let dir = tempfile::tempdir().unwrap();
1593 let path = dir.path().join("config.toml");
1594 std::fs::write(&path, "not valid toml [[[").unwrap();
1595 let result = Config::load_from_path(&path);
1596 assert!(result.is_err());
1597 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
1598 }
1599
1600 #[test]
1601 fn load_from_path_unreadable_path_returns_error() {
1602 let dir = tempfile::tempdir().unwrap();
1604 let result = Config::load_from_path(dir.path());
1605 assert!(result.is_err());
1606 }
1607
1608 #[test]
1609 fn save_to_path_writes_valid_toml_that_round_trips() {
1610 let dir = tempfile::tempdir().unwrap();
1611 let path = dir.path().join("nested").join("config.toml");
1612 let config = Config {
1613 default_provider: "google".to_string(),
1614 ..Config::default()
1615 };
1616 with_tracing(|| config.save_to_path(&path)).unwrap();
1617
1618 let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
1619 assert_eq!(loaded.default_provider, "google");
1620 }
1621
1622 #[test]
1623 fn save_to_path_creates_parent_directory() {
1624 let dir = tempfile::tempdir().unwrap();
1625 let path = dir.path().join("a").join("b").join("config.toml");
1626 let config = Config::default();
1627 with_tracing(|| config.save_to_path(&path)).unwrap();
1628 assert!(path.exists());
1629 }
1630
1631 #[test]
1632 fn save_to_path_with_no_parent_skips_create_config_dir() {
1633 let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
1640 assert!(result.is_err());
1641 }
1642
1643 #[cfg(unix)]
1644 #[test]
1645 fn save_to_path_sets_restrictive_file_permissions() {
1646 use std::os::unix::fs::PermissionsExt;
1647 let dir = tempfile::tempdir().unwrap();
1648 let path = dir.path().join("config.toml");
1649 with_tracing(|| Config::default().save_to_path(&path)).unwrap();
1650 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1651 assert_eq!(mode & 0o777, 0o600);
1652 }
1653
1654 #[test]
1655 fn save_to_path_write_failure_returns_error() {
1656 let dir = tempfile::tempdir().unwrap();
1661 let path = dir.path().join("config.toml");
1662 std::fs::create_dir_all(&path).unwrap();
1663
1664 let result = Config::default().save_to_path(&path);
1665
1666 assert!(result.is_err());
1667 assert!(
1668 result
1669 .unwrap_err()
1670 .to_string()
1671 .contains("Failed to write config")
1672 );
1673 }
1674
1675 #[test]
1676 fn save_to_path_create_config_dir_failure_returns_error() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let blocking_file = dir.path().join("not-a-dir");
1679 std::fs::write(&blocking_file, "").unwrap();
1680 let path = blocking_file.join("config.toml");
1681 let result = Config::default().save_to_path(&path);
1682 assert!(result.is_err());
1683 assert!(
1684 result
1685 .unwrap_err()
1686 .to_string()
1687 .contains("Failed to create config directory")
1688 );
1689 }
1690
1691 #[test]
1692 fn load_propagates_error_when_real_config_file_is_malformed() {
1693 with_isolated_config_path("load-malformed", |fake_dir| {
1698 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
1699
1700 let result = Config::load();
1701
1702 assert!(result.is_err());
1703 });
1704 }
1705
1706 #[cfg(unix)]
1709 #[test]
1710 fn check_permissions_at_missing_file_is_noop() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let path = dir.path().join("nonexistent.toml");
1713 check_permissions_at(&path); }
1715
1716 #[cfg(unix)]
1717 #[test]
1718 fn check_permissions_at_fixes_overly_permissive_file() {
1719 use std::os::unix::fs::PermissionsExt;
1720 let dir = tempfile::tempdir().unwrap();
1721 let path = dir.path().join("config.toml");
1722 std::fs::write(&path, "").unwrap();
1723 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1724
1725 with_tracing(|| check_permissions_at(&path));
1726
1727 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1728 assert_eq!(mode & 0o777, 0o600);
1729 }
1730
1731 #[cfg(unix)]
1732 #[test]
1733 fn check_permissions_at_leaves_already_restrictive_file_alone() {
1734 use std::os::unix::fs::PermissionsExt;
1735 let dir = tempfile::tempdir().unwrap();
1736 let path = dir.path().join("config.toml");
1737 std::fs::write(&path, "").unwrap();
1738 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1739
1740 check_permissions_at(&path);
1741
1742 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1743 assert_eq!(mode & 0o777, 0o600);
1744 }
1745
1746 #[test]
1754 fn check_permissions_at_with_logs_when_fix_fails() {
1755 fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
1756 Err(std::io::Error::other("simulated chmod failure"))
1757 }
1758 with_tracing(|| {
1760 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
1761 });
1762 }
1763
1764 #[test]
1765 fn check_permissions_at_with_logs_when_file_is_permissive() {
1766 fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
1767 Ok(Some(0o100644))
1768 }
1769 with_tracing(|| {
1770 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
1771 });
1772 }
1773
1774 fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
1780 Err(std::io::Error::other(
1781 "simulated permission-hardening failure",
1782 ))
1783 }
1784
1785 #[test]
1786 fn set_dir_permissions_error_branch_logs_not_panics() {
1787 with_tracing(|| {
1788 set_dir_permissions_with(
1789 std::path::Path::new("/does/not/matter"),
1790 always_failing_secure,
1791 )
1792 }); }
1794
1795 #[test]
1800 fn create_config_dir_creates_nested_dirs() {
1801 let dir = tempfile::tempdir().unwrap();
1802 let target = dir.path().join("a").join("b").join("c");
1803 create_config_dir(&target).unwrap();
1804 assert!(target.is_dir());
1805 }
1806
1807 #[cfg(unix)]
1808 #[test]
1809 fn create_config_dir_sets_restrictive_permissions() {
1810 use std::os::unix::fs::PermissionsExt;
1811 let dir = tempfile::tempdir().unwrap();
1812 let target = dir.path().join("leviath");
1813 create_config_dir(&target).unwrap();
1814 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
1815 assert_eq!(mode & 0o777, 0o700);
1816 }
1817
1818 #[cfg(unix)]
1822 #[test]
1830 fn config_path_honors_leviath_home() {
1831 temp_env::with_vars(
1832 [
1833 ("LEVIATH_CONFIG_PATH", None::<&str>),
1834 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
1835 ],
1836 || {
1837 assert_eq!(
1838 Config::config_path(),
1839 std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
1840 );
1841 },
1842 );
1843 }
1844
1845 #[test]
1847 fn config_path_prefers_the_explicit_override() {
1848 temp_env::with_vars(
1849 [
1850 ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
1851 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
1852 ],
1853 || {
1854 assert_eq!(
1855 Config::config_path(),
1856 std::path::PathBuf::from("/tmp/exact.toml")
1857 );
1858 },
1859 );
1860 }
1861
1862 #[test]
1866 fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
1867 let mut config = Config::default();
1868 config
1869 .tool_permissions
1870 .insert("shell".to_string(), ToolPolicy::Ask);
1871 config
1872 .tool_permissions
1873 .insert("write_file".to_string(), ToolPolicy::Deny);
1874 config.agent_tool_permissions.insert(
1875 "coder".to_string(),
1876 HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
1877 );
1878
1879 let coder = config.permissions_for_agent("coder");
1880 assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
1881 assert_eq!(
1882 coder.get("write_file"),
1883 Some(&ToolPolicy::Deny),
1884 "the rest of the global ceiling still applies"
1885 );
1886
1887 let other = config.permissions_for_agent("researcher");
1889 assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
1890 }
1891
1892 #[test]
1895 fn provider_config_debug_never_prints_the_keys() {
1896 let providers = ProviderConfig {
1897 anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
1898 openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
1899 google_api_key: Some("AIza-SECRET-VALUE".to_string()),
1900 claude_code_enabled: true,
1901 claude_code_binary: None,
1902 claude_code_effort: None,
1903 };
1904 let rendered = format!("{providers:?}");
1905 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
1906 assert!(rendered.contains("<set>"), "{rendered}");
1908 assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
1909
1910 let empty = format!(
1911 "{:?}",
1912 ProviderConfig {
1913 anthropic_api_key: None,
1914 openai_api_key: None,
1915 google_api_key: None,
1916 claude_code_enabled: false,
1917 claude_code_binary: None,
1918 claude_code_effort: None,
1919 }
1920 );
1921 assert!(empty.contains("<unset>"), "{empty}");
1922 }
1923
1924 #[cfg(unix)]
1928 #[test]
1929 fn saving_a_config_never_leaves_it_group_or_world_readable() {
1930 use std::os::unix::fs::PermissionsExt;
1931 let dir = tempfile::tempdir().unwrap();
1932 let path = dir.path().join("config.toml");
1933
1934 Config::default().save_to_path(&path).unwrap();
1935 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1936 assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
1937
1938 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1940 Config::default().save_to_path(&path).unwrap();
1941 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1942 assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
1943 }
1944
1945 #[cfg(unix)]
1946 #[test]
1947 fn set_dir_permissions_sets_0700() {
1948 use std::os::unix::fs::PermissionsExt;
1949 let dir = tempfile::tempdir().unwrap();
1950 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1951 set_dir_permissions(dir.path());
1952 let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
1953 assert_eq!(mode & 0o777, 0o700);
1954 }
1955
1956 #[test]
1957 fn test_validate_keys_good_anthropic() {
1958 let config = Config {
1959 providers: ProviderConfig {
1960 anthropic_api_key: Some("sk-ant-test123".to_string()),
1961 openai_api_key: None,
1962 google_api_key: None,
1963 claude_code_enabled: false,
1964 claude_code_binary: None,
1965 claude_code_effort: None,
1966 },
1967 ..Config::default()
1968 };
1969 assert!(config.validate_keys().is_empty());
1970 }
1971
1972 #[test]
1973 fn test_validate_keys_bad_anthropic() {
1974 let config = Config {
1975 providers: ProviderConfig {
1976 anthropic_api_key: Some("bad-key".to_string()),
1977 openai_api_key: None,
1978 google_api_key: None,
1979 claude_code_enabled: false,
1980 claude_code_binary: None,
1981 claude_code_effort: None,
1982 },
1983 ..Config::default()
1984 };
1985 let warnings = config.validate_keys();
1986 assert_eq!(warnings.len(), 1);
1987 assert!(warnings[0].contains("Anthropic"));
1988 }
1989
1990 #[test]
1991 fn test_validate_keys_good_openai() {
1992 let config = Config {
1993 providers: ProviderConfig {
1994 anthropic_api_key: None,
1995 openai_api_key: Some("sk-test123".to_string()),
1996 google_api_key: None,
1997 claude_code_enabled: false,
1998 claude_code_binary: None,
1999 claude_code_effort: None,
2000 },
2001 ..Config::default()
2002 };
2003 assert!(config.validate_keys().is_empty());
2004 }
2005
2006 #[test]
2007 fn test_validate_keys_bad_openai() {
2008 let config = Config {
2009 providers: ProviderConfig {
2010 anthropic_api_key: None,
2011 openai_api_key: Some("bad-key".to_string()),
2012 google_api_key: None,
2013 claude_code_enabled: false,
2014 claude_code_binary: None,
2015 claude_code_effort: None,
2016 },
2017 ..Config::default()
2018 };
2019 let warnings = config.validate_keys();
2020 assert_eq!(warnings.len(), 1);
2021 assert!(warnings[0].contains("OpenAI"));
2022 }
2023
2024 #[test]
2025 fn test_validate_keys_no_keys() {
2026 let config = Config::default();
2027 assert!(config.validate_keys().is_empty());
2028 }
2029
2030 #[test]
2033 fn config_default_values() {
2034 let config = Config::default();
2035 assert_eq!(config.default_provider, "anthropic");
2036 assert!(config.providers.anthropic_api_key.is_none());
2037 assert!(config.providers.openai_api_key.is_none());
2038 assert!(config.providers.google_api_key.is_none());
2039 assert!(config.openrouter_api_key.is_none());
2040 assert!(config.ollama_base_url.is_none());
2041 assert!(config.mcp_servers.is_empty());
2042 assert!(config.default_model.is_none());
2043 assert!(config.model_capabilities.is_empty());
2044 assert!(config.tool_permissions.is_empty());
2045 }
2046
2047 #[test]
2050 fn title_config_default() {
2051 let tc = TitleConfig::default();
2052 assert!(tc.enabled);
2053 assert!(tc.provider.is_none());
2054 assert!(tc.model.is_none());
2055 }
2056
2057 #[test]
2058 fn title_config_serde_roundtrip() {
2059 let tc = TitleConfig {
2060 enabled: false,
2061 provider: Some("openai".to_string()),
2062 model: Some("gpt-5.4-mini".to_string()),
2063 };
2064 let json = serde_json::to_string(&tc).unwrap();
2065 let back: TitleConfig = serde_json::from_str(&json).unwrap();
2066 assert!(!back.enabled);
2067 assert_eq!(back.provider.as_deref(), Some("openai"));
2068 assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2069 }
2070
2071 #[test]
2074 fn tool_policy_default_is_ask() {
2075 let policy = ToolPolicy::default();
2076 assert_eq!(policy, ToolPolicy::Ask);
2077 }
2078
2079 #[test]
2080 fn tool_policy_serde_roundtrip() {
2081 for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2082 let json = serde_json::to_string(&policy).unwrap();
2083 let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2084 assert_eq!(policy, back);
2085 }
2086 }
2087
2088 #[test]
2089 fn tool_policy_snake_case_serialization() {
2090 assert_eq!(
2091 serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2092 "\"allow\""
2093 );
2094 assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2095 assert_eq!(
2096 serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2097 "\"deny\""
2098 );
2099 }
2100
2101 #[test]
2104 fn config_from_toml_with_all_fields() {
2105 let toml_content = r#"
2106default_provider = "openai"
2107openrouter_api_key = "sk-or-test"
2108ollama_base_url = "http://my-ollama:11434"
2109default_model = "gpt-5"
2110agent_paths = []
2111
2112[providers]
2113anthropic_api_key = "sk-ant-test"
2114openai_api_key = "sk-test"
2115google_api_key = "AIza-test"
2116
2117[tool_permissions]
2118bash = "deny"
2119read_file = "allow"
2120
2121[title]
2122enabled = false
2123provider = "anthropic"
2124model = "claude-haiku-4-5"
2125"#;
2126 let config: Config = toml::from_str(toml_content).unwrap();
2127 assert_eq!(config.default_provider, "openai");
2128 assert_eq!(
2129 config.providers.anthropic_api_key.as_deref(),
2130 Some("sk-ant-test")
2131 );
2132 assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2133 assert_eq!(
2134 config.providers.google_api_key.as_deref(),
2135 Some("AIza-test")
2136 );
2137 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2138 assert_eq!(
2139 config.ollama_base_url.as_deref(),
2140 Some("http://my-ollama:11434")
2141 );
2142 assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2143 assert!(!config.title.enabled);
2144 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2145 assert_eq!(
2146 config.tool_permissions.get("read_file"),
2147 Some(&ToolPolicy::Allow)
2148 );
2149 }
2150
2151 #[test]
2152 fn config_from_minimal_toml() {
2153 let toml_content = r#"
2154default_provider = "anthropic"
2155agent_paths = []
2156
2157[providers]
2158"#;
2159 let config: Config = toml::from_str(toml_content).unwrap();
2160 assert_eq!(config.default_provider, "anthropic");
2161 assert!(config.providers.anthropic_api_key.is_none());
2162 }
2163
2164 #[test]
2165 fn config_from_toml_with_mcp_servers() {
2166 let toml_content = r#"
2167default_provider = "anthropic"
2168agent_paths = []
2169
2170[providers]
2171
2172[[mcp_servers]]
2173name = "test-server"
2174command = "echo"
2175args = ["hello"]
2176"#;
2177 let config: Config = toml::from_str(toml_content).unwrap();
2178 assert_eq!(config.mcp_servers.len(), 1);
2179 assert_eq!(config.mcp_servers[0].name, "test-server");
2180 }
2181
2182 #[test]
2183 fn load_rejects_a_malformed_mcp_server_entry() {
2184 let dir = tempfile::tempdir().unwrap();
2188 let path = dir.path().join("config.toml");
2189 std::fs::write(
2190 &path,
2191 r#"
2192default_provider = "anthropic"
2193agent_paths = []
2194
2195[providers]
2196
2197[[mcp_servers]]
2198name = "broken"
2199"#,
2200 )
2201 .unwrap();
2202
2203 let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2204 let msg = err.to_string();
2205 assert!(msg.contains("broken"), "must name the server: {msg}");
2206 }
2207
2208 #[test]
2209 fn load_accepts_a_well_formed_http_mcp_server() {
2210 let dir = tempfile::tempdir().unwrap();
2211 let path = dir.path().join("config.toml");
2212 std::fs::write(
2213 &path,
2214 r#"
2215default_provider = "anthropic"
2216agent_paths = []
2217
2218[providers]
2219
2220[[mcp_servers]]
2221name = "remote"
2222url = "https://mcp.example.com/mcp"
2223"#,
2224 )
2225 .unwrap();
2226
2227 let config = Config::load_from_path(&path).expect("valid http entry should load");
2228 assert_eq!(
2229 config.mcp_servers[0].url.as_deref(),
2230 Some("https://mcp.example.com/mcp")
2231 );
2232 }
2233
2234 #[test]
2235 fn config_from_toml_with_model_capabilities() {
2236 let toml_content = r#"
2237default_provider = "anthropic"
2238agent_paths = []
2239
2240[providers]
2241
2242[model_capabilities."my-custom-model"]
2243supports_temperature = true
2244supports_streaming = false
2245supports_tools = true
2246supports_system_prompt = true
2247max_context_tokens = 4096
2248max_output_tokens = 2048
2249"#;
2250 let config: Config = toml::from_str(toml_content).unwrap();
2251 let caps = config.model_capabilities.get("my-custom-model").unwrap();
2252 assert!(caps.supports_temperature);
2253 assert!(!caps.supports_streaming);
2254 assert_eq!(caps.max_context_tokens, 4096);
2255 assert_eq!(caps.max_output_tokens, 2048);
2256 }
2257
2258 #[test]
2264 fn validate_keys_is_quiet_about_blank_keys() {
2265 let mut config = Config::default();
2266 config.providers.anthropic_api_key = Some(String::new());
2267 config.providers.openai_api_key = Some(" ".to_string());
2268 assert!(config.validate_keys().is_empty());
2269 config.providers.anthropic_api_key = Some("nope".to_string());
2271 assert_eq!(config.validate_keys().len(), 1);
2272 }
2273
2274 #[test]
2275 fn validate_keys_both_bad() {
2276 let config = Config {
2277 providers: ProviderConfig {
2278 anthropic_api_key: Some("bad".to_string()),
2279 openai_api_key: Some("bad".to_string()),
2280 google_api_key: None,
2281 claude_code_enabled: false,
2282 claude_code_binary: None,
2283 claude_code_effort: None,
2284 },
2285 ..Config::default()
2286 };
2287 let warnings = config.validate_keys();
2288 assert_eq!(warnings.len(), 2);
2289 }
2290
2291 #[test]
2294 fn config_path_contains_leviath() {
2295 temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2300 let path = Config::config_path();
2301 assert!(path.to_str().unwrap().contains(".leviath"));
2302 assert!(path.to_str().unwrap().ends_with("config.toml"));
2303 });
2304 }
2305
2306 #[test]
2309 fn config_toml_roundtrip() {
2310 let config = Config {
2311 default_provider: "openai".to_string(),
2312 providers: ProviderConfig {
2313 anthropic_api_key: Some("sk-ant-key".to_string()),
2314 openai_api_key: None,
2315 google_api_key: None,
2316 claude_code_enabled: false,
2317 claude_code_binary: None,
2318 claude_code_effort: None,
2319 },
2320 tool_permissions: {
2321 let mut m = HashMap::new();
2322 m.insert("bash".to_string(), ToolPolicy::Deny);
2323 m
2324 },
2325 ..Config::default()
2326 };
2327
2328 let serialized = toml::to_string_pretty(&config).unwrap();
2329 let deserialized: Config = toml::from_str(&serialized).unwrap();
2330 assert_eq!(deserialized.default_provider, "openai");
2331 assert_eq!(
2332 deserialized.providers.anthropic_api_key.as_deref(),
2333 Some("sk-ant-key")
2334 );
2335 assert_eq!(
2336 deserialized.tool_permissions.get("bash"),
2337 Some(&ToolPolicy::Deny)
2338 );
2339 }
2340
2341 #[test]
2344 fn validate_keys_both_valid() {
2345 let config = Config {
2346 providers: ProviderConfig {
2347 anthropic_api_key: Some("sk-ant-good-key".to_string()),
2348 openai_api_key: Some("sk-good-key".to_string()),
2349 google_api_key: None,
2350 claude_code_enabled: false,
2351 claude_code_binary: None,
2352 claude_code_effort: None,
2353 },
2354 ..Config::default()
2355 };
2356 assert!(config.validate_keys().is_empty());
2357 }
2358
2359 #[test]
2362 fn validate_keys_google_key_not_validated() {
2363 let config = Config {
2364 providers: ProviderConfig {
2365 anthropic_api_key: None,
2366 openai_api_key: None,
2367 google_api_key: Some("anything-goes".to_string()),
2368 claude_code_enabled: false,
2369 claude_code_binary: None,
2370 claude_code_effort: None,
2371 },
2372 ..Config::default()
2373 };
2374 assert!(config.validate_keys().is_empty());
2376 }
2377
2378 #[test]
2381 fn config_from_toml_custom_registries() {
2382 let toml_content = r#"
2383default_provider = "anthropic"
2384agent_paths = ["/my/agents"]
2385
2386[providers]
2387"#;
2388 let config: Config = toml::from_str(toml_content).unwrap();
2389 assert_eq!(config.agent_paths.len(), 1);
2390 }
2391
2392 #[test]
2395 fn config_save_creates_file() {
2396 let dir = tempfile::tempdir().unwrap();
2397 let config_path = dir.path().join("subdir").join("config.toml");
2398 let config = Config::default();
2401 let content = toml::to_string_pretty(&config).unwrap();
2402 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2403 std::fs::write(&config_path, &content).unwrap();
2404 assert!(config_path.exists());
2405 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2406 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2407 assert_eq!(loaded.default_provider, "anthropic");
2408 }
2409
2410 #[test]
2413 fn title_config_from_toml_defaults() {
2414 let toml_content = r#"
2415default_provider = "anthropic"
2416agent_paths = []
2417
2418[providers]
2419"#;
2420 let config: Config = toml::from_str(toml_content).unwrap();
2421 assert!(config.title.enabled);
2422 assert!(config.title.provider.is_none());
2423 assert!(config.title.model.is_none());
2424 }
2425
2426 #[test]
2427 fn title_config_from_toml_disabled() {
2428 let toml_content = r#"
2429default_provider = "anthropic"
2430agent_paths = []
2431
2432[providers]
2433
2434[title]
2435enabled = false
2436"#;
2437 let config: Config = toml::from_str(toml_content).unwrap();
2438 assert!(!config.title.enabled);
2439 }
2440
2441 #[test]
2442 fn title_config_missing_enabled_key_uses_default_true() {
2443 let toml_content = r#"
2450default_provider = "anthropic"
2451agent_paths = []
2452
2453[providers]
2454
2455[title]
2456provider = "openai"
2457"#;
2458 let config: Config = toml::from_str(toml_content).unwrap();
2459 assert!(config.title.enabled);
2460 assert_eq!(config.title.provider.as_deref(), Some("openai"));
2461 }
2462
2463 #[test]
2466 fn config_tool_permissions_allow() {
2467 let toml_content = r#"
2468default_provider = "anthropic"
2469agent_paths = []
2470
2471[providers]
2472
2473[tool_permissions]
2474read_file = "allow"
2475write_file = "ask"
2476bash = "deny"
2477"#;
2478 let config: Config = toml::from_str(toml_content).unwrap();
2479 assert_eq!(
2480 config.tool_permissions.get("read_file"),
2481 Some(&ToolPolicy::Allow)
2482 );
2483 assert_eq!(
2484 config.tool_permissions.get("write_file"),
2485 Some(&ToolPolicy::Ask)
2486 );
2487 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2488 }
2489
2490 #[test]
2493 fn config_with_agent_paths() {
2494 let toml_content = r#"
2495default_provider = "anthropic"
2496agent_paths = ["/home/user/agents", "/opt/agents"]
2497
2498[providers]
2499"#;
2500 let config: Config = toml::from_str(toml_content).unwrap();
2501 assert_eq!(config.agent_paths.len(), 2);
2502 }
2503
2504 #[test]
2507 fn config_load_from_nonexistent_path_returns_default() {
2508 let config = Config::default();
2512 assert_eq!(config.default_provider, "anthropic");
2513 assert!(config.providers.anthropic_api_key.is_none());
2514 }
2515
2516 #[test]
2517 fn config_load_from_toml_string() {
2518 let toml_content = r#"
2520default_provider = "openai"
2521agent_paths = []
2522
2523[providers]
2524anthropic_api_key = "sk-ant-test-key"
2525"#;
2526 let config: Config = toml::from_str(toml_content).unwrap();
2527 assert_eq!(config.default_provider, "openai");
2528 assert_eq!(
2529 config.providers.anthropic_api_key.as_deref(),
2530 Some("sk-ant-test-key")
2531 );
2532 }
2533
2534 #[test]
2535 fn config_save_and_load_with_file() {
2536 let dir = tempfile::tempdir().unwrap();
2538 let config_path = dir.path().join("config.toml");
2539
2540 let config = Config {
2541 default_provider: "openai".to_string(),
2542 providers: ProviderConfig {
2543 anthropic_api_key: Some("sk-ant-test".to_string()),
2544 openai_api_key: Some("sk-test".to_string()),
2545 google_api_key: None,
2546 claude_code_enabled: false,
2547 claude_code_binary: None,
2548 claude_code_effort: None,
2549 },
2550 openrouter_api_key: Some("sk-or-test".to_string()),
2551 default_model: Some("gpt-5".to_string()),
2552 ..Config::default()
2553 };
2554
2555 let content = toml::to_string_pretty(&config).unwrap();
2556 std::fs::write(&config_path, &content).unwrap();
2557
2558 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2559 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2560
2561 assert_eq!(loaded.default_provider, "openai");
2562 assert_eq!(
2563 loaded.providers.anthropic_api_key.as_deref(),
2564 Some("sk-ant-test")
2565 );
2566 assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
2567 }
2568
2569 #[test]
2570 fn config_create_config_dir_creates_parent() {
2571 let dir = tempfile::tempdir().unwrap();
2572 let new_dir = dir.path().join("nested").join("config");
2573 std::fs::create_dir_all(&new_dir).unwrap();
2575 assert!(new_dir.exists());
2576 }
2577
2578 #[test]
2579 fn config_default_title_enabled() {
2580 let config = Config::default();
2581 assert!(config.title.enabled);
2582 }
2583
2584 #[test]
2585 fn config_serialize_with_all_options() {
2586 let mut model_caps = HashMap::new();
2587 model_caps.insert(
2588 "my-model".to_string(),
2589 ModelCapabilities {
2590 supports_temperature: true,
2591 supports_streaming: true,
2592 supports_tools: true,
2593 supports_system_prompt: true,
2594 max_context_tokens: 8192,
2595 max_output_tokens: 4096,
2596 },
2597 );
2598 let mut tool_perms = HashMap::new();
2599 tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
2600
2601 let config = Config {
2602 default_provider: "anthropic".to_string(),
2603 providers: ProviderConfig {
2604 anthropic_api_key: Some("sk-ant-key".to_string()),
2605 openai_api_key: None,
2606 google_api_key: None,
2607 claude_code_enabled: false,
2608 claude_code_binary: None,
2609 claude_code_effort: None,
2610 },
2611 agent_paths: vec![std::path::PathBuf::from("/my/agents")],
2612 openrouter_api_key: None,
2613 ollama_base_url: Some("http://custom:11434".to_string()),
2614 mcp_servers: vec![],
2615 default_model: None,
2616 model_capabilities: model_caps,
2617 model_providers: HashMap::new(),
2618 tool_permissions: tool_perms,
2619 agent_tool_permissions: HashMap::new(),
2620 title: TitleConfig {
2621 enabled: false,
2622 provider: Some("openai".to_string()),
2623 model: Some("gpt-5-mini".to_string()),
2624 },
2625 request_timeout_secs: None,
2626 rate_limits: HashMap::new(),
2627 taint_tracking: false,
2628 limits: LimitsConfig {
2629 max_concurrent_inferences: Some(4),
2630 max_concurrent_tools: 3,
2631 default_max_iterations: Some(99),
2632 exact_token_counting: false,
2633 script_shell_timeout_secs: 45,
2634 },
2635 batch_tool_hint: true,
2636 webhook: WebhookConfig {
2637 max_retries: 5,
2638 base_delay_ms: 250,
2639 max_delay_ms: 10_000,
2640 timeout_secs: 7,
2641 },
2642 observability: ObservabilityConfig {
2643 enabled: true,
2644 exporter: TelemetryExporterKind::Stdout,
2645 endpoint: Some("http://collector:4318".to_string()),
2646 service_name: Some("leviath-prod".to_string()),
2647 },
2648 sandbox: Some(leviath_core::ToolSandboxConfig {
2649 kind: leviath_core::SandboxKind::Container,
2650 image: Some("ubuntu:24.04".to_string()),
2651 network: false,
2652 ..Default::default()
2653 }),
2654 tool_script_permissions: ScriptToolPermissions {
2655 http_get: ScriptPermission::Allow,
2656 http_post: ScriptPermission::Deny,
2657 shell: ScriptPermission::Deny,
2658 read_file: ScriptPermission::Inherit,
2659 write_file: ScriptPermission::Deny,
2660 env_var: ScriptPermission::Allow,
2661 },
2662 security: SecurityConfig {
2663 allow_seed_commands: false,
2664 allow_local_network: true,
2665 allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
2666 credential_store: leviath_core::CredentialStoreKind::Keychain,
2667 },
2668 };
2669
2670 let serialized = toml::to_string_pretty(&config).unwrap();
2671 let deserialized: Config = toml::from_str(&serialized).unwrap();
2672
2673 assert_eq!(deserialized.default_provider, "anthropic");
2674 assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
2675 assert_eq!(deserialized.limits.max_concurrent_tools, 3);
2676 assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
2677 assert_eq!(
2678 deserialized.tool_script_permissions.http_get,
2679 ScriptPermission::Allow
2680 );
2681 assert_eq!(
2682 deserialized.tool_script_permissions.shell,
2683 ScriptPermission::Deny
2684 );
2685 assert_eq!(
2686 deserialized.tool_script_permissions.write_file,
2687 ScriptPermission::Deny
2688 );
2689 assert!(!deserialized.security.allow_seed_commands);
2690 assert_eq!(deserialized.webhook.max_retries, 5);
2691 assert_eq!(deserialized.webhook.base_delay_ms, 250);
2692 assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
2693 assert_eq!(deserialized.webhook.timeout_secs, 7);
2694 assert!(deserialized.observability.enabled);
2695 assert_eq!(
2696 deserialized.observability.exporter,
2697 TelemetryExporterKind::Stdout
2698 );
2699 assert_eq!(
2700 deserialized.observability.endpoint.as_deref(),
2701 Some("http://collector:4318")
2702 );
2703 assert_eq!(
2704 deserialized.observability.service_name.as_deref(),
2705 Some("leviath-prod")
2706 );
2707 assert_eq!(deserialized.limits.default_max_iterations, Some(99));
2708 assert_eq!(
2709 deserialized.providers.anthropic_api_key.as_deref(),
2710 Some("sk-ant-key")
2711 );
2712 assert_eq!(deserialized.agent_paths.len(), 1);
2713 assert!(deserialized.model_capabilities.contains_key("my-model"));
2714 assert_eq!(
2715 deserialized.tool_permissions.get("bash"),
2716 Some(&ToolPolicy::Allow)
2717 );
2718 assert!(!deserialized.title.enabled);
2719 assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
2720 let sandbox = deserialized.sandbox.expect("sandbox round-trips");
2721 assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
2722 assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
2723 assert!(!sandbox.network);
2724 }
2725
2726 #[test]
2729 fn config_multiple_model_capabilities() {
2730 let toml_content = r#"
2731default_provider = "anthropic"
2732agent_paths = []
2733
2734[providers]
2735
2736[model_capabilities."model-a"]
2737supports_temperature = true
2738supports_streaming = true
2739supports_tools = true
2740supports_system_prompt = true
2741max_context_tokens = 8192
2742max_output_tokens = 4096
2743
2744[model_capabilities."model-b"]
2745supports_temperature = false
2746supports_streaming = false
2747supports_tools = false
2748supports_system_prompt = false
2749max_context_tokens = 2048
2750max_output_tokens = 1024
2751"#;
2752 let config: Config = toml::from_str(toml_content).unwrap();
2753 assert_eq!(config.model_capabilities.len(), 2);
2754 let caps_a = config.model_capabilities.get("model-a").unwrap();
2755 assert!(caps_a.supports_temperature);
2756 assert_eq!(caps_a.max_context_tokens, 8192);
2757 let caps_b = config.model_capabilities.get("model-b").unwrap();
2758 assert!(!caps_b.supports_temperature);
2759 assert_eq!(caps_b.max_context_tokens, 2048);
2760 }
2761}