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_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/// 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/// Retained for compatibility. Workspace credentials are read on demand and
126/// are never imported into the process environment.
127#[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/// A provider with a discoverable credential — ready to use without prompting
133/// the user to paste a key.
134#[derive(Debug, Clone, Copy)]
135pub struct DiscoveredProvider {
136    pub provider: Provider,
137    pub source: CredentialSource,
138    /// The specific environment variable that satisfied discovery, when
139    /// `source == Env`. Carries the *alternate* name (e.g. `GOOGLE_API_KEY`)
140    /// when discovery used the alternate rather than the primary env var, so
141    /// the UI can tell the user exactly what was read — e.g. "Found
142    /// GOOGLE_API_KEY in your environment" instead of a generic "found in
143    /// environment". `None` for non-env sources and for providers with no env
144    /// var (local / managed-auth).
145    pub env_var: Option<&'static str>,
146}
147
148/// Determine whether a single built-in provider has a usable credential right
149/// now, and the full detail of where it came from. Returns `None` when no
150/// credential is found.
151///
152/// Mirrors the resolution order of [`get_api_key`]: env var (including
153/// provider-specific alternate env vars) → OAuth session → secure storage.
154/// Local and managed-auth providers are always considered ready.
155///
156/// Prefer this over [`provider_credential_source`] when you need to surface
157/// *which* env var was read (e.g. the first-run wizard and `api_key_hint`).
158pub fn provider_credential_detail(provider: Provider) -> Option<DiscoveredProvider> {
159    provider_credential_detail_with_mode(provider, crate::auth::AuthCredentialsStoreMode::default())
160}
161
162/// Determine whether a provider has a usable credential using `storage_mode`.
163pub 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
197/// Thin wrapper over [`provider_credential_detail`] that returns only the
198/// credential source. Kept for backward compatibility with callers that don't
199/// need the env-var detail.
200pub fn provider_credential_source(provider: Provider) -> Option<CredentialSource> {
201    provider_credential_detail(provider).map(|detail| detail.source)
202}
203
204/// Scan all built-in providers and return those with a discoverable credential.
205///
206/// "Discoverable" means the provider can be used right now without the user
207/// pasting a key: the env var is set (shell export or loaded `.env`), a key is
208/// in secure storage, an OAuth session is active, auth is managed by an
209/// external CLI, or the provider is local and needs no key.
210///
211/// Results follow `Provider::all_providers()` order. This does not consult
212/// `vtcode.toml` custom providers — the first-run wizard runs before a config
213/// exists. Runtime custom-provider auth is handled by `resolve_runtime_provider_auth`.
214pub fn discover_available_providers() -> Vec<DiscoveredProvider> {
215    discover_available_providers_with_mode(crate::auth::AuthCredentialsStoreMode::default())
216}
217
218/// Scan all built-in providers using the configured secure-storage backend.
219pub 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
228/// Look up a provider in a discovery snapshot.
229pub fn find_discovered(discovered: &[DiscoveredProvider], provider: Provider) -> Option<&DiscoveredProvider> {
230    discovered.iter().find(|entry| entry.provider == provider)
231}
232
233/// Check whether any provider in the slice has an OAuth session or managed auth.
234///
235/// Used by secret-management UIs to decide whether to show the generic
236/// `secret add/delete` hints or the OAuth-specific `login` hint.
237pub 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
256/// Alternate env var names that `get_api_key` accepts for a provider.
257fn 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    // Serialise all env-override tests so that one test's Drop restore cannot
278    // overwrite another test's set.
279    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        // Clear both GEMINI_API_KEY and set GOOGLE_API_KEY to verify fallback
336        with_overrides(
337            &[
338                ("GEMINI_API_KEY", Some("gemini-primary")),
339                ("GOOGLE_API_KEY", Some("google-fallback")),
340            ],
341            || {
342                // With GEMINI_API_KEY set, it should be preferred
343                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            // Without GEMINI_API_KEY, it should fall back to GOOGLE_API_KEY
349            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        // Local providers need no key and should be discoverable with empty env.
649        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        // When only the alternate GOOGLE_API_KEY is set, the detail must report
708        // *that* name (not the primary GEMINI_API_KEY) so the UI can tell the
709        // user exactly which variable was read.
710        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        // Local and managed-auth providers are discovered without an env var.
729        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                // OpenRouter has no env var, no OAuth token in tests, no keyring
751                // entry in tests → not discovered.
752                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 OPENROUTER_API_KEY set, OpenRouter must appear in discovery
782        // alongside the always-ready local + managed-auth providers.
783        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}