1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ResolvedCredential {
19 pub identity: CredentialIdentity,
21 pub source: CredentialSource,
23 pub secret: Option<String>,
26 pub env_var: Option<String>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum CredentialSource {
36 Env,
39 Workspace,
41 SecureStorage,
43 OAuth,
45 ManagedAuth,
47 Local,
49}
50
51impl CredentialSource {
52 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
71pub fn get_api_key(provider: &str, sources: &ApiKeySources) -> Result<String> {
73 get_api_key_with_mode(provider, sources, crate::auth::AuthCredentialsStoreMode::default())
74}
75
76pub 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
89pub 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 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 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
153pub 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
175pub 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
202pub 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
211pub 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 #[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
320pub 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
337pub 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
346pub 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 storage
362 .load_with_legacy_fallback(storage_mode, allow_legacy)
363 .map(|value| value.filter(|key| !key.trim().is_empty()))
364}