use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::types::ReasoningEffort;
static DOTENV_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
fn load_dotenv_once(path: &Path) -> &'static HashMap<String, String> {
DOTENV_VARS.get_or_init(|| {
let mut map = HashMap::new();
let Ok(content) = std::fs::read_to_string(path) else {
return map;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
let k = k.trim().to_string();
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
map.insert(k, v);
}
}
map
})
}
fn env_or_dotenv(key: &str, dotenv: &HashMap<String, String>) -> Option<String> {
std::env::var(key)
.ok()
.filter(|v| !v.is_empty())
.or_else(|| dotenv.get(key).filter(|v| !v.is_empty()).cloned())
}
pub fn get_secret(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|v| !v.is_empty())
.or_else(|| {
DOTENV_VARS
.get()?
.get(key)
.filter(|v| !v.is_empty())
.cloned()
})
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolOverrideConfig {
#[serde(default)]
pub model: String,
#[serde(default)]
pub fallback_model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(skip)]
pub home_dir: PathBuf,
#[serde(default = "default_model")]
pub model: String,
#[serde(default = "default_max_iterations")]
pub max_iterations: u32,
#[serde(default)]
pub sub_agent_max_iterations: Option<u32>,
#[serde(default)]
pub tool_delay_ms: u64,
#[serde(default = "default_provider")]
pub provider: String,
pub base_url: Option<String>,
#[serde(default)]
pub routing: std::collections::HashMap<String, String>,
#[serde(default)]
pub tools: std::collections::HashMap<String, ToolOverrideConfig>,
#[serde(default)]
pub skills: std::collections::HashMap<String, ToolOverrideConfig>,
#[serde(skip)]
pub api_key: Option<String>,
#[serde(skip)]
pub fallback_api_keys: Vec<String>,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
#[serde(default)]
pub max_concurrent_requests: Option<usize>,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub memory_expiry: MemoryExpiryConfig,
#[serde(default = "default_nudge_interval")]
pub nudge_interval: u32,
#[serde(default = "default_llm_max_retries")]
pub llm_max_retries: u32,
#[serde(default = "default_llm_retry_base_ms")]
pub llm_retry_base_ms: u64,
#[serde(default)]
pub platform: PlatformConfig,
#[serde(default = "default_auto_skill_threshold")]
pub auto_skill_threshold: u32,
#[serde(default = "default_llm_timeout_secs")]
pub llm_timeout_secs: u64,
#[serde(default = "default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
#[serde(default = "default_shutdown_timeout_secs")]
pub shutdown_timeout_secs: u64,
#[serde(default)]
pub max_tokens_per_task: Option<u32>,
#[serde(default)]
pub max_output_tokens: Option<u32>,
#[serde(default)]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
pub context_window: Option<usize>,
#[serde(default)]
pub disabled_toolsets: Vec<String>,
#[serde(default)]
pub disabled_tools: Vec<String>,
#[serde(default)]
pub show_usage_footer: bool,
#[serde(default)]
pub platforms: WebhookPlatformsConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub cron: CronConfig,
}
pub const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-6";
pub const DEFAULT_PROVIDER: &str = "openrouter";
fn default_model() -> String {
DEFAULT_MODEL.into()
}
fn default_provider() -> String {
DEFAULT_PROVIDER.into()
}
fn default_max_iterations() -> u32 {
90
}
fn default_nudge_interval() -> u32 {
5
}
fn default_auto_skill_threshold() -> u32 {
5
}
fn default_llm_max_retries() -> u32 {
3
}
fn default_llm_retry_base_ms() -> u64 {
1000
}
fn default_llm_timeout_secs() -> u64 {
120
}
fn default_tool_timeout_secs() -> u64 {
60
}
fn default_shutdown_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryExpiryConfig {
#[serde(default = "default_fact_days")]
pub fact_days: Option<u32>,
#[serde(default = "default_project_days")]
pub project_days: Option<u32>,
#[serde(default = "default_other_days")]
pub other_days: Option<u32>,
#[serde(default)]
pub preference_days: Option<u32>,
#[serde(default)]
pub skill_days: Option<u32>,
}
#[allow(clippy::unnecessary_wraps)]
fn default_fact_days() -> Option<u32> {
Some(90)
}
#[allow(clippy::unnecessary_wraps)]
fn default_project_days() -> Option<u32> {
Some(30)
}
#[allow(clippy::unnecessary_wraps)]
fn default_other_days() -> Option<u32> {
Some(60)
}
impl Default for MemoryExpiryConfig {
fn default() -> Self {
Self {
fact_days: default_fact_days(),
project_days: default_project_days(),
other_days: default_other_days(),
preference_days: None,
skill_days: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TerminalSandbox {
#[default]
None,
Docker,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(skip)]
pub gateway_api_key: Option<String>,
#[serde(default)]
pub allowed_read_paths: Vec<PathBuf>,
#[serde(default)]
pub allowed_write_paths: Vec<PathBuf>,
#[serde(default = "default_approval_mode")]
pub approval_mode: String,
#[serde(default)]
pub rate_limit_rpm: Option<u32>,
#[serde(default)]
pub terminal_sandbox: TerminalSandbox,
#[serde(default = "default_sandbox_image")]
pub terminal_sandbox_image: String,
#[serde(default)]
pub terminal_sandbox_opts: Vec<String>,
}
fn default_approval_mode() -> String {
"smart".to_string()
}
fn default_sandbox_image() -> String {
"ubuntu:24.04".to_string()
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
gateway_api_key: None,
allowed_read_paths: Vec::new(),
allowed_write_paths: Vec::new(),
approval_mode: default_approval_mode(),
rate_limit_rpm: None,
terminal_sandbox: TerminalSandbox::None,
terminal_sandbox_image: default_sandbox_image(),
terminal_sandbox_opts: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformConfig {
#[serde(default)]
pub allowed_user_ids: Vec<String>,
#[serde(default)]
pub require_mention: bool,
#[serde(default)]
pub bot_username: String,
#[serde(default = "default_true")]
pub session_per_user: bool,
}
fn default_true() -> bool {
true
}
impl Default for PlatformConfig {
fn default() -> Self {
Self {
allowed_user_ids: Vec::new(),
require_mention: false,
bot_username: String::new(),
session_per_user: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookPlatformConfig {
#[serde(default)]
pub enabled: bool,
pub port: u16,
pub webhook_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebhookPlatformsConfig {
#[serde(default)]
pub line: Option<WebhookPlatformConfig>,
#[serde(default)]
pub whatsapp: Option<WebhookPlatformConfig>,
#[serde(default)]
pub webhook: Option<WebhookPlatformConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_server_port")]
pub port: u16,
}
fn default_server_port() -> u16 {
3000
}
fn parse_cron_jobs_str(s: &str) -> Vec<CronJob> {
s.split(',')
.filter_map(|entry| {
let (expr, task) = entry.trim().split_once('=')?;
Some(CronJob {
schedule: expr.trim().to_string(),
task: task.trim().to_string(),
})
})
.collect()
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: default_server_port(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronJob {
pub schedule: String,
pub task: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CronConfig {
#[serde(default)]
pub jobs: Vec<CronJob>,
#[serde(default)]
pub memory_consolidation: Option<String>,
#[serde(default)]
pub memory_expiry: Option<String>,
}
impl WebhookPlatformConfig {
pub fn default_webhook() -> Self {
Self {
enabled: true,
port: 3001,
webhook_path: "/webhook".to_string(),
}
}
pub fn default_line() -> Self {
Self {
enabled: true,
port: 3002,
webhook_path: "/line".to_string(),
}
}
pub fn default_whatsapp() -> Self {
Self {
enabled: true,
port: 3003,
webhook_path: "/whatsapp".to_string(),
}
}
}
impl Default for AgentConfig {
fn default() -> Self {
let cwd = std::env::current_dir().unwrap_or_default();
let home = dirs::home_dir().unwrap_or_default();
Self {
home_dir: Self::garudust_dir(),
model: DEFAULT_MODEL.into(),
max_iterations: 90,
sub_agent_max_iterations: None,
tool_delay_ms: 0,
provider: DEFAULT_PROVIDER.into(),
base_url: None,
routing: std::collections::HashMap::new(),
tools: std::collections::HashMap::new(),
skills: std::collections::HashMap::new(),
api_key: None,
fallback_api_keys: Vec::new(),
compression: CompressionConfig::default(),
network: NetworkConfig::default(),
mcp_servers: Vec::new(),
max_concurrent_requests: None,
security: SecurityConfig {
gateway_api_key: None,
allowed_read_paths: vec![cwd.clone(), home],
allowed_write_paths: vec![cwd],
approval_mode: default_approval_mode(),
rate_limit_rpm: None,
terminal_sandbox: TerminalSandbox::None,
terminal_sandbox_image: default_sandbox_image(),
terminal_sandbox_opts: Vec::new(),
},
memory_expiry: MemoryExpiryConfig::default(),
nudge_interval: default_nudge_interval(),
llm_max_retries: default_llm_max_retries(),
llm_retry_base_ms: default_llm_retry_base_ms(),
platform: PlatformConfig::default(),
auto_skill_threshold: default_auto_skill_threshold(),
llm_timeout_secs: default_llm_timeout_secs(),
tool_timeout_secs: default_tool_timeout_secs(),
shutdown_timeout_secs: default_shutdown_timeout_secs(),
max_tokens_per_task: None,
max_output_tokens: None,
reasoning_effort: None,
context_window: None,
disabled_toolsets: Vec::new(),
disabled_tools: Vec::new(),
show_usage_footer: false,
platforms: WebhookPlatformsConfig {
webhook: Some(WebhookPlatformConfig::default_webhook()),
line: None,
whatsapp: None,
},
server: ServerConfig::default(),
cron: CronConfig::default(),
}
}
}
pub(crate) fn resolve_key_for_provider(
provider: &str,
dotenv: &HashMap<String, String>,
) -> Option<String> {
match provider {
"anthropic" => env_or_dotenv("ANTHROPIC_API_KEY", dotenv),
"openai" => env_or_dotenv("OPENAI_API_KEY", dotenv),
"gemini" => env_or_dotenv("GEMINI_API_KEY", dotenv),
"groq" => env_or_dotenv("GROQ_API_KEY", dotenv),
"mistral" => env_or_dotenv("MISTRAL_API_KEY", dotenv),
"deepseek" => env_or_dotenv("DEEPSEEK_API_KEY", dotenv),
"xai" => env_or_dotenv("XAI_API_KEY", dotenv),
"vllm" => env_or_dotenv("VLLM_API_KEY", dotenv),
"thaillm" => env_or_dotenv("THAILLM_API_KEY", dotenv),
"ollama" | "bedrock" | "codex" => None,
_ => env_or_dotenv("OPENROUTER_API_KEY", dotenv),
}
}
pub(crate) fn detect_provider_from_env(config: &mut AgentConfig, dotenv: &HashMap<String, String>) {
if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "anthropic".into();
} else if let Some(k) = env_or_dotenv("OPENAI_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "openai".into();
} else if let Some(k) = env_or_dotenv("GEMINI_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "gemini".into();
} else if let Some(k) = env_or_dotenv("GROQ_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "groq".into();
} else if let Some(k) = env_or_dotenv("MISTRAL_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "mistral".into();
} else if let Some(k) = env_or_dotenv("DEEPSEEK_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "deepseek".into();
} else if let Some(k) = env_or_dotenv("XAI_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "xai".into();
} else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
config.provider = "ollama".into();
config.base_url = Some(url);
} else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
config.provider = "vllm".into();
config.base_url = Some(url);
config.api_key = env_or_dotenv("VLLM_API_KEY", dotenv);
} else if let Some(k) = env_or_dotenv("THAILLM_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "thaillm".into();
} else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
config.api_key = Some(k);
config.provider = "openrouter".into();
}
}
impl AgentConfig {
pub fn garudust_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".garudust")
}
pub fn load() -> Self {
let home_dir = Self::garudust_dir();
let env_file = home_dir.join(".env");
let dotenv = load_dotenv_once(&env_file);
let yaml_path = home_dir.join("config.yaml");
let mut config: AgentConfig = if yaml_path.exists() {
let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
serde_yaml::from_str(&src).unwrap_or_default()
} else {
AgentConfig::default()
};
config.home_dir = home_dir;
if config.security.allowed_read_paths.is_empty() {
let cwd = std::env::current_dir().unwrap_or_default();
let home = dirs::home_dir().unwrap_or_default();
config.security.allowed_read_paths = vec![cwd.clone(), home];
config.security.allowed_write_paths = vec![cwd];
}
let yaml_authoritative = yaml_path.exists();
if yaml_authoritative {
if config.api_key.is_none() {
config.api_key = resolve_key_for_provider(&config.provider, dotenv);
}
} else {
detect_provider_from_env(&mut config, dotenv);
}
if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
config.model = m;
}
if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
config.base_url = Some(u);
}
if let Some(v) = env_or_dotenv("LLM_FALLBACK_API_KEYS", dotenv) {
config.fallback_api_keys = v
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
config.security.gateway_api_key = Some(k);
}
if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
if let Ok(n) = v.parse::<u32>() {
config.security.rate_limit_rpm = Some(n);
}
}
if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
config.security.approval_mode = mode;
}
if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
"docker" => TerminalSandbox::Docker,
_ => TerminalSandbox::None,
};
}
if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
config.security.terminal_sandbox_image = image;
}
if let Some(v) = env_or_dotenv("GARUDUST_PORT", dotenv) {
if let Ok(n) = v.parse::<u16>() {
config.server.port = n;
}
}
if let Some(v) = env_or_dotenv("GARUDUST_MEMORY_CRON", dotenv) {
config.cron.memory_consolidation = Some(v);
}
if let Some(v) = env_or_dotenv("GARUDUST_MEMORY_EXPIRY_CRON", dotenv) {
config.cron.memory_expiry = Some(v);
}
if let Some(v) = env_or_dotenv("GARUDUST_CRON_JOBS", dotenv) {
config.cron.jobs = parse_cron_jobs_str(&v);
}
config
}
pub fn save_yaml(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.home_dir)?;
let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
std::fs::write(self.home_dir.join("config.yaml"), yaml)
}
pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
std::fs::create_dir_all(home_dir)?;
let env_path = home_dir.join(".env");
let existing = if env_path.exists() {
std::fs::read_to_string(&env_path)?
} else {
String::new()
};
let prefix = format!("{key}=");
let mut lines: Vec<String> = existing
.lines()
.filter(|l| !l.starts_with(&prefix))
.map(String::from)
.collect();
lines.push(format!("{key}={value}"));
std::fs::write(&env_path, lines.join("\n") + "\n")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressionConfig {
pub enabled: bool,
pub threshold_fraction: f32,
pub model: Option<String>,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
enabled: true,
threshold_fraction: 0.8,
model: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NetworkConfig {
pub force_ipv4: bool,
pub proxy: Option<String>,
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{detect_provider_from_env, resolve_key_for_provider, AgentConfig};
fn dotenv(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn resolve_openai_key() {
let map = dotenv(&[("OPENAI_API_KEY", "sk-test-openai")]);
assert_eq!(
resolve_key_for_provider("openai", &map),
Some("sk-test-openai".into())
);
}
#[test]
fn resolve_gemini_key() {
let map = dotenv(&[("GEMINI_API_KEY", "AIza-test")]);
assert_eq!(
resolve_key_for_provider("gemini", &map),
Some("AIza-test".into())
);
}
#[test]
fn resolve_groq_key() {
let map = dotenv(&[("GROQ_API_KEY", "gsk-test")]);
assert_eq!(
resolve_key_for_provider("groq", &map),
Some("gsk-test".into())
);
}
#[test]
fn resolve_mistral_key() {
let map = dotenv(&[("MISTRAL_API_KEY", "ms-test")]);
assert_eq!(
resolve_key_for_provider("mistral", &map),
Some("ms-test".into())
);
}
#[test]
fn resolve_deepseek_key() {
let map = dotenv(&[("DEEPSEEK_API_KEY", "ds-test")]);
assert_eq!(
resolve_key_for_provider("deepseek", &map),
Some("ds-test".into())
);
}
#[test]
fn resolve_xai_key() {
let map = dotenv(&[("XAI_API_KEY", "xai-test")]);
assert_eq!(
resolve_key_for_provider("xai", &map),
Some("xai-test".into())
);
}
#[test]
fn resolve_ollama_returns_none() {
let map = dotenv(&[("OPENROUTER_API_KEY", "or-test")]);
assert_eq!(resolve_key_for_provider("ollama", &map), None);
}
#[test]
fn resolve_unknown_provider_falls_back_to_openrouter() {
let map = dotenv(&[("OPENROUTER_API_KEY", "or-test")]);
assert_eq!(
resolve_key_for_provider("custom-provider", &map),
Some("or-test".into())
);
}
fn detect(pairs: &[(&str, &str)]) -> AgentConfig {
let mut cfg = AgentConfig::default();
detect_provider_from_env(&mut cfg, &dotenv(pairs));
cfg
}
#[test]
fn detect_openai_only() {
let cfg = detect(&[("OPENAI_API_KEY", "sk-test-openai")]);
assert_eq!(cfg.provider, "openai");
assert_eq!(cfg.api_key.as_deref(), Some("sk-test-openai"));
}
#[test]
fn detect_gemini_only() {
let cfg = detect(&[("GEMINI_API_KEY", "AIza-test")]);
assert_eq!(cfg.provider, "gemini");
assert_eq!(cfg.api_key.as_deref(), Some("AIza-test"));
}
#[test]
fn detect_groq_only() {
let cfg = detect(&[("GROQ_API_KEY", "gsk-test")]);
assert_eq!(cfg.provider, "groq");
assert_eq!(cfg.api_key.as_deref(), Some("gsk-test"));
}
#[test]
fn detect_mistral_only() {
let cfg = detect(&[("MISTRAL_API_KEY", "ms-test")]);
assert_eq!(cfg.provider, "mistral");
assert_eq!(cfg.api_key.as_deref(), Some("ms-test"));
}
#[test]
fn detect_deepseek_only() {
let cfg = detect(&[("DEEPSEEK_API_KEY", "ds-test")]);
assert_eq!(cfg.provider, "deepseek");
assert_eq!(cfg.api_key.as_deref(), Some("ds-test"));
}
#[test]
fn detect_xai_only() {
let cfg = detect(&[("XAI_API_KEY", "xai-test")]);
assert_eq!(cfg.provider, "xai");
assert_eq!(cfg.api_key.as_deref(), Some("xai-test"));
}
#[test]
fn detect_openrouter_only() {
let cfg = detect(&[("OPENROUTER_API_KEY", "or-test")]);
assert_eq!(cfg.provider, "openrouter");
assert_eq!(cfg.api_key.as_deref(), Some("or-test"));
}
#[test]
fn detect_ollama_sets_base_url_not_key() {
let cfg = detect(&[("OLLAMA_BASE_URL", "http://localhost:11434")]);
assert_eq!(cfg.provider, "ollama");
assert_eq!(cfg.base_url.as_deref(), Some("http://localhost:11434"));
assert!(cfg.api_key.is_none());
}
#[test]
fn detect_vllm_sets_base_url_and_key() {
let cfg = detect(&[
("VLLM_BASE_URL", "http://localhost:8000/v1"),
("VLLM_API_KEY", "vllm-test"),
]);
assert_eq!(cfg.provider, "vllm");
assert_eq!(cfg.base_url.as_deref(), Some("http://localhost:8000/v1"));
assert_eq!(cfg.api_key.as_deref(), Some("vllm-test"));
}
#[test]
fn detect_empty_env_leaves_defaults() {
let cfg = detect(&[]);
assert_eq!(cfg.provider, "openrouter");
assert!(cfg.api_key.is_none());
}
#[test]
fn detect_anthropic_wins_over_openai_in_dotenv() {
let cfg = detect(&[
("ANTHROPIC_API_KEY", "sk-ant-test"),
("OPENAI_API_KEY", "sk-oai-test"),
]);
assert_eq!(cfg.provider, "anthropic");
assert_eq!(cfg.api_key.as_deref(), Some("sk-ant-test"));
}
}