use anyhow::Result;
use std::env;
use std::str::FromStr;
use crate::auth::CustomApiKeyStorage;
use crate::constants::defaults;
use crate::models::Provider;
#[derive(Debug, Clone)]
pub struct ApiKeySources {
pub gemini_env: String,
pub anthropic_env: String,
pub openai_env: String,
pub openrouter_env: String,
pub deepseek_env: String,
pub zai_env: String,
pub ollama_env: String,
pub lmstudio_env: String,
pub gemini_config: Option<String>,
pub anthropic_config: Option<String>,
pub openai_config: Option<String>,
pub openrouter_config: Option<String>,
pub deepseek_config: Option<String>,
pub zai_config: Option<String>,
pub ollama_config: Option<String>,
pub lmstudio_config: Option<String>,
}
impl Default for ApiKeySources {
fn default() -> Self {
Self {
gemini_env: "GEMINI_API_KEY".to_string(),
anthropic_env: "ANTHROPIC_API_KEY".to_string(),
openai_env: "OPENAI_API_KEY".to_string(),
openrouter_env: "OPENROUTER_API_KEY".to_string(),
deepseek_env: "DEEPSEEK_API_KEY".to_string(),
zai_env: "ZAI_API_KEY".to_string(),
ollama_env: "OLLAMA_API_KEY".to_string(),
lmstudio_env: "LMSTUDIO_API_KEY".to_string(),
gemini_config: None,
anthropic_config: None,
openai_config: None,
openrouter_config: None,
deepseek_config: None,
zai_config: None,
ollama_config: None,
lmstudio_config: None,
}
}
}
impl ApiKeySources {
pub fn for_provider(_provider: &str) -> Self {
Self::default()
}
}
pub fn api_key_env_var(provider: &str) -> String {
let trimmed = provider.trim();
if trimmed.is_empty() {
return defaults::DEFAULT_API_KEY_ENV.to_owned();
}
if trimmed.eq_ignore_ascii_case("codex") {
return String::new();
}
if let Ok(resolved) = Provider::from_str(trimmed)
&& resolved.uses_managed_auth()
{
return String::new();
}
Provider::from_str(trimmed)
.map(|resolved| resolved.default_api_key_env().to_owned())
.unwrap_or_else(|_| format!("{}_API_KEY", trimmed.to_ascii_uppercase()))
}
pub fn resolve_api_key_env(provider: &str, configured_env: &str) -> String {
let trimmed = configured_env.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV) {
api_key_env_var(provider)
} else {
trimmed.to_owned()
}
}
#[cfg(test)]
mod test_env_overrides {
use hashbrown::HashMap;
use std::sync::{LazyLock, Mutex};
static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub(super) fn get(key: &str) -> Option<Option<String>> {
OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
}
pub(super) fn set(key: &str, value: Option<&str>) {
if let Ok(mut map) = OVERRIDES.lock() {
map.insert(key.to_string(), value.map(ToString::to_string));
}
}
pub(super) fn restore(key: &str, previous: Option<Option<String>>) {
if let Ok(mut map) = OVERRIDES.lock() {
match previous {
Some(value) => {
map.insert(key.to_string(), value);
}
None => {
map.remove(key);
}
}
}
}
}
fn read_env_var(key: &str) -> Option<String> {
#[cfg(test)]
if let Some(override_value) = test_env_overrides::get(key) {
return override_value;
}
env::var(key).ok()
}
pub fn load_dotenv() -> Result<()> {
match dotenvy::dotenv() {
Ok(path) => {
if read_env_var("VTCODE_VERBOSE").is_some() || read_env_var("RUST_LOG").is_some() {
tracing::info!("Loaded environment variables from: {}", path.display());
}
Ok(())
}
Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(())
}
Err(e) => {
tracing::warn!("Failed to load .env file: {}", e);
Ok(())
}
}
}
pub fn get_api_key(provider: &str, sources: &ApiKeySources) -> Result<String> {
let normalized_provider = provider.to_lowercase();
let inferred_env = api_key_env_var(&normalized_provider);
if let Some(key) = read_env_var(&inferred_env)
&& !key.is_empty()
{
return Ok(key);
}
if let Ok(Some(key)) = get_custom_api_key_from_secure_storage(&normalized_provider) {
return Ok(key);
}
match normalized_provider.as_str() {
"gemini" => get_gemini_api_key(sources),
"anthropic" => get_anthropic_api_key(sources),
"openai" => get_openai_api_key(sources),
"copilot" => Err(anyhow::anyhow!(
"GitHub Copilot authentication is managed by the official `copilot` CLI. Run `vtcode login copilot`."
)),
"deepseek" => get_deepseek_api_key(sources),
"openrouter" => get_openrouter_api_key(sources),
"codex" => Err(anyhow::anyhow!(
"Codex authentication is managed by the official `codex app-server`. Run `vtcode login codex`. The default `[agent.codex_app_server].command = \"codex\"` requires the `codex` CLI on `$PATH`, or you can set `[agent.codex_app_server].command` to a custom executable path."
)),
"zai" => get_zai_api_key(sources),
"ollama" => get_ollama_api_key(sources),
"lmstudio" => get_lmstudio_api_key(sources),
"huggingface" => {
read_env_var("HF_TOKEN").ok_or_else(|| anyhow::anyhow!("HF_TOKEN not set"))
}
_ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
}
}
fn get_custom_api_key_from_secure_storage(provider: &str) -> Result<Option<String>> {
let storage = CustomApiKeyStorage::new(provider);
let mode = crate::auth::AuthCredentialsStoreMode::default();
storage.load(mode)
}
fn get_api_key_with_fallback(
env_var: &str,
config_value: Option<&String>,
provider_name: &str,
) -> Result<String> {
if let Some(key) = read_env_var(env_var)
&& !key.is_empty()
{
return Ok(key);
}
if let Some(key) = config_value
&& !key.is_empty()
{
return Ok(key.clone());
}
Err(anyhow::anyhow!(
"No API key found for {} provider. Set {} environment variable (or add to .env file) or configure in vtcode.toml",
provider_name,
env_var
))
}
fn get_optional_api_key_with_fallback(env_var: &str, config_value: Option<&String>) -> String {
if let Some(key) = read_env_var(env_var)
&& !key.is_empty()
{
return key;
}
if let Some(key) = config_value
&& !key.is_empty()
{
return key.clone();
}
String::new()
}
fn get_gemini_api_key(sources: &ApiKeySources) -> Result<String> {
if let Some(key) = read_env_var(&sources.gemini_env)
&& !key.is_empty()
{
return Ok(key);
}
if let Some(key) = read_env_var("GOOGLE_API_KEY")
&& !key.is_empty()
{
return Ok(key);
}
if let Some(key) = &sources.gemini_config
&& !key.is_empty()
{
return Ok(key.clone());
}
Err(anyhow::anyhow!(
"No API key found for Gemini provider. Set {} or GOOGLE_API_KEY environment variable (or add to .env file) or configure in vtcode.toml",
sources.gemini_env
))
}
fn get_anthropic_api_key(sources: &ApiKeySources) -> Result<String> {
get_api_key_with_fallback(
&sources.anthropic_env,
sources.anthropic_config.as_ref(),
"Anthropic",
)
}
fn get_openai_api_key(sources: &ApiKeySources) -> Result<String> {
get_api_key_with_fallback(
&sources.openai_env,
sources.openai_config.as_ref(),
"OpenAI",
)
}
fn get_openrouter_api_key(sources: &ApiKeySources) -> Result<String> {
if let Ok(Some(token)) = crate::auth::load_oauth_token() {
tracing::debug!("Using OAuth token for OpenRouter authentication");
return Ok(token.api_key);
}
get_api_key_with_fallback(
&sources.openrouter_env,
sources.openrouter_config.as_ref(),
"OpenRouter",
)
}
fn get_deepseek_api_key(sources: &ApiKeySources) -> Result<String> {
get_api_key_with_fallback(
&sources.deepseek_env,
sources.deepseek_config.as_ref(),
"DeepSeek",
)
}
fn get_zai_api_key(sources: &ApiKeySources) -> Result<String> {
get_api_key_with_fallback(&sources.zai_env, sources.zai_config.as_ref(), "Z.AI")
}
fn get_ollama_api_key(sources: &ApiKeySources) -> Result<String> {
Ok(get_optional_api_key_with_fallback(
&sources.ollama_env,
sources.ollama_config.as_ref(),
))
}
fn get_lmstudio_api_key(sources: &ApiKeySources) -> Result<String> {
Ok(get_optional_api_key_with_fallback(
&sources.lmstudio_env,
sources.lmstudio_config.as_ref(),
))
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvOverrideGuard {
key: &'static str,
previous: Option<Option<String>>,
}
impl EnvOverrideGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let previous = test_env_overrides::get(key);
test_env_overrides::set(key, value);
Self { key, previous }
}
}
impl Drop for EnvOverrideGuard {
fn drop(&mut self) {
test_env_overrides::restore(self.key, self.previous.clone());
}
}
fn with_override<F>(key: &'static str, value: Option<&str>, f: F)
where
F: FnOnce(),
{
let _guard = EnvOverrideGuard::set(key, value);
f();
}
fn with_overrides<F>(overrides: &[(&'static str, Option<&str>)], f: F)
where
F: FnOnce(),
{
let _guards: Vec<_> = overrides
.iter()
.map(|(key, value)| EnvOverrideGuard::set(key, *value))
.collect();
f();
}
#[test]
fn test_get_gemini_api_key_from_env() {
with_override("TEST_GEMINI_KEY", Some("test-gemini-key"), || {
let sources = ApiKeySources {
gemini_env: "TEST_GEMINI_KEY".to_string(),
..Default::default()
};
let result = get_gemini_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-gemini-key");
});
}
#[test]
fn test_get_anthropic_api_key_from_env() {
with_override("TEST_ANTHROPIC_KEY", Some("test-anthropic-key"), || {
let sources = ApiKeySources {
anthropic_env: "TEST_ANTHROPIC_KEY".to_string(),
..Default::default()
};
let result = get_anthropic_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-anthropic-key");
});
}
#[test]
fn test_get_openai_api_key_from_env() {
with_override("TEST_OPENAI_KEY", Some("test-openai-key"), || {
let sources = ApiKeySources {
openai_env: "TEST_OPENAI_KEY".to_string(),
..Default::default()
};
let result = get_openai_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-openai-key");
});
}
#[test]
fn test_get_deepseek_api_key_from_env() {
with_override("TEST_DEEPSEEK_KEY", Some("test-deepseek-key"), || {
let sources = ApiKeySources {
deepseek_env: "TEST_DEEPSEEK_KEY".to_string(),
..Default::default()
};
let result = get_deepseek_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-deepseek-key");
});
}
#[test]
fn test_get_gemini_api_key_from_config() {
with_overrides(
&[
("TEST_GEMINI_CONFIG_KEY", None),
("GOOGLE_API_KEY", None),
("GEMINI_API_KEY", None),
],
|| {
let sources = ApiKeySources {
gemini_env: "TEST_GEMINI_CONFIG_KEY".to_string(),
gemini_config: Some("config-gemini-key".to_string()),
..Default::default()
};
let result = get_gemini_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "config-gemini-key");
},
);
}
#[test]
fn test_get_api_key_with_fallback_prefers_env() {
with_override("TEST_FALLBACK_KEY", Some("env-key"), || {
let sources = ApiKeySources {
openai_env: "TEST_FALLBACK_KEY".to_string(),
openai_config: Some("config-key".to_string()),
..Default::default()
};
let result = get_openai_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "env-key"); });
}
#[test]
fn test_get_api_key_fallback_to_config() {
let sources = ApiKeySources {
openai_env: "NONEXISTENT_ENV_VAR".to_string(),
openai_config: Some("config-key".to_string()),
..Default::default()
};
let result = get_openai_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "config-key");
}
#[test]
fn test_get_api_key_error_when_not_found() {
let sources = ApiKeySources {
openai_env: "NONEXISTENT_ENV_VAR".to_string(),
..Default::default()
};
let result = get_openai_api_key(&sources);
assert!(result.is_err());
}
#[test]
fn test_get_ollama_api_key_missing_sources() {
let sources = ApiKeySources {
ollama_env: "NONEXISTENT_OLLAMA_ENV".to_string(),
..Default::default()
};
let result = get_ollama_api_key(&sources).expect("Ollama key retrieval should succeed");
assert!(result.is_empty());
}
#[test]
fn test_get_ollama_api_key_from_env() {
with_override("TEST_OLLAMA_KEY", Some("test-ollama-key"), || {
let sources = ApiKeySources {
ollama_env: "TEST_OLLAMA_KEY".to_string(),
..Default::default()
};
let result = get_ollama_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-ollama-key");
});
}
#[test]
fn test_get_lmstudio_api_key_missing_sources() {
let sources = ApiKeySources {
lmstudio_env: "NONEXISTENT_LMSTUDIO_ENV".to_string(),
..Default::default()
};
let result =
get_lmstudio_api_key(&sources).expect("LM Studio key retrieval should succeed");
assert!(result.is_empty());
}
#[test]
fn test_get_lmstudio_api_key_from_env() {
with_override("TEST_LMSTUDIO_KEY", Some("test-lmstudio-key"), || {
let sources = ApiKeySources {
lmstudio_env: "TEST_LMSTUDIO_KEY".to_string(),
..Default::default()
};
let result = get_lmstudio_api_key(&sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-lmstudio-key");
});
}
#[test]
fn test_get_api_key_ollama_provider() {
with_override(
"TEST_OLLAMA_PROVIDER_KEY",
Some("test-ollama-env-key"),
|| {
let sources = ApiKeySources {
ollama_env: "TEST_OLLAMA_PROVIDER_KEY".to_string(),
..Default::default()
};
let result = get_api_key("ollama", &sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-ollama-env-key");
},
);
}
#[test]
fn test_get_api_key_lmstudio_provider() {
with_override(
"TEST_LMSTUDIO_PROVIDER_KEY",
Some("test-lmstudio-env-key"),
|| {
let sources = ApiKeySources {
lmstudio_env: "TEST_LMSTUDIO_PROVIDER_KEY".to_string(),
..Default::default()
};
let result = get_api_key("lmstudio", &sources);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-lmstudio-env-key");
},
);
}
#[test]
fn api_key_env_var_uses_provider_defaults() {
assert_eq!(api_key_env_var("codex"), "");
assert_eq!(api_key_env_var("minimax"), "MINIMAX_API_KEY");
assert_eq!(api_key_env_var("huggingface"), "HF_TOKEN");
}
#[test]
fn resolve_api_key_env_uses_provider_default_for_placeholder() {
assert_eq!(
resolve_api_key_env("minimax", defaults::DEFAULT_API_KEY_ENV),
"MINIMAX_API_KEY"
);
}
#[test]
fn resolve_api_key_env_preserves_explicit_override() {
assert_eq!(
resolve_api_key_env("openai", "CUSTOM_OPENAI_KEY"),
"CUSTOM_OPENAI_KEY"
);
}
}