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