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    /// inherited by the process at launch.
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    get_api_key_in_workspace_with_mode(provider, None, storage_mode)
87}
88
89/// Get an API key with optional workspace `.env` lookup and explicit storage mode.
90pub fn get_api_key_in_workspace_with_mode(
91    provider: &str,
92    workspace: Option<&Path>,
93    storage_mode: crate::auth::AuthCredentialsStoreMode,
94) -> Result<String> {
95    let normalized_provider = provider.trim().to_lowercase();
96    let inferred_env = api_key_env_var(&normalized_provider);
97
98    // Local providers intentionally accept an empty key. Managed-auth
99    // providers have their own login flows and must not be treated as API-key
100    // providers.
101    match normalized_provider.as_str() {
102        "ollama" | "lmstudio" | "llamacpp" | "llama.cpp" | "llama-cpp" => {
103            if let Some(secret) = read_env_var(&inferred_env).filter(|value| !value.trim().is_empty()) {
104                return Ok(secret);
105            }
106            return Ok(match workspace {
107                Some(workspace) => crate::workspace_env::read_workspace_env_value(workspace, &inferred_env)?,
108                None => None,
109            }
110            .unwrap_or_default());
111        }
112        "copilot" => {
113            return Err(anyhow::anyhow!(
114                "GitHub Copilot authentication is managed by the official `copilot` CLI. Run `vtcode login copilot`."
115            ));
116        }
117        "codex" => {
118            return Err(anyhow::anyhow!(
119                "Codex authentication is managed by the official `codex app-server`. Run `vtcode login codex`."
120            ));
121        }
122        _ => {}
123    }
124
125    if let Some(resolved) = resolve_credential_with_mode(&normalized_provider, &inferred_env, workspace, storage_mode)?
126    {
127        if let Some(secret) = resolved.secret {
128            return Ok(secret);
129        }
130
131        // A ChatGPT OAuth session is a valid OpenAI credential, but it does
132        // not provide an API key to callers that explicitly request API-key
133        // authentication. Continue to the independent key-scoped API-key
134        // entry instead of treating the OAuth marker as missing.
135        if resolved.source == CredentialSource::OAuth
136            && let Some(secret) = load_stored_api_key_with_mode(&normalized_provider, storage_mode)?
137        {
138            return Ok(secret);
139        }
140    }
141
142    let message = match normalized_provider.as_str() {
143        "gemini" => "GEMINI_API_KEY or GOOGLE_API_KEY not set".to_owned(),
144        "qwen" => "QWEN_API_KEY or DASHSCOPE_API_KEY not set".to_owned(),
145        "meta" => "META_API_KEY or MODEL_API_KEY not set".to_owned(),
146        _ => format!(
147            "{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).",
148        ),
149    };
150    Err(anyhow::anyhow!(message))
151}
152
153/// Store a provider/key credential in the configured secure-storage backend.
154pub fn store_credential_with_mode(
155    provider: &str,
156    key_name: &str,
157    secret: &str,
158    storage_mode: crate::auth::AuthCredentialsStoreMode,
159) -> Result<Option<CredentialIdentity>> {
160    let Some(identity) = credential_identity(provider, key_name)? else {
161        return Ok(None);
162    };
163    CustomApiKeyStorage::for_identity(identity.clone())?
164        .store(secret, storage_mode)
165        .with_context(|| {
166            format!(
167                "failed to persist credential for provider '{}' and key '{}' securely",
168                identity.provider(),
169                identity.key_name()
170            )
171        })?;
172    Ok(Some(identity))
173}
174
175/// Clear a provider/key credential from secure storage.
176///
177/// Legacy provider-only storage is cleared only for the provider's default
178/// key name. Non-default identities must never delete or reuse that legacy
179/// entry because its profile is ambiguous.
180pub fn clear_credential_with_mode(
181    provider: &str,
182    key_name: &str,
183    storage_mode: crate::auth::AuthCredentialsStoreMode,
184) -> Result<Option<CredentialIdentity>> {
185    let Some(identity) = credential_identity(provider, key_name)? else {
186        return Ok(None);
187    };
188    let default_key_name = api_key_env_var(provider);
189    let clear_legacy = identity.uses_default_key_name(&default_key_name);
190    CustomApiKeyStorage::for_identity(identity.clone())?
191        .clear_with_legacy_fallback(storage_mode, clear_legacy)
192        .with_context(|| {
193            format!(
194                "failed to clear credential for provider '{}' and key '{}' securely",
195                identity.provider(),
196                identity.key_name()
197            )
198        })?;
199    Ok(Some(identity))
200}
201
202/// Resolve a credential using the platform-default secure-storage backend.
203pub fn resolve_credential(
204    provider: &str,
205    key_name: &str,
206    workspace: Option<&Path>,
207) -> Result<Option<ResolvedCredential>> {
208    resolve_credential_with_mode(provider, key_name, workspace, crate::auth::AuthCredentialsStoreMode::default())
209}
210
211/// Resolve a provider/key credential with explicit storage mode.
212///
213/// Precedence is process environment, workspace `.env`, provider OAuth, and
214/// key-scoped secure storage. Provider-only storage is considered only when
215/// `key_name` is equivalent to the provider's default environment variable;
216/// such an entry is migrated lazily into the key-scoped namespace.
217pub fn resolve_credential_with_mode(
218    provider: &str,
219    key_name: &str,
220    workspace: Option<&Path>,
221    storage_mode: crate::auth::AuthCredentialsStoreMode,
222) -> Result<Option<ResolvedCredential>> {
223    let normalized_provider = provider.trim().to_ascii_lowercase();
224    let default_key_name = api_key_env_var(&normalized_provider);
225    let requested_key_name = if key_name.trim().is_empty() {
226        default_key_name.clone()
227    } else {
228        key_name.trim().to_owned()
229    };
230    if requested_key_name.is_empty() {
231        return Ok(None);
232    }
233
234    let requested_identity = CredentialIdentity::new(&normalized_provider, &requested_key_name)?;
235    let mut candidates = vec![requested_identity.clone()];
236    if requested_identity.uses_default_key_name(&default_key_name)
237        && let Ok(provider_enum) = Provider::from_str(&normalized_provider)
238        && let Some(alternate) = alternate_env_var(provider_enum)
239        && !alternate.eq_ignore_ascii_case(requested_identity.key_name())
240    {
241        candidates.push(CredentialIdentity::new(&normalized_provider, alternate)?);
242    }
243
244    for identity in &candidates {
245        if let Some(secret) = read_env_var(identity.key_name()).filter(|value| !value.trim().is_empty()) {
246            return Ok(Some(ResolvedCredential {
247                identity: identity.clone(),
248                source: CredentialSource::Env,
249                secret: Some(secret.trim().to_owned()),
250                env_var: Some(identity.key_name().to_owned()),
251            }));
252        }
253    }
254
255    for identity in &candidates {
256        if let Some(workspace) = workspace
257            && let Some(secret) = crate::workspace_env::read_workspace_env_value(workspace, identity.key_name())?
258            && !secret.trim().is_empty()
259        {
260            return Ok(Some(ResolvedCredential {
261                identity: identity.clone(),
262                source: CredentialSource::Workspace,
263                secret: Some(secret.trim().to_owned()),
264                env_var: Some(identity.key_name().to_owned()),
265            }));
266        }
267    }
268
269    // Tests explicitly override an environment variable to model an unset
270    // variable. Do not let host credentials make those tests depend on the
271    // developer's keyring or home directory. This guard must run before the
272    // OAuth branches below: a real ChatGPT/OpenRouter session on the host
273    // would otherwise leak a credential into discovery even when the test
274    // explicitly set the key to None.
275    #[cfg(test)]
276    if candidates
277        .iter()
278        .any(|identity| super::test_storage_lookup_is_overridden(identity.key_name()))
279    {
280        return Ok(None);
281    }
282
283    let uses_default_key = requested_identity.uses_default_key_name(&default_key_name);
284    if uses_default_key {
285        if normalized_provider == "openrouter"
286            && let Some(token) = crate::auth::load_oauth_token_with_mode(storage_mode)?
287        {
288            return Ok(Some(ResolvedCredential {
289                identity: requested_identity.clone(),
290                source: CredentialSource::OAuth,
291                secret: Some(token.api_key),
292                env_var: None,
293            }));
294        }
295        if normalized_provider == "openai"
296            && crate::auth::load_openai_chatgpt_session_with_mode(storage_mode)?.is_some()
297        {
298            return Ok(Some(ResolvedCredential {
299                identity: requested_identity.clone(),
300                source: CredentialSource::OAuth,
301                secret: None,
302                env_var: None,
303            }));
304        }
305    }
306
307    let storage = CustomApiKeyStorage::for_identity(requested_identity.clone())?;
308    if let Some(secret) = storage.load_with_legacy_fallback(storage_mode, uses_default_key)? {
309        return Ok(Some(ResolvedCredential {
310            identity: requested_identity,
311            source: CredentialSource::SecureStorage,
312            secret: Some(secret),
313            env_var: None,
314        }));
315    }
316
317    Ok(None)
318}
319
320/// Resolve the API-key input for OpenAI account authentication.
321pub fn resolve_openai_api_key_for_auth(
322    storage_mode: crate::auth::AuthCredentialsStoreMode,
323    allow_chatgpt_fallback: bool,
324) -> Result<Option<String>> {
325    match get_api_key_with_mode("openai", &ApiKeySources::default(), storage_mode) {
326        Ok(api_key) => Ok(Some(api_key)),
327        Err(_err)
328            if allow_chatgpt_fallback
329                && crate::auth::load_openai_chatgpt_session_with_mode(storage_mode)?.is_some() =>
330        {
331            Ok(None)
332        }
333        Err(err) => Err(err),
334    }
335}
336
337/// Load a provider's default API key from secure storage only.
338pub fn load_stored_api_key_with_mode(
339    provider: &str,
340    storage_mode: crate::auth::AuthCredentialsStoreMode,
341) -> Result<Option<String>> {
342    let key_name = api_key_env_var(provider);
343    load_stored_credential_with_mode(provider, &key_name, storage_mode)
344}
345
346/// Load only the secure-storage value for a provider/key identity.
347pub fn load_stored_credential_with_mode(
348    provider: &str,
349    key_name: &str,
350    storage_mode: crate::auth::AuthCredentialsStoreMode,
351) -> Result<Option<String>> {
352    let Some(identity) = credential_identity(provider, key_name)? else {
353        return Ok(None);
354    };
355    let default_key_name = api_key_env_var(provider);
356    let allow_legacy = identity.uses_default_key_name(&default_key_name);
357    let storage = CustomApiKeyStorage::for_identity(identity)?;
358    // The auth layer handles keyring-to-file fallback internally when the
359    // configured mode permits it. Provider-only storage is a legacy fallback
360    // only for the provider's default key identity.
361    storage
362        .load_with_legacy_fallback(storage_mode, allow_legacy)
363        .map(|value| value.filter(|key| !key.trim().is_empty()))
364}