1use anyhow::Result;
13use std::str::FromStr;
14
15use crate::auth::CredentialIdentity;
16use crate::constants::defaults;
17use crate::models::Provider;
18
19mod credential_resolution;
20
21pub use credential_resolution::{
22 CredentialSource, ResolvedCredential, clear_credential_with_mode, get_api_key, get_api_key_in_workspace_with_mode,
23 get_api_key_with_mode, load_stored_api_key_with_mode, load_stored_credential_with_mode, resolve_credential,
24 resolve_credential_with_mode, resolve_openai_api_key_for_auth, store_credential_with_mode,
25};
26
27#[derive(Debug, Clone, Default)]
32pub struct ApiKeySources {
33 gemini_env: String,
34 anthropic_env: String,
35 openai_env: String,
36 openrouter_env: String,
37 deepseek_env: String,
38 zai_env: String,
39 ollama_env: String,
40 lmstudio_env: String,
41 gemini_config: Option<String>,
42 anthropic_config: Option<String>,
43 openai_config: Option<String>,
44 openrouter_config: Option<String>,
45 deepseek_config: Option<String>,
46 zai_config: Option<String>,
47 ollama_config: Option<String>,
48 lmstudio_config: Option<String>,
49}
50
51pub fn api_key_env_var(provider: &str) -> String {
52 let trimmed = provider.trim();
53 if trimmed.is_empty() {
54 return defaults::DEFAULT_API_KEY_ENV.to_owned();
55 }
56
57 if trimmed.eq_ignore_ascii_case("codex") {
58 return String::new();
59 }
60
61 if let Ok(resolved) = Provider::from_str(trimmed)
62 && resolved.uses_managed_auth()
63 {
64 return String::new();
65 }
66
67 Provider::from_str(trimmed)
68 .map(|resolved| resolved.default_api_key_env().to_owned())
69 .unwrap_or_else(|_| {
70 let mut key = String::new();
71 for ch in trimmed.chars() {
72 if ch.is_ascii_alphanumeric() {
73 key.push(ch.to_ascii_uppercase());
74 } else if !key.ends_with('_') {
75 key.push('_');
76 }
77 }
78 if key.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
79 key.insert(0, '_');
80 }
81 if !key.ends_with("_API_KEY") {
82 if !key.ends_with('_') {
83 key.push('_');
84 }
85 key.push_str("API_KEY");
86 }
87 key
88 })
89}
90
91pub fn resolve_api_key_env(provider: &str, configured_env: &str) -> String {
92 let trimmed = configured_env.trim();
93 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV) {
94 api_key_env_var(provider)
95 } else {
96 trimmed.to_owned()
97 }
98}
99
100pub fn credential_identity(provider: &str, key_name: &str) -> Result<Option<CredentialIdentity>> {
103 let default_key_name = api_key_env_var(provider);
104 let requested_key_name = if key_name.trim().is_empty() {
105 default_key_name
106 } else {
107 key_name.trim().to_owned()
108 };
109 if requested_key_name.is_empty() {
110 return Ok(None);
111 }
112 CredentialIdentity::new(provider, &requested_key_name).map(Some)
113}
114
115pub fn credential_metadata_key(provider: &str, key_name: &str) -> Result<Option<String>> {
117 Ok(credential_identity(provider, key_name)?
118 .map(|identity| format!("{}/{}", identity.provider(), identity.key_name())))
119}
120
121fn read_env_var(key: &str) -> Option<String> {
122 crate::env_helpers::read_env_var(key)
123}
124
125#[deprecated(since = "0.180.6", note = "workspace `.env` is never imported; this is a no-op")]
128pub fn load_dotenv() -> Result<()> {
129 Ok(())
130}
131
132#[derive(Debug, Clone, Copy)]
135pub struct DiscoveredProvider {
136 pub provider: Provider,
137 pub source: CredentialSource,
138 pub env_var: Option<&'static str>,
146}
147
148pub fn provider_credential_detail(provider: Provider) -> Option<DiscoveredProvider> {
159 provider_credential_detail_with_mode(provider, crate::auth::AuthCredentialsStoreMode::default())
160}
161
162pub fn provider_credential_detail_with_mode(
164 provider: Provider,
165 storage_mode: crate::auth::AuthCredentialsStoreMode,
166) -> Option<DiscoveredProvider> {
167 if provider.is_local() {
168 return Some(DiscoveredProvider {
169 provider,
170 source: CredentialSource::Local,
171 env_var: None,
172 });
173 }
174 if provider.uses_managed_auth() {
175 return Some(DiscoveredProvider {
176 provider,
177 source: CredentialSource::ManagedAuth,
178 env_var: None,
179 });
180 }
181
182 let resolved =
183 resolve_credential_with_mode(provider.as_ref(), provider.default_api_key_env(), None, storage_mode).ok()??;
184 if matches!(resolved.source, CredentialSource::SecureStorage)
185 || matches!(resolved.source, CredentialSource::Env | CredentialSource::Workspace | CredentialSource::OAuth)
186 {
187 return Some(DiscoveredProvider {
188 provider,
189 source: resolved.source,
190 env_var: resolved.env_var.as_deref().and_then(static_env_var),
191 });
192 }
193
194 None
195}
196
197pub fn provider_credential_source(provider: Provider) -> Option<CredentialSource> {
201 provider_credential_detail(provider).map(|detail| detail.source)
202}
203
204pub fn discover_available_providers() -> Vec<DiscoveredProvider> {
215 discover_available_providers_with_mode(crate::auth::AuthCredentialsStoreMode::default())
216}
217
218pub fn discover_available_providers_with_mode(
220 storage_mode: crate::auth::AuthCredentialsStoreMode,
221) -> Vec<DiscoveredProvider> {
222 Provider::all_providers()
223 .into_iter()
224 .filter_map(|provider| provider_credential_detail_with_mode(provider, storage_mode))
225 .collect()
226}
227
228pub fn find_discovered(discovered: &[DiscoveredProvider], provider: Provider) -> Option<&DiscoveredProvider> {
230 discovered.iter().find(|entry| entry.provider == provider)
231}
232
233pub fn has_oauth_or_managed_auth(discovered: &[DiscoveredProvider]) -> bool {
238 discovered
239 .iter()
240 .any(|entry| matches!(entry.source, CredentialSource::OAuth | CredentialSource::ManagedAuth))
241}
242
243fn static_env_var(env_key: &str) -> Option<&'static str> {
244 Provider::all_providers()
245 .into_iter()
246 .find(|provider| provider.default_api_key_env().eq_ignore_ascii_case(env_key))
247 .map(|provider| provider.default_api_key_env())
248 .or(match env_key {
249 "GOOGLE_API_KEY" => Some("GOOGLE_API_KEY"),
250 "DASHSCOPE_API_KEY" => Some("DASHSCOPE_API_KEY"),
251 "MODEL_API_KEY" => Some("MODEL_API_KEY"),
252 _ => None,
253 })
254}
255
256fn alternate_env_var(provider: Provider) -> Option<&'static str> {
258 match provider {
259 Provider::Gemini => Some("GOOGLE_API_KEY"),
260 Provider::Qwen => Some("DASHSCOPE_API_KEY"),
261 Provider::Meta => Some("MODEL_API_KEY"),
262 _ => None,
263 }
264}
265
266#[cfg(test)]
267fn test_storage_lookup_is_overridden(key_name: &str) -> bool {
268 crate::env_helpers::test_env_overrides::is_overridden(key_name)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use std::sync::Mutex;
275 use tempfile::tempdir;
276
277 static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
280
281 struct EnvOverrideGuard {
282 key: &'static str,
283 previous: Option<Option<String>>,
284 }
285
286 impl EnvOverrideGuard {
287 fn set(key: &'static str, value: Option<&str>) -> Self {
288 let previous = crate::env_helpers::test_env_overrides::get(key);
289 crate::env_helpers::test_env_overrides::set(key, value);
290 Self { key, previous }
291 }
292 }
293
294 impl Drop for EnvOverrideGuard {
295 fn drop(&mut self) {
296 crate::env_helpers::test_env_overrides::restore(self.key, self.previous.clone());
297 }
298 }
299
300 fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
301 where
302 F: FnOnce(),
303 {
304 let _lock = ENV_TEST_LOCK.lock().expect("env test lock poisoned");
305 let _guard = EnvOverrideGuard::set(key, value);
306 f();
307 }
308
309 fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
310 where
311 F: FnOnce(),
312 {
313 let _lock = ENV_TEST_LOCK.lock().expect("env test lock poisoned");
314 let _guards: Vec<_> = overrides
315 .iter()
316 .map(|(key, value)| EnvOverrideGuard::set(key, *value))
317 .collect();
318 f();
319 }
320
321 fn default_sources() -> ApiKeySources {
322 ApiKeySources::default()
323 }
324
325 #[test]
326 fn gemini_reads_env_var() {
327 with_override("GEMINI_API_KEY", Some("test-gemini-key"), || {
328 let result = get_api_key("gemini", &default_sources());
329 assert_eq!(result.unwrap(), "test-gemini-key");
330 });
331 }
332
333 #[test]
334 fn gemini_falls_back_to_google_api_key() {
335 with_overrides(
337 &[
338 ("GEMINI_API_KEY", Some("gemini-primary")),
339 ("GOOGLE_API_KEY", Some("google-fallback")),
340 ],
341 || {
342 let result = get_api_key("gemini", &default_sources());
344 assert_eq!(result.unwrap(), "gemini-primary");
345 },
346 );
347 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("google-fallback"))], || {
348 let result = get_api_key("gemini", &default_sources());
350 assert_eq!(result.unwrap(), "google-fallback");
351 });
352 }
353
354 #[test]
355 fn anthropic_reads_env_var() {
356 with_override("ANTHROPIC_API_KEY", Some("test-anthropic-key"), || {
357 let result = get_api_key("anthropic", &default_sources());
358 assert_eq!(result.unwrap(), "test-anthropic-key");
359 });
360 }
361
362 #[test]
363 fn openai_reads_env_var() {
364 with_override("OPENAI_API_KEY", Some("test-openai-key"), || {
365 let result = get_api_key("openai", &default_sources());
366 assert_eq!(result.unwrap(), "test-openai-key");
367 });
368 }
369
370 #[test]
371 fn deepseek_reads_env_var() {
372 with_override("DEEPSEEK_API_KEY", Some("test-deepseek-key"), || {
373 let result = get_api_key("deepseek", &default_sources());
374 assert_eq!(result.unwrap(), "test-deepseek-key");
375 });
376 }
377
378 #[test]
379 fn qwen_falls_back_to_dashscope() {
380 with_overrides(&[("QWEN_API_KEY", None), ("DASHSCOPE_API_KEY", Some("dashscope-key"))], || {
381 let result = get_api_key("qwen", &default_sources());
382 assert_eq!(result.unwrap(), "dashscope-key");
383 });
384 }
385
386 #[test]
387 fn ollama_allows_empty_key() {
388 with_override("OLLAMA_API_KEY", None, || {
389 let result = get_api_key("ollama", &default_sources());
390 assert!(result.is_ok());
391 assert!(result.unwrap().is_empty());
392 });
393 }
394
395 #[test]
396 fn lmstudio_allows_empty_key() {
397 with_override("LMSTUDIO_API_KEY", None, || {
398 let result = get_api_key("lmstudio", &default_sources());
399 assert!(result.is_ok());
400 assert!(result.unwrap().is_empty());
401 });
402 }
403
404 #[test]
405 fn ollama_reads_env_var_when_set() {
406 with_override("OLLAMA_API_KEY", Some("test-ollama-key"), || {
407 let result = get_api_key("ollama", &default_sources());
408 assert_eq!(result.unwrap(), "test-ollama-key");
409 });
410 }
411
412 #[test]
413 fn local_provider_workspace_key_respects_environment_and_empty_fallback() {
414 let workspace = tempdir().expect("workspace");
415 std::fs::write(workspace.path().join(".env"), "OLLAMA_API_KEY=workspace-key\n").expect("dotenv");
416 for (environment, expected) in [(None, "workspace-key"), (Some("inherited-key"), "inherited-key")] {
417 with_override("OLLAMA_API_KEY", environment, || {
418 assert_eq!(
419 get_api_key_in_workspace_with_mode(
420 "ollama",
421 Some(workspace.path()),
422 crate::auth::AuthCredentialsStoreMode::File,
423 )
424 .expect("local key"),
425 expected
426 );
427 });
428 }
429 with_override("OLLAMA_API_KEY", None, || {
430 assert_eq!(
431 get_api_key_in_workspace_with_mode("ollama", None, crate::auth::AuthCredentialsStoreMode::File)
432 .expect("local provider accepts no key"),
433 ""
434 );
435 });
436 }
437
438 #[test]
439 fn copilot_returns_managed_auth_error() {
440 let result = get_api_key("copilot", &default_sources());
441 assert!(result.is_err());
442 assert!(result.unwrap_err().to_string().contains("copilot"));
443 }
444
445 #[test]
446 fn codex_returns_managed_auth_error() {
447 let result = get_api_key("codex", &default_sources());
448 assert!(result.is_err());
449 assert!(result.unwrap_err().to_string().contains("codex"));
450 }
451
452 #[test]
453 fn unknown_provider_returns_error_with_env_hint() {
454 with_override("SOMEUNKNOWN_API_KEY", None, || {
455 let result = get_api_key("someunknown", &default_sources());
456 assert!(result.is_err());
457 let msg = result.unwrap_err().to_string();
458 assert!(msg.contains("SOMEUNKNOWN_API_KEY"));
459 });
460 }
461
462 #[test]
463 fn poolside_reads_env_var() {
464 with_override("POOLSIDE_API_KEY", Some("test-poolside-key"), || {
465 let result = get_api_key("poolside", &default_sources());
466 assert_eq!(result.unwrap(), "test-poolside-key");
467 });
468 }
469
470 #[test]
471 fn poolside_returns_error_when_missing() {
472 with_override("POOLSIDE_API_KEY", None, || {
473 let result = get_api_key("poolside", &default_sources());
474 assert!(result.is_err());
475 assert!(result.unwrap_err().to_string().contains("POOLSIDE_API_KEY"));
476 });
477 }
478
479 #[test]
480 fn merge_gateway_reads_env_var() {
481 with_override("MERGE_GATEWAY_API_KEY", Some("test-merge-gateway-key"), || {
482 let result = get_api_key("merge-gateway", &default_sources());
483 assert_eq!(result.unwrap(), "test-merge-gateway-key");
484 });
485 }
486
487 #[test]
488 fn meta_reads_provider_specific_env_var() {
489 with_overrides(
490 &[
491 ("META_API_KEY", Some("meta-primary")),
492 ("MODEL_API_KEY", Some("model-fallback")),
493 ],
494 || {
495 let result = get_api_key("meta", &default_sources());
496 assert_eq!(result.expect("Meta key"), "meta-primary");
497 },
498 );
499 }
500
501 #[test]
502 fn meta_falls_back_to_documented_model_api_key() {
503 with_overrides(&[("META_API_KEY", None), ("MODEL_API_KEY", Some("model-key"))], || {
504 let result = get_api_key("meta", &default_sources());
505 assert_eq!(result.expect("Meta fallback key"), "model-key");
506 });
507 }
508
509 #[test]
510 fn meta_missing_key_error_names_both_supported_env_vars() {
511 with_overrides(&[("META_API_KEY", None), ("MODEL_API_KEY", None)], || {
512 let error = get_api_key("meta", &default_sources()).expect_err("missing Meta key");
513 assert!(error.to_string().contains("META_API_KEY or MODEL_API_KEY"));
514 });
515 }
516
517 #[test]
518 fn api_key_env_var_uses_provider_defaults() {
519 assert_eq!(api_key_env_var("codex"), "");
520 assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
521 assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
522 assert_eq!(api_key_env_var("poolside"), "POOLSIDE_API_KEY");
523 assert_eq!(api_key_env_var("merge-gateway"), "MERGE_GATEWAY_API_KEY");
524 assert_eq!(api_key_env_var("my-corp"), "MY_CORP_API_KEY");
525 assert_eq!(api_key_env_var("123corp"), "_123CORP_API_KEY");
526 }
527
528 #[test]
529 fn resolve_api_key_env_uses_provider_default_for_placeholder() {
530 assert_eq!(resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV), "MINIMAX_API_KEY");
531 }
532
533 #[test]
534 fn resolve_api_key_env_preserves_explicit_override() {
535 assert_eq!(resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"), "CUSTOM_OPENAI_KEY");
536 }
537
538 #[test]
539 fn credential_metadata_key_normalizes_provider_and_key() {
540 assert_eq!(
541 credential_metadata_key(" MyCorp ", "mycorp_billing_key").expect("metadata key"),
542 Some("mycorp/MYCORP_BILLING_KEY".to_string())
543 );
544 }
545
546 #[test]
547 fn resolver_prefers_process_environment_over_workspace_dotenv() {
548 let workspace = tempdir().expect("workspace");
549 std::fs::write(workspace.path().join(".env"), "MYCORP_API_KEY=workspace-key\n").expect("write dotenv");
550
551 with_override("MYCORP_API_KEY", Some("process-key"), || {
552 let resolved = resolve_credential_with_mode(
553 "mycorp",
554 "MYCORP_API_KEY",
555 Some(workspace.path()),
556 crate::auth::AuthCredentialsStoreMode::File,
557 )
558 .expect("resolve credential")
559 .expect("credential");
560 assert_eq!(resolved.secret.as_deref(), Some("process-key"));
561 assert_eq!(resolved.source, CredentialSource::Env);
562 assert_eq!(resolved.identity.provider(), "mycorp");
563 assert_eq!(resolved.identity.key_name(), "MYCORP_API_KEY");
564 });
565 }
566
567 #[test]
568 #[allow(deprecated, reason = "compatibility shim must stay callable")]
569 fn load_dotenv_does_not_import_workspace_settings() {
570 const CHILD: &str = "VTCODE_DOTENV_ISOLATION_TEST_CHILD";
571 const SETTING: &str = "VTCODE_DOTENV_ISOLATION_TEST_SETTING";
572 if std::env::var_os(CHILD).is_some() {
573 load_dotenv().expect("compatibility call");
574 assert!(std::env::var_os(SETTING).is_none());
575 return;
576 }
577
578 let workspace = tempdir().expect("workspace");
579 std::fs::write(workspace.path().join(".env"), format!("{SETTING}=untrusted\n")).expect("write dotenv");
580 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
581 .arg("--exact")
582 .arg("api_keys::tests::load_dotenv_does_not_import_workspace_settings")
583 .env(CHILD, "1")
584 .env_remove(SETTING)
585 .current_dir(workspace.path())
586 .output()
587 .expect("run isolated test");
588 assert!(output.status.success());
589 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
590 }
591
592 #[test]
593 fn resolver_prefers_alternate_process_environment_over_primary_workspace_dotenv() {
594 let workspace = tempdir().expect("workspace");
595 std::fs::write(workspace.path().join(".env"), "GEMINI_API_KEY=workspace-key\n").expect("write dotenv");
596
597 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("process-key"))], || {
598 let resolved = resolve_credential_with_mode(
599 "gemini",
600 "GEMINI_API_KEY",
601 Some(workspace.path()),
602 crate::auth::AuthCredentialsStoreMode::File,
603 )
604 .expect("resolve credential")
605 .expect("credential");
606 assert_eq!(resolved.secret.as_deref(), Some("process-key"));
607 assert_eq!(resolved.source, CredentialSource::Env);
608 assert_eq!(resolved.env_var.as_deref(), Some("GOOGLE_API_KEY"));
609 });
610 }
611
612 #[test]
613 fn resolver_reads_workspace_dotenv_for_custom_provider_key() {
614 let workspace = tempdir().expect("workspace");
615 std::fs::write(workspace.path().join(".env"), "MYCORP_BILLING_KEY=workspace-key\n").expect("write dotenv");
616
617 with_override("MYCORP_BILLING_KEY", None, || {
618 let resolved = resolve_credential_with_mode(
619 "mycorp",
620 "mycorp_billing_key",
621 Some(workspace.path()),
622 crate::auth::AuthCredentialsStoreMode::File,
623 )
624 .expect("resolve credential")
625 .expect("credential");
626 assert_eq!(resolved.secret.as_deref(), Some("workspace-key"));
627 assert_eq!(resolved.source, CredentialSource::Workspace);
628 assert_eq!(resolved.env_var.as_deref(), Some("MYCORP_BILLING_KEY"));
629 });
630 }
631
632 #[test]
633 fn resolver_does_not_reuse_legacy_storage_for_non_default_key() {
634 with_override("MIMO_TOKEN_PLAN_KEY", None, || {
635 let resolved = resolve_credential_with_mode(
636 "mimo",
637 "MIMO_TOKEN_PLAN_KEY",
638 None,
639 crate::auth::AuthCredentialsStoreMode::File,
640 )
641 .expect("resolve credential");
642 assert!(resolved.is_none());
643 });
644 }
645
646 #[test]
647 fn local_providers_are_always_discovered() {
648 with_overrides(
650 &[
651 ("OLLAMA_API_KEY", None),
652 ("LMSTUDIO_API_KEY", None),
653 ("LLAMACPP_API_KEY", None),
654 ],
655 || {
656 assert_eq!(provider_credential_source(Provider::Ollama), Some(CredentialSource::Local));
657 assert_eq!(provider_credential_source(Provider::LmStudio), Some(CredentialSource::Local));
658 assert_eq!(provider_credential_source(Provider::LlamaCpp), Some(CredentialSource::Local));
659 },
660 );
661 }
662
663 #[test]
664 fn copilot_is_managed_auth_discovered() {
665 assert_eq!(provider_credential_source(Provider::Copilot), Some(CredentialSource::ManagedAuth));
666 }
667
668 #[test]
669 fn env_var_makes_provider_discovered() {
670 with_override("OPENROUTER_API_KEY", Some("or-test-key"), || {
671 assert_eq!(provider_credential_source(Provider::OpenRouter), Some(CredentialSource::Env));
672 });
673 }
674
675 #[test]
676 fn missing_env_var_leaves_provider_undiscovered() {
677 with_override("OPENROUTER_API_KEY", None, || {
678 assert_eq!(provider_credential_source(Provider::OpenRouter), None);
679 });
680 }
681
682 #[test]
683 fn gemini_alt_env_var_is_discovered() {
684 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("g-key"))], || {
685 assert_eq!(provider_credential_source(Provider::Gemini), Some(CredentialSource::Env));
686 });
687 }
688
689 #[test]
690 fn qwen_alt_env_var_is_discovered() {
691 with_overrides(&[("QWEN_API_KEY", None), ("DASHSCOPE_API_KEY", Some("ds-key"))], || {
692 assert_eq!(provider_credential_source(Provider::Qwen), Some(CredentialSource::Env));
693 });
694 }
695
696 #[test]
697 fn credential_detail_surfaces_primary_env_var_name() {
698 with_override("OPENROUTER_API_KEY", Some("or-key"), || {
699 let detail = provider_credential_detail(Provider::OpenRouter).expect("OpenRouter discovered");
700 assert_eq!(detail.source, CredentialSource::Env);
701 assert_eq!(detail.env_var, Some("OPENROUTER_API_KEY"));
702 });
703 }
704
705 #[test]
706 fn credential_detail_surfaces_alternate_env_var_name() {
707 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("g-key"))], || {
711 let detail = provider_credential_detail(Provider::Gemini).expect("Gemini discovered");
712 assert_eq!(detail.source, CredentialSource::Env);
713 assert_eq!(detail.env_var, Some("GOOGLE_API_KEY"));
714 });
715 }
716
717 #[test]
718 fn credential_detail_surfaces_meta_alternate_env_var_name() {
719 with_overrides(&[("META_API_KEY", None), ("MODEL_API_KEY", Some("model-key"))], || {
720 let detail = provider_credential_detail(Provider::Meta).expect("Meta discovered");
721 assert_eq!(detail.source, CredentialSource::Env);
722 assert_eq!(detail.env_var, Some("MODEL_API_KEY"));
723 });
724 }
725
726 #[test]
727 fn credential_detail_env_var_is_none_for_non_env_sources() {
728 assert_eq!(
730 provider_credential_detail(Provider::Ollama).map(|d| d.env_var),
731 Some(None),
732 "local providers must report env_var = None"
733 );
734 assert_eq!(
735 provider_credential_detail(Provider::Copilot).map(|d| d.env_var),
736 Some(None),
737 "managed-auth providers must report env_var = None"
738 );
739 }
740
741 #[test]
742 fn credential_detail_returns_none_when_no_credential() {
743 with_overrides(
744 &[
745 ("OPENROUTER_API_KEY", None),
746 ("OPENAI_API_KEY", None),
747 ("ANTHROPIC_API_KEY", None),
748 ],
749 || {
750 assert!(provider_credential_detail(Provider::OpenRouter).is_none());
753 },
754 );
755 }
756
757 #[test]
758 fn discover_available_providers_carries_env_var_detail() {
759 with_overrides(
760 &[
761 ("OPENROUTER_API_KEY", Some("or-key")),
762 ("GEMINI_API_KEY", None),
763 ("GOOGLE_API_KEY", Some("g-key")),
764 ("OPENAI_API_KEY", None),
765 ("ANTHROPIC_API_KEY", None),
766 ],
767 || {
768 let discovered = discover_available_providers();
769 let or = find_discovered(&discovered, Provider::OpenRouter).unwrap();
770 assert_eq!(or.source, CredentialSource::Env);
771 assert_eq!(or.env_var, Some("OPENROUTER_API_KEY"));
772 let gemini = find_discovered(&discovered, Provider::Gemini).unwrap();
773 assert_eq!(gemini.source, CredentialSource::Env);
774 assert_eq!(gemini.env_var, Some("GOOGLE_API_KEY"));
775 },
776 );
777 }
778
779 #[test]
780 fn discover_available_providers_includes_ready_providers() {
781 with_overrides(
784 &[
785 ("OPENROUTER_API_KEY", Some("or-key")),
786 ("OPENAI_API_KEY", None),
787 ("ANTHROPIC_API_KEY", None),
788 ("GEMINI_API_KEY", None),
789 ],
790 || {
791 let discovered = discover_available_providers();
792 let providers: Vec<Provider> = discovered.iter().map(|d| d.provider).collect();
793
794 assert!(providers.contains(&Provider::OpenRouter), "OpenRouter should be discovered");
795 assert!(providers.contains(&Provider::Ollama), "Ollama should be discovered (local)");
796 assert!(providers.contains(&Provider::Copilot), "Copilot should be discovered (managed auth)");
797 assert!(
798 !providers.contains(&Provider::OpenAI),
799 "OpenAI should NOT be discovered when OPENAI_API_KEY is unset"
800 );
801
802 let or = find_discovered(&discovered, Provider::OpenRouter).unwrap();
803 assert_eq!(or.source, CredentialSource::Env);
804 },
805 );
806 }
807
808 #[test]
809 fn credential_source_describes_origin() {
810 assert_eq!(CredentialSource::Env.describe(Provider::OpenRouter), "found in environment");
811 assert_eq!(CredentialSource::Local.describe(Provider::Ollama), "local — no key required");
812 }
813
814 #[test]
815 fn get_api_key_trims_non_empty_environment_values() {
816 with_override("STEPFUN_API_KEY", Some(" test-stepfun-key "), || {
817 let result =
818 get_api_key_with_mode("stepfun", &default_sources(), crate::auth::AuthCredentialsStoreMode::File);
819 assert_eq!(result.unwrap(), "test-stepfun-key");
820 });
821 }
822}