Skip to main content

phi_agent/config/
llm.rs

1//! LLM configuration types and resolution helpers.
2//!
3//! Supports multi-source config resolution: CLI flags > environment
4//! variables > `.env` file > built-in defaults.
5
6use agent_base::{AgentError, AgentResult};
7
8const DEFAULT_MODEL: &str = "copilot";
9const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
10
11/// Resolved LLM configuration.
12#[derive(Clone, Debug)]
13pub struct LlmConfig {
14    /// API key for the LLM provider.
15    pub api_key: String,
16    /// Model name (e.g. `"opus"`, `"gpt-4o"`).
17    pub model: String,
18    /// Base URL for the LLM API endpoint.
19    pub base_url: String,
20}
21
22/// Resolve LLM configuration (API key, model, base_url).
23///
24/// Priority: CLI arg > environment variable (.env) > default
25pub fn resolve_llm_config(model: Option<&str>, base_url: Option<&str>) -> AgentResult<LlmConfig> {
26    let api_key =
27        super::optional_env("LLM_API_KEY").or_else(|| super::optional_env("OPENAI_API_KEY")).ok_or_else(|| {
28            AgentError::config_error("Missing environment variable LLM_API_KEY. Please configure it in .env.")
29        })?;
30
31    let resolved_model = model
32        .map(|s| s.to_string())
33        .or_else(|| super::optional_env("LLM_MODEL"))
34        .or_else(|| super::optional_env("OPENAI_MODEL"))
35        .unwrap_or_else(|| DEFAULT_MODEL.to_string());
36
37    let resolved_base_url = base_url
38        .map(|s| s.to_string())
39        .or_else(|| super::optional_env("LLM_BASE_URL"))
40        .or_else(|| super::optional_env("OPENAI_BASE_URL"))
41        .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
42
43    Ok(LlmConfig { api_key, model: resolved_model, base_url: resolved_base_url })
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn test_llm_config_debug_clone() {
52        let cfg = LlmConfig {
53            api_key: "sk-test".into(),
54            model: "gpt-4".into(),
55            base_url: "https://api.openai.com/v1".into(),
56        };
57        let cloned = cfg.clone();
58        assert_eq!(cloned.api_key, "sk-test");
59        assert_eq!(cloned.model, "gpt-4");
60        let _ = format!("{:?}", cfg);
61    }
62}
63
64#[cfg(test)]
65mod proptests {
66    use super::*;
67
68    struct EnvGuard {
69        keys: Vec<&'static str>,
70        saved: Vec<Option<String>>,
71    }
72
73    impl EnvGuard {
74        fn new(keys: &[&'static str]) -> Self {
75            let saved: Vec<Option<String>> = keys.iter().map(|k| std::env::var(k).ok()).collect();
76            for k in keys {
77                unsafe { std::env::remove_var(k) };
78            }
79            Self { keys: keys.to_vec(), saved }
80        }
81    }
82
83    impl Drop for EnvGuard {
84        fn drop(&mut self) {
85            for (i, k) in self.keys.iter().enumerate() {
86                unsafe { std::env::remove_var(k) };
87                if let Some(ref v) = self.saved[i] {
88                    unsafe { std::env::set_var(k, v) };
89                }
90            }
91        }
92    }
93
94    proptest::proptest! {
95        #[test]
96        fn resolve_llm_config_never_panics(
97            model in proptest::option::of("[a-zA-Z0-9_.-]{0,50}"),
98            base_url in proptest::option::of("https://[a-z]{1,20}\\.example\\.com/v[0-9]"),
99        ) {
100            let vars = &["LLM_API_KEY", "OPENAI_API_KEY", "LLM_MODEL", "OPENAI_MODEL", "LLM_BASE_URL", "OPENAI_BASE_URL"];
101            let _guard = EnvGuard::new(vars);
102            unsafe { std::env::set_var("LLM_API_KEY", "sk-proptest"); }
103            let _ = resolve_llm_config(model.as_deref(), base_url.as_deref());
104        }
105    }
106}