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    pub fn provider_from_model(model_id: &str) -> &str {
77        model_id
78            .split_once('/')
79            .map(|(p, _)| p)
80            .unwrap_or("anthropic")
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_provider_from_model() {
90        assert_eq!(
91            CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
92            "anthropic"
93        );
94        assert_eq!(
95            CredentialStore::provider_from_model("openai/gpt-4o"),
96            "openai"
97        );
98        assert_eq!(
99            CredentialStore::provider_from_model("bare-model"),
100            "anthropic"
101        );
102    }
103
104    #[test]
105    fn test_config_key_takes_priority() {
106        // If config_key is set, it's always returned (even if other sources exist)
107        let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
108        assert!(result.is_some());
109        let (key, source) = result.unwrap();
110        assert_eq!(key, "sk-test-config-key");
111        assert!(matches!(source, CredentialSource::Config));
112    }
113
114    #[test]
115    fn test_empty_config_key_skipped() {
116        let result = CredentialStore::resolve("anthropic", Some(""));
117        // Empty string is treated as None — falls through to next source
118        // (result depends on whether auth.json or env vars exist)
119        // Just verify it doesn't panic
120        let _ = result;
121    }
122
123    #[test]
124    fn test_none_config_key_skipped() {
125        let result = CredentialStore::resolve("anthropic", None);
126        let _ = result; // depends on system state
127    }
128}