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 trimmed.eq_ignore_ascii_case("codex") {
90 return String::new();
91 }
92
93 if let Ok(resolved) = Provider::from_str(trimmed)
94 && resolved.uses_managed_auth()
95 {
96 return String::new();
97 }
98
99 Provider::from_str(trimmed)
100 .map(|resolved| resolved.default_api_key_env().to_owned())
101 .unwrap_or_else(|_| format!("{}_API_KEY", trimmed.to_ascii_uppercase()))
102}
103
104pub fn resolve_api_key_env(provider: &str, configured_env: &str) -> String {
105 let trimmed = configured_env.trim();
106 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV) {
107 api_key_env_var(provider)
108 } else {
109 trimmed.to_owned()
110 }
111}
112
113#[cfg(test)]
114mod test_env_overrides {
115 use hashbrown::HashMap;
116 use std::sync::{LazyLock, Mutex};
117
118 static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
119 LazyLock::new(|| Mutex::new(HashMap::new()));
120
121 pub(super) fn get(key: &str) -> Option<Option<String>> {
122 OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
123 }
124
125 pub(super) fn set(key: &str, value: Option<&str>) {
126 if let Ok(mut map) = OVERRIDES.lock() {
127 map.insert(key.to_string(), value.map(ToString::to_string));
128 }
129 }
130
131 pub(super) fn restore(key: &str, previous: Option<Option<String>>) {
132 if let Ok(mut map) = OVERRIDES.lock() {
133 match previous {
134 Some(value) => {
135 map.insert(key.to_string(), value);
136 }
137 None => {
138 map.remove(key);
139 }
140 }
141 }
142 }
143}
144
145fn read_env_var(key: &str) -> Option<String> {
146 #[cfg(test)]
147 if let Some(override_value) = test_env_overrides::get(key) {
148 return override_value;
149 }
150
151 env::var(key).ok()
152}
153
154pub fn load_dotenv() -> Result<()> {
160 match dotenvy::dotenv() {
161 Ok(path) => {
162 if read_env_var("VTCODE_VERBOSE").is_some() || read_env_var("RUST_LOG").is_some() {
164 tracing::info!("Loaded environment variables from: {}", path.display());
165 }
166 Ok(())
167 }
168 Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
169 Ok(())
171 }
172 Err(e) => {
173 tracing::warn!("Failed to load .env file: {}", e);
174 Ok(())
175 }
176 }
177}
178
179pub fn get_api_key(provider: &str, sources: &ApiKeySources) -> Result<String> {
198 let normalized_provider = provider.to_lowercase();
199 let inferred_env = api_key_env_var(&normalized_provider);
201
202 if let Some(key) = read_env_var(&inferred_env)
204 && !key.is_empty()
205 {
206 return Ok(key);
207 }
208
209 if let Ok(Some(key)) = get_custom_api_key_from_secure_storage(&normalized_provider) {
211 return Ok(key);
212 }
213
214 match normalized_provider.as_str() {
216 "gemini" => get_gemini_api_key(sources),
217 "anthropic" => get_anthropic_api_key(sources),
218 "openai" => get_openai_api_key(sources),
219 "copilot" => Err(anyhow::anyhow!(
220 "GitHub Copilot authentication is managed by the official `copilot` CLI. Run `vtcode login copilot`."
221 )),
222 "deepseek" => get_deepseek_api_key(sources),
223 "openrouter" => get_openrouter_api_key(sources),
224 "codex" => Err(anyhow::anyhow!(
225 "Codex authentication is managed by the official `codex app-server`. Run `vtcode login codex`."
226 )),
227 "zai" => get_zai_api_key(sources),
228 "ollama" => get_ollama_api_key(sources),
229 "lmstudio" => get_lmstudio_api_key(sources),
230 "huggingface" => {
231 read_env_var("HF_TOKEN").ok_or_else(|| anyhow::anyhow!("HF_TOKEN not set"))
232 }
233 _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
234 }
235}
236
237fn get_custom_api_key_from_secure_storage(provider: &str) -> Result<Option<String>> {
251 let storage = CustomApiKeyStorage::new(provider);
252 let mode = crate::auth::AuthCredentialsStoreMode::default();
254 storage.load(mode)
255}
256
257fn get_api_key_with_fallback(
259 env_var: &str,
260 config_value: Option<&String>,
261 provider_name: &str,
262) -> Result<String> {
263 if let Some(key) = read_env_var(env_var)
265 && !key.is_empty()
266 {
267 return Ok(key);
268 }
269
270 if let Some(key) = config_value
272 && !key.is_empty()
273 {
274 return Ok(key.clone());
275 }
276
277 Err(anyhow::anyhow!(
279 "No API key found for {} provider. Set {} environment variable (or add to .env file) or configure in vtcode.toml",
280 provider_name,
281 env_var
282 ))
283}
284
285fn get_optional_api_key_with_fallback(env_var: &str, config_value: Option<&String>) -> String {
286 if let Some(key) = read_env_var(env_var)
287 && !key.is_empty()
288 {
289 return key;
290 }
291
292 if let Some(key) = config_value
293 && !key.is_empty()
294 {
295 return key.clone();
296 }
297
298 String::new()
299}
300
301fn get_gemini_api_key(sources: &ApiKeySources) -> Result<String> {
303 if let Some(key) = read_env_var(&sources.gemini_env)
305 && !key.is_empty()
306 {
307 return Ok(key);
308 }
309
310 if let Some(key) = read_env_var("GOOGLE_API_KEY")
312 && !key.is_empty()
313 {
314 return Ok(key);
315 }
316
317 if let Some(key) = &sources.gemini_config
319 && !key.is_empty()
320 {
321 return Ok(key.clone());
322 }
323
324 Err(anyhow::anyhow!(
326 "No API key found for Gemini provider. Set {} or GOOGLE_API_KEY environment variable (or add to .env file) or configure in vtcode.toml",
327 sources.gemini_env
328 ))
329}
330
331fn get_anthropic_api_key(sources: &ApiKeySources) -> Result<String> {
333 get_api_key_with_fallback(
334 &sources.anthropic_env,
335 sources.anthropic_config.as_ref(),
336 "Anthropic",
337 )
338}
339
340fn get_openai_api_key(sources: &ApiKeySources) -> Result<String> {
342 get_api_key_with_fallback(
343 &sources.openai_env,
344 sources.openai_config.as_ref(),
345 "OpenAI",
346 )
347}
348
349fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
356 if let Ok(Some(token)) = crate::auth::load_oauth_token() {
358 tracing::debug!("Using OAuth token for OpenRouter authentication");
359 return Ok(token.api_key);
360 }
361
362 get_api_key_with_fallback(
364 &sources.openrouter_env,
365 sources.openrouter_config.as_ref(),
366 "OpenRouter",
367 )
368}
369
370fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
372 get_api_key_with_fallback(
373 &sources.deepseek_env,
374 sources.deepseek_config.as_ref(),
375 "DeepSeek",
376 )
377}
378
379fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
381 get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_ref(), "Z.AI")
382}
383
384fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
386 Ok(get_optional_api_key_with_fallback(
389 &sources.ollama_env,
390 sources.ollama_config.as_ref(),
391 ))
392}
393
394fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
396 Ok(get_optional_api_key_with_fallback(
397 &sources.lmstudio_env,
398 sources.lmstudio_config.as_ref(),
399 ))
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 struct EnvOverrideGuard {
407 key: &'static str,
408 previous: Option<Option<String>>,
409 }
410
411 impl EnvOverrideGuard {
412 fn set(key: &'static str, value: Option<&str>) -> Self {
413 let previous = test_env_overrides::get(key);
414 test_env_overrides::set(key, value);
415 Self { key, previous }
416 }
417 }
418
419 impl Drop for EnvOverrideGuard {
420 fn drop(&mut self) {
421 test_env_overrides::restore(self.key, self.previous.clone());
422 }
423 }
424
425 fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
426 where
427 F: FnOnce(),
428 {
429 let _guard = EnvOverrideGuard::set(key, value);
430 f();
431 }
432
433 fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
434 where
435 F: FnOnce(),
436 {
437 let _guards: Vec<_> = overrides
438 .iter()
439 .map(|(key, value)| EnvOverrideGuard::set(key, *value))
440 .collect();
441 f();
442 }
443
444 #[test]
445 fn test_get_gemini_api_key_from_env() {
446 with_override("TEST_GEMINI_KEY", Some("test-gemini-key"), || {
447 let sources = ApiKeySources {
448 gemini_env: "TEST_GEMINI_KEY".to_string(),
449 ..Default::default()
450 };
451
452 let result = get_gemini_api_key(&sources);
453 assert!(result.is_ok());
454 assert_eq!(result.unwrap(), "test-gemini-key");
455 });
456 }
457
458 #[test]
459 fn test_get_anthropic_api_key_from_env() {
460 with_override("TEST_ANTHROPIC_KEY", Some("test-anthropic-key"), || {
461 let sources = ApiKeySources {
462 anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
463 ..Default::default()
464 };
465
466 let result = get_anthropic_api_key(&sources);
467 assert!(result.is_ok());
468 assert_eq!(result.unwrap(), "test-anthropic-key");
469 });
470 }
471
472 #[test]
473 fn test_get_openai_api_key_from_env() {
474 with_override("TEST_OPENAI_KEY", Some("test-openai-key"), || {
475 let sources = ApiKeySources {
476 openai_env: "TEST_OPENAI_KEY".to_string(),
477 ..Default::default()
478 };
479
480 let result = get_openai_api_key(&sources);
481 assert!(result.is_ok());
482 assert_eq!(result.unwrap(), "test-openai-key");
483 });
484 }
485
486 #[test]
487 fn test_get_deepseek_api_key_from_env() {
488 with_override("TEST_DEEPSEEK_KEY", Some("test-deepseek-key"), || {
489 let sources = ApiKeySources {
490 deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
491 ..Default::default()
492 };
493
494 let result = get_deepseek_api_key(&sources);
495 assert!(result.is_ok());
496 assert_eq!(result.unwrap(), "test-deepseek-key");
497 });
498 }
499
500 #[test]
501 fn test_get_gemini_api_key_from_config() {
502 with_overrides(
503 &[
504 ("TEST_GEMINI_CONFIG_KEY", None),
505 ("GOOGLE_API_KEY", None),
506 ("GEMINI_API_KEY", None),
507 ],
508 || {
509 let sources = ApiKeySources {
510 gemini_env: "TEST_GEMINI_CONFIG_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 );
520 }
521
522 #[test]
523 fn test_get_api_key_with_fallback_prefers_env() {
524 with_override("TEST_FALLBACK_KEY", Some("env-key"), || {
525 let sources = ApiKeySources {
526 openai_env: "TEST_FALLBACK_KEY".to_string(),
527 openai_config: Some("config-key".to_string()),
528 ..Default::default()
529 };
530
531 let result = get_openai_api_key(&sources);
532 assert!(result.is_ok());
533 assert_eq!(result.unwrap(), "env-key"); });
535 }
536
537 #[test]
538 fn test_get_api_key_fallback_to_config() {
539 let sources = ApiKeySources {
540 openai_env: "NONEXISTENT_ENV_VAR".to_string(),
541 openai_config: Some("config-key".to_string()),
542 ..Default::default()
543 };
544
545 let result = get_openai_api_key(&sources);
546 assert!(result.is_ok());
547 assert_eq!(result.unwrap(), "config-key");
548 }
549
550 #[test]
551 fn test_get_api_key_error_when_not_found() {
552 let sources = ApiKeySources {
553 openai_env: "NONEXISTENT_ENV_VAR".to_string(),
554 ..Default::default()
555 };
556
557 let result = get_openai_api_key(&sources);
558 assert!(result.is_err());
559 }
560
561 #[test]
562 fn test_get_ollama_api_key_missing_sources() {
563 let sources = ApiKeySources {
564 ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
565 ..Default::default()
566 };
567
568 let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
569 assert!(result.is_empty());
570 }
571
572 #[test]
573 fn test_get_ollama_api_key_from_env() {
574 with_override("TEST_OLLAMA_KEY", Some("test-ollama-key"), || {
575 let sources = ApiKeySources {
576 ollama_env: "TEST_OLLAMA_KEY".to_string(),
577 ..Default::default()
578 };
579
580 let result = get_ollama_api_key(&sources);
581 assert!(result.is_ok());
582 assert_eq!(result.unwrap(), "test-ollama-key");
583 });
584 }
585
586 #[test]
587 fn test_get_lmstudio_api_key_missing_sources() {
588 let sources = ApiKeySources {
589 lmstudio_env: "NONEXISTENT_LMSTUDIO_ENV".to_string(),
590 ..Default::default()
591 };
592
593 let result =
594 get_lmstudio_api_key(&sources).expect("LM Studio key retrieval should succeed");
595 assert!(result.is_empty());
596 }
597
598 #[test]
599 fn test_get_lmstudio_api_key_from_env() {
600 with_override("TEST_LMSTUDIO_KEY", Some("test-lmstudio-key"), || {
601 let sources = ApiKeySources {
602 lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
603 ..Default::default()
604 };
605
606 let result = get_lmstudio_api_key(&sources);
607 assert!(result.is_ok());
608 assert_eq!(result.unwrap(), "test-lmstudio-key");
609 });
610 }
611
612 #[test]
613 fn test_get_api_key_ollama_provider() {
614 with_override(
615 "TEST_OLLAMA_PROVIDER_KEY",
616 Some("test-ollama-env-key"),
617 || {
618 let sources = ApiKeySources {
619 ollama_env: "TEST_OLLAMA_PROVIDER_KEY".to_string(),
620 ..Default::default()
621 };
622 let result = get_api_key("ollama", &sources);
623 assert!(result.is_ok());
624 assert_eq!(result.unwrap(), "test-ollama-env-key");
625 },
626 );
627 }
628
629 #[test]
630 fn test_get_api_key_lmstudio_provider() {
631 with_override(
632 "TEST_LMSTUDIO_PROVIDER_KEY",
633 Some("test-lmstudio-env-key"),
634 || {
635 let sources = ApiKeySources {
636 lmstudio_env: "TEST_LMSTUDIO_PROVIDER_KEY".to_string(),
637 ..Default::default()
638 };
639 let result = get_api_key("lmstudio", &sources);
640 assert!(result.is_ok());
641 assert_eq!(result.unwrap(), "test-lmstudio-env-key");
642 },
643 );
644 }
645
646 #[test]
647 fn api_key_env_var_uses_provider_defaults() {
648 assert_eq!(api_key_env_var("codex"), "");
649 assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
650 assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
651 }
652
653 #[test]
654 fn resolve_api_key_env_uses_provider_default_for_placeholder() {
655 assert_eq!(
656 resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV),
657 "MINIMAX_API_KEY"
658 );
659 }
660
661 #[test]
662 fn resolve_api_key_env_preserves_explicit_override() {
663 assert_eq!(
664 resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"),
665 "CUSTOM_OPENAI_KEY"
666 );
667 }
668}