Skip to main content

oxios_kernel/
credential.rs

1//! Multi-source credential resolution.
2//!
3//! Reads API keys from multiple sources with clear priority:
4//! 1. `config.toml` → `[engine].api_key` (explicit override)
5//! 2. `~/.oxi/auth.json` (shared with oxi CLI if installed)
6//! 3. oxi-ai env var fallback (CI/CD, containers)
7
8use anyhow::Result;
9
10/// Where a credential was found.
11#[derive(Debug, Clone)]
12pub enum CredentialSource {
13    /// From config.toml [engine].api_key
14    Config,
15    /// From ~/.oxi/auth.json (oxi CLI credential store)
16    OxiAuthStore,
17    /// From environment variable
18    EnvVar,
19}
20
21/// Multi-source credential resolver.
22pub struct CredentialStore;
23
24impl CredentialStore {
25    /// Resolve the best available API key for a provider.
26    ///
27    /// Priority: OXIOS_<PROVIDER>_API_KEY env → config.toml → oxi auth.json → oxi-ai env fallback
28    /// Environment variables take highest priority for container/K8s deployments.
29    pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
30        // 1. Explicit Oxios env var: OXIOS_<PROVIDER>_API_KEY (highest priority for containers)
31        let env_var = format!("OXIOS_{}_API_KEY", provider.to_uppercase());
32        if let Ok(key) = std::env::var(&env_var) {
33            if !key.is_empty() {
34                return Some((key, CredentialSource::EnvVar));
35            }
36        }
37
38        // 2. config.toml explicit key
39        if let Some(key) = config_key {
40            if !key.is_empty() {
41                return Some((key.to_string(), CredentialSource::Config));
42            }
43        }
44
45        // 3. oxi auth store (~/.oxi/auth.json)
46        if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
47            if !token.access_token.is_empty() {
48                return Some((token.access_token, CredentialSource::OxiAuthStore));
49            }
50        }
51
52        // 4. oxi-ai env var fallback
53        if let Some(key) = oxi_sdk::get_env_api_key(provider) {
54            return Some((key, CredentialSource::EnvVar));
55        }
56
57        None
58    }
59
60    /// Check if any credential is available for a provider.
61    pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
62        Self::resolve(provider, config_key).is_some()
63    }
64
65    /// Store an API key to oxi's auth store (~/.oxi/auth.json).
66    ///
67    /// This is called by the onboarding wizard. If oxi CLI is also
68    /// installed on this machine, it will pick up the same credential.
69    pub fn store(provider: &str, api_key: &str) -> Result<()> {
70        let token = oxi_sdk::TokenBundle {
71            access_token: api_key.to_string(),
72            refresh_token: None,
73            token_type: "Bearer".to_string(),
74            obtained_at: chrono::Utc::now(),
75            expires_in: 0,
76            scope: None,
77        };
78        oxi_sdk::save_token(provider, &token)?;
79        tracing::info!(provider = %provider, "API key stored to oxi auth store");
80        Ok(())
81    }
82
83    /// Extract the provider name from a model ID.
84    /// "anthropic/claude-sonnet-4-20250514" → "anthropic"
85    /// Returns `None` if the model ID is empty or has no provider prefix.
86    pub fn provider_from_model(model_id: &str) -> Option<&str> {
87        if model_id.is_empty() {
88            return None;
89        }
90        model_id.split_once('/').map(|(p, _)| p)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_provider_from_model() {
100        assert_eq!(
101            CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
102            Some("anthropic")
103        );
104        assert_eq!(
105            CredentialStore::provider_from_model("openai/gpt-4o"),
106            Some("openai")
107        );
108        assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
109        assert_eq!(CredentialStore::provider_from_model(""), None);
110    }
111
112    #[test]
113    fn test_config_key_takes_priority() {
114        // If config_key is set, it's always returned (even if other sources exist)
115        let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
116        assert!(result.is_some());
117        let (key, source) = result.unwrap();
118        assert_eq!(key, "sk-test-config-key");
119        assert!(matches!(source, CredentialSource::Config));
120    }
121
122    #[test]
123    fn test_empty_config_key_skipped() {
124        let result = CredentialStore::resolve("anthropic", Some(""));
125        // Empty string is treated as None — falls through to next source
126        // (result depends on whether auth.json or env vars exist)
127        // Just verify it doesn't panic
128        let _ = result;
129    }
130
131    #[test]
132    fn test_none_config_key_skipped() {
133        let result = CredentialStore::resolve("anthropic", None);
134        let _ = result; // depends on system state
135    }
136}