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