vtcode_config/
api_keys.rs

1//! API key management module for secure retrieval from environment variables,
2//! .env files, and configuration files.
3//!
4//! This module provides a unified interface for retrieving API keys for different providers,
5//! prioritizing security by checking environment variables first, then .env files, and finally
6//! falling back to configuration file values.
7
8use anyhow::Result;
9use std::env;
10
11/// API key sources for different providers
12#[derive(Debug, Clone)]
13pub struct ApiKeySources {
14    /// Gemini API key environment variable name
15    pub gemini_env: String,
16    /// Anthropic API key environment variable name
17    pub anthropic_env: String,
18    /// OpenAI API key environment variable name
19    pub openai_env: String,
20    /// OpenRouter API key environment variable name
21    pub openrouter_env: String,
22    /// xAI API key environment variable name
23    pub xai_env: String,
24    /// DeepSeek API key environment variable name
25    pub deepseek_env: String,
26    /// Z.AI API key environment variable name
27    pub zai_env: String,
28    /// Ollama API key environment variable name
29    pub ollama_env: String,
30    /// LM Studio API key environment variable name
31    pub lmstudio_env: String,
32    /// Gemini API key from configuration file
33    pub gemini_config: Option<String>,
34    /// Anthropic API key from configuration file
35    pub anthropic_config: Option<String>,
36    /// OpenAI API key from configuration file
37    pub openai_config: Option<String>,
38    /// OpenRouter API key from configuration file
39    pub openrouter_config: Option<String>,
40    /// xAI API key from configuration file
41    pub xai_config: Option<String>,
42    /// DeepSeek API key from configuration file
43    pub deepseek_config: Option<String>,
44    /// Z.AI API key from configuration file
45    pub zai_config: Option<String>,
46    /// Ollama API key from configuration file
47    pub ollama_config: Option<String>,
48    /// LM Studio API key from configuration file
49    pub lmstudio_config: Option<String>,
50}
51
52impl Default for ApiKeySources {
53    fn default() -> Self {
54        Self {
55            gemini_env: "GEMINI_API_KEY".to_string(),
56            anthropic_env: "ANTHROPIC_API_KEY".to_string(),
57            openai_env: "OPENAI_API_KEY".to_string(),
58            openrouter_env: "OPENROUTER_API_KEY".to_string(),
59            xai_env: "XAI_API_KEY".to_string(),
60            deepseek_env: "DEEPSEEK_API_KEY".to_string(),
61            zai_env: "ZAI_API_KEY".to_string(),
62            ollama_env: "OLLAMA_API_KEY".to_string(),
63            lmstudio_env: "LMSTUDIO_API_KEY".to_string(),
64            gemini_config: None,
65            anthropic_config: None,
66            openai_config: None,
67            openrouter_config: None,
68            xai_config: None,
69            deepseek_config: None,
70            zai_config: None,
71            ollama_config: None,
72            lmstudio_config: None,
73        }
74    }
75}
76
77impl ApiKeySources {
78    /// Create API key sources for a specific provider with automatic environment variable inference
79    pub fn for_provider(provider: &str) -> Self {
80        let (primary_env, _fallback_envs) = match provider.to_lowercase().as_str() {
81            "gemini" => ("GEMINI_API_KEY", vec!["GOOGLE_API_KEY"]),
82            "anthropic" => ("ANTHROPIC_API_KEY", vec![]),
83            "openai" => ("OPENAI_API_KEY", vec![]),
84            "deepseek" => ("DEEPSEEK_API_KEY", vec![]),
85            "openrouter" => ("OPENROUTER_API_KEY", vec![]),
86            "xai" => ("XAI_API_KEY", vec![]),
87            "zai" => ("ZAI_API_KEY", vec![]),
88            "ollama" => ("OLLAMA_API_KEY", vec![]),
89            "lmstudio" => ("LMSTUDIO_API_KEY", vec![]),
90            _ => ("GEMINI_API_KEY", vec!["GOOGLE_API_KEY"]),
91        };
92
93        // For backward compatibility, we still set all env vars but prioritize the primary one
94        Self {
95            gemini_env: if provider == "gemini" {
96                primary_env.to_string()
97            } else {
98                "GEMINI_API_KEY".to_string()
99            },
100            anthropic_env: if provider == "anthropic" {
101                primary_env.to_string()
102            } else {
103                "ANTHROPIC_API_KEY".to_string()
104            },
105            openai_env: if provider == "openai" {
106                primary_env.to_string()
107            } else {
108                "OPENAI_API_KEY".to_string()
109            },
110            openrouter_env: if provider == "openrouter" {
111                primary_env.to_string()
112            } else {
113                "OPENROUTER_API_KEY".to_string()
114            },
115            xai_env: if provider == "xai" {
116                primary_env.to_string()
117            } else {
118                "XAI_API_KEY".to_string()
119            },
120            deepseek_env: if provider == "deepseek" {
121                primary_env.to_string()
122            } else {
123                "DEEPSEEK_API_KEY".to_string()
124            },
125            zai_env: if provider == "zai" {
126                primary_env.to_string()
127            } else {
128                "ZAI_API_KEY".to_string()
129            },
130            ollama_env: if provider == "ollama" {
131                primary_env.to_string()
132            } else {
133                "OLLAMA_API_KEY".to_string()
134            },
135            lmstudio_env: if provider == "lmstudio" {
136                primary_env.to_string()
137            } else {
138                "LMSTUDIO_API_KEY".to_string()
139            },
140            gemini_config: None,
141            anthropic_config: None,
142            openai_config: None,
143            openrouter_config: None,
144            xai_config: None,
145            deepseek_config: None,
146            zai_config: None,
147            ollama_config: None,
148            lmstudio_config: None,
149        }
150    }
151}
152
153/// Load environment variables from .env file
154///
155/// This function attempts to load environment variables from a .env file
156/// in the current directory. It logs a warning if the file exists but cannot
157/// be loaded, but doesn't fail if the file doesn't exist.
158pub fn load_dotenv() -> Result<()> {
159    match dotenvy::dotenv() {
160        Ok(path) => {
161            // Only print in verbose mode to avoid polluting stdout/stderr in scripts
162            if std::env::var("VTCODE_VERBOSE").is_ok() || std::env::var("RUST_LOG").is_ok() {
163                eprintln!("Loaded environment variables from: {}", path.display());
164            }
165            Ok(())
166        }
167        Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
168            // .env file doesn't exist, which is fine
169            Ok(())
170        }
171        Err(e) => {
172            eprintln!("Warning: Failed to load .env file: {}", e);
173            Ok(())
174        }
175    }
176}
177
178/// Get API key for a specific provider with secure fallback mechanism
179///
180/// This function implements a secure retrieval mechanism that:
181/// 1. First checks environment variables (highest priority for security)
182/// 2. Then checks .env file values
183/// 3. Falls back to configuration file values if neither above is set
184/// 4. Supports all major providers: Gemini, Anthropic, OpenAI, OpenRouter, and xAI
185/// 5. Automatically infers the correct environment variable based on provider
186///
187/// # Arguments
188///
189/// * `provider` - The provider name ("gemini", "anthropic", or "openai")
190/// * `sources` - Configuration for where to look for API keys
191///
192/// # Returns
193///
194/// * `Ok(String)` - The API key if found
195/// * `Err` - If no API key could be found for the provider
196pub fn get_api_key(provider: &str, sources: &ApiKeySources) -> Result<String> {
197    // Automatically infer the correct environment variable based on provider
198    let inferred_env = match provider.to_lowercase().as_str() {
199        "gemini" => "GEMINI_API_KEY",
200        "anthropic" => "ANTHROPIC_API_KEY",
201        "openai" => "OPENAI_API_KEY",
202        "deepseek" => "DEEPSEEK_API_KEY",
203        "openrouter" => "OPENROUTER_API_KEY",
204        "xai" => "XAI_API_KEY",
205        "zai" => "ZAI_API_KEY",
206        "ollama" => "OLLAMA_API_KEY",
207        "lmstudio" => "LMSTUDIO_API_KEY",
208        _ => "GEMINI_API_KEY",
209    };
210
211    // Try the inferred environment variable first
212    if let Ok(key) = env::var(inferred_env)
213        && !key.is_empty()
214    {
215        return Ok(key);
216    }
217
218    // Fall back to the provider-specific sources
219    match provider.to_lowercase().as_str() {
220        "gemini" => get_gemini_api_key(sources),
221        "anthropic" => get_anthropic_api_key(sources),
222        "openai" => get_openai_api_key(sources),
223        "deepseek" => get_deepseek_api_key(sources),
224        "openrouter" => get_openrouter_api_key(sources),
225        "xai" => get_xai_api_key(sources),
226        "zai" => get_zai_api_key(sources),
227        "ollama" => get_ollama_api_key(sources),
228        "lmstudio" => get_lmstudio_api_key(sources),
229        _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
230    }
231}
232
233/// Get API key for a specific environment variable with fallback
234fn get_api_key_with_fallback(
235    env_var: &str,
236    config_value: Option<&String>,
237    provider_name: &str,
238) -> Result<String> {
239    // First try environment variable (most secure)
240    if let Ok(key) = env::var(env_var)
241        && !key.is_empty()
242    {
243        return Ok(key);
244    }
245
246    // Then try configuration file value
247    if let Some(key) = config_value
248        && !key.is_empty()
249    {
250        return Ok(key.clone());
251    }
252
253    // If neither worked, return an error
254    Err(anyhow::anyhow!(
255        "No API key found for {} provider. Set {} environment variable (or add to .env file) or configure in vtcode.toml",
256        provider_name,
257        env_var
258    ))
259}
260
261/// Get Gemini API key with secure fallback
262fn get_gemini_api_key(sources: &ApiKeySources) -> Result<String> {
263    // Try primary Gemini environment variable
264    if let Ok(key) = env::var(&sources.gemini_env)
265        && !key.is_empty()
266    {
267        return Ok(key);
268    }
269
270    // Try Google API key as fallback (for backward compatibility)
271    if let Ok(key) = env::var("GOOGLE_API_KEY")
272        && !key.is_empty()
273    {
274        return Ok(key);
275    }
276
277    // Try configuration file value
278    if let Some(key) = &sources.gemini_config
279        && !key.is_empty()
280    {
281        return Ok(key.clone());
282    }
283
284    // If nothing worked, return an error
285    Err(anyhow::anyhow!(
286        "No API key found for Gemini provider. Set {} or GOOGLE_API_KEY environment variable (or add to .env file) or configure in vtcode.toml",
287        sources.gemini_env
288    ))
289}
290
291/// Get Anthropic API key with secure fallback
292fn get_anthropic_api_key(sources: &ApiKeySources) -> Result<String> {
293    get_api_key_with_fallback(
294        &sources.anthropic_env,
295        sources.anthropic_config.as_ref(),
296        "Anthropic",
297    )
298}
299
300/// Get OpenAI API key with secure fallback
301fn get_openai_api_key(sources: &ApiKeySources) -> Result<String> {
302    get_api_key_with_fallback(
303        &sources.openai_env,
304        sources.openai_config.as_ref(),
305        "OpenAI",
306    )
307}
308
309/// Get OpenRouter API key with secure fallback
310fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
311    get_api_key_with_fallback(
312        &sources.openrouter_env,
313        sources.openrouter_config.as_ref(),
314        "OpenRouter",
315    )
316}
317
318/// Get xAI API key with secure fallback
319fn get_xai_api_key(sources: &ApiKeySources) -> Result<String> {
320    get_api_key_with_fallback(&sources.xai_env, sources.xai_config.as_ref(), "xAI")
321}
322
323/// Get DeepSeek API key with secure fallback
324fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
325    get_api_key_with_fallback(
326        &sources.deepseek_env,
327        sources.deepseek_config.as_ref(),
328        "DeepSeek",
329    )
330}
331
332/// Get Z.AI API key with secure fallback
333fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
334    get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_ref(), "Z.AI")
335}
336
337/// Get Ollama API key with secure fallback
338fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
339    // For Ollama we allow running without credentials when connecting to a local
340    // deployment. Cloud variants still rely on the standard environment or
341    // configuration values when present.
342    if let Ok(key) = env::var(&sources.ollama_env)
343        && !key.is_empty()
344    {
345        return Ok(key);
346    }
347
348    if let Some(key) = sources.ollama_config.as_ref()
349        && !key.is_empty()
350    {
351        return Ok(key.clone());
352    }
353
354    Ok(String::new())
355}
356
357/// Get LM Studio API key with secure fallback
358fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
359    if let Ok(key) = env::var(&sources.lmstudio_env)
360        && !key.is_empty()
361    {
362        return Ok(key);
363    }
364
365    if let Some(key) = sources.lmstudio_config.as_ref()
366        && !key.is_empty()
367    {
368        return Ok(key.clone());
369    }
370
371    Ok(String::new())
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::env;
378
379    #[test]
380    fn test_get_gemini_api_key_from_env() {
381        // Set environment variable
382        unsafe {
383            env::set_var("TEST_GEMINI_KEY", "test-gemini-key");
384        }
385
386        let sources = ApiKeySources {
387            gemini_env: "TEST_GEMINI_KEY".to_string(),
388            ..Default::default()
389        };
390
391        let result = get_gemini_api_key(&sources);
392        assert!(result.is_ok());
393        assert_eq!(result.unwrap(), "test-gemini-key");
394
395        // Clean up
396        unsafe {
397            env::remove_var("TEST_GEMINI_KEY");
398        }
399    }
400
401    #[test]
402    fn test_get_anthropic_api_key_from_env() {
403        // Set environment variable
404        unsafe {
405            env::set_var("TEST_ANTHROPIC_KEY", "test-anthropic-key");
406        }
407
408        let sources = ApiKeySources {
409            anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
410            ..Default::default()
411        };
412
413        let result = get_anthropic_api_key(&sources);
414        assert!(result.is_ok());
415        assert_eq!(result.unwrap(), "test-anthropic-key");
416
417        // Clean up
418        unsafe {
419            env::remove_var("TEST_ANTHROPIC_KEY");
420        }
421    }
422
423    #[test]
424    fn test_get_openai_api_key_from_env() {
425        // Set environment variable
426        unsafe {
427            env::set_var("TEST_OPENAI_KEY", "test-openai-key");
428        }
429
430        let sources = ApiKeySources {
431            openai_env: "TEST_OPENAI_KEY".to_string(),
432            ..Default::default()
433        };
434
435        let result = get_openai_api_key(&sources);
436        assert!(result.is_ok());
437        assert_eq!(result.unwrap(), "test-openai-key");
438
439        // Clean up
440        unsafe {
441            env::remove_var("TEST_OPENAI_KEY");
442        }
443    }
444
445    #[test]
446    fn test_get_deepseek_api_key_from_env() {
447        unsafe {
448            env::set_var("TEST_DEEPSEEK_KEY", "test-deepseek-key");
449        }
450
451        let sources = ApiKeySources {
452            deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
453            ..Default::default()
454        };
455
456        let result = get_deepseek_api_key(&sources);
457        assert!(result.is_ok());
458        assert_eq!(result.unwrap(), "test-deepseek-key");
459
460        unsafe {
461            env::remove_var("TEST_DEEPSEEK_KEY");
462        }
463    }
464
465    #[test]
466    fn test_get_xai_api_key_from_env() {
467        unsafe {
468            env::set_var("TEST_XAI_KEY", "test-xai-key");
469        }
470
471        let sources = ApiKeySources {
472            xai_env: "TEST_XAI_KEY".to_string(),
473            ..Default::default()
474        };
475
476        let result = get_xai_api_key(&sources);
477        assert!(result.is_ok());
478        assert_eq!(result.unwrap(), "test-xai-key");
479
480        unsafe {
481            env::remove_var("TEST_XAI_KEY");
482        }
483    }
484
485    #[test]
486    fn test_get_gemini_api_key_from_config() {
487        let sources = ApiKeySources {
488            gemini_config: Some("config-gemini-key".to_string()),
489            ..Default::default()
490        };
491
492        let result = get_gemini_api_key(&sources);
493        assert!(result.is_ok());
494        assert_eq!(result.unwrap(), "config-gemini-key");
495    }
496
497    #[test]
498    fn test_get_api_key_with_fallback_prefers_env() {
499        // Set environment variable
500        unsafe {
501            env::set_var("TEST_FALLBACK_KEY", "env-key");
502        }
503
504        let sources = ApiKeySources {
505            openai_env: "TEST_FALLBACK_KEY".to_string(),
506            openai_config: Some("config-key".to_string()),
507            ..Default::default()
508        };
509
510        let result = get_openai_api_key(&sources);
511        assert!(result.is_ok());
512        assert_eq!(result.unwrap(), "env-key"); // Should prefer env var
513
514        // Clean up
515        unsafe {
516            env::remove_var("TEST_FALLBACK_KEY");
517        }
518    }
519
520    #[test]
521    fn test_get_api_key_fallback_to_config() {
522        let sources = ApiKeySources {
523            openai_env: "NONEXISTENT_ENV_VAR".to_string(),
524            openai_config: Some("config-key".to_string()),
525            ..Default::default()
526        };
527
528        let result = get_openai_api_key(&sources);
529        assert!(result.is_ok());
530        assert_eq!(result.unwrap(), "config-key");
531    }
532
533    #[test]
534    fn test_get_api_key_error_when_not_found() {
535        let sources = ApiKeySources {
536            openai_env: "NONEXISTENT_ENV_VAR".to_string(),
537            ..Default::default()
538        };
539
540        let result = get_openai_api_key(&sources);
541        assert!(result.is_err());
542    }
543
544    #[test]
545    fn test_get_ollama_api_key_missing_sources() {
546        let sources = ApiKeySources {
547            ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
548            ..Default::default()
549        };
550
551        let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
552        assert!(result.is_empty());
553    }
554
555    #[test]
556    fn test_get_ollama_api_key_from_env() {
557        // Set environment variable
558        unsafe {
559            env::set_var("TEST_OLLAMA_KEY", "test-ollama-key");
560        }
561
562        let sources = ApiKeySources {
563            ollama_env: "TEST_OLLAMA_KEY".to_string(),
564            ..Default::default()
565        };
566
567        let result = get_ollama_api_key(&sources);
568        assert!(result.is_ok());
569        assert_eq!(result.unwrap(), "test-ollama-key");
570
571        // Clean up
572        unsafe {
573            env::remove_var("TEST_OLLAMA_KEY");
574        }
575    }
576
577    #[test]
578    fn test_get_lmstudio_api_key_missing_sources() {
579        let sources = ApiKeySources {
580            lmstudio_env: "NONEXISTENT_LMSTUDIO_ENV".to_string(),
581            ..Default::default()
582        };
583
584        let result =
585            get_lmstudio_api_key(&sources).expect("LM Studio key retrieval should succeed");
586        assert!(result.is_empty());
587    }
588
589    #[test]
590    fn test_get_lmstudio_api_key_from_env() {
591        unsafe {
592            env::set_var("TEST_LMSTUDIO_KEY", "test-lmstudio-key");
593        }
594
595        let sources = ApiKeySources {
596            lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
597            ..Default::default()
598        };
599
600        let result = get_lmstudio_api_key(&sources);
601        assert!(result.is_ok());
602        assert_eq!(result.unwrap(), "test-lmstudio-key");
603
604        unsafe {
605            env::remove_var("TEST_LMSTUDIO_KEY");
606        }
607    }
608
609    #[test]
610    fn test_get_api_key_ollama_provider() {
611        // Set environment variable
612        unsafe {
613            env::set_var("OLLAMA_API_KEY", "test-ollama-env-key");
614        }
615
616        let sources = ApiKeySources::default();
617        let result = get_api_key("ollama", &sources);
618        assert!(result.is_ok());
619        assert_eq!(result.unwrap(), "test-ollama-env-key");
620
621        // Clean up
622        unsafe {
623            env::remove_var("OLLAMA_API_KEY");
624        }
625    }
626
627    #[test]
628    fn test_get_api_key_lmstudio_provider() {
629        unsafe {
630            env::set_var("LMSTUDIO_API_KEY", "test-lmstudio-env-key");
631        }
632
633        let sources = ApiKeySources::default();
634        let result = get_api_key("lmstudio", &sources);
635        assert!(result.is_ok());
636        assert_eq!(result.unwrap(), "test-lmstudio-env-key");
637
638        unsafe {
639            env::remove_var("LMSTUDIO_API_KEY");
640        }
641    }
642}