1use anyhow::Result;
9use std::env;
10use std::str::FromStr;
11
12use crate::auth::CustomApiKeyStorage;
13use crate::constants::defaults;
14use crate::models::Provider;
15
16#[derive(Debug, Clone)]
18pub struct ApiKeySources {
19 pub gemini_env: String,
21 pub anthropic_env: String,
23 pub openai_env: String,
25 pub openrouter_env: String,
27 pub deepseek_env: String,
29 pub zai_env: String,
31 pub ollama_env: String,
33 pub lmstudio_env: String,
35 pub gemini_config: Option<String>,
37 pub anthropic_config: Option<String>,
39 pub openai_config: Option<String>,
41 pub openrouter_config: Option<String>,
43 pub deepseek_config: Option<String>,
45 pub zai_config: Option<String>,
47 pub ollama_config: Option<String>,
49 pub lmstudio_config: Option<String>,
51}
52
53impl Default for ApiKeySources {
54 fn default() -> Self {
55 Self {
56 gemini_env: "GEMINI_API_KEY".to_string(),
57 anthropic_env: "ANTHROPIC_API_KEY".to_string(),
58 openai_env: "OPENAI_API_KEY".to_string(),
59 openrouter_env: "OPENROUTER_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 deepseek_config: None,
69 zai_config: None,
70 ollama_config: None,
71 lmstudio_config: None,
72 }
73 }
74}
75
76impl ApiKeySources {
77 pub fn for_provider(_provider: &str) -> Self {
79 Self::default()
80 }
81}
82
83pub fn api_key_env_var(provider: &str) -> String {
84 let trimmed = provider.trim();
85 if trimmed.is_empty() {
86 return defaults::DEFAULT_API_KEY_ENV.to_owned();
87 }
88
89 if let Ok(resolved) = Provider::from_str(trimmed)
90 && resolved.uses_managed_auth()
91 {
92 return String::new();
93 }
94
95 Provider::from_str(trimmed)
96 .map(|resolved| resolved.default_api_key_env().to_owned())
97 .unwrap_or_else(|_| format!("{}_API_KEY", trimmed.to_ascii_uppercase()))
98}
99
100pub fn resolve_api_key_env(provider: &str, configured_env: &str) -> String {
101 let trimmed = configured_env.trim();
102 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV) {
103 api_key_env_var(provider)
104 } else {
105 trimmed.to_owned()
106 }
107}
108
109#[cfg(test)]
110mod test_env_overrides {
111 use hashbrown::HashMap;
112 use std::sync::{LazyLock, Mutex};
113
114 static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
115 LazyLock::new(|| Mutex::new(HashMap::new()));
116
117 pub(super) fn get(key: &str) -> Option<Option<String>> {
118 OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
119 }
120
121 pub(super) fn set(key: &str, value: Option<&str>) {
122 if let Ok(mut map) = OVERRIDES.lock() {
123 map.insert(key.to_string(), value.map(ToString::to_string));
124 }
125 }
126
127 pub(super) fn restore(key: &str, previous: Option<Option<String>>) {
128 if let Ok(mut map) = OVERRIDES.lock() {
129 match previous {
130 Some(value) => {
131 map.insert(key.to_string(), value);
132 }
133 None => {
134 map.remove(key);
135 }
136 }
137 }
138 }
139}
140
141fn read_env_var(key: &str) -> Option<String> {
142 #[cfg(test)]
143 if let Some(override_value) = test_env_overrides::get(key) {
144 return override_value;
145 }
146
147 env::var(key).ok()
148}
149
150pub fn load_dotenv() -> Result<()> {
156 match dotenvy::dotenv() {
157 Ok(path) => {
158 if read_env_var("VTCODE_VERBOSE").is_some() || read_env_var("RUST_LOG").is_some() {
160 tracing::info!("Loaded environment variables from: {}", path.display());
161 }
162 Ok(())
163 }
164 Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
165 Ok(())
167 }
168 Err(e) => {
169 tracing::warn!("Failed to load .env file: {}", e);
170 Ok(())
171 }
172 }
173}
174
175pub fn get_api_key(provider: &str, sources: &ApiKeySources) -> Result<String> {
194 let normalized_provider = provider.to_lowercase();
195 let inferred_env = api_key_env_var(&normalized_provider);
197
198 if let Some(key) = read_env_var(&inferred_env)
200 && !key.is_empty()
201 {
202 return Ok(key);
203 }
204
205 if let Ok(Some(key)) = get_custom_api_key_from_secure_storage(&normalized_provider) {
207 return Ok(key);
208 }
209
210 match normalized_provider.as_str() {
212 "gemini" => get_gemini_api_key(sources),
213 "anthropic" => get_anthropic_api_key(sources),
214 "openai" => get_openai_api_key(sources),
215 "copilot" => Err(anyhow::anyhow!(
216 "GitHub Copilot authentication is managed by the official `copilot` CLI. Run `vtcode login copilot`."
217 )),
218 "deepseek" => get_deepseek_api_key(sources),
219 "openrouter" => get_openrouter_api_key(sources),
220 "zai" => get_zai_api_key(sources),
221 "ollama" => get_ollama_api_key(sources),
222 "lmstudio" => get_lmstudio_api_key(sources),
223 "huggingface" => {
224 read_env_var("HF_TOKEN").ok_or_else(|| anyhow::anyhow!("HF_TOKEN not set"))
225 }
226 _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
227 }
228}
229
230fn get_custom_api_key_from_secure_storage(provider: &str) -> Result<Option<String>> {
244 let storage = CustomApiKeyStorage::new(provider);
245 let mode = crate::auth::AuthCredentialsStoreMode::default();
247 storage.load(mode)
248}
249
250fn get_api_key_with_fallback(
252 env_var: &str,
253 config_value: Option<&String>,
254 provider_name: &str,
255) -> Result<String> {
256 if let Some(key) = read_env_var(env_var)
258 && !key.is_empty()
259 {
260 return Ok(key);
261 }
262
263 if let Some(key) = config_value
265 && !key.is_empty()
266 {
267 return Ok(key.clone());
268 }
269
270 Err(anyhow::anyhow!(
272 "No API key found for {} provider. Set {} environment variable (or add to .env file) or configure in vtcode.toml",
273 provider_name,
274 env_var
275 ))
276}
277
278fn get_optional_api_key_with_fallback(env_var: &str, config_value: Option<&String>) -> String {
279 if let Some(key) = read_env_var(env_var)
280 && !key.is_empty()
281 {
282 return key;
283 }
284
285 if let Some(key) = config_value
286 && !key.is_empty()
287 {
288 return key.clone();
289 }
290
291 String::new()
292}
293
294fn get_gemini_api_key(sources: &ApiKeySources) -> Result<String> {
296 if let Some(key) = read_env_var(&sources.gemini_env)
298 && !key.is_empty()
299 {
300 return Ok(key);
301 }
302
303 if let Some(key) = read_env_var("GOOGLE_API_KEY")
305 && !key.is_empty()
306 {
307 return Ok(key);
308 }
309
310 if let Some(key) = &sources.gemini_config
312 && !key.is_empty()
313 {
314 return Ok(key.clone());
315 }
316
317 Err(anyhow::anyhow!(
319 "No API key found for Gemini provider. Set {} or GOOGLE_API_KEY environment variable (or add to .env file) or configure in vtcode.toml",
320 sources.gemini_env
321 ))
322}
323
324fn get_anthropic_api_key(sources: &ApiKeySources) -> Result<String> {
326 get_api_key_with_fallback(
327 &sources.anthropic_env,
328 sources.anthropic_config.as_ref(),
329 "Anthropic",
330 )
331}
332
333fn get_openai_api_key(sources: &ApiKeySources) -> Result<String> {
335 get_api_key_with_fallback(
336 &sources.openai_env,
337 sources.openai_config.as_ref(),
338 "OpenAI",
339 )
340}
341
342fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
349 if let Ok(Some(token)) = crate::auth::load_oauth_token() {
351 tracing::debug!("Using OAuth token for OpenRouter authentication");
352 return Ok(token.api_key);
353 }
354
355 get_api_key_with_fallback(
357 &sources.openrouter_env,
358 sources.openrouter_config.as_ref(),
359 "OpenRouter",
360 )
361}
362
363fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
365 get_api_key_with_fallback(
366 &sources.deepseek_env,
367 sources.deepseek_config.as_ref(),
368 "DeepSeek",
369 )
370}
371
372fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
374 get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_ref(), "Z.AI")
375}
376
377fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
379 Ok(get_optional_api_key_with_fallback(
382 &sources.ollama_env,
383 sources.ollama_config.as_ref(),
384 ))
385}
386
387fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
389 Ok(get_optional_api_key_with_fallback(
390 &sources.lmstudio_env,
391 sources.lmstudio_config.as_ref(),
392 ))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 struct EnvOverrideGuard {
400 key: &'static str,
401 previous: Option<Option<String>>,
402 }
403
404 impl EnvOverrideGuard {
405 fn set(key: &'static str, value: Option<&str>) -> Self {
406 let previous = test_env_overrides::get(key);
407 test_env_overrides::set(key, value);
408 Self { key, previous }
409 }
410 }
411
412 impl Drop for EnvOverrideGuard {
413 fn drop(&mut self) {
414 test_env_overrides::restore(self.key, self.previous.clone());
415 }
416 }
417
418 fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
419 where
420 F: FnOnce(),
421 {
422 let _guard = EnvOverrideGuard::set(key, value);
423 f();
424 }
425
426 fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
427 where
428 F: FnOnce(),
429 {
430 let _guards: Vec<_> = overrides
431 .iter()
432 .map(|(key, value)| EnvOverrideGuard::set(key, *value))
433 .collect();
434 f();
435 }
436
437 #[test]
438 fn test_get_gemini_api_key_from_env() {
439 with_override("TEST_GEMINI_KEY", Some("test-gemini-key"), || {
440 let sources = ApiKeySources {
441 gemini_env: "TEST_GEMINI_KEY".to_string(),
442 ..Default::default()
443 };
444
445 let result = get_gemini_api_key(&sources);
446 assert!(result.is_ok());
447 assert_eq!(result.unwrap(), "test-gemini-key");
448 });
449 }
450
451 #[test]
452 fn test_get_anthropic_api_key_from_env() {
453 with_override("TEST_ANTHROPIC_KEY", Some("test-anthropic-key"), || {
454 let sources = ApiKeySources {
455 anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
456 ..Default::default()
457 };
458
459 let result = get_anthropic_api_key(&sources);
460 assert!(result.is_ok());
461 assert_eq!(result.unwrap(), "test-anthropic-key");
462 });
463 }
464
465 #[test]
466 fn test_get_openai_api_key_from_env() {
467 with_override("TEST_OPENAI_KEY", Some("test-openai-key"), || {
468 let sources = ApiKeySources {
469 openai_env: "TEST_OPENAI_KEY".to_string(),
470 ..Default::default()
471 };
472
473 let result = get_openai_api_key(&sources);
474 assert!(result.is_ok());
475 assert_eq!(result.unwrap(), "test-openai-key");
476 });
477 }
478
479 #[test]
480 fn test_get_deepseek_api_key_from_env() {
481 with_override("TEST_DEEPSEEK_KEY", Some("test-deepseek-key"), || {
482 let sources = ApiKeySources {
483 deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
484 ..Default::default()
485 };
486
487 let result = get_deepseek_api_key(&sources);
488 assert!(result.is_ok());
489 assert_eq!(result.unwrap(), "test-deepseek-key");
490 });
491 }
492
493 #[test]
494 fn test_get_gemini_api_key_from_config() {
495 with_overrides(
496 &[
497 ("TEST_GEMINI_CONFIG_KEY", None),
498 ("GOOGLE_API_KEY", None),
499 ("GEMINI_API_KEY", None),
500 ],
501 || {
502 let sources = ApiKeySources {
503 gemini_env: "TEST_GEMINI_CONFIG_KEY".to_string(),
504 gemini_config: Some("config-gemini-key".to_string()),
505 ..Default::default()
506 };
507
508 let result = get_gemini_api_key(&sources);
509 assert!(result.is_ok());
510 assert_eq!(result.unwrap(), "config-gemini-key");
511 },
512 );
513 }
514
515 #[test]
516 fn test_get_api_key_with_fallback_prefers_env() {
517 with_override("TEST_FALLBACK_KEY", Some("env-key"), || {
518 let sources = ApiKeySources {
519 openai_env: "TEST_FALLBACK_KEY".to_string(),
520 openai_config: Some("config-key".to_string()),
521 ..Default::default()
522 };
523
524 let result = get_openai_api_key(&sources);
525 assert!(result.is_ok());
526 assert_eq!(result.unwrap(), "env-key"); });
528 }
529
530 #[test]
531 fn test_get_api_key_fallback_to_config() {
532 let sources = ApiKeySources {
533 openai_env: "NONEXISTENT_ENV_VAR".to_string(),
534 openai_config: Some("config-key".to_string()),
535 ..Default::default()
536 };
537
538 let result = get_openai_api_key(&sources);
539 assert!(result.is_ok());
540 assert_eq!(result.unwrap(), "config-key");
541 }
542
543 #[test]
544 fn test_get_api_key_error_when_not_found() {
545 let sources = ApiKeySources {
546 openai_env: "NONEXISTENT_ENV_VAR".to_string(),
547 ..Default::default()
548 };
549
550 let result = get_openai_api_key(&sources);
551 assert!(result.is_err());
552 }
553
554 #[test]
555 fn test_get_ollama_api_key_missing_sources() {
556 let sources = ApiKeySources {
557 ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
558 ..Default::default()
559 };
560
561 let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
562 assert!(result.is_empty());
563 }
564
565 #[test]
566 fn test_get_ollama_api_key_from_env() {
567 with_override("TEST_OLLAMA_KEY", Some("test-ollama-key"), || {
568 let sources = ApiKeySources {
569 ollama_env: "TEST_OLLAMA_KEY".to_string(),
570 ..Default::default()
571 };
572
573 let result = get_ollama_api_key(&sources);
574 assert!(result.is_ok());
575 assert_eq!(result.unwrap(), "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 with_override("TEST_LMSTUDIO_KEY", Some("test-lmstudio-key"), || {
594 let sources = ApiKeySources {
595 lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
596 ..Default::default()
597 };
598
599 let result = get_lmstudio_api_key(&sources);
600 assert!(result.is_ok());
601 assert_eq!(result.unwrap(), "test-lmstudio-key");
602 });
603 }
604
605 #[test]
606 fn test_get_api_key_ollama_provider() {
607 with_override(
608 "TEST_OLLAMA_PROVIDER_KEY",
609 Some("test-ollama-env-key"),
610 || {
611 let sources = ApiKeySources {
612 ollama_env: "TEST_OLLAMA_PROVIDER_KEY".to_string(),
613 ..Default::default()
614 };
615 let result = get_api_key("ollama", &sources);
616 assert!(result.is_ok());
617 assert_eq!(result.unwrap(), "test-ollama-env-key");
618 },
619 );
620 }
621
622 #[test]
623 fn test_get_api_key_lmstudio_provider() {
624 with_override(
625 "TEST_LMSTUDIO_PROVIDER_KEY",
626 Some("test-lmstudio-env-key"),
627 || {
628 let sources = ApiKeySources {
629 lmstudio_env: "TEST_LMSTUDIO_PROVIDER_KEY".to_string(),
630 ..Default::default()
631 };
632 let result = get_api_key("lmstudio", &sources);
633 assert!(result.is_ok());
634 assert_eq!(result.unwrap(), "test-lmstudio-env-key");
635 },
636 );
637 }
638
639 #[test]
640 fn api_key_env_var_uses_provider_defaults() {
641 assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
642 assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
643 }
644
645 #[test]
646 fn resolve_api_key_env_uses_provider_default_for_placeholder() {
647 assert_eq!(
648 resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV),
649 "MINIMAX_API_KEY"
650 );
651 }
652
653 #[test]
654 fn resolve_api_key_env_preserves_explicit_override() {
655 assert_eq!(
656 resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"),
657 "CUSTOM_OPENAI_KEY"
658 );
659 }
660}