Skip to main content

vtcode_config/
api_keys.rs

1//! API key management module for secure retrieval from environment variables,
2//! .env files, and configuration files.
3//!
4//! This module provides a unified interface for retrieving API keys for different providers,
5//! prioritizing security by checking environment variables first, then .env files, and finally
6//! falling back to configuration file values.
7//!
8//! The facade owns provider/key identity and discovery. Source precedence,
9//! storage migration, and credential material stay behind the private
10//! `credential_resolution` boundary.
11
12use 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/// API key sources for different providers
28///
29/// Retained for backward compatibility. New code should use [`get_api_key`] directly —
30/// the struct is no longer consumed by the key resolution logic.
31#[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
100/// Build the normalized identity used by credential storage for a provider
101/// and an optional configured environment-variable name.
102pub 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
115/// Return the stable configuration metadata key for a provider/key identity.
116pub 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/// Load environment variables from .env file
126///
127/// This function attempts to load environment variables from a .env file
128/// in the current directory. It logs a warning if the file exists but cannot
129/// be loaded, but doesn't fail if the file doesn't exist.
130pub fn load_dotenv() -> Result<()> {
131    match dotenvy::dotenv() {
132        Ok(path) => {
133            // Only print in verbose mode to avoid polluting stdout/stderr in scripts
134            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            // .env file doesn't exist, which is fine
141            Ok(())
142        }
143        Err(e) => {
144            tracing::warn!("Failed to load .env file: {}", e);
145            Ok(())
146        }
147    }
148}
149
150/// A provider with a discoverable credential — ready to use without prompting
151/// the user to paste a key.
152#[derive(Debug, Clone, Copy)]
153pub struct DiscoveredProvider {
154    pub provider: Provider,
155    pub source: CredentialSource,
156    /// The specific environment variable that satisfied discovery, when
157    /// `source == Env`. Carries the *alternate* name (e.g. `GOOGLE_API_KEY`)
158    /// when discovery used the alternate rather than the primary env var, so
159    /// the UI can tell the user exactly what was read — e.g. "Found
160    /// GOOGLE_API_KEY in your environment" instead of a generic "found in
161    /// environment". `None` for non-env sources and for providers with no env
162    /// var (local / managed-auth).
163    pub env_var: Option<&'static str>,
164}
165
166/// Determine whether a single built-in provider has a usable credential right
167/// now, and the full detail of where it came from. Returns `None` when no
168/// credential is found.
169///
170/// Mirrors the resolution order of [`get_api_key`]: env var (including
171/// provider-specific alternate env vars) → OAuth session → secure storage.
172/// Local and managed-auth providers are always considered ready.
173///
174/// Prefer this over [`provider_credential_source`] when you need to surface
175/// *which* env var was read (e.g. the first-run wizard and `api_key_hint`).
176pub fn provider_credential_detail(provider: Provider) -> Option<DiscoveredProvider> {
177    provider_credential_detail_with_mode(provider, crate::auth::AuthCredentialsStoreMode::default())
178}
179
180/// Determine whether a provider has a usable credential using `storage_mode`.
181pub 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
215/// Thin wrapper over [`provider_credential_detail`] that returns only the
216/// credential source. Kept for backward compatibility with callers that don't
217/// need the env-var detail.
218pub fn provider_credential_source(provider: Provider) -> Option<CredentialSource> {
219    provider_credential_detail(provider).map(|detail| detail.source)
220}
221
222/// Scan all built-in providers and return those with a discoverable credential.
223///
224/// "Discoverable" means the provider can be used right now without the user
225/// pasting a key: the env var is set (shell export or loaded `.env`), a key is
226/// in secure storage, an OAuth session is active, auth is managed by an
227/// external CLI, or the provider is local and needs no key.
228///
229/// Results follow `Provider::all_providers()` order. This does not consult
230/// `vtcode.toml` custom providers — the first-run wizard runs before a config
231/// exists. Runtime custom-provider auth is handled by `resolve_runtime_provider_auth`.
232pub fn discover_available_providers() -> Vec<DiscoveredProvider> {
233    discover_available_providers_with_mode(crate::auth::AuthCredentialsStoreMode::default())
234}
235
236/// Scan all built-in providers using the configured secure-storage backend.
237pub 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
246/// Look up a provider in a discovery snapshot.
247pub fn find_discovered(discovered: &[DiscoveredProvider], provider: Provider) -> Option<&DiscoveredProvider> {
248    discovered.iter().find(|entry| entry.provider == provider)
249}
250
251/// Check whether any provider in the slice has an OAuth session or managed auth.
252///
253/// Used by secret-management UIs to decide whether to show the generic
254/// `secret add/delete` hints or the OAuth-specific `login` hint.
255pub 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
274/// Alternate env var names that `get_api_key` accepts for a provider.
275fn 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    // Serialise all env-override tests so that one test's Drop restore cannot
296    // overwrite another test's set.
297    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        // Clear both GEMINI_API_KEY and set GOOGLE_API_KEY to verify fallback
354        with_overrides(
355            &[
356                ("GEMINI_API_KEY", Some("gemini-primary")),
357                ("GOOGLE_API_KEY", Some("google-fallback")),
358            ],
359            || {
360                // With GEMINI_API_KEY set, it should be preferred
361                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            // Without GEMINI_API_KEY, it should fall back to GOOGLE_API_KEY
367            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        // Local providers need no key and should be discoverable with empty env.
616        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        // When only the alternate GOOGLE_API_KEY is set, the detail must report
675        // *that* name (not the primary GEMINI_API_KEY) so the UI can tell the
676        // user exactly which variable was read.
677        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        // Local and managed-auth providers are discovered without an env var.
696        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                // OpenRouter has no env var, no OAuth token in tests, no keyring
718                // entry in tests → not discovered.
719                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 OPENROUTER_API_KEY set, OpenRouter must appear in discovery
749        // alongside the always-ready local + managed-auth providers.
750        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}