Skip to main content

vtcode_config/api_keys/
credential_resolution.rs

1//! Credential resolution for a provider/key identity.
2//!
3//! This module owns the source precedence and secure-storage boundary. The
4//! parent `api_keys` module re-exports this API as the stable public facade;
5//! callers must not depend on this implementation module directly.
6
7use anyhow::{Context, Result};
8use std::path::Path;
9use std::str::FromStr;
10
11use crate::auth::{CredentialIdentity, CustomApiKeyStorage};
12use crate::models::Provider;
13
14use super::{ApiKeySources, alternate_env_var, api_key_env_var, credential_identity, read_env_var};
15
16/// A resolved credential and the source that supplied it.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ResolvedCredential {
19    /// Provider/key identity used for this lookup.
20    pub identity: CredentialIdentity,
21    /// Resolution source.
22    pub source: CredentialSource,
23    /// Secret material. OAuth sessions that do not expose an API key have no
24    /// secret here but still report their source.
25    pub secret: Option<String>,
26    /// Environment variable name when the source is environment-backed.
27    pub env_var: Option<String>,
28}
29
30/// Where a provider's credential was discovered.
31///
32/// Used by the first-run wizard and model picker to show why a provider is
33/// ready without re-prompting for a key.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum CredentialSource {
36    /// Process environment variable — covers shell exports (e.g. `~/.zshrc`)
37    /// and values loaded from a workspace `.env` by `load_dotenv()`.
38    Env,
39    /// Workspace `.env` file.
40    Workspace,
41    /// OS keyring / encrypted file storage (`CustomApiKeyStorage`).
42    SecureStorage,
43    /// Active OAuth session (OpenRouter or OpenAI ChatGPT).
44    OAuth,
45    /// Auth is managed by an external CLI (e.g. GitHub Copilot via `copilot`).
46    ManagedAuth,
47    /// Local server — no key required (Ollama, LM Studio, llama.cpp).
48    Local,
49}
50
51impl CredentialSource {
52    /// One-line, user-facing description of where the credential came from.
53    pub fn describe(self, provider: Provider) -> &'static str {
54        match self {
55            Self::Env => "found in environment",
56            Self::Workspace => "found in workspace .env",
57            Self::SecureStorage => "stored in secure storage",
58            Self::OAuth => "OAuth session active",
59            Self::ManagedAuth => "managed by external CLI",
60            Self::Local => {
61                if provider.is_local() {
62                    "local — no key required"
63                } else {
64                    "ready"
65                }
66            }
67        }
68    }
69}
70
71/// Get an API key for a provider using the platform-default storage backend.
72pub fn get_api_key(provider: &str, sources: &ApiKeySources) -> Result<String> {
73    get_api_key_with_mode(provider, sources, crate::auth::AuthCredentialsStoreMode::default())
74}
75
76/// Get an API key using the configured secure-storage backend.
77///
78/// Environment variables remain the highest-priority source. Secure storage
79/// is read with `storage_mode` so a key written to an explicitly configured
80/// backend is resolved from that same backend on every startup.
81pub fn get_api_key_with_mode(
82    provider: &str,
83    _sources: &ApiKeySources,
84    storage_mode: crate::auth::AuthCredentialsStoreMode,
85) -> Result<String> {
86    let normalized_provider = provider.trim().to_lowercase();
87    let inferred_env = api_key_env_var(&normalized_provider);
88
89    // Local providers intentionally accept an empty key. Managed-auth
90    // providers have their own login flows and must not be treated as API-key
91    // providers.
92    match normalized_provider.as_str() {
93        "ollama" | "lmstudio" | "llamacpp" | "llama.cpp" | "llama-cpp" => {
94            return Ok(read_env_var(&inferred_env)
95                .filter(|value| !value.trim().is_empty())
96                .unwrap_or_default());
97        }
98        "copilot" => {
99            return Err(anyhow::anyhow!(
100                "GitHub Copilot authentication is managed by the official `copilot` CLI. Run `vtcode login copilot`."
101            ));
102        }
103        "codex" => {
104            return Err(anyhow::anyhow!(
105                "Codex authentication is managed by the official `codex app-server`. Run `vtcode login codex`."
106            ));
107        }
108        _ => {}
109    }
110
111    if let Some(resolved) = resolve_credential_with_mode(&normalized_provider, &inferred_env, None, storage_mode)? {
112        if let Some(secret) = resolved.secret {
113            return Ok(secret);
114        }
115
116        // A ChatGPT OAuth session is a valid OpenAI credential, but it does
117        // not provide an API key to callers that explicitly request API-key
118        // authentication. Continue to the independent key-scoped API-key
119        // entry instead of treating the OAuth marker as missing.
120        if resolved.source == CredentialSource::OAuth
121            && let Some(secret) = load_stored_api_key_with_mode(&normalized_provider, storage_mode)?
122        {
123            return Ok(secret);
124        }
125    }
126
127    let message = match normalized_provider.as_str() {
128        "gemini" => "GEMINI_API_KEY or GOOGLE_API_KEY not set".to_owned(),
129        "qwen" => "QWEN_API_KEY or DASHSCOPE_API_KEY not set".to_owned(),
130        "meta" => "META_API_KEY or MODEL_API_KEY not set".to_owned(),
131        _ => format!(
132            "{normalized_provider} API key not found. Export {inferred_env} in your shell, or store it with `/secret add {normalized_provider}` (it is kept in secure storage, not a workspace .env).",
133        ),
134    };
135    Err(anyhow::anyhow!(message))
136}
137
138/// Store a provider/key credential in the configured secure-storage backend.
139pub fn store_credential_with_mode(
140    provider: &str,
141    key_name: &str,
142    secret: &str,
143    storage_mode: crate::auth::AuthCredentialsStoreMode,
144) -> Result<Option<CredentialIdentity>> {
145    let Some(identity) = credential_identity(provider, key_name)? else {
146        return Ok(None);
147    };
148    CustomApiKeyStorage::for_identity(identity.clone())?
149        .store(secret, storage_mode)
150        .with_context(|| {
151            format!(
152                "failed to persist credential for provider '{}' and key '{}' securely",
153                identity.provider(),
154                identity.key_name()
155            )
156        })?;
157    Ok(Some(identity))
158}
159
160/// Clear a provider/key credential from secure storage.
161///
162/// Legacy provider-only storage is cleared only for the provider's default
163/// key name. Non-default identities must never delete or reuse that legacy
164/// entry because its profile is ambiguous.
165pub fn clear_credential_with_mode(
166    provider: &str,
167    key_name: &str,
168    storage_mode: crate::auth::AuthCredentialsStoreMode,
169) -> Result<Option<CredentialIdentity>> {
170    let Some(identity) = credential_identity(provider, key_name)? else {
171        return Ok(None);
172    };
173    let default_key_name = api_key_env_var(provider);
174    let clear_legacy = identity.uses_default_key_name(&default_key_name);
175    CustomApiKeyStorage::for_identity(identity.clone())?
176        .clear_with_legacy_fallback(storage_mode, clear_legacy)
177        .with_context(|| {
178            format!(
179                "failed to clear credential for provider '{}' and key '{}' securely",
180                identity.provider(),
181                identity.key_name()
182            )
183        })?;
184    Ok(Some(identity))
185}
186
187/// Resolve a credential using the platform-default secure-storage backend.
188pub fn resolve_credential(
189    provider: &str,
190    key_name: &str,
191    workspace: Option<&Path>,
192) -> Result<Option<ResolvedCredential>> {
193    resolve_credential_with_mode(provider, key_name, workspace, crate::auth::AuthCredentialsStoreMode::default())
194}
195
196/// Resolve a provider/key credential with explicit storage mode.
197///
198/// Precedence is process environment, workspace `.env`, provider OAuth, and
199/// key-scoped secure storage. Provider-only storage is considered only when
200/// `key_name` is equivalent to the provider's default environment variable;
201/// such an entry is migrated lazily into the key-scoped namespace.
202pub fn resolve_credential_with_mode(
203    provider: &str,
204    key_name: &str,
205    workspace: Option<&Path>,
206    storage_mode: crate::auth::AuthCredentialsStoreMode,
207) -> Result<Option<ResolvedCredential>> {
208    let normalized_provider = provider.trim().to_ascii_lowercase();
209    let default_key_name = api_key_env_var(&normalized_provider);
210    let requested_key_name = if key_name.trim().is_empty() {
211        default_key_name.clone()
212    } else {
213        key_name.trim().to_owned()
214    };
215    if requested_key_name.is_empty() {
216        return Ok(None);
217    }
218
219    let requested_identity = CredentialIdentity::new(&normalized_provider, &requested_key_name)?;
220    let mut candidates = vec![requested_identity.clone()];
221    if requested_identity.uses_default_key_name(&default_key_name)
222        && let Ok(provider_enum) = Provider::from_str(&normalized_provider)
223        && let Some(alternate) = alternate_env_var(provider_enum)
224        && !alternate.eq_ignore_ascii_case(requested_identity.key_name())
225    {
226        candidates.push(CredentialIdentity::new(&normalized_provider, alternate)?);
227    }
228
229    for identity in &candidates {
230        if let Some(secret) = read_env_var(identity.key_name()).filter(|value| !value.trim().is_empty()) {
231            return Ok(Some(ResolvedCredential {
232                identity: identity.clone(),
233                source: CredentialSource::Env,
234                secret: Some(secret.trim().to_owned()),
235                env_var: Some(identity.key_name().to_owned()),
236            }));
237        }
238    }
239
240    for identity in &candidates {
241        if let Some(workspace) = workspace
242            && let Some(secret) = crate::workspace_env::read_workspace_env_value(workspace, identity.key_name())?
243            && !secret.trim().is_empty()
244        {
245            return Ok(Some(ResolvedCredential {
246                identity: identity.clone(),
247                source: CredentialSource::Workspace,
248                secret: Some(secret.trim().to_owned()),
249                env_var: Some(identity.key_name().to_owned()),
250            }));
251        }
252    }
253
254    let uses_default_key = requested_identity.uses_default_key_name(&default_key_name);
255    if uses_default_key {
256        if normalized_provider == "openrouter"
257            && let Some(token) = crate::auth::load_oauth_token_with_mode(storage_mode)?
258        {
259            return Ok(Some(ResolvedCredential {
260                identity: requested_identity.clone(),
261                source: CredentialSource::OAuth,
262                secret: Some(token.api_key),
263                env_var: None,
264            }));
265        }
266        if normalized_provider == "openai"
267            && crate::auth::load_openai_chatgpt_session_with_mode(storage_mode)?.is_some()
268        {
269            return Ok(Some(ResolvedCredential {
270                identity: requested_identity.clone(),
271                source: CredentialSource::OAuth,
272                secret: None,
273                env_var: None,
274            }));
275        }
276    }
277
278    // Tests explicitly override an environment variable to model an unset
279    // variable. Do not let host credentials make those tests depend on the
280    // developer's keyring or home directory.
281    #[cfg(test)]
282    if candidates
283        .iter()
284        .any(|identity| super::test_storage_lookup_is_overridden(identity.key_name()))
285    {
286        return Ok(None);
287    }
288
289    let storage = CustomApiKeyStorage::for_identity(requested_identity.clone())?;
290    if let Some(secret) = storage.load_with_legacy_fallback(storage_mode, uses_default_key)? {
291        return Ok(Some(ResolvedCredential {
292            identity: requested_identity,
293            source: CredentialSource::SecureStorage,
294            secret: Some(secret),
295            env_var: None,
296        }));
297    }
298
299    Ok(None)
300}
301
302/// Resolve the API-key input for OpenAI account authentication.
303pub fn resolve_openai_api_key_for_auth(
304    storage_mode: crate::auth::AuthCredentialsStoreMode,
305    allow_chatgpt_fallback: bool,
306) -> Result<Option<String>> {
307    match get_api_key_with_mode("openai", &ApiKeySources::default(), storage_mode) {
308        Ok(api_key) => Ok(Some(api_key)),
309        Err(_err)
310            if allow_chatgpt_fallback
311                && crate::auth::load_openai_chatgpt_session_with_mode(storage_mode)?.is_some() =>
312        {
313            Ok(None)
314        }
315        Err(err) => Err(err),
316    }
317}
318
319/// Load a provider's default API key from secure storage only.
320pub fn load_stored_api_key_with_mode(
321    provider: &str,
322    storage_mode: crate::auth::AuthCredentialsStoreMode,
323) -> Result<Option<String>> {
324    let key_name = api_key_env_var(provider);
325    load_stored_credential_with_mode(provider, &key_name, storage_mode)
326}
327
328/// Load only the secure-storage value for a provider/key identity.
329pub fn load_stored_credential_with_mode(
330    provider: &str,
331    key_name: &str,
332    storage_mode: crate::auth::AuthCredentialsStoreMode,
333) -> Result<Option<String>> {
334    let Some(identity) = credential_identity(provider, key_name)? else {
335        return Ok(None);
336    };
337    let default_key_name = api_key_env_var(provider);
338    let allow_legacy = identity.uses_default_key_name(&default_key_name);
339    let storage = CustomApiKeyStorage::for_identity(identity)?;
340    // The auth layer handles keyring-to-file fallback internally when the
341    // configured mode permits it. Provider-only storage is a legacy fallback
342    // only for the provider's default key identity.
343    storage
344        .load_with_legacy_fallback(storage_mode, allow_legacy)
345        .map(|value| value.filter(|key| !key.trim().is_empty()))
346}