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`. The default `[agent.codex_app_server].command = \"codex\"` requires the `codex` CLI on `$PATH`, or you can set `[agent.codex_app_server].command` to a custom executable path."
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 "qwen" => {
234 if let Some(key) = read_env_var("QWEN_API_KEY").filter(|k| !k.is_empty()) {
235 return Ok(key);
236 }
237 if let Some(key) = read_env_var("DASHSCOPE_API_KEY").filter(|k| !k.is_empty()) {
238 return Ok(key);
239 }
240 Err(anyhow::anyhow!(
241 "QWEN_API_KEY or DASHSCOPE_API_KEY not set. Get your key from https://dashscope.console.aliyun.com"
242 ))
243 }
244 _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
245 }
246}
247
248fn get_custom_api_key_from_secure_storage(provider: &str) -> Result<Option<String>> {
262 let storage = CustomApiKeyStorage::new(provider);
263 let mode = crate::auth::AuthCredentialsStoreMode::default();
265 storage.load(mode)
266}
267
268fn get_api_key_with_fallback(
270 env_var: &str,
271 config_value: Option<&str>,
272 provider_name: &str,
273) -> Result<String> {
274 if let Some(key) = read_env_var(env_var)
276 && !key.is_empty()
277 {
278 return Ok(key);
279 }
280
281 if let Some(key) = config_value
283 && !key.is_empty()
284 {
285 return Ok(key.to_string());
286 }
287
288 Err(anyhow::anyhow!(
290 "No API key found for {} provider. Set {} environment variable (or add to .env file) or configure in vtcode.toml",
291 provider_name,
292 env_var
293 ))
294}
295
296fn get_optional_api_key_with_fallback(env_var: &str, config_value: Option<&str>) -> String {
297 if let Some(key) = read_env_var(env_var)
298 && !key.is_empty()
299 {
300 return key;
301 }
302
303 if let Some(key) = config_value
304 && !key.is_empty()
305 {
306 return key.to_string();
307 }
308
309 String::new()
310}
311
312fn get_gemini_api_key(sources: &ApiKeySources) -> Result<String> {
314 if let Some(key) = read_env_var(&sources.gemini_env)
316 && !key.is_empty()
317 {
318 return Ok(key);
319 }
320
321 if let Some(key) = read_env_var("GOOGLE_API_KEY")
323 && !key.is_empty()
324 {
325 return Ok(key);
326 }
327
328 if let Some(key) = &sources.gemini_config
330 && !key.is_empty()
331 {
332 return Ok(key.clone());
333 }
334
335 Err(anyhow::anyhow!(
337 "No API key found for Gemini provider. Set {} or GOOGLE_API_KEY environment variable (or add to .env file) or configure in vtcode.toml",
338 sources.gemini_env
339 ))
340}
341
342fn get_anthropic_api_key(sources: &ApiKeySources) -> Result<String> {
344 get_api_key_with_fallback(
345 &sources.anthropic_env,
346 sources.anthropic_config.as_deref(),
347 "Anthropic",
348 )
349}
350
351fn get_openai_api_key(sources: &ApiKeySources) -> Result<String> {
353 get_api_key_with_fallback(
354 &sources.openai_env,
355 sources.openai_config.as_deref(),
356 "OpenAI",
357 )
358}
359
360fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
367 if let Ok(Some(token)) = crate::auth::load_oauth_token() {
369 tracing::debug!("Using OAuth token for OpenRouter authentication");
370 return Ok(token.api_key);
371 }
372
373 get_api_key_with_fallback(
375 &sources.openrouter_env,
376 sources.openrouter_config.as_deref(),
377 "OpenRouter",
378 )
379}
380
381fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
383 get_api_key_with_fallback(
384 &sources.deepseek_env,
385 sources.deepseek_config.as_deref(),
386 "DeepSeek",
387 )
388}
389
390fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
392 get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_deref(), "Z.AI")
393}
394
395fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
397 Ok(get_optional_api_key_with_fallback(
400 &sources.ollama_env,
401 sources.ollama_config.as_deref(),
402 ))
403}
404
405fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
407 Ok(get_optional_api_key_with_fallback(
408 &sources.lmstudio_env,
409 sources.lmstudio_config.as_deref(),
410 ))
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 struct EnvOverrideGuard {
418 key: &'static str,
419 previous: Option<Option<String>>,
420 }
421
422 impl EnvOverrideGuard {
423 fn set(key: &'static str, value: Option<&str>) -> Self {
424 let previous = test_env_overrides::get(key);
425 test_env_overrides::set(key, value);
426 Self { key, previous }
427 }
428 }
429
430 impl Drop for EnvOverrideGuard {
431 fn drop(&mut self) {
432 test_env_overrides::restore(self.key, self.previous.clone());
433 }
434 }
435
436 fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
437 where
438 F: FnOnce(),
439 {
440 let _guard = EnvOverrideGuard::set(key, value);
441 f();
442 }
443
444 fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
445 where
446 F: FnOnce(),
447 {
448 let _guards: Vec<_> = overrides
449 .iter()
450 .map(|(key, value)| EnvOverrideGuard::set(key, *value))
451 .collect();
452 f();
453 }
454
455 #[test]
456 fn test_get_gemini_api_key_from_env() {
457 with_override("TEST_GEMINI_KEY", Some("test-gemini-key"), || {
458 let sources = ApiKeySources {
459 gemini_env: "TEST_GEMINI_KEY".to_string(),
460 ..Default::default()
461 };
462
463 let result = get_gemini_api_key(&sources);
464 assert!(result.is_ok());
465 assert_eq!(result.unwrap(), "test-gemini-key");
466 });
467 }
468
469 #[test]
470 fn test_get_anthropic_api_key_from_env() {
471 with_override("TEST_ANTHROPIC_KEY", Some("test-anthropic-key"), || {
472 let sources = ApiKeySources {
473 anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
474 ..Default::default()
475 };
476
477 let result = get_anthropic_api_key(&sources);
478 assert!(result.is_ok());
479 assert_eq!(result.unwrap(), "test-anthropic-key");
480 });
481 }
482
483 #[test]
484 fn test_get_openai_api_key_from_env() {
485 with_override("TEST_OPENAI_KEY", Some("test-openai-key"), || {
486 let sources = ApiKeySources {
487 openai_env: "TEST_OPENAI_KEY".to_string(),
488 ..Default::default()
489 };
490
491 let result = get_openai_api_key(&sources);
492 assert!(result.is_ok());
493 assert_eq!(result.unwrap(), "test-openai-key");
494 });
495 }
496
497 #[test]
498 fn test_get_deepseek_api_key_from_env() {
499 with_override("TEST_DEEPSEEK_KEY", Some("test-deepseek-key"), || {
500 let sources = ApiKeySources {
501 deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
502 ..Default::default()
503 };
504
505 let result = get_deepseek_api_key(&sources);
506 assert!(result.is_ok());
507 assert_eq!(result.unwrap(), "test-deepseek-key");
508 });
509 }
510
511 #[test]
512 fn test_get_gemini_api_key_from_config() {
513 with_overrides(
514 &[
515 ("TEST_GEMINI_CONFIG_KEY", None),
516 ("GOOGLE_API_KEY", None),
517 ("GEMINI_API_KEY", None),
518 ],
519 || {
520 let sources = ApiKeySources {
521 gemini_env: "TEST_GEMINI_CONFIG_KEY".to_string(),
522 gemini_config: Some("config-gemini-key".to_string()),
523 ..Default::default()
524 };
525
526 let result = get_gemini_api_key(&sources);
527 assert!(result.is_ok());
528 assert_eq!(result.unwrap(), "config-gemini-key");
529 },
530 );
531 }
532
533 #[test]
534 fn test_get_api_key_with_fallback_prefers_env() {
535 with_override("TEST_FALLBACK_KEY", Some("env-key"), || {
536 let sources = ApiKeySources {
537 openai_env: "TEST_FALLBACK_KEY".to_string(),
538 openai_config: Some("config-key".to_string()),
539 ..Default::default()
540 };
541
542 let result = get_openai_api_key(&sources);
543 assert!(result.is_ok());
544 assert_eq!(result.unwrap(), "env-key"); });
546 }
547
548 #[test]
549 fn test_get_api_key_fallback_to_config() {
550 let sources = ApiKeySources {
551 openai_env: "NONEXISTENT_ENV_VAR".to_string(),
552 openai_config: Some("config-key".to_string()),
553 ..Default::default()
554 };
555
556 let result = get_openai_api_key(&sources);
557 assert!(result.is_ok());
558 assert_eq!(result.unwrap(), "config-key");
559 }
560
561 #[test]
562 fn test_get_api_key_error_when_not_found() {
563 let sources = ApiKeySources {
564 openai_env: "NONEXISTENT_ENV_VAR".to_string(),
565 ..Default::default()
566 };
567
568 let result = get_openai_api_key(&sources);
569 assert!(result.is_err());
570 }
571
572 #[test]
573 fn test_get_ollama_api_key_missing_sources() {
574 let sources = ApiKeySources {
575 ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
576 ..Default::default()
577 };
578
579 let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
580 assert!(result.is_empty());
581 }
582
583 #[test]
584 fn test_get_ollama_api_key_from_env() {
585 with_override("TEST_OLLAMA_KEY", Some("test-ollama-key"), || {
586 let sources = ApiKeySources {
587 ollama_env: "TEST_OLLAMA_KEY".to_string(),
588 ..Default::default()
589 };
590
591 let result = get_ollama_api_key(&sources);
592 assert!(result.is_ok());
593 assert_eq!(result.unwrap(), "test-ollama-key");
594 });
595 }
596
597 #[test]
598 fn test_get_lmstudio_api_key_missing_sources() {
599 let sources = ApiKeySources {
600 lmstudio_env: "NONEXISTENT_LMSTUDIO_ENV".to_string(),
601 ..Default::default()
602 };
603
604 let result =
605 get_lmstudio_api_key(&sources).expect("LM Studio key retrieval should succeed");
606 assert!(result.is_empty());
607 }
608
609 #[test]
610 fn test_get_lmstudio_api_key_from_env() {
611 with_override("TEST_LMSTUDIO_KEY", Some("test-lmstudio-key"), || {
612 let sources = ApiKeySources {
613 lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
614 ..Default::default()
615 };
616
617 let result = get_lmstudio_api_key(&sources);
618 assert!(result.is_ok());
619 assert_eq!(result.unwrap(), "test-lmstudio-key");
620 });
621 }
622
623 #[test]
624 fn test_get_api_key_ollama_provider() {
625 with_override(
626 "TEST_OLLAMA_PROVIDER_KEY",
627 Some("test-ollama-env-key"),
628 || {
629 let sources = ApiKeySources {
630 ollama_env: "TEST_OLLAMA_PROVIDER_KEY".to_string(),
631 ..Default::default()
632 };
633 let result = get_api_key("ollama", &sources);
634 assert!(result.is_ok());
635 assert_eq!(result.unwrap(), "test-ollama-env-key");
636 },
637 );
638 }
639
640 #[test]
641 fn test_get_api_key_lmstudio_provider() {
642 with_override(
643 "TEST_LMSTUDIO_PROVIDER_KEY",
644 Some("test-lmstudio-env-key"),
645 || {
646 let sources = ApiKeySources {
647 lmstudio_env: "TEST_LMSTUDIO_PROVIDER_KEY".to_string(),
648 ..Default::default()
649 };
650 let result = get_api_key("lmstudio", &sources);
651 assert!(result.is_ok());
652 assert_eq!(result.unwrap(), "test-lmstudio-env-key");
653 },
654 );
655 }
656
657 #[test]
658 fn api_key_env_var_uses_provider_defaults() {
659 assert_eq!(api_key_env_var("codex"), "");
660 assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
661 assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
662 }
663
664 #[test]
665 fn resolve_api_key_env_uses_provider_default_for_placeholder() {
666 assert_eq!(
667 resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV),
668 "MINIMAX_API_KEY"
669 );
670 }
671
672 #[test]
673 fn resolve_api_key_env_preserves_explicit_override() {
674 assert_eq!(
675 resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"),
676 "CUSTOM_OPENAI_KEY"
677 );
678 }
679}