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_with_mode,
23 load_stored_api_key_with_mode, load_stored_credential_with_mode, resolve_credential, resolve_credential_with_mode,
24 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
125pub fn load_dotenv() -> Result<()> {
131 match dotenvy::dotenv() {
132 Ok(path) => {
133 if read_env_var("VTCODE_VERBOSE").is_some() || read_env_var("RUST_LOG").is_some() {
135 tracing::info!("Loaded environment variables from: {}", path.display());
136 }
137 Ok(())
138 }
139 Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
140 Ok(())
142 }
143 Err(e) => {
144 tracing::warn!("Failed to load .env file: {}", e);
145 Ok(())
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy)]
153pub struct DiscoveredProvider {
154 pub provider: Provider,
155 pub source: CredentialSource,
156 pub env_var: Option<&'static str>,
164}
165
166pub fn provider_credential_detail(provider: Provider) -> Option<DiscoveredProvider> {
177 provider_credential_detail_with_mode(provider, crate::auth::AuthCredentialsStoreMode::default())
178}
179
180pub fn provider_credential_detail_with_mode(
182 provider: Provider,
183 storage_mode: crate::auth::AuthCredentialsStoreMode,
184) -> Option<DiscoveredProvider> {
185 if provider.is_local() {
186 return Some(DiscoveredProvider {
187 provider,
188 source: CredentialSource::Local,
189 env_var: None,
190 });
191 }
192 if provider.uses_managed_auth() {
193 return Some(DiscoveredProvider {
194 provider,
195 source: CredentialSource::ManagedAuth,
196 env_var: None,
197 });
198 }
199
200 let resolved =
201 resolve_credential_with_mode(provider.as_ref(), provider.default_api_key_env(), None, storage_mode).ok()??;
202 if matches!(resolved.source, CredentialSource::SecureStorage)
203 || matches!(resolved.source, CredentialSource::Env | CredentialSource::Workspace | CredentialSource::OAuth)
204 {
205 return Some(DiscoveredProvider {
206 provider,
207 source: resolved.source,
208 env_var: resolved.env_var.as_deref().and_then(static_env_var),
209 });
210 }
211
212 None
213}
214
215pub fn provider_credential_source(provider: Provider) -> Option<CredentialSource> {
219 provider_credential_detail(provider).map(|detail| detail.source)
220}
221
222pub fn discover_available_providers() -> Vec<DiscoveredProvider> {
233 discover_available_providers_with_mode(crate::auth::AuthCredentialsStoreMode::default())
234}
235
236pub fn discover_available_providers_with_mode(
238 storage_mode: crate::auth::AuthCredentialsStoreMode,
239) -> Vec<DiscoveredProvider> {
240 Provider::all_providers()
241 .into_iter()
242 .filter_map(|provider| provider_credential_detail_with_mode(provider, storage_mode))
243 .collect()
244}
245
246pub fn find_discovered(discovered: &[DiscoveredProvider], provider: Provider) -> Option<&DiscoveredProvider> {
248 discovered.iter().find(|entry| entry.provider == provider)
249}
250
251pub fn has_oauth_or_managed_auth(discovered: &[DiscoveredProvider]) -> bool {
256 discovered
257 .iter()
258 .any(|entry| matches!(entry.source, CredentialSource::OAuth | CredentialSource::ManagedAuth))
259}
260
261fn static_env_var(env_key: &str) -> Option<&'static str> {
262 Provider::all_providers()
263 .into_iter()
264 .find(|provider| provider.default_api_key_env().eq_ignore_ascii_case(env_key))
265 .map(|provider| provider.default_api_key_env())
266 .or(match env_key {
267 "GOOGLE_API_KEY" => Some("GOOGLE_API_KEY"),
268 "DASHSCOPE_API_KEY" => Some("DASHSCOPE_API_KEY"),
269 "MODEL_API_KEY" => Some("MODEL_API_KEY"),
270 _ => None,
271 })
272}
273
274fn alternate_env_var(provider: Provider) -> Option<&'static str> {
276 match provider {
277 Provider::Gemini => Some("GOOGLE_API_KEY"),
278 Provider::Qwen => Some("DASHSCOPE_API_KEY"),
279 Provider::Meta => Some("MODEL_API_KEY"),
280 _ => None,
281 }
282}
283
284#[cfg(test)]
285fn test_storage_lookup_is_overridden(key_name: &str) -> bool {
286 crate::env_helpers::test_env_overrides::is_overridden(key_name)
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::sync::Mutex;
293 use tempfile::tempdir;
294
295 static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
298
299 struct EnvOverrideGuard {
300 key: &'static str,
301 previous: Option<Option<String>>,
302 }
303
304 impl EnvOverrideGuard {
305 fn set(key: &'static str, value: Option<&str>) -> Self {
306 let previous = crate::env_helpers::test_env_overrides::get(key);
307 crate::env_helpers::test_env_overrides::set(key, value);
308 Self { key, previous }
309 }
310 }
311
312 impl Drop for EnvOverrideGuard {
313 fn drop(&mut self) {
314 crate::env_helpers::test_env_overrides::restore(self.key, self.previous.clone());
315 }
316 }
317
318 fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
319 where
320 F: FnOnce(),
321 {
322 let _lock = ENV_TEST_LOCK.lock().expect("env test lock poisoned");
323 let _guard = EnvOverrideGuard::set(key, value);
324 f();
325 }
326
327 fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
328 where
329 F: FnOnce(),
330 {
331 let _lock = ENV_TEST_LOCK.lock().expect("env test lock poisoned");
332 let _guards: Vec<_> = overrides
333 .iter()
334 .map(|(key, value)| EnvOverrideGuard::set(key, *value))
335 .collect();
336 f();
337 }
338
339 fn default_sources() -> ApiKeySources {
340 ApiKeySources::default()
341 }
342
343 #[test]
344 fn gemini_reads_env_var() {
345 with_override("GEMINI_API_KEY", Some("test-gemini-key"), || {
346 let result = get_api_key("gemini", &default_sources());
347 assert_eq!(result.unwrap(), "test-gemini-key");
348 });
349 }
350
351 #[test]
352 fn gemini_falls_back_to_google_api_key() {
353 with_overrides(
355 &[
356 ("GEMINI_API_KEY", Some("gemini-primary")),
357 ("GOOGLE_API_KEY", Some("google-fallback")),
358 ],
359 || {
360 let result = get_api_key("gemini", &default_sources());
362 assert_eq!(result.unwrap(), "gemini-primary");
363 },
364 );
365 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("google-fallback"))], || {
366 let result = get_api_key("gemini", &default_sources());
368 assert_eq!(result.unwrap(), "google-fallback");
369 });
370 }
371
372 #[test]
373 fn anthropic_reads_env_var() {
374 with_override("ANTHROPIC_API_KEY", Some("test-anthropic-key"), || {
375 let result = get_api_key("anthropic", &default_sources());
376 assert_eq!(result.unwrap(), "test-anthropic-key");
377 });
378 }
379
380 #[test]
381 fn openai_reads_env_var() {
382 with_override("OPENAI_API_KEY", Some("test-openai-key"), || {
383 let result = get_api_key("openai", &default_sources());
384 assert_eq!(result.unwrap(), "test-openai-key");
385 });
386 }
387
388 #[test]
389 fn deepseek_reads_env_var() {
390 with_override("DEEPSEEK_API_KEY", Some("test-deepseek-key"), || {
391 let result = get_api_key("deepseek", &default_sources());
392 assert_eq!(result.unwrap(), "test-deepseek-key");
393 });
394 }
395
396 #[test]
397 fn qwen_falls_back_to_dashscope() {
398 with_overrides(&[("QWEN_API_KEY", None), ("DASHSCOPE_API_KEY", Some("dashscope-key"))], || {
399 let result = get_api_key("qwen", &default_sources());
400 assert_eq!(result.unwrap(), "dashscope-key");
401 });
402 }
403
404 #[test]
405 fn ollama_allows_empty_key() {
406 with_override("OLLAMA_API_KEY", None, || {
407 let result = get_api_key("ollama", &default_sources());
408 assert!(result.is_ok());
409 assert!(result.unwrap().is_empty());
410 });
411 }
412
413 #[test]
414 fn lmstudio_allows_empty_key() {
415 with_override("LMSTUDIO_API_KEY", None, || {
416 let result = get_api_key("lmstudio", &default_sources());
417 assert!(result.is_ok());
418 assert!(result.unwrap().is_empty());
419 });
420 }
421
422 #[test]
423 fn ollama_reads_env_var_when_set() {
424 with_override("OLLAMA_API_KEY", Some("test-ollama-key"), || {
425 let result = get_api_key("ollama", &default_sources());
426 assert_eq!(result.unwrap(), "test-ollama-key");
427 });
428 }
429
430 #[test]
431 fn copilot_returns_managed_auth_error() {
432 let result = get_api_key("copilot", &default_sources());
433 assert!(result.is_err());
434 assert!(result.unwrap_err().to_string().contains("copilot"));
435 }
436
437 #[test]
438 fn codex_returns_managed_auth_error() {
439 let result = get_api_key("codex", &default_sources());
440 assert!(result.is_err());
441 assert!(result.unwrap_err().to_string().contains("codex"));
442 }
443
444 #[test]
445 fn unknown_provider_returns_error_with_env_hint() {
446 with_override("SOMEUNKNOWN_API_KEY", None, || {
447 let result = get_api_key("someunknown", &default_sources());
448 assert!(result.is_err());
449 let msg = result.unwrap_err().to_string();
450 assert!(msg.contains("SOMEUNKNOWN_API_KEY"));
451 });
452 }
453
454 #[test]
455 fn poolside_reads_env_var() {
456 with_override("POOLSIDE_API_KEY", Some("test-poolside-key"), || {
457 let result = get_api_key("poolside", &default_sources());
458 assert_eq!(result.unwrap(), "test-poolside-key");
459 });
460 }
461
462 #[test]
463 fn poolside_returns_error_when_missing() {
464 with_override("POOLSIDE_API_KEY", None, || {
465 let result = get_api_key("poolside", &default_sources());
466 assert!(result.is_err());
467 assert!(result.unwrap_err().to_string().contains("POOLSIDE_API_KEY"));
468 });
469 }
470
471 #[test]
472 fn merge_gateway_reads_env_var() {
473 with_override("MERGE_GATEWAY_API_KEY", Some("test-merge-gateway-key"), || {
474 let result = get_api_key("merge-gateway", &default_sources());
475 assert_eq!(result.unwrap(), "test-merge-gateway-key");
476 });
477 }
478
479 #[test]
480 fn meta_reads_provider_specific_env_var() {
481 with_overrides(
482 &[
483 ("META_API_KEY", Some("meta-primary")),
484 ("MODEL_API_KEY", Some("model-fallback")),
485 ],
486 || {
487 let result = get_api_key("meta", &default_sources());
488 assert_eq!(result.expect("Meta key"), "meta-primary");
489 },
490 );
491 }
492
493 #[test]
494 fn meta_falls_back_to_documented_model_api_key() {
495 with_overrides(&[("META_API_KEY", None), ("MODEL_API_KEY", Some("model-key"))], || {
496 let result = get_api_key("meta", &default_sources());
497 assert_eq!(result.expect("Meta fallback key"), "model-key");
498 });
499 }
500
501 #[test]
502 fn meta_missing_key_error_names_both_supported_env_vars() {
503 with_overrides(&[("META_API_KEY", None), ("MODEL_API_KEY", None)], || {
504 let error = get_api_key("meta", &default_sources()).expect_err("missing Meta key");
505 assert!(error.to_string().contains("META_API_KEY or MODEL_API_KEY"));
506 });
507 }
508
509 #[test]
510 fn api_key_env_var_uses_provider_defaults() {
511 assert_eq!(api_key_env_var("codex"), "");
512 assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
513 assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
514 assert_eq!(api_key_env_var("poolside"), "POOLSIDE_API_KEY");
515 assert_eq!(api_key_env_var("merge-gateway"), "MERGE_GATEWAY_API_KEY");
516 assert_eq!(api_key_env_var("my-corp"), "MY_CORP_API_KEY");
517 assert_eq!(api_key_env_var("123corp"), "_123CORP_API_KEY");
518 }
519
520 #[test]
521 fn resolve_api_key_env_uses_provider_default_for_placeholder() {
522 assert_eq!(resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV), "MINIMAX_API_KEY");
523 }
524
525 #[test]
526 fn resolve_api_key_env_preserves_explicit_override() {
527 assert_eq!(resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"), "CUSTOM_OPENAI_KEY");
528 }
529
530 #[test]
531 fn credential_metadata_key_normalizes_provider_and_key() {
532 assert_eq!(
533 credential_metadata_key(" MyCorp ", "mycorp_billing_key").expect("metadata key"),
534 Some("mycorp/MYCORP_BILLING_KEY".to_string())
535 );
536 }
537
538 #[test]
539 fn resolver_prefers_process_environment_over_workspace_dotenv() {
540 let workspace = tempdir().expect("workspace");
541 std::fs::write(workspace.path().join(".env"), "MYCORP_API_KEY=workspace-key\n").expect("write dotenv");
542
543 with_override("MYCORP_API_KEY", Some("process-key"), || {
544 let resolved = resolve_credential_with_mode(
545 "mycorp",
546 "MYCORP_API_KEY",
547 Some(workspace.path()),
548 crate::auth::AuthCredentialsStoreMode::File,
549 )
550 .expect("resolve credential")
551 .expect("credential");
552 assert_eq!(resolved.secret.as_deref(), Some("process-key"));
553 assert_eq!(resolved.source, CredentialSource::Env);
554 assert_eq!(resolved.identity.provider(), "mycorp");
555 assert_eq!(resolved.identity.key_name(), "MYCORP_API_KEY");
556 });
557 }
558
559 #[test]
560 fn resolver_prefers_alternate_process_environment_over_primary_workspace_dotenv() {
561 let workspace = tempdir().expect("workspace");
562 std::fs::write(workspace.path().join(".env"), "GEMINI_API_KEY=workspace-key\n").expect("write dotenv");
563
564 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("process-key"))], || {
565 let resolved = resolve_credential_with_mode(
566 "gemini",
567 "GEMINI_API_KEY",
568 Some(workspace.path()),
569 crate::auth::AuthCredentialsStoreMode::File,
570 )
571 .expect("resolve credential")
572 .expect("credential");
573 assert_eq!(resolved.secret.as_deref(), Some("process-key"));
574 assert_eq!(resolved.source, CredentialSource::Env);
575 assert_eq!(resolved.env_var.as_deref(), Some("GOOGLE_API_KEY"));
576 });
577 }
578
579 #[test]
580 fn resolver_reads_workspace_dotenv_for_custom_provider_key() {
581 let workspace = tempdir().expect("workspace");
582 std::fs::write(workspace.path().join(".env"), "MYCORP_BILLING_KEY=workspace-key\n").expect("write dotenv");
583
584 with_override("MYCORP_BILLING_KEY", None, || {
585 let resolved = resolve_credential_with_mode(
586 "mycorp",
587 "mycorp_billing_key",
588 Some(workspace.path()),
589 crate::auth::AuthCredentialsStoreMode::File,
590 )
591 .expect("resolve credential")
592 .expect("credential");
593 assert_eq!(resolved.secret.as_deref(), Some("workspace-key"));
594 assert_eq!(resolved.source, CredentialSource::Workspace);
595 assert_eq!(resolved.env_var.as_deref(), Some("MYCORP_BILLING_KEY"));
596 });
597 }
598
599 #[test]
600 fn resolver_does_not_reuse_legacy_storage_for_non_default_key() {
601 with_override("MIMO_TOKEN_PLAN_KEY", None, || {
602 let resolved = resolve_credential_with_mode(
603 "mimo",
604 "MIMO_TOKEN_PLAN_KEY",
605 None,
606 crate::auth::AuthCredentialsStoreMode::File,
607 )
608 .expect("resolve credential");
609 assert!(resolved.is_none());
610 });
611 }
612
613 #[test]
614 fn local_providers_are_always_discovered() {
615 with_overrides(
617 &[
618 ("OLLAMA_API_KEY", None),
619 ("LMSTUDIO_API_KEY", None),
620 ("LLAMACPP_API_KEY", None),
621 ],
622 || {
623 assert_eq!(provider_credential_source(Provider::Ollama), Some(CredentialSource::Local));
624 assert_eq!(provider_credential_source(Provider::LmStudio), Some(CredentialSource::Local));
625 assert_eq!(provider_credential_source(Provider::LlamaCpp), Some(CredentialSource::Local));
626 },
627 );
628 }
629
630 #[test]
631 fn copilot_is_managed_auth_discovered() {
632 assert_eq!(provider_credential_source(Provider::Copilot), Some(CredentialSource::ManagedAuth));
633 }
634
635 #[test]
636 fn env_var_makes_provider_discovered() {
637 with_override("OPENROUTER_API_KEY", Some("or-test-key"), || {
638 assert_eq!(provider_credential_source(Provider::OpenRouter), Some(CredentialSource::Env));
639 });
640 }
641
642 #[test]
643 fn missing_env_var_leaves_provider_undiscovered() {
644 with_override("OPENROUTER_API_KEY", None, || {
645 assert_eq!(provider_credential_source(Provider::OpenRouter), None);
646 });
647 }
648
649 #[test]
650 fn gemini_alt_env_var_is_discovered() {
651 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("g-key"))], || {
652 assert_eq!(provider_credential_source(Provider::Gemini), Some(CredentialSource::Env));
653 });
654 }
655
656 #[test]
657 fn qwen_alt_env_var_is_discovered() {
658 with_overrides(&[("QWEN_API_KEY", None), ("DASHSCOPE_API_KEY", Some("ds-key"))], || {
659 assert_eq!(provider_credential_source(Provider::Qwen), Some(CredentialSource::Env));
660 });
661 }
662
663 #[test]
664 fn credential_detail_surfaces_primary_env_var_name() {
665 with_override("OPENROUTER_API_KEY", Some("or-key"), || {
666 let detail = provider_credential_detail(Provider::OpenRouter).expect("OpenRouter discovered");
667 assert_eq!(detail.source, CredentialSource::Env);
668 assert_eq!(detail.env_var, Some("OPENROUTER_API_KEY"));
669 });
670 }
671
672 #[test]
673 fn credential_detail_surfaces_alternate_env_var_name() {
674 with_overrides(&[("GEMINI_API_KEY", None), ("GOOGLE_API_KEY", Some("g-key"))], || {
678 let detail = provider_credential_detail(Provider::Gemini).expect("Gemini discovered");
679 assert_eq!(detail.source, CredentialSource::Env);
680 assert_eq!(detail.env_var, Some("GOOGLE_API_KEY"));
681 });
682 }
683
684 #[test]
685 fn credential_detail_surfaces_meta_alternate_env_var_name() {
686 with_overrides(&[("META_API_KEY", None), ("MODEL_API_KEY", Some("model-key"))], || {
687 let detail = provider_credential_detail(Provider::Meta).expect("Meta discovered");
688 assert_eq!(detail.source, CredentialSource::Env);
689 assert_eq!(detail.env_var, Some("MODEL_API_KEY"));
690 });
691 }
692
693 #[test]
694 fn credential_detail_env_var_is_none_for_non_env_sources() {
695 assert_eq!(
697 provider_credential_detail(Provider::Ollama).map(|d| d.env_var),
698 Some(None),
699 "local providers must report env_var = None"
700 );
701 assert_eq!(
702 provider_credential_detail(Provider::Copilot).map(|d| d.env_var),
703 Some(None),
704 "managed-auth providers must report env_var = None"
705 );
706 }
707
708 #[test]
709 fn credential_detail_returns_none_when_no_credential() {
710 with_overrides(
711 &[
712 ("OPENROUTER_API_KEY", None),
713 ("OPENAI_API_KEY", None),
714 ("ANTHROPIC_API_KEY", None),
715 ],
716 || {
717 assert!(provider_credential_detail(Provider::OpenRouter).is_none());
720 },
721 );
722 }
723
724 #[test]
725 fn discover_available_providers_carries_env_var_detail() {
726 with_overrides(
727 &[
728 ("OPENROUTER_API_KEY", Some("or-key")),
729 ("GEMINI_API_KEY", None),
730 ("GOOGLE_API_KEY", Some("g-key")),
731 ("OPENAI_API_KEY", None),
732 ("ANTHROPIC_API_KEY", None),
733 ],
734 || {
735 let discovered = discover_available_providers();
736 let or = find_discovered(&discovered, Provider::OpenRouter).unwrap();
737 assert_eq!(or.source, CredentialSource::Env);
738 assert_eq!(or.env_var, Some("OPENROUTER_API_KEY"));
739 let gemini = find_discovered(&discovered, Provider::Gemini).unwrap();
740 assert_eq!(gemini.source, CredentialSource::Env);
741 assert_eq!(gemini.env_var, Some("GOOGLE_API_KEY"));
742 },
743 );
744 }
745
746 #[test]
747 fn discover_available_providers_includes_ready_providers() {
748 with_overrides(
751 &[
752 ("OPENROUTER_API_KEY", Some("or-key")),
753 ("OPENAI_API_KEY", None),
754 ("ANTHROPIC_API_KEY", None),
755 ("GEMINI_API_KEY", None),
756 ],
757 || {
758 let discovered = discover_available_providers();
759 let providers: Vec<Provider> = discovered.iter().map(|d| d.provider).collect();
760
761 assert!(providers.contains(&Provider::OpenRouter), "OpenRouter should be discovered");
762 assert!(providers.contains(&Provider::Ollama), "Ollama should be discovered (local)");
763 assert!(providers.contains(&Provider::Copilot), "Copilot should be discovered (managed auth)");
764 assert!(
765 !providers.contains(&Provider::OpenAI),
766 "OpenAI should NOT be discovered when OPENAI_API_KEY is unset"
767 );
768
769 let or = find_discovered(&discovered, Provider::OpenRouter).unwrap();
770 assert_eq!(or.source, CredentialSource::Env);
771 },
772 );
773 }
774
775 #[test]
776 fn credential_source_describes_origin() {
777 assert_eq!(CredentialSource::Env.describe(Provider::OpenRouter), "found in environment");
778 assert_eq!(CredentialSource::Local.describe(Provider::Ollama), "local — no key required");
779 }
780
781 #[test]
782 fn get_api_key_trims_non_empty_environment_values() {
783 with_override("STEPFUN_API_KEY", Some(" test-stepfun-key "), || {
784 let result =
785 get_api_key_with_mode("stepfun", &default_sources(), crate::auth::AuthCredentialsStoreMode::File);
786 assert_eq!(result.unwrap(), "test-stepfun-key");
787 });
788 }
789}