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: config.toml → oxi auth.json → env var
28    pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
29        // 1. config.toml explicit key
30        if let Some(key) = config_key {
31            if !key.is_empty() {
32                return Some((key.to_string(), CredentialSource::Config));
33            }
34        }
35
36        // 2. oxi auth store (~/.oxi/auth.json)
37        if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
38            if !token.access_token.is_empty() {
39                return Some((token.access_token, CredentialSource::OxiAuthStore));
40            }
41        }
42
43        // 3. oxi-ai env var fallback
44        if let Some(key) = oxi_sdk::get_env_api_key(provider) {
45            return Some((key, CredentialSource::EnvVar));
46        }
47
48        None
49    }
50
51    /// Check if any credential is available for a provider.
52    pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
53        Self::resolve(provider, config_key).is_some()
54    }
55
56    /// Store an API key to oxi's auth store (~/.oxi/auth.json).
57    ///
58    /// This is called by the onboarding wizard. If oxi CLI is also
59    /// installed on this machine, it will pick up the same credential.
60    pub fn store(provider: &str, api_key: &str) -> Result<()> {
61        let token = oxi_sdk::TokenBundle {
62            access_token: api_key.to_string(),
63            refresh_token: None,
64            token_type: "Bearer".to_string(),
65            obtained_at: chrono::Utc::now(),
66            expires_in: 0,
67            scope: None,
68        };
69        oxi_sdk::save_token(provider, &token)?;
70        tracing::info!(provider = %provider, "API key stored to oxi auth store");
71        Ok(())
72    }
73
74    /// Extract the provider name from a model ID.
75    /// "anthropic/claude-sonnet-4-20250514" → "anthropic"
76    /// Returns `None` if the model ID is empty or has no provider prefix.
77    pub fn provider_from_model(model_id: &str) -> Option<&str> {
78        if model_id.is_empty() {
79            return None;
80        }
81        model_id.split_once('/').map(|(p, _)| p)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_provider_from_model() {
91        assert_eq!(
92            CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
93            Some("anthropic")
94        );
95        assert_eq!(
96            CredentialStore::provider_from_model("openai/gpt-4o"),
97            Some("openai")
98        );
99        assert_eq!(
100            CredentialStore::provider_from_model("bare-model"),
101            None
102        );
103        assert_eq!(
104            CredentialStore::provider_from_model(""),
105            None
106        );
107    }
108
109    #[test]
110    fn test_config_key_takes_priority() {
111        // If config_key is set, it's always returned (even if other sources exist)
112        let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
113        assert!(result.is_some());
114        let (key, source) = result.unwrap();
115        assert_eq!(key, "sk-test-config-key");
116        assert!(matches!(source, CredentialSource::Config));
117    }
118
119    #[test]
120    fn test_empty_config_key_skipped() {
121        let result = CredentialStore::resolve("anthropic", Some(""));
122        // Empty string is treated as None — falls through to next source
123        // (result depends on whether auth.json or env vars exist)
124        // Just verify it doesn't panic
125        let _ = result;
126    }
127
128    #[test]
129    fn test_none_config_key_skipped() {
130        let result = CredentialStore::resolve("anthropic", None);
131        let _ = result; // depends on system state
132    }
133}