Skip to main content

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
312///
313/// This function checks for credentials in the following order:
314/// 1. OAuth token from encrypted storage (if OAuth is enabled)
315/// 2. Environment variable (OPENROUTER_API_KEY)
316/// 3. Configuration file value
317fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
318    // First, try to load OAuth token from encrypted storage
319    if let Ok(Some(token)) = crate::auth::load_oauth_token() {
320        tracing::debug!("Using OAuth token for OpenRouter authentication");
321        return Ok(token.api_key);
322    }
323
324    // Fall back to standard API key retrieval
325    get_api_key_with_fallback(
326        &sources.openrouter_env,
327        sources.openrouter_config.as_ref(),
328        "OpenRouter",
329    )
330}
331
332/// Get xAI API key with secure fallback
333fn get_xai_api_key(sources: &ApiKeySources) -> Result<String> {
334    get_api_key_with_fallback(&sources.xai_env, sources.xai_config.as_ref(), "xAI")
335}
336
337/// Get DeepSeek API key with secure fallback
338fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
339    get_api_key_with_fallback(
340        &sources.deepseek_env,
341        sources.deepseek_config.as_ref(),
342        "DeepSeek",
343    )
344}
345
346/// Get Z.AI API key with secure fallback
347fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
348    get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_ref(), "Z.AI")
349}
350
351/// Get Ollama API key with secure fallback
352fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
353    // For Ollama we allow running without credentials when connecting to a local
354    // deployment. Cloud variants still rely on the standard environment or
355    // configuration values when present.
356    if let Ok(key) = env::var(&sources.ollama_env)
357        && !key.is_empty()
358    {
359        return Ok(key);
360    }
361
362    if let Some(key) = sources.ollama_config.as_ref()
363        && !key.is_empty()
364    {
365        return Ok(key.clone());
366    }
367
368    Ok(String::new())
369}
370
371/// Get LM Studio API key with secure fallback
372fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
373    if let Ok(key) = env::var(&sources.lmstudio_env)
374        && !key.is_empty()
375    {
376        return Ok(key);
377    }
378
379    if let Some(key) = sources.lmstudio_config.as_ref()
380        && !key.is_empty()
381    {
382        return Ok(key.clone());
383    }
384
385    Ok(String::new())
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::env;
392
393    #[test]
394    fn test_get_gemini_api_key_from_env() {
395        // Set environment variable
396        unsafe {
397            env::set_var("TEST_GEMINI_KEY", "test-gemini-key");
398        }
399
400        let sources = ApiKeySources {
401            gemini_env: "TEST_GEMINI_KEY".to_string(),
402            ..Default::default()
403        };
404
405        let result = get_gemini_api_key(&sources);
406        assert!(result.is_ok());
407        assert_eq!(result.unwrap(), "test-gemini-key");
408
409        // Clean up
410        unsafe {
411            env::remove_var("TEST_GEMINI_KEY");
412        }
413    }
414
415    #[test]
416    fn test_get_anthropic_api_key_from_env() {
417        // Set environment variable
418        unsafe {
419            env::set_var("TEST_ANTHROPIC_KEY", "test-anthropic-key");
420        }
421
422        let sources = ApiKeySources {
423            anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
424            ..Default::default()
425        };
426
427        let result = get_anthropic_api_key(&sources);
428        assert!(result.is_ok());
429        assert_eq!(result.unwrap(), "test-anthropic-key");
430
431        // Clean up
432        unsafe {
433            env::remove_var("TEST_ANTHROPIC_KEY");
434        }
435    }
436
437    #[test]
438    fn test_get_openai_api_key_from_env() {
439        // Set environment variable
440        unsafe {
441            env::set_var("TEST_OPENAI_KEY", "test-openai-key");
442        }
443
444        let sources = ApiKeySources {
445            openai_env: "TEST_OPENAI_KEY".to_string(),
446            ..Default::default()
447        };
448
449        let result = get_openai_api_key(&sources);
450        assert!(result.is_ok());
451        assert_eq!(result.unwrap(), "test-openai-key");
452
453        // Clean up
454        unsafe {
455            env::remove_var("TEST_OPENAI_KEY");
456        }
457    }
458
459    #[test]
460    fn test_get_deepseek_api_key_from_env() {
461        unsafe {
462            env::set_var("TEST_DEEPSEEK_KEY", "test-deepseek-key");
463        }
464
465        let sources = ApiKeySources {
466            deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
467            ..Default::default()
468        };
469
470        let result = get_deepseek_api_key(&sources);
471        assert!(result.is_ok());
472        assert_eq!(result.unwrap(), "test-deepseek-key");
473
474        unsafe {
475            env::remove_var("TEST_DEEPSEEK_KEY");
476        }
477    }
478
479    #[test]
480    fn test_get_xai_api_key_from_env() {
481        unsafe {
482            env::set_var("TEST_XAI_KEY", "test-xai-key");
483        }
484
485        let sources = ApiKeySources {
486            xai_env: "TEST_XAI_KEY".to_string(),
487            ..Default::default()
488        };
489
490        let result = get_xai_api_key(&sources);
491        assert!(result.is_ok());
492        assert_eq!(result.unwrap(), "test-xai-key");
493
494        unsafe {
495            env::remove_var("TEST_XAI_KEY");
496        }
497    }
498
499    #[test]
500    fn test_get_gemini_api_key_from_config() {
501        let prior_gemini_key = env::var("TEST_GEMINI_KEY").ok();
502        let prior_google_key = env::var("GOOGLE_API_KEY").ok();
503
504        unsafe {
505            env::remove_var("TEST_GEMINI_KEY");
506            env::remove_var("GOOGLE_API_KEY");
507        }
508
509        let sources = ApiKeySources {
510            gemini_env: "TEST_GEMINI_KEY".to_string(),
511            gemini_config: Some("config-gemini-key".to_string()),
512            ..Default::default()
513        };
514
515        let result = get_gemini_api_key(&sources);
516        assert!(result.is_ok());
517        assert_eq!(result.unwrap(), "config-gemini-key");
518
519        unsafe {
520            if let Some(value) = prior_gemini_key {
521                env::set_var("TEST_GEMINI_KEY", value);
522            } else {
523                env::remove_var("TEST_GEMINI_KEY");
524            }
525            if let Some(value) = prior_google_key {
526                env::set_var("GOOGLE_API_KEY", value);
527            } else {
528                env::remove_var("GOOGLE_API_KEY");
529            }
530        }
531    }
532
533    #[test]
534    fn test_get_api_key_with_fallback_prefers_env() {
535        // Set environment variable
536        unsafe {
537            env::set_var("TEST_FALLBACK_KEY", "env-key");
538        }
539
540        let sources = ApiKeySources {
541            openai_env: "TEST_FALLBACK_KEY".to_string(),
542            openai_config: Some("config-key".to_string()),
543            ..Default::default()
544        };
545
546        let result = get_openai_api_key(&sources);
547        assert!(result.is_ok());
548        assert_eq!(result.unwrap(), "env-key"); // Should prefer env var
549
550        // Clean up
551        unsafe {
552            env::remove_var("TEST_FALLBACK_KEY");
553        }
554    }
555
556    #[test]
557    fn test_get_api_key_fallback_to_config() {
558        let sources = ApiKeySources {
559            openai_env: "NONEXISTENT_ENV_VAR".to_string(),
560            openai_config: Some("config-key".to_string()),
561            ..Default::default()
562        };
563
564        let result = get_openai_api_key(&sources);
565        assert!(result.is_ok());
566        assert_eq!(result.unwrap(), "config-key");
567    }
568
569    #[test]
570    fn test_get_api_key_error_when_not_found() {
571        let sources = ApiKeySources {
572            openai_env: "NONEXISTENT_ENV_VAR".to_string(),
573            ..Default::default()
574        };
575
576        let result = get_openai_api_key(&sources);
577        assert!(result.is_err());
578    }
579
580    #[test]
581    fn test_get_ollama_api_key_missing_sources() {
582        let sources = ApiKeySources {
583            ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
584            ..Default::default()
585        };
586
587        let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
588        assert!(result.is_empty());
589    }
590
591    #[test]
592    fn test_get_ollama_api_key_from_env() {
593        // Set environment variable
594        unsafe {
595            env::set_var("TEST_OLLAMA_KEY", "test-ollama-key");
596        }
597
598        let sources = ApiKeySources {
599            ollama_env: "TEST_OLLAMA_KEY".to_string(),
600            ..Default::default()
601        };
602
603        let result = get_ollama_api_key(&sources);
604        assert!(result.is_ok());
605        assert_eq!(result.unwrap(), "test-ollama-key");
606
607        // Clean up
608        unsafe {
609            env::remove_var("TEST_OLLAMA_KEY");
610        }
611    }
612
613    #[test]
614    fn test_get_lmstudio_api_key_missing_sources() {
615        let sources = ApiKeySources {
616            lmstudio_env: "NONEXISTENT_LMSTUDIO_ENV".to_string(),
617            ..Default::default()
618        };
619
620        let result =
621            get_lmstudio_api_key(&sources).expect("LM Studio key retrieval should succeed");
622        assert!(result.is_empty());
623    }
624
625    #[test]
626    fn test_get_lmstudio_api_key_from_env() {
627        unsafe {
628            env::set_var("TEST_LMSTUDIO_KEY", "test-lmstudio-key");
629        }
630
631        let sources = ApiKeySources {
632            lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
633            ..Default::default()
634        };
635
636        let result = get_lmstudio_api_key(&sources);
637        assert!(result.is_ok());
638        assert_eq!(result.unwrap(), "test-lmstudio-key");
639
640        unsafe {
641            env::remove_var("TEST_LMSTUDIO_KEY");
642        }
643    }
644
645    #[test]
646    fn test_get_api_key_ollama_provider() {
647        // Set environment variable
648        unsafe {
649            env::set_var("OLLAMA_API_KEY", "test-ollama-env-key");
650        }
651
652        let sources = ApiKeySources::default();
653        let result = get_api_key("ollama", &sources);
654        assert!(result.is_ok());
655        assert_eq!(result.unwrap(), "test-ollama-env-key");
656
657        // Clean up
658        unsafe {
659            env::remove_var("OLLAMA_API_KEY");
660        }
661    }
662
663    #[test]
664    fn test_get_api_key_lmstudio_provider() {
665        unsafe {
666            env::set_var("LMSTUDIO_API_KEY", "test-lmstudio-env-key");
667        }
668
669        let sources = ApiKeySources::default();
670        let result = get_api_key("lmstudio", &sources);
671        assert!(result.is_ok());
672        assert_eq!(result.unwrap(), "test-lmstudio-env-key");
673
674        unsafe {
675            env::remove_var("LMSTUDIO_API_KEY");
676        }
677    }
678}