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 let normalized_provider = provider.trim().to_lowercase();
87 let inferred_env = api_key_env_var(&normalized_provider);
88
89 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 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
138pub 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
160pub 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
187pub 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
196pub 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 #[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
302pub 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
319pub 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
328pub 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 storage
344 .load_with_legacy_fallback(storage_mode, allow_legacy)
345 .map(|value| value.filter(|key| !key.trim().is_empty()))
346}