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        "huggingface" => "HF_TOKEN",
209        _ => "GEMINI_API_KEY",
210    };
211
212    // Try the inferred environment variable first
213    if let Ok(key) = env::var(inferred_env)
214        && !key.is_empty()
215    {
216        return Ok(key);
217    }
218
219    // Fall back to the provider-specific sources
220    match provider.to_lowercase().as_str() {
221        "gemini" => get_gemini_api_key(sources),
222        "anthropic" => get_anthropic_api_key(sources),
223        "openai" => get_openai_api_key(sources),
224        "deepseek" => get_deepseek_api_key(sources),
225        "openrouter" => get_openrouter_api_key(sources),
226        "xai" => get_xai_api_key(sources),
227        "zai" => get_zai_api_key(sources),
228        "ollama" => get_ollama_api_key(sources),
229        "lmstudio" => get_lmstudio_api_key(sources),
230        "huggingface" => env::var("HF_TOKEN").map_err(|_| anyhow::anyhow!("HF_TOKEN not set")),
231        _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
232    }
233}
234
235/// Get API key for a specific environment variable with fallback
236fn get_api_key_with_fallback(
237    env_var: &str,
238    config_value: Option<&String>,
239    provider_name: &str,
240) -> Result<String> {
241    // First try environment variable (most secure)
242    if let Ok(key) = env::var(env_var)
243        && !key.is_empty()
244    {
245        return Ok(key);
246    }
247
248    // Then try configuration file value
249    if let Some(key) = config_value
250        && !key.is_empty()
251    {
252        return Ok(key.clone());
253    }
254
255    // If neither worked, return an error
256    Err(anyhow::anyhow!(
257        "No API key found for {} provider. Set {} environment variable (or add to .env file) or configure in vtcode.toml",
258        provider_name,
259        env_var
260    ))
261}
262
263/// Get Gemini API key with secure fallback
264fn get_gemini_api_key(sources: &ApiKeySources) -> Result<String> {
265    // Try primary Gemini environment variable
266    if let Ok(key) = env::var(&sources.gemini_env)
267        && !key.is_empty()
268    {
269        return Ok(key);
270    }
271
272    // Try Google API key as fallback (for backward compatibility)
273    if let Ok(key) = env::var("GOOGLE_API_KEY")
274        && !key.is_empty()
275    {
276        return Ok(key);
277    }
278
279    // Try configuration file value
280    if let Some(key) = &sources.gemini_config
281        && !key.is_empty()
282    {
283        return Ok(key.clone());
284    }
285
286    // If nothing worked, return an error
287    Err(anyhow::anyhow!(
288        "No API key found for Gemini provider. Set {} or GOOGLE_API_KEY environment variable (or add to .env file) or configure in vtcode.toml",
289        sources.gemini_env
290    ))
291}
292
293/// Get Anthropic API key with secure fallback
294fn get_anthropic_api_key(sources: &ApiKeySources) -> Result<String> {
295    get_api_key_with_fallback(
296        &sources.anthropic_env,
297        sources.anthropic_config.as_ref(),
298        "Anthropic",
299    )
300}
301
302/// Get OpenAI API key with secure fallback
303fn get_openai_api_key(sources: &ApiKeySources) -> Result<String> {
304    get_api_key_with_fallback(
305        &sources.openai_env,
306        sources.openai_config.as_ref(),
307        "OpenAI",
308    )
309}
310
311/// Get OpenRouter API key with secure fallback
312fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
313    get_api_key_with_fallback(
314        &sources.openrouter_env,
315        sources.openrouter_config.as_ref(),
316        "OpenRouter",
317    )
318}
319
320/// Get xAI API key with secure fallback
321fn get_xai_api_key(sources: &ApiKeySources) -> Result<String> {
322    get_api_key_with_fallback(&sources.xai_env, sources.xai_config.as_ref(), "xAI")
323}
324
325/// Get DeepSeek API key with secure fallback
326fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
327    get_api_key_with_fallback(
328        &sources.deepseek_env,
329        sources.deepseek_config.as_ref(),
330        "DeepSeek",
331    )
332}
333
334/// Get Z.AI API key with secure fallback
335fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
336    get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_ref(), "Z.AI")
337}
338
339/// Get Ollama API key with secure fallback
340fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
341    // For Ollama we allow running without credentials when connecting to a local
342    // deployment. Cloud variants still rely on the standard environment or
343    // configuration values when present.
344    if let Ok(key) = env::var(&sources.ollama_env)
345        && !key.is_empty()
346    {
347        return Ok(key);
348    }
349
350    if let Some(key) = sources.ollama_config.as_ref()
351        && !key.is_empty()
352    {
353        return Ok(key.clone());
354    }
355
356    Ok(String::new())
357}
358
359/// Get LM Studio API key with secure fallback
360fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
361    if let Ok(key) = env::var(&sources.lmstudio_env)
362        && !key.is_empty()
363    {
364        return Ok(key);
365    }
366
367    if let Some(key) = sources.lmstudio_config.as_ref()
368        && !key.is_empty()
369    {
370        return Ok(key.clone());
371    }
372
373    Ok(String::new())
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use std::env;
380
381    #[test]
382    fn test_get_gemini_api_key_from_env() {
383        // Set environment variable
384        unsafe {
385            env::set_var("TEST_GEMINI_KEY", "test-gemini-key");
386        }
387
388        let sources = ApiKeySources {
389            gemini_env: "TEST_GEMINI_KEY".to_string(),
390            ..Default::default()
391        };
392
393        let result = get_gemini_api_key(&sources);
394        assert!(result.is_ok());
395        assert_eq!(result.unwrap(), "test-gemini-key");
396
397        // Clean up
398        unsafe {
399            env::remove_var("TEST_GEMINI_KEY");
400        }
401    }
402
403    #[test]
404    fn test_get_anthropic_api_key_from_env() {
405        // Set environment variable
406        unsafe {
407            env::set_var("TEST_ANTHROPIC_KEY", "test-anthropic-key");
408        }
409
410        let sources = ApiKeySources {
411            anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
412            ..Default::default()
413        };
414
415        let result = get_anthropic_api_key(&sources);
416        assert!(result.is_ok());
417        assert_eq!(result.unwrap(), "test-anthropic-key");
418
419        // Clean up
420        unsafe {
421            env::remove_var("TEST_ANTHROPIC_KEY");
422        }
423    }
424
425    #[test]
426    fn test_get_openai_api_key_from_env() {
427        // Set environment variable
428        unsafe {
429            env::set_var("TEST_OPENAI_KEY", "test-openai-key");
430        }
431
432        let sources = ApiKeySources {
433            openai_env: "TEST_OPENAI_KEY".to_string(),
434            ..Default::default()
435        };
436
437        let result = get_openai_api_key(&sources);
438        assert!(result.is_ok());
439        assert_eq!(result.unwrap(), "test-openai-key");
440
441        // Clean up
442        unsafe {
443            env::remove_var("TEST_OPENAI_KEY");
444        }
445    }
446
447    #[test]
448    fn test_get_deepseek_api_key_from_env() {
449        unsafe {
450            env::set_var("TEST_DEEPSEEK_KEY", "test-deepseek-key");
451        }
452
453        let sources = ApiKeySources {
454            deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
455            ..Default::default()
456        };
457
458        let result = get_deepseek_api_key(&sources);
459        assert!(result.is_ok());
460        assert_eq!(result.unwrap(), "test-deepseek-key");
461
462        unsafe {
463            env::remove_var("TEST_DEEPSEEK_KEY");
464        }
465    }
466
467    #[test]
468    fn test_get_xai_api_key_from_env() {
469        unsafe {
470            env::set_var("TEST_XAI_KEY", "test-xai-key");
471        }
472
473        let sources = ApiKeySources {
474            xai_env: "TEST_XAI_KEY".to_string(),
475            ..Default::default()
476        };
477
478        let result = get_xai_api_key(&sources);
479        assert!(result.is_ok());
480        assert_eq!(result.unwrap(), "test-xai-key");
481
482        unsafe {
483            env::remove_var("TEST_XAI_KEY");
484        }
485    }
486
487    #[test]
488    fn test_get_gemini_api_key_from_config() {
489        let sources = ApiKeySources {
490            gemini_config: Some("config-gemini-key".to_string()),
491            ..Default::default()
492        };
493
494        let result = get_gemini_api_key(&sources);
495        assert!(result.is_ok());
496        assert_eq!(result.unwrap(), "config-gemini-key");
497    }
498
499    #[test]
500    fn test_get_api_key_with_fallback_prefers_env() {
501        // Set environment variable
502        unsafe {
503            env::set_var("TEST_FALLBACK_KEY", "env-key");
504        }
505
506        let sources = ApiKeySources {
507            openai_env: "TEST_FALLBACK_KEY".to_string(),
508            openai_config: Some("config-key".to_string()),
509            ..Default::default()
510        };
511
512        let result = get_openai_api_key(&sources);
513        assert!(result.is_ok());
514        assert_eq!(result.unwrap(), "env-key"); // Should prefer env var
515
516        // Clean up
517        unsafe {
518            env::remove_var("TEST_FALLBACK_KEY");
519        }
520    }
521
522    #[test]
523    fn test_get_api_key_fallback_to_config() {
524        let sources = ApiKeySources {
525            openai_env: "NONEXISTENT_ENV_VAR".to_string(),
526            openai_config: Some("config-key".to_string()),
527            ..Default::default()
528        };
529
530        let result = get_openai_api_key(&sources);
531        assert!(result.is_ok());
532        assert_eq!(result.unwrap(), "config-key");
533    }
534
535    #[test]
536    fn test_get_api_key_error_when_not_found() {
537        let sources = ApiKeySources {
538            openai_env: "NONEXISTENT_ENV_VAR".to_string(),
539            ..Default::default()
540        };
541
542        let result = get_openai_api_key(&sources);
543        assert!(result.is_err());
544    }
545
546    #[test]
547    fn test_get_ollama_api_key_missing_sources() {
548        let sources = ApiKeySources {
549            ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
550            ..Default::default()
551        };
552
553        let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
554        assert!(result.is_empty());
555    }
556
557    #[test]
558    fn test_get_ollama_api_key_from_env() {
559        // Set environment variable
560        unsafe {
561            env::set_var("TEST_OLLAMA_KEY", "test-ollama-key");
562        }
563
564        let sources = ApiKeySources {
565            ollama_env: "TEST_OLLAMA_KEY".to_string(),
566            ..Default::default()
567        };
568
569        let result = get_ollama_api_key(&sources);
570        assert!(result.is_ok());
571        assert_eq!(result.unwrap(), "test-ollama-key");
572
573        // Clean up
574        unsafe {
575            env::remove_var("TEST_OLLAMA_KEY");
576        }
577    }
578
579    #[test]
580    fn test_get_lmstudio_api_key_missing_sources() {
581        let sources = ApiKeySources {
582            lmstudio_env: "NONEXISTENT_LMSTUDIO_ENV".to_string(),
583            ..Default::default()
584        };
585
586        let result =
587            get_lmstudio_api_key(&sources).expect("LM Studio key retrieval should succeed");
588        assert!(result.is_empty());
589    }
590
591    #[test]
592    fn test_get_lmstudio_api_key_from_env() {
593        unsafe {
594            env::set_var("TEST_LMSTUDIO_KEY", "test-lmstudio-key");
595        }
596
597        let sources = ApiKeySources {
598            lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
599            ..Default::default()
600        };
601
602        let result = get_lmstudio_api_key(&sources);
603        assert!(result.is_ok());
604        assert_eq!(result.unwrap(), "test-lmstudio-key");
605
606        unsafe {
607            env::remove_var("TEST_LMSTUDIO_KEY");
608        }
609    }
610
611    #[test]
612    fn test_get_api_key_ollama_provider() {
613        // Set environment variable
614        unsafe {
615            env::set_var("OLLAMA_API_KEY", "test-ollama-env-key");
616        }
617
618        let sources = ApiKeySources::default();
619        let result = get_api_key("ollama", &sources);
620        assert!(result.is_ok());
621        assert_eq!(result.unwrap(), "test-ollama-env-key");
622
623        // Clean up
624        unsafe {
625            env::remove_var("OLLAMA_API_KEY");
626        }
627    }
628
629    #[test]
630    fn test_get_api_key_lmstudio_provider() {
631        unsafe {
632            env::set_var("LMSTUDIO_API_KEY", "test-lmstudio-env-key");
633        }
634
635        let sources = ApiKeySources::default();
636        let result = get_api_key("lmstudio", &sources);
637        assert!(result.is_ok());
638        assert_eq!(result.unwrap(), "test-lmstudio-env-key");
639
640        unsafe {
641            env::remove_var("LMSTUDIO_API_KEY");
642        }
643    }
644}