Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/0fl01_oxide-agent.json
T

1 line
93 KiB
JSON

{"repo": "0fl01/oxide-agent", "files": {"crates/oxide-agent-core/src/config.rs": {"3e238dd63f6f64a658bdc7a2a4e3619d7c6e27c1": {"t": 1785596345.6747038, "n": 91936, "h": "9a8d927a5978d595", "c": "//! Configuration and settings management\n//!\n//! Loads settings from environment variables and defines configuration constants.\n//!\nuse crate::capabilities::{\n CompiledCapabilityManifest, EnabledCapabilityManifest, ManifestError,\n compiled_capability_manifest,\n};\nuse crate::llm::providers::{\n canonical_route_provider, provider_missing_route_config_message, provider_module_id,\n};\nuse crate::llm::{provider_capabilities_for_model, provider_media_capabilities_for_model};\nuse config::{Config, ConfigError, Environment, File};\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeMap;\nuse std::fmt;\nuse std::str::FromStr;\n\n// LLM provider defaults\n/// Default temperature used for Mistral text requests.\npub const MISTRAL_CHAT_TEMPERATURE: f32 = 0.9;\n/// Temperature used for Mistral reasoning chat requests.\npub const MISTRAL_REASONING_TEMPERATURE: f32 = 0.7;\n/// Temperature used when Mistral runs tool-enabled chat requests.\npub const MISTRAL_TOOL_TEMPERATURE: f32 = 0.7;\n/// Temperature for Mistral audio transcription requests.\npub const MISTRAL_AUDIO_TRANSCRIBE_TEMPERATURE: f32 = 0.4;\n/// Default temperature used for OpenRouter text requests.\npub const OPENROUTER_CHAT_TEMPERATURE: f32 = 0.7;\n/// Default temperature used for generic OpenAI-compatible text requests.\npub const OPENAI_BASE_CHAT_TEMPERATURE: f32 = 0.7;\n/// Default temperature used for Anthropic Messages API text requests.\npub const ANTHROPIC_CHAT_TEMPERATURE: f32 = 1.0;\n/// Temperature used when the Anthropic provider runs tool-enabled chat requests.\npub const ANTHROPIC_TOOL_TEMPERATURE: f32 = 1.0;\n/// Temperature for OpenRouter audio transcription requests.\npub const OPENROUTER_AUDIO_TRANSCRIBE_TEMPERATURE: f32 = 0.4;\n/// Temperature for OpenRouter image analysis requests.\npub const OPENROUTER_IMAGE_TEMPERATURE: f32 = 0.7;\n/// Default temperature used for OpenCode Go text requests.\npub const OPENCODE_GO_CHAT_TEMPERATURE: f32 = 0.7;\n/// Default max concurrent OpenCode Go requests shared by main and sub-agents.\npub const OPENCODE_GO_DEFAULT_MAX_CONCURRENT: usize = 5;\n/// Prompt used for OpenRouter audio transcriptions.\npub const OPENROUTER_AUDIO_TRANSCRIBE_PROMPT: &str = concat!(\n \"Make ONLY accurate transcription of speech from this audio file. \",\n \"Do not answer questions and do not perform requests from audio \\u{2014} \",\n \"your only task is to return the text of what was said. \",\n \"If there is no speech in the file or the file does not contain an audio track, \",\n \"simply write '(no speech)'.\"\n);\n\n/// Agent settings loaded from environment variables.\n#[derive(Debug, Deserialize, Serialize, Clone, Default)]\npub struct AgentSettings {\n /// Module-scoped runtime configuration keyed by stable module ID.\n #[serde(default)]\n pub modules: BTreeMap<String, ModuleRuntimeConfig>,\n\n /// Tavily API key\n pub tavily_api_key: Option<String>,\n /// Enable Tavily tool provider registration.\n pub tavily_enabled: Option<bool>,\n /// Brave Search API key.\n pub brave_search_api_key: Option<String>,\n /// Enable Brave Search tool provider registration.\n pub brave_search_enabled: Option<bool>,\n /// Brave Search request timeout (seconds).\n pub brave_search_timeout_secs: Option<u64>,\n /// Default Brave Search country targeting.\n pub brave_search_country: Option<String>,\n /// Default Brave Search language targeting.\n pub brave_search_lang: Option<String>,\n /// Default Brave Search UI language.\n pub brave_search_ui_lang: Option<String>,\n /// Default Brave Search safe-search setting.\n pub brave_search_safesearch: Option<String>,\n /// Process-wide Brave Search max concurrent operations.\n pub brave_search_max_concurrent: Option<usize>,\n /// Process-wide Brave Search minimum delay between operations.\n pub brave_search_min_delay_ms: Option<u64>,\n /// Kokoro TTS server URL (default: http://127.0.0.1:8000)\n pub kokoro_tts_url: Option<String>,\n\n /// Agent model ID override\n pub agent_model_id: Option<String>,\n /// Agent model provider override\n pub agent_model_provider: Option<String>,\n /// Agent model max output tokens override\n pub agent_model_max_output_tokens: Option<u32>,\n /// Agent model context window tokens override\n pub agent_model_context_window_tokens: Option<u32>,\n /// Agent model temperature override.\n pub agent_model_temperature: Option<f32>,\n /// Optional weighted fallback routes for the main agent model.\n #[serde(default)]\n pub agent_model_routes: Option<Vec<ModelInfo>>,\n\n /// Sub-agent model ID override\n pub sub_agent_model_id: Option<String>,\n /// Sub-agent model provider override\n pub sub_agent_model_provider: Option<String>,\n /// Sub-agent model max output tokens override\n pub sub_agent_max_output_tokens: Option<u32>,\n /// Sub-agent model context window tokens override\n pub sub_agent_context_window_tokens: Option<u32>,\n /// Optional weighted fallback routes for the sub-agent model.\n #[serde(default)]\n pub sub_agent_model_routes: Option<Vec<ModelInfo>>,\n\n /// Enable asynchronous LLM-assisted Wiki Memory writer after completed runs.\n pub wiki_memory_writer_enabled: Option<bool>,\n /// Dedicated Wiki Memory writer model ID override.\n pub wiki_memory_writer_model_id: Option<String>,\n /// Dedicated Wiki Memory writer model provider override.\n pub wiki_memory_writer_model_provider: Option<String>,\n /// Dedicated Wiki Memory writer max output tokens override.\n pub wiki_memory_writer_max_output_tokens: Option<u32>,\n /// Dedicated Wiki Memory writer context window tokens override.\n pub wiki_memory_writer_context_window_tokens: Option<u32>,\n /// Dedicated Wiki Memory writer timeout override in seconds.\n pub wiki_memory_writer_timeout_secs: Option<u64>,\n\n /// Media model ID override (for voice/images)\n pub media_model_id: Option<String>,\n /// Media model provider override\n pub media_model_provider: Option<String>,\n /// Media model max output tokens override.\n pub media_model_max_output_tokens: Option<u32>,\n /// Media model context window tokens override.\n pub media_model_context_window_tokens: Option<u32>,\n\n /// Agent timeout in seconds\n pub agent_timeout_secs: Option<u64>,\n /// Sub-agent timeout in seconds\n pub sub_agent_timeout_secs: Option<u64>,\n}\n\n/// Runtime config for a single capability module.\n#[derive(Debug, Deserialize, Serialize, Clone, Default, Eq, PartialEq)]\npub struct ModuleRuntimeConfig {\n /// Whether this compiled module is enabled at runtime.\n pub enabled: Option<bool>,\n\n #[serde(default, flatten)]\n raw_config: BTreeMap<String, serde_json::Value>,\n}\n\nimpl ModuleRuntimeConfig {\n /// Creates a config value that explicitly disables a compiled module.\n #[must_use]\n pub fn disabled() -> Self {\n Self {\n enabled: Some(false),\n raw_config: BTreeMap::new(),\n }\n }\n\n /// Adds or replaces a module-local JSON config value.\n #[must_use]\n pub fn with_value(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {\n self.raw_config.insert(key.into(), value);\n self\n }\n\n /// Adds or replaces a module-local string config value.\n #[must_use]\n pub fn with_string_value(self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.with_value(key, serde_json::Value::String(value.into()))\n }\n\n /// Returns true unless the module is explicitly disabled.\n #[must_use]\n pub const fn enabled_or_default(&self) -> bool {\n match self.enabled {\n Some(enabled) => enabled,\n None => true,\n }\n }\n\n /// Returns a raw module-local config value.\n #[must_use]\n pub fn value(&self, key: &str) -> Option<&serde_json::Value> {\n self.raw_config.get(key)\n }\n\n /// Returns a module-local string config value.\n #[must_use]\n pub fn string_value(&self, key: &str) -> Option<&str> {\n self.value(key).and_then(serde_json::Value::as_str)\n }\n\n /// Returns a nested module-local string config value.\n #[must_use]\n pub fn nested_string_value(&self, object_key: &str, key: &str) -> Option<&str> {\n self.value(object_key)\n .and_then(serde_json::Value::as_object)\n .and_then(|object| object.get(key))\n .and_then(serde_json::Value::as_str)\n }\n}\n\n/// Lightweight module-only runtime config.\n#[derive(Debug, Deserialize, Serialize, Clone, Default, Eq, PartialEq)]\npub struct ModuleRuntimeSettings {\n /// Module-scoped runtime configuration keyed by stable module ID.\n #[serde(default)]\n pub modules: BTreeMap<String, ModuleRuntimeConfig>,\n}\n\nimpl ModuleRuntimeSettings {\n /// Validates configured module IDs and builds the enabled manifest.\n pub fn enabled_capability_manifest(\n &self,\n compiled: &CompiledCapabilityManifest,\n ) -> Result<EnabledCapabilityManifest, ManifestError> {\n compiled.enabled_manifest_from_configured_modules(\n self.modules\n .iter()\n .map(|(module_id, config)| (module_id.as_str(), config.enabled_or_default())),\n )\n }\n}\n\n/// Build the base configuration loader.\n///\n/// # Errors\n///\n/// Returns a `ConfigError` if building sources fails.\npub fn build_config() -> Result<Config, ConfigError> {\n build_config_with_optional_file(None)\n}\n\n/// Build the base configuration loader with an optional explicit config file.\n///\n/// # Errors\n///\n/// Returns a `ConfigError` if building sources fails.\npub fn build_config_with_optional_file(config_path: Option<&str>) -> Result<Config, ConfigError> {\n let run_mode = std::env::var(\"RUN_MODE\").unwrap_or_else(|_| \"development\".into());\n\n let mut builder = Config::builder()\n // Start off by merging in the \"default\" configuration file\n .add_source(File::with_name(\"config/default\").required(false))\n // Add in the current environment file\n .add_source(File::with_name(&format!(\"config/{run_mode}\")).required(false))\n // Add in a local configuration file\n // This file shouldn't be checked into git\n .add_source(File::with_name(\"config/local\").required(false));\n\n if let Some(config_path) = config_path {\n builder = builder.add_source(File::with_name(config_path).required(true));\n }\n\n builder\n // Add in settings from the environment (with a prefix of APP)\n // Eg.. `APP_DEBUG=1 ./target/app` would set the `debug` key\n .add_source(Environment::with_prefix(\"APP\").separator(\"__\"))\n // Also add settings from environment variables directly (without prefix)\n // Note: Environment::default() auto-converts UPPER_SNAKE_CASE to snake_case\n // ignore_empty treats empty env vars as unset\n .add_source(Environment::default().ignore_empty(true))\n .build()\n}\n\n/// Load only module-scoped runtime config.\n///\n/// # Errors\n///\n/// Returns a `ConfigError` if config loading or deserialization fails.\npub fn load_module_runtime_settings(\n config_path: Option<&str>,\n) -> Result<ModuleRuntimeSettings, ConfigError> {\n build_config_with_optional_file(config_path)?.try_deserialize()\n}\n\nfn capability_config_error(error: ManifestError) -> ConfigError {\n ConfigError::Message(format!(\n \"Capability module config validation failed: {error}\"\n ))\n}\n\nimpl AgentSettings {\n /// Returns module-scoped runtime config by stable module ID.\n #[must_use]\n pub fn module_config(&self, module_id: &str) -> Option<&ModuleRuntimeConfig> {\n self.modules.get(module_id)\n }\n\n /// Returns a non-empty module-local string value.\n #[must_use]\n pub fn module_string_value(&self, module_id: &str, key: &str) -> Option<String> {\n self.module_config(module_id)\n .and_then(|config| config.string_value(key))\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n }\n\n /// Returns a module-local string value, falling back to a provider-owned env var.\n #[must_use]\n pub fn module_string_value_or_env(\n &self,\n module_id: &str,\n key: &str,\n env_name: &str,\n ) -> Option<String> {\n self.module_string_value(module_id, key).or_else(|| {\n std::env::var(env_name)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n }\n\n /// Returns a module-local string value, falling back to provider-owned env vars in order.\n #[must_use]\n pub fn module_string_value_or_envs(\n &self,\n module_id: &str,\n key: &str,\n env_names: &[&str],\n ) -> Option<String> {\n self.module_string_value(module_id, key).or_else(|| {\n env_names.iter().find_map(|env_name| {\n std::env::var(env_name)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n })\n }\n\n /// Returns a module-local string value, provider-owned env var, or default.\n #[must_use]\n pub fn module_string_value_or_env_or_default(\n &self,\n module_id: &str,\n key: &str,\n env_name: &str,\n default: &str,\n ) -> String {\n self.module_string_value_or_env(module_id, key, env_name)\n .unwrap_or_else(|| default.to_string())\n }\n\n /// Create new settings by loading from environment and files\n ///\n /// # Examples\n ///\n /// ```no_run\n /// use oxide_agent_core::config::AgentSettings;\n ///\n /// let settings = AgentSettings::new().expect(\"Failed to load configuration\");\n /// ```\n ///\n /// # Errors\n ///\n /// Returns a `ConfigError` if loading fails.\n pub fn new() -> Result<Self, ConfigError> {\n let mut settings: Self = build_config()?.try_deserialize()?;\n settings.validate_configured_modules()?;\n settings.apply_model_routes_from_env();\n settings.apply_opencode_go_bootstrap_route_if_available();\n\n if settings.agent_model_temperature.is_none() {\n settings.agent_model_temperature = parse_optional_env_f32(\"AGENT_MODEL_TEMPERATURE\");\n }\n\n settings.apply_tool_provider_env_fallbacks();\n\n if !settings.has_configured_agent_route() {\n return Err(ConfigError::Message(\n \"Critical: AGENT_MODEL_ID and AGENT_MODEL_PROVIDER or AGENT_MODEL_ROUTES are required for operation\".to_string(),\n ));\n }\n settings.validate_route_providers()?;\n settings.canonicalize_route_provider_ids()?;\n settings.validate_route_model_capabilities()?;\n settings.validate_route_credentials()?;\n\n Ok(settings)\n }\n\n fn validate_configured_modules(&self) -> Result<(), ConfigError> {\n let manifest = compiled_capability_manifest().map_err(capability_config_error)?;\n let module_settings = ModuleRuntimeSettings {\n modules: self.modules.clone(),\n };\n module_settings\n .enabled_capability_manifest(&manifest)\n .map(|_| ())\n .map_err(capability_config_error)\n }\n\n fn validate_route_providers(&self) -> Result<(), ConfigError> {\n self.validate_optional_route_provider(\n \"AGENT_MODEL_PROVIDER\",\n self.agent_model_provider.as_deref(),\n )?;\n self.validate_optional_route_provider(\n \"SUB_AGENT_MODEL_PROVIDER\",\n self.sub_agent_model_provider.as_deref(),\n )?;\n self.validate_optional_route_provider(\n \"MEDIA_MODEL_PROVIDER\",\n self.media_model_provider.as_deref(),\n )?;\n self.validate_optional_route_provider(\n \"WIKI_MEMORY_WRITER_MODEL_PROVIDER\",\n self.wiki_memory_writer_model_provider.as_deref(),\n )?;\n\n if let Some(routes) = self.agent_model_routes.as_deref() {\n for (index, route) in routes.iter().enumerate() {\n self.validate_optional_route_provider(\n &format!(\"AGENT_MODEL_ROUTES[{index}].provider\"),\n Some(route.provider.as_str()),\n )?;\n }\n }\n\n if let Some(routes) = self.sub_agent_model_routes.as_deref() {\n for (index, route) in routes.iter().enumerate() {\n self.validate_optional_route_provider(\n &format!(\"SUB_AGENT_MODEL_ROUTES[{index}].provider\"),\n Some(route.provider.as_str()),\n )?;\n }\n }\n\n Ok(())\n }\n\n fn validate_optional_route_provider(\n &self,\n source: &str,\n provider: Option<&str>,\n ) -> Result<(), ConfigError> {\n let Some(provider) = provider.map(str::trim).filter(|value| !value.is_empty()) else {\n return Ok(());\n };\n\n let Some(module_id) = provider_module_id(provider) else {\n return Err(ConfigError::Message(format!(\n \"Critical: {source} references provider '{provider}', but no compiled LLM provider module owns that provider alias or ID\"\n )));\n };\n\n if !self.is_module_enabled(module_id) {\n return Err(ConfigError::Message(format!(\n \"Critical: {source} references provider '{provider}', but module '{module_id}' is disabled\"\n )));\n }\n\n Ok(())\n }\n\n fn validate_route_credentials(&self) -> Result<(), ConfigError> {\n let mut checked_module_ids = std::collections::BTreeSet::new();\n\n for provider in self.configured_route_provider_values() {\n let Some(module_id) = provider_module_id(provider) else {\n continue;\n };\n if module_id != \"llm-provider/openai-base\" && !checked_module_ids.insert(module_id) {\n continue;\n }\n if let Some(message) = provider_missing_route_config_message(provider, self) {\n return Err(ConfigError::Message(message));\n }\n }\n\n Ok(())\n }\n\n fn validate_route_model_capabilities(&self) -> Result<(), ConfigError> {\n for (source, route) in self.agent_route_capability_entries() {\n let capabilities = provider_capabilities_for_model(&route);\n if !capabilities.can_run_agent_tools() {\n return Err(ConfigError::Message(format!(\n \"Critical: {source} route {}/{} is not approved for Agent Mode tool execution\",\n route.provider, route.id\n )));\n }\n }\n\n if let Some((_, route)) = self.media_model_spec() {\n let capabilities = provider_media_capabilities_for_model(&route);\n if !capabilities.supports_audio_transcription\n && !capabilities.supports_image_understanding\n && !capabilities.supports_video_understanding\n {\n return Err(ConfigError::Message(format!(\n \"Critical: MEDIA_MODEL route {}/{} is not approved for any media operation\",\n route.provider, route.id\n )));\n }\n }\n\n Ok(())\n }\n\n fn agent_route_capability_entries(&self) -> Vec<(String, ModelInfo)> {\n let mut routes = Vec::new();\n if let Some(agent_routes) = self.agent_model_routes.as_deref() {\n for (index, route) in Self::normalize_model_routes(\n agent_routes,\n self.agent_model_max_output_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS),\n self.agent_model_context_window_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS),\n )\n .into_iter()\n .enumerate()\n {\n routes.push((format!(\"AGENT_MODEL_ROUTES[{index}]\"), route));\n }\n } else if let Some((_, route)) = self.agent_model_spec() {\n routes.push((\"AGENT_MODEL\".to_string(), route));\n }\n\n if let Some(sub_agent_routes) = self.sub_agent_model_routes.as_deref() {\n for (index, route) in Self::normalize_model_routes(\n sub_agent_routes,\n self.sub_agent_max_output_tokens\n .unwrap_or(DEFAULT_SUB_AGENT_MODEL_MAX_OUTPUT_TOKENS),\n self.sub_agent_context_window_tokens_or_inherited(),\n )\n .into_iter()\n .enumerate()\n {\n routes.push((format!(\"SUB_AGENT_MODEL_ROUTES[{index}]\"), route));\n }\n } else if self.sub_agent_model_spec().is_some() {\n routes.push((\n \"SUB_AGENT_MODEL\".to_string(),\n self.resolve_execution_model(true),\n ));\n }\n\n routes\n }\n\n fn canonicalize_route_provider_ids(&mut self) -> Result<(), ConfigError> {\n Self::canonicalize_optional_provider_field(\n \"AGENT_MODEL_PROVIDER\",\n &mut self.agent_model_provider,\n )?;\n Self::canonicalize_optional_provider_field(\n \"SUB_AGENT_MODEL_PROVIDER\",\n &mut self.sub_agent_model_provider,\n )?;\n Self::canonicalize_optional_provider_field(\n \"MEDIA_MODEL_PROVIDER\",\n &mut self.media_model_provider,\n )?;\n Self::canonicalize_optional_provider_field(\n \"WIKI_MEMORY_WRITER_MODEL_PROVIDER\",\n &mut self.wiki_memory_writer_model_provider,\n )?;\n\n if let Some(routes) = self.agent_model_routes.as_mut() {\n for (index, route) in routes.iter_mut().enumerate() {\n Self::canonicalize_model_route_provider(\n &format!(\"AGENT_MODEL_ROUTES[{index}].provider\"),\n route,\n )?;\n }\n }\n\n if let Some(routes) = self.sub_agent_model_routes.as_mut() {\n for (index, route) in routes.iter_mut().enumerate() {\n Self::canonicalize_model_route_provider(\n &format!(\"SUB_AGENT_MODEL_ROUTES[{index}].provider\"),\n route,\n )?;\n }\n }\n\n Ok(())\n }\n\n fn canonicalize_optional_provider_field(\n source: &str,\n provider: &mut Option<String>,\n ) -> Result<(), ConfigError> {\n let Some(value) = provider.as_deref().map(str::trim) else {\n return Ok(());\n };\n\n if value.is_empty() {\n *provider = None;\n return Ok(());\n }\n\n let Some(route_provider) = canonical_route_provider(value) else {\n return Err(ConfigError::Message(format!(\n \"Critical: {source} references provider '{value}', but no compiled LLM provider module owns that provider alias or ID\"\n )));\n };\n\n *provider = Some(route_provider);\n Ok(())\n }\n\n fn canonicalize_model_route_provider(\n source: &str,\n route: &mut ModelInfo,\n ) -> Result<(), ConfigError> {\n let provider = route.provider.trim();\n if provider.is_empty() {\n route.provider.clear();\n return Ok(());\n }\n\n let Some(route_provider) = canonical_route_provider(provider) else {\n return Err(ConfigError::Message(format!(\n \"Critical: {source} references provider '{provider}', but no compiled LLM provider module owns that provider alias or ID\"\n )));\n };\n\n route.provider = route_provider;\n Ok(())\n }\n\n fn configured_route_provider_values(&self) -> impl Iterator<Item = &str> {\n let direct_providers = [\n self.agent_model_provider.as_deref(),\n self.sub_agent_model_provider.as_deref(),\n self.media_model_provider.as_deref(),\n self.wiki_memory_writer_model_provider.as_deref(),\n ];\n let agent_route_providers = self\n .agent_model_routes\n .iter()\n .flat_map(|routes| routes.iter().map(|route| route.provider.as_str()));\n let sub_agent_route_providers = self\n .sub_agent_model_routes\n .iter()\n .flat_map(|routes| routes.iter().map(|route| route.provider.as_str()));\n\n direct_providers\n .into_iter()\n .flatten()\n .chain(agent_route_providers)\n .chain(sub_agent_route_providers)\n .map(str::trim)\n .filter(|provider| !provider.is_empty())\n }\n\n fn has_configured_agent_route(&self) -> bool {\n let has_primary_route = self.agent_model_routes.as_deref().is_some_and(|routes| {\n routes\n .iter()\n .any(|route| !route.id.trim().is_empty() && !route.provider.trim().is_empty())\n });\n let has_direct_route = self\n .agent_model_id\n .as_deref()\n .is_some_and(|id| !id.trim().is_empty())\n && self\n .agent_model_provider\n .as_deref()\n .is_some_and(|provider| !provider.trim().is_empty());\n\n has_primary_route || has_direct_route\n }\n\n fn apply_opencode_go_bootstrap_route_if_available(&mut self) {\n if self.has_configured_agent_route() {\n return;\n }\n let Some(module_id) = provider_module_id(\"opencode-go\") else {\n return;\n };\n if !self.is_module_enabled(module_id) {\n return;\n }\n if self\n .module_string_value_or_envs(\n module_id,\n \"api_key\",\n &[\n \"OPENCODE_API_KEY\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n ],\n )\n .is_none()\n {\n return;\n }\n\n let route = ModelInfo {\n id: \"opencode-go/deepseek-v4-flash\".to_string(),\n max_output_tokens: DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS,\n context_window_tokens: DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS,\n provider: \"opencode-go\".to_string(),\n weight: 1,\n };\n self.agent_model_id = Some(route.id.clone());\n self.agent_model_provider = Some(route.provider.clone());\n self.agent_model_max_output_tokens = Some(route.max_output_tokens);\n self.agent_model_context_window_tokens = Some(route.context_window_tokens);\n self.agent_model_routes = Some(vec![route]);\n }\n\n fn apply_model_routes_from_env(&mut self) {\n if let Some(routes) = Self::parse_model_routes_from_env(\"AGENT_MODEL_ROUTES\") {\n if let Some(primary) = routes.first() {\n self.agent_model_id = Some(primary.id.clone());\n self.agent_model_provider = Some(primary.provider.clone());\n self.agent_model_max_output_tokens = Some(primary.max_output_tokens);\n if primary.context_window_tokens != 0 {\n self.agent_model_context_window_tokens = Some(primary.context_window_tokens);\n }\n }\n self.agent_model_routes = Some(routes);\n }\n\n if let Some(routes) = Self::parse_model_routes_from_env(\"SUB_AGENT_MODEL_ROUTES\") {\n if let Some(primary) = routes.first() {\n self.sub_agent_model_id = Some(primary.id.clone());\n self.sub_agent_model_provider = Some(primary.provider.clone());\n self.sub_agent_max_output_tokens = Some(primary.max_output_tokens);\n if primary.context_window_tokens != 0 {\n self.sub_agent_context_window_tokens = Some(primary.context_window_tokens);\n }\n }\n self.sub_agent_model_routes = Some(routes);\n }\n }\n\n fn apply_tool_provider_env_fallbacks(&mut self) {\n if self.tavily_api_key.is_none()\n && let Ok(val) = std::env::var(\"TAVILY_API_KEY\")\n && !val.is_empty()\n {\n self.tavily_api_key = Some(val);\n }\n\n if self.tavily_enabled.is_none() {\n self.tavily_enabled = parse_optional_env_bool(\"TAVILY_ENABLED\");\n }\n\n if self.brave_search_api_key.is_none()\n && let Ok(val) = std::env::var(\"BRAVE_SEARCH_API_KEY\")\n {\n let val = val.trim();\n if !val.is_empty() {\n self.brave_search_api_key = Some(val.to_string());\n }\n }\n\n if self.brave_search_enabled.is_none() {\n self.brave_search_enabled = parse_optional_env_bool(\"BRAVE_SEARCH_ENABLED\");\n }\n }\n\n fn parse_model_routes_from_env(prefix: &str) -> Option<Vec<ModelInfo>> {\n let mut routes = BTreeMap::<usize, PartialModelRoute>::new();\n\n for (key, value) in std::env::vars() {\n if value.trim().is_empty() {\n continue;\n }\n\n let Some(rest) = key.strip_prefix(prefix) else {\n continue;\n };\n let Some(rest) = rest.strip_prefix(\"__\") else {\n continue;\n };\n\n let mut parts = rest.split(\"__\");\n let Some(index) = parts.next().and_then(|part| part.parse::<usize>().ok()) else {\n continue;\n };\n let Some(field) = parts.next() else {\n continue;\n };\n if parts.next().is_some() {\n continue;\n }\n\n let route = routes.entry(index).or_default();\n match field {\n \"ID\" => route.id = Some(value),\n \"PROVIDER\" => route.provider = Some(value),\n \"MAX_OUTPUT_TOKENS\" => route.max_output_tokens = value.parse::<u32>().ok(),\n \"CONTEXT_WINDOW_TOKENS\" => route.context_window_tokens = value.parse::<u32>().ok(),\n \"WEIGHT\" => route.weight = value.parse::<u32>().ok(),\n _ => {}\n }\n }\n\n let parsed_routes: Vec<ModelInfo> = routes\n .into_iter()\n .filter_map(|(_index, route)| route.into_model_info())\n .collect();\n\n (!parsed_routes.is_empty()).then_some(parsed_routes)\n }\n\n fn upsert_model(models: &mut Vec<(String, ModelInfo)>, name: String, info: ModelInfo) {\n if let Some(pos) = models.iter().position(|(n, _)| n == &name) {\n models[pos] = (name, info);\n } else {\n models.push((name, info));\n }\n }\n\n fn build_model_info(\n id: &str,\n provider: &str,\n max_output_tokens: u32,\n context_window_tokens: u32,\n ) -> ModelInfo {\n ModelInfo {\n id: id.to_string(),\n max_output_tokens,\n context_window_tokens,\n provider: provider.to_string(),\n weight: default_model_route_weight(),\n }\n }\n\n fn normalize_model_routes(\n routes: &[ModelInfo],\n default_max_output_tokens: u32,\n default_context_window_tokens: u32,\n ) -> Vec<ModelInfo> {\n routes\n .iter()\n .filter_map(|route| {\n let id = route.id.trim();\n let provider = route.provider.trim();\n if id.is_empty() || provider.is_empty() {\n return None;\n }\n\n Some(ModelInfo {\n id: id.to_string(),\n max_output_tokens: if route.max_output_tokens == 0 {\n default_max_output_tokens\n } else {\n route.max_output_tokens\n },\n context_window_tokens: if route.context_window_tokens == 0 {\n default_context_window_tokens\n } else {\n route.context_window_tokens\n },\n provider: provider.to_string(),\n weight: route.weight.max(1),\n })\n })\n .collect()\n }\n\n fn agent_model_spec(&self) -> Option<(String, ModelInfo)> {\n let id = self.agent_model_id.as_ref()?;\n let provider = self.agent_model_provider.as_ref()?;\n let max_output_tokens = self\n .agent_model_max_output_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS);\n let context_window_tokens = self\n .agent_model_context_window_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS);\n\n Some((\n id.clone(),\n Self::build_model_info(id, provider, max_output_tokens, context_window_tokens),\n ))\n }\n\n fn sub_agent_model_spec(&self) -> Option<(String, ModelInfo)> {\n let id = self.sub_agent_model_id.as_ref()?;\n let provider = self.sub_agent_model_provider.as_ref()?;\n let max_output_tokens = self\n .sub_agent_max_output_tokens\n .unwrap_or(DEFAULT_SUB_AGENT_MODEL_MAX_OUTPUT_TOKENS);\n let context_window_tokens = self.sub_agent_context_window_tokens_or_inherited();\n\n Some((\n id.clone(),\n Self::build_model_info(id, provider, max_output_tokens, context_window_tokens),\n ))\n }\n\n fn wiki_memory_writer_model_spec(&self) -> Option<(String, ModelInfo)> {\n let id = self.wiki_memory_writer_model_id.as_ref()?;\n let provider = self.wiki_memory_writer_model_provider.as_ref()?;\n let max_output_tokens = self\n .wiki_memory_writer_max_output_tokens\n .unwrap_or(WIKI_MEMORY_WRITER_MAX_TOKENS);\n let context_window_tokens = self\n .wiki_memory_writer_context_window_tokens\n .unwrap_or(DEFAULT_INTERNAL_TEXT_CONTEXT_WINDOW_TOKENS);\n\n Some((\n id.clone(),\n Self::build_model_info(id, provider, max_output_tokens, context_window_tokens),\n ))\n }\n\n fn media_model_spec(&self) -> Option<(String, ModelInfo)> {\n let id = self.media_model_id.as_ref()?;\n let provider = self.media_model_provider.as_ref()?;\n let max_output_tokens = self\n .media_model_max_output_tokens\n .unwrap_or(DEFAULT_MEDIA_MODEL_MAX_OUTPUT_TOKENS);\n let context_window_tokens = self\n .media_model_context_window_tokens\n .unwrap_or(DEFAULT_MEDIA_MODEL_CONTEXT_WINDOW_TOKENS);\n\n Some((\n id.clone(),\n Self::build_model_info(id, provider, max_output_tokens, context_window_tokens),\n ))\n }\n\n /// Returns a list of available models configured from environment variables\n pub fn get_available_models(&self) -> Vec<(String, ModelInfo)> {\n let mut models = Vec::new();\n\n if let Some((name, info)) = self.agent_model_spec() {\n Self::upsert_model(&mut models, name, info);\n }\n\n if let Some((name, info)) = self.sub_agent_model_spec() {\n Self::upsert_model(&mut models, name, info);\n }\n\n if let Some((name, info)) = self.wiki_memory_writer_model_spec() {\n Self::upsert_model(&mut models, name, info);\n }\n\n if let Some((name, info)) = self.media_model_spec() {\n Self::upsert_model(&mut models, name, info);\n }\n\n models\n }\n\n fn resolve_execution_model(&self, prefer_sub_agent: bool) -> ModelInfo {\n if prefer_sub_agent && let Some((_, info)) = self.sub_agent_model_spec() {\n return info;\n }\n if let Some((_, info)) = self.agent_model_spec() {\n return info;\n }\n ModelInfo::default()\n }\n\n /// Returns the configured model info for the main agent.\n pub fn get_configured_agent_model(&self) -> ModelInfo {\n self.configured_agent_route_primary()\n .unwrap_or_else(|| self.resolve_execution_model(false))\n }\n\n /// Returns the configured temperature for the main agent.\n pub fn get_configured_agent_temperature(&self) -> Option<f32> {\n self.agent_model_temperature\n }\n\n /// Returns the configured weighted routes for the main agent.\n pub fn get_configured_agent_model_routes(&self) -> Vec<ModelInfo> {\n let routes = self\n .agent_model_routes\n .as_deref()\n .map(|routes| {\n Self::normalize_model_routes(\n routes,\n self.agent_model_max_output_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS),\n self.agent_model_context_window_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS),\n )\n })\n .unwrap_or_default();\n\n if routes.is_empty() {\n vec![self.resolve_execution_model(false)]\n } else {\n routes\n }\n }\n\n /// Returns the configured model info for the sub-agent.\n pub fn get_configured_sub_agent_model(&self) -> ModelInfo {\n if let Some(primary) = self.configured_sub_agent_route_primary() {\n return primary;\n }\n self.resolve_execution_model(true)\n }\n\n /// Returns the configured weighted routes for the sub-agent.\n pub fn get_configured_sub_agent_model_routes(&self) -> Vec<ModelInfo> {\n let routes = self\n .sub_agent_model_routes\n .as_deref()\n .map(|routes| {\n Self::normalize_model_routes(\n routes,\n self.sub_agent_max_output_tokens\n .unwrap_or(DEFAULT_SUB_AGENT_MODEL_MAX_OUTPUT_TOKENS),\n self.sub_agent_context_window_tokens_or_inherited(),\n )\n })\n .unwrap_or_default();\n\n if !routes.is_empty() {\n return routes;\n }\n\n if self.sub_agent_model_spec().is_some() {\n vec![self.resolve_execution_model(true)]\n } else {\n self.get_configured_agent_model_routes()\n }\n }\n\n /// Determine whether LLM-assisted background Wiki Memory writing is enabled.\n #[must_use]\n pub fn is_wiki_memory_writer_enabled(&self) -> bool {\n self.wiki_memory_writer_enabled.unwrap_or(false)\n && self.wiki_memory_writer_model_spec().is_some()\n }\n\n /// Returns the configured model info for the background Wiki Memory writer.\n #[must_use]\n pub fn get_configured_wiki_memory_writer_model(&self) -> Option<ModelInfo> {\n self.wiki_memory_writer_model_spec().map(|(_, model)| model)\n }\n\n /// Returns the background Wiki Memory writer timeout in seconds.\n #[must_use]\n pub fn get_wiki_memory_writer_timeout_secs(&self) -> u64 {\n self.wiki_memory_writer_timeout_secs\n .unwrap_or(WIKI_MEMORY_WRITER_TIMEOUT_SECS)\n .max(1)\n }\n\n fn configured_agent_route_primary(&self) -> Option<ModelInfo> {\n self.agent_model_routes.as_deref().and_then(|routes| {\n Self::normalize_model_routes(\n routes,\n self.agent_model_max_output_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS),\n self.agent_model_context_window_tokens\n .unwrap_or(DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS),\n )\n .into_iter()\n .next()\n })\n }\n\n fn configured_sub_agent_route_primary(&self) -> Option<ModelInfo> {\n self.sub_agent_model_routes.as_deref().and_then(|routes| {\n Self::normalize_model_routes(\n routes,\n self.sub_agent_max_output_tokens\n .unwrap_or(DEFAULT_SUB_AGENT_MODEL_MAX_OUTPUT_TOKENS),\n self.sub_agent_context_window_tokens_or_inherited(),\n )\n .into_iter()\n .next()\n })\n }\n\n /// Returns the internal Agent Mode context budget for the configured primary route.\n pub fn get_agent_internal_context_budget_tokens(&self) -> usize {\n resolve_internal_context_budget_tokens(\n self.get_configured_agent_model().context_window_tokens,\n DEFAULT_AGENT_INTERNAL_CONTEXT_WINDOW_TOKENS,\n )\n }\n\n /// Returns the internal sub-agent context budget, inheriting the main-agent budget by default.\n pub fn get_sub_agent_internal_context_budget_tokens(&self) -> usize {\n resolve_internal_context_budget_tokens(\n self.get_configured_sub_agent_model().context_window_tokens,\n self.get_agent_internal_context_budget_tokens(),\n )\n }\n\n fn inherited_sub_agent_context_window_tokens(&self) -> u32 {\n let inherited = self.get_configured_agent_model().context_window_tokens;\n if inherited == 0 {\n DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS\n } else {\n inherited\n }\n }\n\n fn sub_agent_context_window_tokens_or_inherited(&self) -> u32 {\n self.sub_agent_context_window_tokens\n .filter(|tokens| *tokens != 0)\n .unwrap_or_else(|| self.inherited_sub_agent_context_window_tokens())\n }\n\n /// Returns the configured media model (id, provider)\n pub fn get_media_model(&self) -> (String, String) {\n if let (Some(id), Some(provider)) = (&self.media_model_id, &self.media_model_provider) {\n return (id.clone(), provider.clone());\n }\n (String::new(), String::new())\n }\n\n /// Returns the configured agent timeout in seconds\n pub fn get_agent_timeout_secs(&self) -> u64 {\n self.agent_timeout_secs.unwrap_or(AGENT_TIMEOUT_SECS)\n }\n\n /// Returns the configured sub-agent timeout in seconds\n pub fn get_sub_agent_timeout_secs(&self) -> u64 {\n self.sub_agent_timeout_secs\n .unwrap_or(SUB_AGENT_TIMEOUT_SECS)\n }\n\n /// Returns true unless the compiled module is explicitly disabled by config.\n #[must_use]\n pub fn is_module_enabled(&self, module_id: &str) -> bool {\n self.modules\n .get(module_id)\n .is_none_or(ModuleRuntimeConfig::enabled_or_default)\n }\n}\n\n#[cfg(test)]\npub(crate) fn test_env_mutex() -> &'static std::sync::Mutex<()> {\n use std::sync::OnceLock;\n\n static ENV_MUTEX: OnceLock<std::sync::Mutex<()>> = OnceLock::new();\n ENV_MUTEX.get_or_init(|| std::sync::Mutex::new(()))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::testing::{test_remove_env, test_set_env};\n use serde_json::json;\n use std::env;\n\n #[cfg(any(\n feature = \"llm-minimax\",\n feature = \"llm-openai-base\",\n feature = \"llm-opencode-go\",\n feature = \"llm-openrouter\"\n ))]\n fn clear_model_route_env() {\n let keys: Vec<String> = env::vars()\n .map(|(key, _)| key)\n .filter(|key| {\n key.starts_with(\"AGENT_MODEL_ROUTES__\")\n || key.starts_with(\"SUB_AGENT_MODEL_ROUTES__\")\n })\n .collect();\n for key in keys {\n test_remove_env(key);\n }\n }\n\n #[cfg(feature = \"llm-opencode-go\")]\n fn clear_opencode_go_env() {\n for key in [\n \"OPENCODE_API_KEY\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"OPENCODE_GO_API_BASE\",\n \"OPENCODE_GO_MODELS_URL\",\n \"OPENCODE_GO_MODEL_CACHE_TTL_SECS\",\n ] {\n test_remove_env(key);\n }\n }\n\n // Tests run sequentially to avoid environment variable race conditions\n #[cfg(feature = \"llm-openrouter\")]\n #[test]\n fn test_config_env_loading() -> Result<(), Box<dyn std::error::Error>> {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n\n test_set_env(\"OPENROUTER_API_KEY\", \"dummy_openrouter_key\");\n\n test_set_env(\"AGENT_MODEL_ID\", \"deepseek/deepseek-v4-flash\");\n test_set_env(\"AGENT_MODEL_PROVIDER\", \"openrouter\");\n test_set_env(\"AGENT_MODEL_TEMPERATURE\", \"0.42\");\n\n let settings = AgentSettings::new()?;\n assert_eq!(settings.get_configured_agent_temperature(), Some(0.42));\n\n test_remove_env(\"AGENT_MODEL_ID\");\n test_remove_env(\"AGENT_MODEL_PROVIDER\");\n test_remove_env(\"AGENT_MODEL_TEMPERATURE\");\n\n // 2. Test empty env var ignored by direct fallback parsing.\n test_set_env(\"AGENT_MODEL_ID\", \"deepseek/deepseek-v4-flash\");\n test_set_env(\"AGENT_MODEL_PROVIDER\", \"openrouter\");\n test_set_env(\"AGENT_MODEL_TEMPERATURE\", \"\");\n\n let settings = AgentSettings::new()?;\n assert_eq!(settings.get_configured_agent_temperature(), None);\n\n test_remove_env(\"AGENT_MODEL_ID\");\n test_remove_env(\"AGENT_MODEL_PROVIDER\");\n test_remove_env(\"AGENT_MODEL_TEMPERATURE\");\n\n // 3. Test explicit environment mapping case.\n test_set_env(\"AGENT_MODEL_ID\", \"deepseek/deepseek-v4-flash\");\n test_set_env(\"AGENT_MODEL_PROVIDER\", \"openrouter\");\n test_set_env(\"AGENT_MODEL_TEMPERATURE\", \"0.13\");\n\n let settings = AgentSettings::new()?;\n assert_eq!(settings.get_configured_agent_temperature(), Some(0.13));\n\n test_remove_env(\"AGENT_MODEL_ID\");\n test_remove_env(\"AGENT_MODEL_PROVIDER\");\n test_remove_env(\"AGENT_MODEL_TEMPERATURE\");\n\n test_remove_env(\"OPENROUTER_API_KEY\");\n Ok(())\n }\n\n #[test]\n fn module_runtime_settings_deserialize_enabled_flags() {\n let settings: ModuleRuntimeSettings = serde_json::from_value(json!({\n \"modules\": {\n \"tool/a\": { \"enabled\": false, \"endpoint\": \"https://example.test\" },\n \"tool/b\": {}\n }\n }))\n .expect(\"module runtime settings should deserialize\");\n\n assert!(!settings.modules[\"tool/a\"].enabled_or_default());\n assert!(settings.modules[\"tool/b\"].enabled_or_default());\n assert_eq!(\n settings.modules[\"tool/a\"].string_value(\"endpoint\"),\n Some(\"https://example.test\")\n );\n }\n\n #[test]\n fn sandbox_backend_config_parses_supported_values() {\n assert_eq!(\n \"docker\"\n .parse::<SandboxBackendConfig>()\n .expect(\"supported docker sandbox backend should parse\"),\n SandboxBackendConfig::Docker\n );\n assert_eq!(\n \" broker \"\n .parse::<SandboxBackendConfig>()\n .expect(\"supported broker sandbox backend should parse\"),\n SandboxBackendConfig::Broker\n );\n }\n\n #[test]\n fn sandbox_backend_config_rejects_invalid_values_with_actionable_error() {\n let error = \"podman\"\n .parse::<SandboxBackendConfig>()\n .expect_err(\"invalid sandbox backend should be rejected\");\n\n assert!(error.contains(\"Invalid SANDBOX_BACKEND='podman'\"));\n assert!(error.contains(\"docker, broker\"));\n }\n\n #[test]\n fn sandbox_backend_env_parsing_handles_broker_mode() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n let previous = env::var_os(\"SANDBOX_BACKEND\");\n\n test_set_env(\"SANDBOX_BACKEND\", \"broker\");\n assert_eq!(\n get_sandbox_backend_config().expect(\"broker sandbox backend env should parse\"),\n SandboxBackendConfig::Broker\n );\n assert!(sandbox_uses_broker());\n\n match previous {\n Some(value) => test_set_env(\"SANDBOX_BACKEND\", value),\n None => test_remove_env(\"SANDBOX_BACKEND\"),\n }\n }\n\n #[test]\n fn route_provider_validation_rejects_non_compiled_provider() {\n let settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"removed-provider\".to_string()),\n ..AgentSettings::default()\n };\n\n let error = settings\n .validate_route_providers()\n .expect_err(\"unknown provider should fail\");\n\n assert!(\n error\n .to_string()\n .contains(\"AGENT_MODEL_PROVIDER references provider 'removed-provider'\")\n );\n assert!(\n error\n .to_string()\n .contains(\"no compiled LLM provider module owns that provider alias or ID\")\n );\n }\n\n #[cfg(feature = \"llm-openai-base\")]\n #[test]\n fn route_validation_accepts_openai_base_zai_instance() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n for key in [\n \"OPENAI_BASE_PROVIDERS__0__NAME\",\n \"OPENAI_BASE_PROVIDERS__0__API_BASE\",\n \"OPENAI_BASE_PROVIDERS__0__API_KEY\",\n \"OPENAI_BASE_PROVIDERS__0__PROFILE\",\n ] {\n test_remove_env(key);\n }\n test_set_env(\"OPENAI_BASE_PROVIDERS__0__NAME\", \"zai\");\n test_set_env(\n \"OPENAI_BASE_PROVIDERS__0__API_BASE\",\n \"https://api.z.ai/api/coding/paas/v4\",\n );\n test_set_env(\"OPENAI_BASE_PROVIDERS__0__API_KEY\", \"test-zai-key\");\n test_set_env(\"OPENAI_BASE_PROVIDERS__0__PROFILE\", \"zai\");\n\n let mut settings = AgentSettings {\n agent_model_id: Some(\"glm-4.7\".to_string()),\n agent_model_provider: Some(\"openai-base:zai\".to_string()),\n ..AgentSettings::default()\n };\n\n settings\n .validate_route_providers()\n .expect(\"openai-base:zai provider should validate\");\n settings\n .validate_route_credentials()\n .expect(\"configured openai-base:zai endpoint should validate\");\n settings\n .canonicalize_route_provider_ids()\n .expect(\"openai-base:zai should canonicalize\");\n settings\n .validate_route_model_capabilities()\n .expect(\"openai-base:zai glm route should support agent tools\");\n assert_eq!(\n settings.agent_model_provider.as_deref(),\n Some(\"openai-base:zai\")\n );\n\n for key in [\n \"OPENAI_BASE_PROVIDERS__0__NAME\",\n \"OPENAI_BASE_PROVIDERS__0__API_BASE\",\n \"OPENAI_BASE_PROVIDERS__0__API_KEY\",\n \"OPENAI_BASE_PROVIDERS__0__PROFILE\",\n ] {\n test_remove_env(key);\n }\n }\n\n #[test]\n fn route_provider_validation_rejects_non_compiled_weighted_route() {\n let settings = AgentSettings {\n agent_model_routes: Some(vec![ModelInfo {\n id: \"route-model\".to_string(),\n provider: \"removed-provider\".to_string(),\n max_output_tokens: 10_000,\n context_window_tokens: 20_000,\n weight: 1,\n }]),\n ..AgentSettings::default()\n };\n\n let error = settings\n .validate_route_providers()\n .expect_err(\"unknown weighted route provider should fail\");\n\n assert!(\n error\n .to_string()\n .contains(\"AGENT_MODEL_ROUTES[0].provider references provider 'removed-provider'\")\n );\n }\n\n #[cfg(feature = \"llm-openrouter\")]\n #[test]\n fn route_provider_validation_accepts_compiled_provider_alias_and_id() {\n let settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"openrouter\".to_string()),\n media_model_id: Some(\"media-model\".to_string()),\n media_model_provider: Some(\"llm-provider/openrouter\".to_string()),\n ..AgentSettings::default()\n };\n\n settings\n .validate_route_providers()\n .expect(\"compiled provider alias and id should validate\");\n }\n\n #[cfg(feature = \"llm-openrouter\")]\n #[test]\n fn route_provider_validation_rejects_disabled_provider_module() {\n let mut settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"openrouter\".to_string()),\n ..AgentSettings::default()\n };\n settings.modules.insert(\n \"llm-provider/openrouter\".to_string(),\n ModuleRuntimeConfig::disabled(),\n );\n\n let error = settings\n .validate_route_providers()\n .expect_err(\"disabled provider module should fail\");\n\n assert!(error.to_string().contains(\n \"AGENT_MODEL_PROVIDER references provider 'openrouter', but module 'llm-provider/openrouter' is disabled\"\n ));\n }\n\n #[cfg(feature = \"llm-openrouter\")]\n #[test]\n fn route_provider_canonicalization_rewrites_aliases_to_module_ids() {\n let mut settings = AgentSettings {\n agent_model_id: Some(\"deepseek/deepseek-v4-flash\".to_string()),\n agent_model_provider: Some(\" OpenRouter \".to_string()),\n media_model_id: Some(\"google/gemini-3-flash-preview\".to_string()),\n media_model_provider: Some(\"llm-provider/openrouter\".to_string()),\n agent_model_routes: Some(vec![ModelInfo {\n id: \"deepseek/deepseek-v4-flash\".to_string(),\n provider: \"openrouter\".to_string(),\n max_output_tokens: 10_000,\n context_window_tokens: 20_000,\n weight: 1,\n }]),\n sub_agent_model_routes: Some(vec![ModelInfo {\n id: \"sub-agent-route\".to_string(),\n provider: \"llm-provider/openrouter\".to_string(),\n max_output_tokens: 10_000,\n context_window_tokens: 20_000,\n weight: 1,\n }]),\n ..AgentSettings::default()\n };\n\n settings\n .validate_route_providers()\n .expect(\"aliases should validate before canonicalization\");\n settings\n .canonicalize_route_provider_ids()\n .expect(\"aliases should canonicalize\");\n\n assert_eq!(\n settings.agent_model_provider.as_deref(),\n Some(\"llm-provider/openrouter\")\n );\n assert_eq!(\n settings.media_model_provider.as_deref(),\n Some(\"llm-provider/openrouter\")\n );\n assert_eq!(\n settings\n .agent_model_routes\n .as_ref()\n .expect(\"agent routes should stay configured\")[0]\n .provider,\n \"llm-provider/openrouter\"\n );\n assert_eq!(\n settings\n .sub_agent_model_routes\n .as_ref()\n .expect(\"sub-agent routes should stay configured\")[0]\n .provider,\n \"llm-provider/openrouter\"\n );\n assert_eq!(\n settings.get_available_models()[0].1.provider,\n \"llm-provider/openrouter\"\n );\n }\n\n #[cfg(feature = \"llm-openrouter\")]\n #[test]\n fn route_model_validation_rejects_unapproved_openrouter_agent_model() {\n let mut settings = AgentSettings {\n agent_model_id: Some(\"unknown/model\".to_string()),\n agent_model_provider: Some(\"openrouter\".to_string()),\n ..AgentSettings::default()\n };\n\n settings\n .canonicalize_route_provider_ids()\n .expect(\"provider should canonicalize\");\n let error = settings\n .validate_route_model_capabilities()\n .expect_err(\"unknown OpenRouter agent model should be rejected\");\n\n assert!(\n error.to_string().contains(\n \"AGENT_MODEL route llm-provider/openrouter/unknown/model is not approved\"\n )\n );\n }\n\n #[cfg(feature = \"llm-openrouter\")]\n #[test]\n fn route_model_validation_rejects_unapproved_openrouter_media_model() {\n let mut settings = AgentSettings {\n agent_model_id: Some(\"deepseek/deepseek-v4-flash\".to_string()),\n agent_model_provider: Some(\"openrouter\".to_string()),\n media_model_id: Some(\"unknown/model\".to_string()),\n media_model_provider: Some(\"openrouter\".to_string()),\n ..AgentSettings::default()\n };\n\n settings\n .canonicalize_route_provider_ids()\n .expect(\"provider should canonicalize\");\n let error = settings\n .validate_route_model_capabilities()\n .expect_err(\"unknown OpenRouter media model should be rejected\");\n\n assert!(\n error.to_string().contains(\n \"MEDIA_MODEL route llm-provider/openrouter/unknown/model is not approved\"\n )\n );\n }\n\n #[cfg(feature = \"llm-opencode-go\")]\n #[test]\n fn route_credentials_validation_resolves_provider_module_ids() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_opencode_go_env();\n let settings = AgentSettings {\n agent_model_id: Some(\"deepseek-v4-flash\".to_string()),\n agent_model_provider: Some(\"llm-provider/opencode-go\".to_string()),\n ..AgentSettings::default()\n };\n\n let error = settings\n .validate_route_credentials()\n .expect_err(\"missing OpenCode Go key should fail for module id provider\");\n\n assert!(error.to_string().contains(\"OPENCODE_API_KEY\"));\n clear_opencode_go_env();\n }\n\n #[test]\n fn test_agent_internal_context_budget_uses_model_window() {\n let settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"mock\".to_string()),\n agent_model_context_window_tokens: Some(500_000),\n ..AgentSettings::default()\n };\n\n assert_eq!(settings.get_agent_internal_context_budget_tokens(), 500_000);\n }\n\n #[test]\n fn test_sub_agent_runtime_model_keeps_separate_output_and_explicit_context_windows() {\n let settings = AgentSettings {\n sub_agent_model_id: Some(\"sub-model\".to_string()),\n sub_agent_model_provider: Some(\"mock\".to_string()),\n sub_agent_max_output_tokens: Some(12_000),\n sub_agent_context_window_tokens: Some(48_000),\n ..AgentSettings::default()\n };\n\n let model = settings.get_configured_sub_agent_model();\n assert_eq!(model.id, \"sub-model\");\n assert_eq!(model.provider, \"mock\");\n assert_eq!(model.max_output_tokens, 12_000);\n assert_eq!(model.context_window_tokens, 48_000);\n assert_eq!(\n settings.get_sub_agent_internal_context_budget_tokens(),\n 48_000\n );\n }\n\n #[test]\n fn test_sub_agent_runtime_model_inherits_agent_context_window_by_default() {\n let settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"mock\".to_string()),\n agent_model_context_window_tokens: Some(320_000),\n sub_agent_model_id: Some(\"sub-model\".to_string()),\n sub_agent_model_provider: Some(\"mock\".to_string()),\n sub_agent_max_output_tokens: Some(12_000),\n ..AgentSettings::default()\n };\n\n let model = settings.get_configured_sub_agent_model();\n assert_eq!(model.id, \"sub-model\");\n assert_eq!(model.provider, \"mock\");\n assert_eq!(model.max_output_tokens, 12_000);\n assert_eq!(model.context_window_tokens, 320_000);\n assert_eq!(\n settings.get_sub_agent_internal_context_budget_tokens(),\n 320_000\n );\n }\n\n #[test]\n fn test_sub_agent_zero_context_window_inherits_agent_context_window() {\n let settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"mock\".to_string()),\n agent_model_context_window_tokens: Some(256_000),\n sub_agent_model_id: Some(\"sub-model\".to_string()),\n sub_agent_model_provider: Some(\"mock\".to_string()),\n sub_agent_context_window_tokens: Some(0),\n ..AgentSettings::default()\n };\n\n let model = settings.get_configured_sub_agent_model();\n assert_eq!(model.id, \"sub-model\");\n assert_eq!(model.context_window_tokens, 256_000);\n assert_eq!(\n settings.get_sub_agent_internal_context_budget_tokens(),\n 256_000\n );\n }\n\n #[test]\n fn test_sub_agent_routes_without_context_window_inherit_agent_context_window() {\n let settings = AgentSettings {\n agent_model_id: Some(\"agent-model\".to_string()),\n agent_model_provider: Some(\"mock\".to_string()),\n agent_model_context_window_tokens: Some(384_000),\n sub_agent_model_routes: Some(vec![ModelInfo {\n id: \"sub-route\".to_string(),\n provider: \"mock\".to_string(),\n max_output_tokens: 12_000,\n context_window_tokens: 0,\n weight: 1,\n }]),\n ..AgentSettings::default()\n };\n\n let routes = settings.get_configured_sub_agent_model_routes();\n assert_eq!(routes.len(), 1);\n assert_eq!(routes[0].id, \"sub-route\");\n assert_eq!(routes[0].context_window_tokens, 384_000);\n assert_eq!(\n settings.get_sub_agent_internal_context_budget_tokens(),\n 384_000\n );\n }\n\n #[cfg(all(feature = \"llm-minimax\", feature = \"llm-openai-base\"))]\n #[test]\n fn test_model_routes_parse_from_env_and_override_primary_models() -> Result<(), ConfigError> {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_model_route_env();\n\n test_set_env(\"OPENAI_BASE_PROVIDERS__1__NAME\", \"zai\");\n test_set_env(\n \"OPENAI_BASE_PROVIDERS__1__API_BASE\",\n \"https://api.z.ai/api/coding/paas/v4\",\n );\n test_set_env(\"OPENAI_BASE_PROVIDERS__1__API_KEY\", \"test-zai-key\");\n test_set_env(\"OPENAI_BASE_PROVIDERS__1__PROFILE\", \"zai\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__ID\", \"claude-3-5-sonnet\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__PROVIDER\", \"anthropic\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__MAX_OUTPUT_TOKENS\", \"32000\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__CONTEXT_WINDOW_TOKENS\", \"204800\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__WEIGHT\", \"10\");\n test_set_env(\"AGENT_MODEL_ROUTES__1__ID\", \"glm-4.7\");\n test_set_env(\"AGENT_MODEL_ROUTES__1__PROVIDER\", \"openai-base:zai\");\n test_set_env(\"AGENT_MODEL_ROUTES__1__MAX_OUTPUT_TOKENS\", \"32000\");\n test_set_env(\"AGENT_MODEL_ROUTES__1__CONTEXT_WINDOW_TOKENS\", \"200000\");\n test_set_env(\"AGENT_MODEL_ROUTES__1__WEIGHT\", \"3\");\n\n let settings = AgentSettings::new()?;\n let routes = settings.get_configured_agent_model_routes();\n let primary = settings.get_configured_agent_model();\n\n assert_eq!(routes.len(), 2);\n assert_eq!(routes[0].provider, \"llm-provider/anthropic\");\n assert_eq!(routes[0].weight, 10);\n assert_eq!(routes[1].provider, \"openai-base:zai\");\n assert_eq!(primary.id, \"claude-3-5-sonnet\");\n assert_eq!(primary.provider, \"llm-provider/anthropic\");\n\n for key in [\n \"AGENT_MODEL_ROUTES__0__ID\",\n \"AGENT_MODEL_ROUTES__0__PROVIDER\",\n \"AGENT_MODEL_ROUTES__0__MAX_OUTPUT_TOKENS\",\n \"AGENT_MODEL_ROUTES__0__CONTEXT_WINDOW_TOKENS\",\n \"AGENT_MODEL_ROUTES__0__WEIGHT\",\n \"AGENT_MODEL_ROUTES__1__ID\",\n \"AGENT_MODEL_ROUTES__1__PROVIDER\",\n \"AGENT_MODEL_ROUTES__1__MAX_OUTPUT_TOKENS\",\n \"AGENT_MODEL_ROUTES__1__CONTEXT_WINDOW_TOKENS\",\n \"AGENT_MODEL_ROUTES__1__WEIGHT\",\n \"OPENAI_BASE_PROVIDERS__1__NAME\",\n \"OPENAI_BASE_PROVIDERS__1__API_BASE\",\n \"OPENAI_BASE_PROVIDERS__1__API_KEY\",\n \"OPENAI_BASE_PROVIDERS__1__PROFILE\",\n ] {\n test_remove_env(key);\n }\n\n Ok(())\n }\n\n #[cfg(feature = \"llm-opencode-go\")]\n #[test]\n fn settings_resolves_opencode_go_module_env_config() -> Result<(), ConfigError> {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_model_route_env();\n clear_opencode_go_env();\n\n test_set_env(\"AGENT_MODEL_ID\", \"chat-model\");\n test_set_env(\"AGENT_MODEL_PROVIDER\", \"opencode-go\");\n test_set_env(\"OPENCODE_API_KEY\", \"opencode-key\");\n test_set_env(\n \"OPENCODE_GO_API_BASE\",\n \"https://opencode.example.test/v1/chat/completions\",\n );\n\n let settings = AgentSettings::new()?;\n\n assert_eq!(\n settings\n .module_string_value_or_envs(\n \"llm-provider/opencode-go\",\n \"api_key\",\n &[\n \"OPENCODE_API_KEY\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\"\n ]\n )\n .as_deref(),\n Some(\"opencode-key\")\n );\n assert_eq!(\n settings.module_string_value_or_env_or_default(\n \"llm-provider/opencode-go\",\n \"api_base\",\n \"OPENCODE_GO_API_BASE\",\n \"https://opencode.ai/zen/go/v1/chat/completions\",\n ),\n \"https://opencode.example.test/v1/chat/completions\"\n );\n\n test_remove_env(\"AGENT_MODEL_ID\");\n test_remove_env(\"AGENT_MODEL_PROVIDER\");\n clear_opencode_go_env();\n Ok(())\n }\n\n #[cfg(feature = \"llm-opencode-go\")]\n #[test]\n fn settings_bootstraps_opencode_go_route_from_api_key_only() -> Result<(), ConfigError> {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_model_route_env();\n clear_opencode_go_env();\n test_remove_env(\"AGENT_MODEL_ID\");\n test_remove_env(\"AGENT_MODEL_PROVIDER\");\n\n test_set_env(\"OPENCODE_API_KEY\", \"opencode-key\");\n\n let settings = AgentSettings::new()?;\n let primary = settings.get_configured_agent_model();\n\n assert_eq!(primary.id, \"opencode-go/deepseek-v4-flash\");\n assert_eq!(primary.provider, \"llm-provider/opencode-go\");\n assert_eq!(\n primary.max_output_tokens,\n DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS\n );\n assert_eq!(\n primary.context_window_tokens,\n DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS\n );\n\n clear_opencode_go_env();\n Ok(())\n }\n\n #[cfg(feature = \"llm-opencode-go\")]\n #[test]\n fn settings_do_not_require_unrelated_provider_key_when_active_routes_use_opencode_go()\n -> Result<(), ConfigError> {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_model_route_env();\n clear_opencode_go_env();\n\n test_set_env(\"OPENCODE_GO_API_KEY\", \"opencode-key\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__ID\", \"deepseek-v4-flash\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__PROVIDER\", \"opencode-go\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__MAX_OUTPUT_TOKENS\", \"32000\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__CONTEXT_WINDOW_TOKENS\", \"200000\");\n\n let settings = AgentSettings::new()?;\n let primary = settings.get_configured_agent_model();\n\n assert_eq!(primary.id, \"deepseek-v4-flash\");\n assert_eq!(primary.provider, \"llm-provider/opencode-go\");\n assert_eq!(\n settings.module_string_value_or_env_or_default(\n \"llm-provider/opencode-go\",\n \"api_base\",\n \"OPENCODE_GO_API_BASE\",\n \"https://opencode.ai/zen/go/v1/chat/completions\",\n ),\n \"https://opencode.ai/zen/go/v1/chat/completions\"\n );\n\n clear_model_route_env();\n clear_opencode_go_env();\n Ok(())\n }\n\n #[cfg(feature = \"llm-opencode-go\")]\n #[test]\n fn settings_error_when_active_opencode_go_key_missing() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_model_route_env();\n clear_opencode_go_env();\n\n test_set_env(\"AGENT_MODEL_ROUTES__0__ID\", \"deepseek-v4-flash\");\n test_set_env(\"AGENT_MODEL_ROUTES__0__PROVIDER\", \"opencode_go\");\n\n let error = AgentSettings::new().expect_err(\"missing OpenCode Go key should fail\");\n assert!(error.to_string().contains(\"OPENCODE_API_KEY\"));\n\n clear_model_route_env();\n clear_opencode_go_env();\n }\n\n #[test]\n fn tavily_enabled_flag_overrides_api_key_fallback() {\n test_set_env(\"TAVILY_API_KEY\", \"dummy-key\");\n test_set_env(\"TAVILY_ENABLED\", \"false\");\n\n assert!(!is_tavily_enabled());\n\n test_remove_env(\"TAVILY_ENABLED\");\n test_remove_env(\"TAVILY_API_KEY\");\n }\n\n fn clear_brave_search_env() {\n for key in [\n \"BRAVE_SEARCH_API_KEY\",\n \"BRAVE_SEARCH_ENABLED\",\n \"BRAVE_SEARCH_TIMEOUT_SECS\",\n \"BRAVE_SEARCH_COUNTRY\",\n \"BRAVE_SEARCH_LANG\",\n \"BRAVE_SEARCH_UI_LANG\",\n \"BRAVE_SEARCH_SAFESEARCH\",\n \"BRAVE_SEARCH_MAX_CONCURRENT\",\n \"BRAVE_SEARCH_MIN_DELAY_MS\",\n ] {\n test_remove_env(key);\n }\n }\n\n #[test]\n fn brave_search_enabled_defaults_to_key_presence() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_brave_search_env();\n\n assert!(!is_brave_search_enabled());\n\n test_set_env(\"BRAVE_SEARCH_API_KEY\", \"brave-key\");\n assert!(is_brave_search_enabled());\n\n clear_brave_search_env();\n }\n\n #[test]\n fn brave_search_enabled_flag_overrides_key_presence() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_brave_search_env();\n\n test_set_env(\"BRAVE_SEARCH_API_KEY\", \"brave-key\");\n test_set_env(\"BRAVE_SEARCH_ENABLED\", \"false\");\n assert!(!is_brave_search_enabled());\n\n test_remove_env(\"BRAVE_SEARCH_API_KEY\");\n test_set_env(\"BRAVE_SEARCH_ENABLED\", \"true\");\n assert!(is_brave_search_enabled());\n assert_eq!(get_brave_search_api_key(), None);\n\n clear_brave_search_env();\n }\n\n #[test]\n fn brave_search_config_uses_defaults_when_env_missing() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_brave_search_env();\n\n assert_eq!(\n get_brave_search_timeout(),\n BRAVE_SEARCH_DEFAULT_TIMEOUT_SECS\n );\n assert_eq!(get_brave_search_country(), BRAVE_SEARCH_DEFAULT_COUNTRY);\n assert_eq!(get_brave_search_lang(), BRAVE_SEARCH_DEFAULT_LANG);\n assert_eq!(get_brave_search_ui_lang(), BRAVE_SEARCH_DEFAULT_UI_LANG);\n assert_eq!(\n get_brave_search_safesearch(),\n BRAVE_SEARCH_DEFAULT_SAFESEARCH\n );\n assert_eq!(\n get_brave_search_max_concurrent(),\n BRAVE_SEARCH_DEFAULT_MAX_CONCURRENT\n );\n assert_eq!(\n get_brave_search_min_delay_ms(),\n BRAVE_SEARCH_DEFAULT_MIN_DELAY_MS\n );\n }\n\n #[test]\n fn brave_search_config_parses_non_empty_env_values() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n clear_brave_search_env();\n\n test_set_env(\"BRAVE_SEARCH_TIMEOUT_SECS\", \"7\");\n test_set_env(\"BRAVE_SEARCH_COUNTRY\", \"DE\");\n test_set_env(\"BRAVE_SEARCH_LANG\", \"de\");\n test_set_env(\"BRAVE_SEARCH_UI_LANG\", \"de-DE\");\n test_set_env(\"BRAVE_SEARCH_SAFESEARCH\", \"strict\");\n test_set_env(\"BRAVE_SEARCH_MAX_CONCURRENT\", \"2\");\n test_set_env(\"BRAVE_SEARCH_MIN_DELAY_MS\", \"500\");\n\n assert_eq!(get_brave_search_timeout(), 7);\n assert_eq!(get_brave_search_country(), \"DE\");\n assert_eq!(get_brave_search_lang(), \"de\");\n assert_eq!(get_brave_search_ui_lang(), \"de-DE\");\n assert_eq!(get_brave_search_safesearch(), \"strict\");\n assert_eq!(get_brave_search_max_concurrent(), 2);\n assert_eq!(get_brave_search_min_delay_ms(), 500);\n\n clear_brave_search_env();\n }\n\n #[test]\n fn crw_disabled_by_default() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_remove_env(\"OXIDE_CRW_ENABLED\");\n\n assert!(!is_crw_enabled());\n }\n\n #[test]\n fn crw_enabled_flag() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_set_env(\"OXIDE_CRW_ENABLED\", \"true\");\n assert!(is_crw_enabled());\n\n test_set_env(\"OXIDE_CRW_ENABLED\", \"false\");\n assert!(!is_crw_enabled());\n\n test_remove_env(\"OXIDE_CRW_ENABLED\");\n }\n\n #[test]\n fn crw_base_url_defaults_to_localhost() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_remove_env(\"OXIDE_CRW_BASE_URL\");\n\n assert_eq!(get_crw_base_url(), \"http://127.0.0.1:3000\");\n }\n\n #[test]\n fn crw_base_url_uses_env() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_set_env(\"OXIDE_CRW_BASE_URL\", \"http://crw:3000\");\n assert_eq!(get_crw_base_url(), \"http://crw:3000\");\n\n test_set_env(\"OXIDE_CRW_BASE_URL\", \" \");\n assert_eq!(get_crw_base_url(), \"http://127.0.0.1:3000\");\n\n test_remove_env(\"OXIDE_CRW_BASE_URL\");\n }\n\n #[test]\n fn crw_api_token_none_when_unset_or_blank() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_remove_env(\"OXIDE_CRW_API_TOKEN\");\n\n assert_eq!(get_crw_api_token(), None);\n\n test_set_env(\"OXIDE_CRW_API_TOKEN\", \" \");\n assert_eq!(get_crw_api_token(), None);\n }\n\n #[test]\n fn crw_api_token_uses_trimmed_env() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_set_env(\"OXIDE_CRW_API_TOKEN\", \" secret-token \");\n assert_eq!(get_crw_api_token(), Some(\"secret-token\".to_string()));\n\n test_remove_env(\"OXIDE_CRW_API_TOKEN\");\n }\n\n #[test]\n fn crw_timeout_defaults_to_30() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_remove_env(\"OXIDE_CRW_TIMEOUT_SECS\");\n\n assert_eq!(get_crw_timeout_secs(), 30);\n }\n\n #[test]\n fn crw_timeout_uses_env() {\n let _guard = test_env_mutex()\n .lock()\n .unwrap_or_else(|poisoned| poisoned.into_inner());\n test_set_env(\"OXIDE_CRW_TIMEOUT_SECS\", \"60\");\n assert_eq!(get_crw_timeout_secs(), 60);\n\n test_remove_env(\"OXIDE_CRW_TIMEOUT_SECS\");\n }\n}\n\n/// Information about a supported LLM model.\n#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]\npub struct ModelInfo {\n /// Internal model identifier\n pub id: String,\n /// Maximum allowed output tokens for a single response.\n pub max_output_tokens: u32,\n /// Maximum model context window available for the full request.\n #[serde(default)]\n pub context_window_tokens: u32,\n /// Provider name\n pub provider: String,\n /// Relative selection weight when used in a fallback route pool.\n #[serde(default = \"default_model_route_weight\")]\n pub weight: u32,\n}\n\nconst fn default_model_route_weight() -> u32 {\n 1\n}\n\n#[derive(Debug, Default)]\nstruct PartialModelRoute {\n id: Option<String>,\n provider: Option<String>,\n max_output_tokens: Option<u32>,\n context_window_tokens: Option<u32>,\n weight: Option<u32>,\n}\n\nimpl PartialModelRoute {\n fn into_model_info(self) -> Option<ModelInfo> {\n let id = self.id?.trim().to_string();\n let provider = self.provider?.trim().to_string();\n if id.is_empty() || provider.is_empty() {\n return None;\n }\n\n Some(ModelInfo {\n id,\n provider,\n max_output_tokens: self.max_output_tokens.unwrap_or_default(),\n context_window_tokens: self.context_window_tokens.unwrap_or_default(),\n weight: self\n .weight\n .unwrap_or_else(default_model_route_weight)\n .max(1),\n })\n }\n}\n\nfn resolve_internal_context_budget_tokens(\n model_context_window_tokens: u32,\n default: usize,\n) -> usize {\n let resolved_window = usize::try_from(model_context_window_tokens).unwrap_or(default);\n if resolved_window == 0 {\n default\n } else {\n resolved_window\n }\n}\n\n/// Get the agent model name from environment.\n#[must_use]\npub fn get_agent_model() -> String {\n std::env::var(\"AGENT_MODEL_ID\")\n .ok()\n .or_else(|| std::env::var(\"AGENT_MODEL_NAME\").ok())\n .unwrap_or_default()\n}\n\n/// Maximum iterations for agent loop\npub const AGENT_MAX_ITERATIONS: usize = 200;\n/// Maximum iterations for sub-agent loop\npub const SUB_AGENT_MAX_ITERATIONS: usize = 2000;\n/// Agent task timeout in seconds\npub const AGENT_TIMEOUT_SECS: u64 = 1800; // 30 minutes\n/// Sub-agent task timeout in seconds\npub const SUB_AGENT_TIMEOUT_SECS: u64 = 3600;\n/// Maximum timeout for individual tool call (in seconds)\n/// This prevents a single tool from blocking the agent indefinitely\npub const AGENT_TOOL_TIMEOUT_SECS: u64 = 300; // 5 minutes\n/// Default media model max output tokens.\npub const DEFAULT_MEDIA_MODEL_MAX_OUTPUT_TOKENS: u32 = 64_000;\n/// Default media model context window tokens.\npub const DEFAULT_MEDIA_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 64_000;\n/// Default internal text route max output tokens.\npub const DEFAULT_INTERNAL_TEXT_MAX_OUTPUT_TOKENS: u32 = 64_000;\n/// Default internal text route context window tokens.\npub const DEFAULT_INTERNAL_TEXT_CONTEXT_WINDOW_TOKENS: u32 = 64_000;\n/// Soft cap for a single agent response. It is not reserved from the input context window.\npub const AGENT_RESPONSE_SOFT_MAX_OUTPUT_TOKENS: u32 = 32_000;\n/// Default main-agent model max output tokens.\npub const DEFAULT_AGENT_MODEL_MAX_OUTPUT_TOKENS: u32 = AGENT_RESPONSE_SOFT_MAX_OUTPUT_TOKENS;\n/// Default main-agent model context window tokens.\npub const DEFAULT_AGENT_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 200_000;\n/// Default internal main-agent context budget when no model window is configured.\npub const DEFAULT_AGENT_INTERNAL_CONTEXT_WINDOW_TOKENS: usize = 200_000;\n/// Default sub-agent model max output tokens.\npub const DEFAULT_SUB_AGENT_MODEL_MAX_OUTPUT_TOKENS: u32 = AGENT_RESPONSE_SOFT_MAX_OUTPUT_TOKENS;\n/// Max forced continuations when todos incomplete\npub const AGENT_CONTINUATION_LIMIT: usize = 10; // Max forced continuations when todos incomplete\n/// Default limit for search tool calls per agent session\npub const AGENT_SEARCH_LIMIT: usize = 10;\n\n/// Maximum tokens for background Wiki Memory writer response.\npub const WIKI_MEMORY_WRITER_MAX_TOKENS: u32 = 4096;\n/// Default timeout for background Wiki Memory writer requests.\npub const WIKI_MEMORY_WRITER_TIMEOUT_SECS: u64 = 60;\n\n/// Get agent search limit from env or default.\n#[must_use]\npub fn get_agent_search_limit() -> usize {\n std::env::var(\"AGENT_SEARCH_LIMIT\")\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(AGENT_SEARCH_LIMIT)\n}\n\n/// Get forced continuation limit from env or default.\n#[must_use]\npub fn get_agent_continuation_limit() -> usize {\n std::env::var(\"AGENT_CONTINUATION_LIMIT\")\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(AGENT_CONTINUATION_LIMIT)\n}\n\n/// Get agent max iterations from env or default.\n#[must_use]\npub fn get_agent_max_iterations() -> usize {\n std::env::var(\"AGENT_MAX_ITERATIONS\")\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(AGENT_MAX_ITERATIONS)\n}\n\n/// Get sub-agent max iterations from env or default.\n#[must_use]\npub fn get_sub_agent_max_iterations() -> usize {\n std::env::var(\"SUB_AGENT_MAX_ITERATIONS\")\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(SUB_AGENT_MAX_ITERATIONS)\n}\n\n// Sandbox configuration\n/// Docker image for the sandbox\npub const SANDBOX_IMAGE: &str = \"agent-sandbox:latest\";\n/// Sandbox backend mode.\npub const SANDBOX_BACKEND: &str = \"docker\";\n/// Unix socket path for sandbox broker.\npub const SANDBOXD_SOCKET: &str = \"/run/sandboxd/sandboxd.sock\";\n/// Memory limit for sandbox container (1GB)\npub const SANDBOX_MEMORY_LIMIT: i64 = 1024 * 1024 * 1024; // 1GB\n/// CPU period for sandbox container\npub const SANDBOX_CPU_PERIOD: i64 = 100_000;\n/// CPU quota for sandbox container (2 CPUs)\npub const SANDBOX_CPU_QUOTA: i64 = 200_000; // 2 CPUs (200% of period)\n/// Timeout for individual command execution in sandbox\npub const SANDBOX_EXEC_TIMEOUT_SECS: u64 = 240; // 4 minutes per command\n\n/// Explicit sandbox backend selection.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum SandboxBackendConfig {\n /// Direct Docker backend.\n Docker,\n /// Unix-socket sandboxd broker backend.\n Broker,\n}\n\nimpl SandboxBackendConfig {\n /// Valid environment/config values.\n pub const VALID_VALUES: &'static [&'static str] = &[\"docker\", \"broker\"];\n\n /// Returns the stable environment string for this backend.\n #[must_use]\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Docker => \"docker\",\n Self::Broker => \"broker\",\n }\n }\n}\n\nimpl fmt::Display for SandboxBackendConfig {\n fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {\n formatter.write_str(self.as_str())\n }\n}\n\nimpl FromStr for SandboxBackendConfig {\n type Err = String;\n\n fn from_str(value: &str) -> Result<Self, Self::Err> {\n match value.trim().to_ascii_lowercase().as_str() {\n \"docker\" => Ok(Self::Docker),\n \"broker\" => Ok(Self::Broker),\n invalid => Err(format!(\n \"Invalid SANDBOX_BACKEND='{invalid}'. Valid values: {}.\",\n Self::VALID_VALUES.join(\", \")\n )),\n }\n }\n}\n\n/// Get sandbox image from env or default.\n///\n/// Environment variable: `SANDBOX_IMAGE`\n#[must_use]\npub fn get_sandbox_image() -> String {\n std::env::var(\"SANDBOX_IMAGE\")\n .ok()\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| SANDBOX_IMAGE.to_string())\n}\n\n/// Get sandbox backend mode from env or default.\n#[must_use]\npub fn get_sandbox_backend() -> String {\n std::env::var(\"SANDBOX_BACKEND\")\n .ok()\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| SANDBOX_BACKEND.to_string())\n}\n\n/// Parse sandbox backend mode from env or default.\n///\n/// # Errors\n///\n/// Returns an actionable error when `SANDBOX_BACKEND` is not one of the\n/// supported backend names.\npub fn get_sandbox_backend_config() -> Result<SandboxBackendConfig, String> {\n get_sandbox_backend().parse()\n}\n\n/// Check whether sandbox broker mode is enabled.\n#[must_use]\npub fn sandbox_uses_broker() -> bool {\n get_sandbox_backend_config() == Ok(SandboxBackendConfig::Broker)\n}\n\n/// Get sandbox broker Unix socket path from env or default.\n#[must_use]\npub fn get_sandboxd_socket() -> String {\n std::env::var(\"SANDBOXD_SOCKET\")\n .ok()\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| SANDBOXD_SOCKET.to_string())\n}\n\n/// Get compose project override for stack log discovery.\n///\n/// Environment variable: `STACK_LOGS_PROJECT`\n#[must_use]\npub fn get_stack_logs_project() -> Option<String> {\n std::env::var(\"STACK_LOGS_PROJECT\")\n .ok()\n .filter(|value| !value.is_empty())\n}\n\n/// Transport API retry configuration for file operations.\npub const TRANSPORT_API_MAX_RETRIES: usize = 3;\n/// Initial backoff delay in milliseconds for transport retries.\npub const TRANSPORT_API_INITIAL_BACKOFF_MS: u64 = 500;\n/// Maximum backoff delay in milliseconds for transport retries.\npub const TRANSPORT_API_MAX_BACKOFF_MS: u64 = 4000;\n\n// Public search provider HTTP client configuration\n/// Default timeout for Brave Search requests (seconds).\npub const BRAVE_SEARCH_DEFAULT_TIMEOUT_SECS: u64 = 10;\n/// Default Brave Search country targeting.\npub const BRAVE_SEARCH_DEFAULT_COUNTRY: &str = \"US\";\n/// Default Brave Search language targeting.\npub const BRAVE_SEARCH_DEFAULT_LANG: &str = \"en\";\n/// Default Brave Search UI language.\npub const BRAVE_SEARCH_DEFAULT_UI_LANG: &str = \"en-US\";\n/// Default Brave Search safe-search setting.\npub const BRAVE_SEARCH_DEFAULT_SAFESEARCH: &str = \"moderate\";\n/// Default process-wide Brave Search max concurrent operations.\npub const BRAVE_SEARCH_DEFAULT_MAX_CONCURRENT: usize = 1;\n/// Default process-wide Brave Search minimum delay between operations.\npub const BRAVE_SEARCH_DEFAULT_MIN_DELAY_MS: u64 = 1_000;\n\n/// Get Brave Search API key from env.\n///\n/// Environment variable: `BRAVE_SEARCH_API_KEY`\n#[must_use]\npub fn get_brave_search_api_key() -> Option<String> {\n std::env::var(\"BRAVE_SEARCH_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n}\n\n/// Get Brave Search timeout from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_TIMEOUT_SECS`\n#[must_use]\npub fn get_brave_search_timeout() -> u64 {\n parse_env_u64(\"BRAVE_SEARCH_TIMEOUT_SECS\").unwrap_or(BRAVE_SEARCH_DEFAULT_TIMEOUT_SECS)\n}\n\n/// Get Brave Search default country from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_COUNTRY`\n#[must_use]\npub fn get_brave_search_country() -> String {\n std::env::var(\"BRAVE_SEARCH_COUNTRY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| BRAVE_SEARCH_DEFAULT_COUNTRY.to_string())\n}\n\n/// Get Brave Search default search language from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_LANG`\n#[must_use]\npub fn get_brave_search_lang() -> String {\n std::env::var(\"BRAVE_SEARCH_LANG\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| BRAVE_SEARCH_DEFAULT_LANG.to_string())\n}\n\n/// Get Brave Search default UI language from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_UI_LANG`\n#[must_use]\npub fn get_brave_search_ui_lang() -> String {\n std::env::var(\"BRAVE_SEARCH_UI_LANG\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| BRAVE_SEARCH_DEFAULT_UI_LANG.to_string())\n}\n\n/// Get Brave Search safe-search setting from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_SAFESEARCH`\n#[must_use]\npub fn get_brave_search_safesearch() -> String {\n std::env::var(\"BRAVE_SEARCH_SAFESEARCH\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| BRAVE_SEARCH_DEFAULT_SAFESEARCH.to_string())\n}\n\n/// Get Brave Search process-wide max concurrent operations from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_MAX_CONCURRENT`\n#[must_use]\npub fn get_brave_search_max_concurrent() -> usize {\n parse_env_usize(\"BRAVE_SEARCH_MAX_CONCURRENT\")\n .filter(|value| *value > 0)\n .unwrap_or(BRAVE_SEARCH_DEFAULT_MAX_CONCURRENT)\n}\n\n/// Get Brave Search process-wide minimum delay from env or default.\n///\n/// Environment variable: `BRAVE_SEARCH_MIN_DELAY_MS`\n#[must_use]\npub fn get_brave_search_min_delay_ms() -> u64 {\n parse_env_u64(\"BRAVE_SEARCH_MIN_DELAY_MS\").unwrap_or(BRAVE_SEARCH_DEFAULT_MIN_DELAY_MS)\n}\n\n// CRW (web research) configuration\n/// Default timeout for CRW requests (seconds).\npub const CRW_DEFAULT_TIMEOUT_SECS: u64 = 30;\n\n/// Determine whether CRW tools should be registered.\n///\n/// Environment variable: `OXIDE_CRW_ENABLED`.\n/// Defaults to `false` unless explicitly set truthy.\n#[must_use]\npub fn is_crw_enabled() -> bool {\n parse_optional_env_bool(\"OXIDE_CRW_ENABLED\").unwrap_or(false)\n}\n\n/// Get CRW base URL from env or default.\n///\n/// Environment variable: `OXIDE_CRW_BASE_URL`.\n/// Default: `http://127.0.0.1:3000`.\n#[must_use]\npub fn get_crw_base_url() -> String {\n std::env::var(\"OXIDE_CRW_BASE_URL\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .unwrap_or_else(|| \"http://127.0.0.1:3000\".to_string())\n}\n\n/// Get optional CRW API token from env.\n///\n/// Environment variable: `OXIDE_CRW_API_TOKEN`.\n/// Used as Bearer token when non-empty.\n#[must_use]\npub fn get_crw_api_token() -> Option<String> {\n std::env::var(\"OXIDE_CRW_API_TOKEN\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n}\n\n/// Get CRW request timeout from env or default.\n///\n/// Environment variable: `OXIDE_CRW_TIMEOUT_SECS`.\n#[must_use]\npub fn get_crw_timeout_secs() -> u64 {\n parse_env_u64(\"OXIDE_CRW_TIMEOUT_SECS\").unwrap_or(CRW_DEFAULT_TIMEOUT_SECS)\n}\n\n/// Determine whether split URL-to-Markdown tools should be merged into `web_crawler`.\n///\n/// Environment variable: `OXIDE_WEB_CRAWLER_MERGE`.\n#[must_use]\npub fn is_web_crawler_merge_enabled() -> bool {\n parse_optional_env_bool(\"OXIDE_WEB_CRAWLER_MERGE\").unwrap_or(false)\n}\n\n/// Get max concurrent OpenCode Go requests from env or default.\n///\n/// Environment variable: `OPENCODE_GO_MAX_CONCURRENT`\n#[must_use]\npub fn get_opencode_go_max_concurrent() -> usize {\n std::env::var(\"OPENCODE_GO_MAX_CONCURRENT\")\n .ok()\n .and_then(|s| s.parse().ok())\n .filter(|value| *value > 0)\n .unwrap_or(OPENCODE_GO_DEFAULT_MAX_CONCURRENT)\n}\n\nfn parse_optional_env_bool(name: &str) -> Option<bool> {\n std::env::var(name)\n .ok()\n .and_then(|value| match value.trim().to_ascii_lowercase().as_str() {\n \"1\" | \"true\" | \"yes\" | \"on\" => Some(true),\n \"0\" | \"false\" | \"no\" | \"off\" => Some(false),\n _ => None,\n })\n}\n\nfn parse_env_u64(name: &str) -> Option<u64> {\n std::env::var(name)\n .ok()\n .and_then(|value| value.trim().parse().ok())\n}\n\nfn parse_env_usize(name: &str) -> Option<usize> {\n std::env::var(name)\n .ok()\n .and_then(|value| value.trim().parse().ok())\n}\n\nfn parse_optional_env_f32(name: &str) -> Option<f32> {\n std::env::var(name)\n .ok()\n .and_then(|value| value.trim().parse::<f32>().ok())\n}\n\n/// Determine whether Tavily tools should be registered.\n///\n/// Environment variable: `TAVILY_ENABLED`\n#[must_use]\npub fn is_tavily_enabled() -> bool {\n parse_optional_env_bool(\"TAVILY_ENABLED\").unwrap_or_else(|| {\n std::env::var(\"TAVILY_API_KEY\")\n .ok()\n .is_some_and(|value| !value.trim().is_empty())\n })\n}\n\n/// Determine whether Brave Search tools should be registered.\n///\n/// `BRAVE_SEARCH_ENABLED=false` disables registration. Without an explicit flag,\n/// registration is enabled only when `BRAVE_SEARCH_API_KEY` is non-empty.\n#[must_use]\npub fn is_brave_search_enabled() -> bool {\n parse_optional_env_bool(\"BRAVE_SEARCH_ENABLED\")\n .unwrap_or_else(|| get_brave_search_api_key().is_some())\n}\n\n// LLM HTTP client configuration\n/// Default timeout for LLM API HTTP requests (seconds).\n/// Generous default for large prompts and slow models; override with env LLM_HTTP_TIMEOUT_SECS.\npub const LLM_HTTP_TIMEOUT_SECS: u64 = 90;\n\n/// Get LLM HTTP timeout from env or default\n///\n/// Environment variable: `LLM_HTTP_TIMEOUT_SECS`\n#[must_use]\npub fn get_llm_http_timeout_secs() -> u64 {\n std::env::var(\"LLM_HTTP_TIMEOUT_SECS\")\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(LLM_HTTP_TIMEOUT_SECS)\n}\n"}}}}