1use anyhow::Result;
9use std::str::FromStr;
10
11use crate::auth::CustomApiKeyStorage;
12use crate::constants::defaults;
13use crate::models::Provider;
14
15#[derive(Debug, Clone, Default)]
20pub struct ApiKeySources {
21 pub gemini_env: String,
22 pub anthropic_env: String,
23 pub openai_env: String,
24 pub openrouter_env: String,
25 pub deepseek_env: String,
26 pub zai_env: String,
27 pub ollama_env: String,
28 pub lmstudio_env: String,
29 pub gemini_config: Option<String>,
30 pub anthropic_config: Option<String>,
31 pub openai_config: Option<String>,
32 pub openrouter_config: Option<String>,
33 pub deepseek_config: Option<String>,
34 pub zai_config: Option<String>,
35 pub ollama_config: Option<String>,
36 pub lmstudio_config: Option<String>,
37}
38
39pub fn api_key_env_var(provider: &str) -> String {
40 let trimmed = provider.trim();
41 if trimmed.is_empty() {
42 return defaults::DEFAULT_API_KEY_ENV.to_owned();
43 }
44
45 if trimmed.eq_ignore_ascii_case("codex") {
46 return String::new();
47 }
48
49 if let Ok(resolved) = Provider::from_str(trimmed)
50 && resolved.uses_managed_auth()
51 {
52 return String::new();
53 }
54
55 Provider::from_str(trimmed)
56 .map(|resolved| resolved.default_api_key_env().to_owned())
57 .unwrap_or_else(|_| format!("{}_API_KEY", trimmed.to_ascii_uppercase()))
58}
59
60pub fn resolve_api_key_env(provider: &str, configured_env: &str) -> String {
61 let trimmed = configured_env.trim();
62 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV) {
63 api_key_env_var(provider)
64 } else {
65 trimmed.to_owned()
66 }
67}
68
69fn read_env_var(key: &str) -> Option<String> {
70 crate::env_helpers::read_env_var(key)
71}
72
73pub fn load_dotenv() -> Result<()> {
79 match dotenvy::dotenv() {
80 Ok(path) => {
81 if read_env_var("VTCODE_VERBOSE").is_some() || read_env_var("RUST_LOG").is_some() {
83 tracing::info!("Loaded environment variables from: {}", path.display());
84 }
85 Ok(())
86 }
87 Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
88 Ok(())
90 }
91 Err(e) => {
92 tracing::warn!("Failed to load .env file: {}", e);
93 Ok(())
94 }
95 }
96}
97
98pub fn get_api_key(provider: &str, _sources: &ApiKeySources) -> Result<String> {
109 let normalized_provider = provider.to_lowercase();
110 let inferred_env = api_key_env_var(&normalized_provider);
111
112 if let Some(key) = read_env_var(&inferred_env)
114 && !key.is_empty()
115 {
116 return Ok(key);
117 }
118
119 let provider_result = match normalized_provider.as_str() {
123 "gemini" => {
125 if let Some(key) = read_env_var("GOOGLE_API_KEY").filter(|k| !k.is_empty()) {
126 return Ok(key);
127 }
128 Err(anyhow::anyhow!("GEMINI_API_KEY or GOOGLE_API_KEY not set"))
129 }
130 "openrouter" => {
132 if let Ok(Some(token)) = crate::auth::load_oauth_token() {
133 tracing::debug!("Using OAuth token for OpenRouter authentication");
134 return Ok(token.api_key);
135 }
136 Err(anyhow::anyhow!("OPENROUTER_API_KEY not set"))
137 }
138 "qwen" => {
140 if let Some(key) = read_env_var("DASHSCOPE_API_KEY").filter(|k| !k.is_empty()) {
141 return Ok(key);
142 }
143 Err(anyhow::anyhow!("QWEN_API_KEY or DASHSCOPE_API_KEY not set"))
144 }
145 "ollama" | "lmstudio" | "llamacpp" | "llama.cpp" | "llama-cpp" => Ok(String::new()),
147 "copilot" => Err(anyhow::anyhow!(
149 "GitHub Copilot authentication is managed by the official `copilot` CLI. Run `vtcode login copilot`."
150 )),
151 "codex" => Err(anyhow::anyhow!(
152 "Codex authentication is managed by the official `codex app-server`. Run `vtcode login codex`."
153 )),
154 _ => {
156 return Err(anyhow::anyhow!(
157 "{} API key not found. Set {} environment variable or add to .env file.",
158 normalized_provider,
159 inferred_env,
160 ));
161 }
162 };
163
164 if provider_result.is_ok() {
165 return provider_result;
166 }
167
168 if let Ok(Some(key)) = get_custom_api_key_from_secure_storage(&normalized_provider) {
170 return Ok(key);
171 }
172
173 provider_result
174}
175
176fn get_custom_api_key_from_secure_storage(provider: &str) -> Result<Option<String>> {
190 let storage = CustomApiKeyStorage::new(provider);
191 let mode = crate::auth::AuthCredentialsStoreMode::default();
193 storage.load(mode)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 struct EnvOverrideGuard {
201 key: &'static str,
202 previous: Option<Option<String>>,
203 }
204
205 impl EnvOverrideGuard {
206 fn set(key: &'static str, value: Option<&str>) -> Self {
207 let previous = crate::env_helpers::test_env_overrides::get(key);
208 crate::env_helpers::test_env_overrides::set(key, value);
209 Self { key, previous }
210 }
211 }
212
213 impl Drop for EnvOverrideGuard {
214 fn drop(&mut self) {
215 crate::env_helpers::test_env_overrides::restore(self.key, self.previous.clone());
216 }
217 }
218
219 fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
220 where
221 F: FnOnce(),
222 {
223 let _guard = EnvOverrideGuard::set(key, value);
224 f();
225 }
226
227 fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
228 where
229 F: FnOnce(),
230 {
231 let _guards: Vec<_> = overrides
232 .iter()
233 .map(|(key, value)| EnvOverrideGuard::set(key, *value))
234 .collect();
235 f();
236 }
237
238 fn default_sources() -> ApiKeySources {
239 ApiKeySources::default()
240 }
241
242 #[test]
243 fn gemini_reads_env_var() {
244 with_override("GEMINI_API_KEY", Some("test-gemini-key"), || {
245 let result = get_api_key("gemini", &default_sources());
246 assert_eq!(result.unwrap(), "test-gemini-key");
247 });
248 }
249
250 #[test]
251 fn gemini_falls_back_to_google_api_key() {
252 with_overrides(
254 &[
255 ("GEMINI_API_KEY", Some("gemini-primary")),
256 ("GOOGLE_API_KEY", Some("google-fallback")),
257 ],
258 || {
259 let result = get_api_key("gemini", &default_sources());
261 assert_eq!(result.unwrap(), "gemini-primary");
262 },
263 );
264 with_overrides(
265 &[
266 ("GEMINI_API_KEY", None),
267 ("GOOGLE_API_KEY", Some("google-fallback")),
268 ],
269 || {
270 let result = get_api_key("gemini", &default_sources());
272 assert_eq!(result.unwrap(), "google-fallback");
273 },
274 );
275 }
276
277 #[test]
278 fn anthropic_reads_env_var() {
279 with_override("ANTHROPIC_API_KEY", Some("test-anthropic-key"), || {
280 let result = get_api_key("anthropic", &default_sources());
281 assert_eq!(result.unwrap(), "test-anthropic-key");
282 });
283 }
284
285 #[test]
286 fn openai_reads_env_var() {
287 with_override("OPENAI_API_KEY", Some("test-openai-key"), || {
288 let result = get_api_key("openai", &default_sources());
289 assert_eq!(result.unwrap(), "test-openai-key");
290 });
291 }
292
293 #[test]
294 fn deepseek_reads_env_var() {
295 with_override("DEEPSEEK_API_KEY", Some("test-deepseek-key"), || {
296 let result = get_api_key("deepseek", &default_sources());
297 assert_eq!(result.unwrap(), "test-deepseek-key");
298 });
299 }
300
301 #[test]
302 fn qwen_falls_back_to_dashscope() {
303 with_overrides(
304 &[
305 ("QWEN_API_KEY", None),
306 ("DASHSCOPE_API_KEY", Some("dashscope-key")),
307 ],
308 || {
309 let result = get_api_key("qwen", &default_sources());
310 assert_eq!(result.unwrap(), "dashscope-key");
311 },
312 );
313 }
314
315 #[test]
316 fn ollama_allows_empty_key() {
317 with_override("OLLAMA_API_KEY", None, || {
318 let result = get_api_key("ollama", &default_sources());
319 assert!(result.is_ok());
320 assert!(result.unwrap().is_empty());
321 });
322 }
323
324 #[test]
325 fn lmstudio_allows_empty_key() {
326 with_override("LMSTUDIO_API_KEY", None, || {
327 let result = get_api_key("lmstudio", &default_sources());
328 assert!(result.is_ok());
329 assert!(result.unwrap().is_empty());
330 });
331 }
332
333 #[test]
334 fn ollama_reads_env_var_when_set() {
335 with_override("OLLAMA_API_KEY", Some("test-ollama-key"), || {
336 let result = get_api_key("ollama", &default_sources());
337 assert_eq!(result.unwrap(), "test-ollama-key");
338 });
339 }
340
341 #[test]
342 fn copilot_returns_managed_auth_error() {
343 let result = get_api_key("copilot", &default_sources());
344 assert!(result.is_err());
345 assert!(result.unwrap_err().to_string().contains("copilot"));
346 }
347
348 #[test]
349 fn codex_returns_managed_auth_error() {
350 let result = get_api_key("codex", &default_sources());
351 assert!(result.is_err());
352 assert!(result.unwrap_err().to_string().contains("codex"));
353 }
354
355 #[test]
356 fn unknown_provider_returns_error_with_env_hint() {
357 let result = get_api_key("someunknown", &default_sources());
358 assert!(result.is_err());
359 let msg = result.unwrap_err().to_string();
360 assert!(msg.contains("SOMEUNKNOWN_API_KEY"));
361 }
362
363 #[test]
364 fn poolside_reads_env_var() {
365 with_override("POOLSIDE_API_KEY", Some("test-poolside-key"), || {
366 let result = get_api_key("poolside", &default_sources());
367 assert_eq!(result.unwrap(), "test-poolside-key");
368 });
369 }
370
371 #[test]
372 fn poolside_returns_error_when_missing() {
373 with_override("POOLSIDE_API_KEY", None, || {
374 let result = get_api_key("poolside", &default_sources());
375 assert!(result.is_err());
376 assert!(result.unwrap_err().to_string().contains("POOLSIDE_API_KEY"));
377 });
378 }
379
380 #[test]
381 fn api_key_env_var_uses_provider_defaults() {
382 assert_eq!(api_key_env_var("codex"), "");
383 assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
384 assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
385 assert_eq!(api_key_env_var("poolside"), "POOLSIDE_API_KEY");
386 }
387
388 #[test]
389 fn resolve_api_key_env_uses_provider_default_for_placeholder() {
390 assert_eq!(
391 resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV),
392 "MINIMAX_API_KEY"
393 );
394 }
395
396 #[test]
397 fn resolve_api_key_env_preserves_explicit_override() {
398 assert_eq!(
399 resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"),
400 "CUSTOM_OPENAI_KEY"
401 );
402 }
403}