Skip to main content

oxicode_ai/
env_api_keys.rs

1//! Environment variable-based API key resolution (LAST RESORT)
2//!
3//! Provides environment variable detection as a **last-resort fallback** for
4//! CI/CD, containers, and other automated environments.
5//!
6//! For local development, credentials should be stored via `oxicode setup` in
7//! `~/.config/oxicode/auth.json`. This file-based approach is more reliable than
8//! environment variables, which are lost when the shell restarts.
9//!
10//! Auth priority (see `provider_registry.rs`):
11//!   1. Runtime override (--api-key)
12//!   2. Stored credential (auth.json)
13//!   3. OAuth token
14//!   4. Ambient credentials (AWS IAM, Google ADC)
15//!   5. Environment variable ← this module
16
17use std::collections::HashMap;
18use std::env;
19use std::fs;
20use std::sync::LazyLock;
21
22/// Cache for Vertex ADC credentials check (expensive fs check)
23static VERTEX_ADC_CHECK: LazyLock<bool> = LazyLock::new(check_vertex_adc_credentials);
24
25/// Check if Vertex AI Application Default Credentials exist
26fn check_vertex_adc_credentials() -> bool {
27    // Check GOOGLE_APPLICATION_CREDENTIALS env var first
28    if let Ok(path) = env::var("GOOGLE_APPLICATION_CREDENTIALS") {
29        return fs::metadata(&path).is_ok();
30    }
31
32    // Fall back to default ADC path
33    let default_path =
34        dirs::home_dir().map(|h| h.join(".config/gcloud/application_default_credentials.json"));
35
36    default_path
37        .map(|p| fs::metadata(p).is_ok())
38        .unwrap_or(false)
39}
40
41/// Get a value from environment, supporting both std::env and /proc/self/environ fallback
42/// for sandboxed environments (e.g., Bun compiled binaries on Linux)
43fn get_env(key: &str) -> Option<String> {
44    env::var(key).ok().or_else(|| get_proc_env(key))
45}
46
47/// Bun/Linux sandbox fallback: read from /proc/self/environ
48#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
49fn get_proc_env(key: &str) -> Option<String> {
50    #[allow(unused_imports)]
51    use std::os::unix::ffi::OsStrExt;
52
53    // Only try this on Linux where Bun sandbox may empty process.env
54    #[cfg(target_os = "linux")]
55    {
56        // If process.env has entries, no need for /proc fallback
57        if env::var("PATH").is_err() || std::env::vars().count() > 0 {
58            return None;
59        }
60
61        let Ok(contents) = fs::read_to_string("/proc/self/environ") else {
62            return None;
63        };
64
65        for segment in contents.split('\0') {
66            if let Some(pos) = segment.find('=') {
67                let k = &segment.as_bytes()[..pos];
68                let v = &segment[pos + 1..];
69                if k == key.as_bytes() {
70                    return Some(v.to_string());
71                }
72            }
73        }
74    }
75
76    None
77}
78
79/// Get environment variables that can provide an API key for a provider
80pub fn find_env_keys(provider: &str) -> Option<Vec<&'static str>> {
81    let keys = match provider {
82        // GitHub Copilot: multiple possible env vars
83        "github-copilot" | "copilot" => vec!["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
84
85        // Anthropic: OAuth token takes precedence over API key
86        "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
87
88        // OpenAI variants
89        "openai" | "openai-responses" => vec![
90            "OPENAI_API_KEY",
91            "AZURE_OPENAI_API_KEY", // Some deployments use this
92        ],
93
94        // Google/Gemini
95        "google" | "gemini" => vec!["GEMINI_API_KEY", "GOOGLE_API_KEY"],
96
97        // Google Vertex AI
98        "vertex" | "google-vertex" => vec!["GOOGLE_CLOUD_API_KEY"],
99
100        // Azure
101        "azure" | "azure-openai" => vec!["AZURE_OPENAI_API_KEY"],
102
103        // Groq
104        "groq" => vec!["GROQ_API_KEY"],
105
106        // Cerebras
107        "cerebras" => vec!["CEREBRAS_API_KEY"],
108
109        // xAI / Grok
110        "xai" => vec!["XAI_API_KEY"],
111
112        // OpenRouter
113        "openrouter" => vec!["OPENROUTER_API_KEY"],
114
115        // Vercel AI Gateway
116        "vercel-ai-gateway" => vec!["AI_GATEWAY_API_KEY"],
117
118        // ZAI
119        "zai" => vec!["ZAI_API_KEY"],
120
121        // Mistral
122        "mistral" => vec!["MISTRAL_API_KEY"],
123
124        // MiniMax (China)
125        "minimax" | "minimax-cn" => vec!["MINIMAX_API_KEY", "MINIMAX_CN_API_KEY"],
126
127        // Moonshot AI / Kimi (China)
128        "moonshotai" | "moonshotai-cn" | "kimi" | "kimi-coding" => {
129            vec!["MOONSHOT_API_KEY", "KIMI_API_KEY"]
130        }
131
132        // Hugging Face
133        "huggingface" | "hf" => vec!["HF_TOKEN", "HUGGINGFACE_TOKEN"],
134
135        // Fireworks AI
136        "fireworks" => vec!["FIREWORKS_API_KEY"],
137
138        // DeepSeek
139        "deepseek" => vec!["DEEPSEEK_API_KEY"],
140
141        // OpenCode
142        "opencode" => vec!["OPENCODE_API_KEY"],
143
144        // Xiaomi
145        "xiaomi" => vec!["XIAOMI_API_KEY"],
146
147        // Cloudflare Workers AI
148        "cloudflare" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" => {
149            vec!["CLOUDFLARE_API_KEY", "CLOUDFLARE_AI_GATEWAY_API_KEY"]
150        }
151
152        _ => return None,
153    };
154
155    Some(keys)
156}
157
158/// Check if any environment variable is set for a provider
159pub fn has_env_key(provider: &str) -> bool {
160    find_env_keys(provider)
161        .map(|keys| keys.iter().any(|k| get_env(k).is_some()))
162        .unwrap_or(false)
163}
164
165/// Get first available environment variable value for a provider
166fn first_of(keys: &[&str]) -> Option<String> {
167    for key in keys {
168        if let Some(value) = get_env(key)
169            && !value.is_empty()
170        {
171            return Some(value);
172        }
173    }
174    None
175}
176
177/// Get API key from environment variables for a provider
178///
179/// Returns `Some("<authenticated>")` for providers that support ambient credentials
180/// (e.g., AWS IAM roles, Google ADC) rather than explicit API keys.
181pub fn get_env_api_key(provider: &str) -> Option<String> {
182    let keys = find_env_keys(provider)?;
183    let key = first_of(&keys)?;
184
185    // Filter out placeholder values
186    if key == "<authenticated>" || key.starts_with("sk-") && key.len() < 10 {
187        return None;
188    }
189
190    Some(key)
191}
192
193/// Check Vertex AI-specific ambient credentials
194///
195/// Vertex AI supports Application Default Credentials (ADC) configured via:
196/// - `gcloud auth application-default login`
197/// - `GOOGLE_APPLICATION_CREDENTIALS` pointing to service account JSON
198///
199/// Returns true if all required ADC components are present.
200pub fn has_vertex_adc() -> bool {
201    *VERTEX_ADC_CHECK
202}
203
204/// Check if Vertex AI has all required components for ADC authentication
205pub fn has_vertex_adc_full() -> bool {
206    // Check credentials file exists
207    let has_creds = has_vertex_adc();
208
209    // Check project ID
210    let has_project = get_env("GOOGLE_CLOUD_PROJECT")
211        .or_else(|| get_env("GCLOUD_PROJECT"))
212        .is_some();
213
214    // Check location
215    let has_location = get_env("GOOGLE_CLOUD_LOCATION").is_some();
216
217    has_creds && has_project && has_location
218}
219
220/// Check Amazon Bedrock ambient credentials
221///
222/// Bedrock supports multiple credential sources:
223/// 1. AWS_PROFILE - named profile from ~/.aws/credentials
224/// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
225/// 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token
226/// 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles
227/// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
228/// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
229pub fn has_bedrock_creds() -> bool {
230    // Explicit API key scenario
231    if get_env("AWS_ACCESS_KEY_ID").is_some() && get_env("AWS_SECRET_ACCESS_KEY").is_some() {
232        return true;
233    }
234
235    // Named profile
236    if get_env("AWS_PROFILE").is_some() {
237        return true;
238    }
239
240    // Bedrock bearer token
241    if get_env("AWS_BEARER_TOKEN_BEDROCK").is_some() {
242        return true;
243    }
244
245    // ECS container credentials (IRSA)
246    if get_env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some()
247        || get_env("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some()
248    {
249        return true;
250    }
251
252    // Web identity token (IRSA)
253    if get_env("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() {
254        return true;
255    }
256
257    false
258}
259
260/// Check if Bedrock has full credential configuration
261pub fn has_bedrock_creds_full() -> bool {
262    // For explicit API key auth, need both key ID and secret
263    if get_env("AWS_ACCESS_KEY_ID").is_some() && get_env("AWS_SECRET_ACCESS_KEY").is_some() {
264        return true;
265    }
266
267    // For profile-based auth, just need the profile
268    if get_env("AWS_PROFILE").is_some() {
269        return true;
270    }
271
272    // For ECS/IRSA, we need the credential endpoint/file
273    if get_env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some()
274        || get_env("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some()
275        || get_env("AWS_WEB_IDENTITY_TOKEN_FILE").is_some()
276    {
277        return true;
278    }
279
280    // Bearer token
281    if get_env("AWS_BEARER_TOKEN_BEDROCK").is_some() {
282        return true;
283    }
284
285    false
286}
287
288/// Get all providers with environment variable credentials
289pub fn get_all_env_keys() -> HashMap<String, String> {
290    let mut result = HashMap::new();
291
292    // Map provider names to their possible env vars
293    #[allow(clippy::type_complexity)]
294    let mappings: [(&str, fn() -> Option<String>); 17] = [
295        ("anthropic", || {
296            first_of(&["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"])
297        }),
298        ("openai", || first_of(&["OPENAI_API_KEY"])),
299        ("github-copilot", || {
300            first_of(&["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_GITHUB_TOKEN"])
301        }),
302        ("google", || first_of(&["GEMINI_API_KEY"])),
303        ("vertex", || first_of(&["GOOGLE_CLOUD_API_KEY"])),
304        ("groq", || first_of(&["GROQ_API_KEY"])),
305        ("cerebras", || first_of(&["CEREBRAS_API_KEY"])),
306        ("xai", || first_of(&["XAI_API_KEY"])),
307        ("openrouter", || first_of(&["OPENROUTER_API_KEY"])),
308        ("mistral", || first_of(&["MISTRAL_API_KEY"])),
309        ("deepseek", || first_of(&["DEEPSEEK_API_KEY"])),
310        ("azure", || first_of(&["AZURE_OPENAI_API_KEY"])),
311        ("cloudflare", || first_of(&["CLOUDFLARE_API_KEY"])),
312        ("huggingface", || first_of(&["HF_TOKEN"])),
313        ("fireworks", || first_of(&["FIREWORKS_API_KEY"])),
314        ("moonshotai", || first_of(&["MOONSHOT_API_KEY"])),
315        ("bedrock", || {
316            first_of(&["AWS_ACCESS_KEY_ID", "AWS_PROFILE"])
317        }),
318    ];
319
320    for (provider, get_key) in mappings.iter() {
321        if let Some(value) = get_key() {
322            result.insert(provider.to_string(), value);
323        }
324    }
325
326    result
327}
328
329/// Check if a provider supports OAuth tokens via environment
330pub fn supports_oauth_env(provider: &str) -> bool {
331    matches!(provider, "anthropic")
332}
333
334/// Get OAuth token from environment (for providers that support it)
335pub fn get_oauth_env_token(provider: &str) -> Option<String> {
336    match provider {
337        "anthropic" => get_env("ANTHROPIC_OAUTH_TOKEN"),
338        _ => None,
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::env;
346
347    #[test]
348    fn test_find_env_keys_anthropic() {
349        let keys = find_env_keys("anthropic").unwrap();
350        assert!(keys.contains(&"ANTHROPIC_API_KEY"));
351        assert!(keys.contains(&"ANTHROPIC_OAUTH_TOKEN"));
352    }
353
354    #[test]
355    fn test_find_env_keys_copilot() {
356        let keys = find_env_keys("github-copilot").unwrap();
357        assert!(keys.contains(&"COPILOT_GITHUB_TOKEN"));
358        assert!(keys.contains(&"GH_TOKEN"));
359        assert!(keys.contains(&"GITHUB_TOKEN"));
360    }
361
362    #[test]
363    fn test_find_env_keys_unknown() {
364        assert!(find_env_keys("unknown-provider").is_none());
365    }
366
367    #[test]
368    fn test_first_of_returns_first() {
369        unsafe { env::set_var("TEST_FIRST_OF_1", "value1") };
370        unsafe { env::set_var("TEST_FIRST_OF_2", "value2") };
371
372        let result = first_of(&["TEST_FIRST_OF_1", "TEST_FIRST_OF_2"]);
373        assert_eq!(result, Some("value1".to_string()));
374
375        unsafe { env::remove_var("TEST_FIRST_OF_1") };
376        unsafe { env::remove_var("TEST_FIRST_OF_2") };
377    }
378
379    #[test]
380    fn test_first_of_skips_empty() {
381        unsafe { env::set_var("TEST_FIRST_OF_SKIP", "") };
382        unsafe { env::set_var("TEST_FIRST_OF_SECOND", "second") };
383
384        let result = first_of(&["TEST_FIRST_OF_SKIP", "TEST_FIRST_OF_SECOND"]);
385        assert_eq!(result, Some("second".to_string()));
386
387        unsafe { env::remove_var("TEST_FIRST_OF_SKIP") };
388        unsafe { env::remove_var("TEST_FIRST_OF_SECOND") };
389    }
390
391    #[test]
392    fn test_get_env_api_key() {
393        unsafe { env::set_var("ANTHROPIC_API_KEY", "sk-test-key-123") };
394
395        // Provider with known keys
396        let result = get_env_api_key("anthropic");
397        assert_eq!(result, Some("sk-test-key-123".to_string()));
398
399        unsafe { env::remove_var("ANTHROPIC_API_KEY") };
400    }
401
402    #[test]
403    fn test_has_env_key() {
404        unsafe { env::set_var("DEEPSEEK_API_KEY", "test-value") };
405
406        // Check that deepseek has an env key
407        let result = has_env_key("deepseek"); // DEEPSEEK_API_KEY
408        assert!(result);
409
410        unsafe { env::remove_var("DEEPSEEK_API_KEY") };
411    }
412
413    #[test]
414    fn test_vertex_adc_check_lazy() {
415        // The lazy static should be evaluated on first access without panicking.
416        let _: bool = *VERTEX_ADC_CHECK;
417    }
418
419    #[test]
420    fn test_get_all_env_keys() {
421        // Use a unique key to avoid race conditions in parallel test runs
422        let key = "OXICODE_TEST_GET_ALL_ENV_KEYS";
423        unsafe { env::set_var(key, "test-value") };
424
425        // Verify the function runs without panic and returns a map
426        let all = get_all_env_keys();
427        // The map may or may not contain entries depending on the test runner's
428        // environment, but it should always be a valid HashMap
429        assert!(all.len() <= 17); // max number of providers in the mapping
430
431        unsafe { env::remove_var(key) };
432    }
433
434    #[test]
435    fn test_oauth_env_token() {
436        unsafe { env::set_var("ANTHROPIC_OAUTH_TOKEN", "oauth-token-123") };
437
438        let result = get_oauth_env_token("anthropic");
439        assert_eq!(result, Some("oauth-token-123".to_string()));
440
441        let not_oauth = get_oauth_env_token("openai");
442        assert!(not_oauth.is_none());
443
444        unsafe { env::remove_var("ANTHROPIC_OAUTH_TOKEN") };
445    }
446
447    #[test]
448    fn test_bedrock_creds_check() {
449        // Without AWS credentials set, should return false. Either way, the
450        // check must evaluate without panicking.
451        let _: bool = has_bedrock_creds();
452    }
453
454    #[test]
455    fn test_supports_oauth_env() {
456        assert!(supports_oauth_env("anthropic"));
457        assert!(!supports_oauth_env("openai"));
458        assert!(!supports_oauth_env("deepseek"));
459    }
460
461    #[test]
462    fn test_google_legacy_alias() {
463        // "google" should map to GEMINI_API_KEY
464        let keys = find_env_keys("google");
465        assert!(keys.is_some());
466        assert!(keys.unwrap().contains(&"GEMINI_API_KEY"));
467    }
468
469    #[test]
470    fn test_moonshotai_aliases() {
471        let keys = find_env_keys("moonshotai");
472        assert!(keys.is_some());
473        assert!(keys.unwrap().contains(&"MOONSHOT_API_KEY"));
474
475        let kimi = find_env_keys("kimi");
476        assert!(kimi.is_some());
477        assert!(kimi.unwrap().contains(&"KIMI_API_KEY"));
478    }
479}