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//!
8//! Handles legacy `oxi-cli` auth.json entries (`{"type":"api_key","key":"..."}`)
9//! by auto-migrating them to the `TokenBundle` format on first write.
10
11use anyhow::Result;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15/// Where a credential was found.
16#[derive(Debug, Clone)]
17pub enum CredentialSource {
18    /// From config.toml [engine].api_key
19    Config,
20    /// From ~/.oxi/auth.json (oxi CLI credential store)
21    OxiAuthStore,
22    /// From environment variable
23    EnvVar,
24}
25
26/// Multi-source credential resolver.
27pub struct CredentialStore;
28
29impl CredentialStore {
30    /// Resolve the best available API key for a provider.
31    ///
32    /// Priority: OXIOS_<PROVIDER>_API_KEY env → config.toml → oxi auth.json → oxi-ai env fallback
33    /// Environment variables take highest priority for container/K8s deployments.
34    pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
35        // 1. Explicit Oxios env var: OXIOS_<PROVIDER>_API_KEY (highest priority for containers)
36        let env_var = format!("OXIOS_{}_API_KEY", provider.to_uppercase());
37        if let Ok(key) = std::env::var(&env_var)
38            && !key.is_empty()
39        {
40            return Some((key, CredentialSource::EnvVar));
41        }
42
43        // 2. config.toml explicit key
44        if let Some(key) = config_key
45            && !key.is_empty()
46        {
47            return Some((key.to_string(), CredentialSource::Config));
48        }
49
50        // 3. oxi auth store (~/.oxi/auth.json)
51        //    Try standard TokenBundle format first, then fall back to legacy
52        //    oxi-cli format (`{"type":"api_key","key":"..."}`).
53        if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
54            if !token.access_token.is_empty() {
55                return Some((token.access_token, CredentialSource::OxiAuthStore));
56            }
57        } else if let Some(key) = try_load_legacy_key(provider) {
58            return Some((key, CredentialSource::OxiAuthStore));
59        }
60
61        // 4. oxi-ai env var fallback
62        if let Some(key) = oxi_sdk::get_env_api_key(provider) {
63            return Some((key, CredentialSource::EnvVar));
64        }
65
66        None
67    }
68
69    /// Check if any credential is available for a provider.
70    pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
71        Self::resolve(provider, config_key).is_some()
72    }
73
74    /// Store an API key to oxi's auth store (~/.oxi/auth.json).
75    ///
76    /// This is called by the onboarding wizard. If oxi CLI is also
77    /// installed on this machine, it will pick up the same credential.
78    ///
79    /// If the auth store contains legacy entries from `oxi-cli` that don't
80    /// deserialize as `TokenBundle`, they are auto-migrated before saving.
81    pub fn store(provider: &str, api_key: &str) -> Result<()> {
82        let token = oxi_sdk::TokenBundle {
83            access_token: api_key.to_string(),
84            refresh_token: None,
85            token_type: "Bearer".to_string(),
86            obtained_at: chrono::Utc::now(),
87            expires_in: 0,
88            scope: None,
89        };
90
91        // Try the normal path first.
92        if let Err(e) = oxi_sdk::save_token(provider, &token) {
93            // If the auth store has legacy entries (e.g. `oxi-cli` wrote
94            // `{"type":"api_key","key":"..."}`), `save_token` fails because
95            // it can't deserialize them as `TokenBundle`.  Migrate and retry.
96            if is_legacy_auth_error(&e) {
97                tracing::info!("auth.json has legacy format, migrating to TokenBundle");
98                migrate_legacy_auth_store(provider, &token)?;
99            } else {
100                return Err(e.into());
101            }
102        }
103
104        tracing::info!(provider = %provider, "API key stored to oxi auth store");
105        Ok(())
106    }
107
108    /// Extract the provider name from a model ID.
109    /// "anthropic/claude-sonnet-4-20250514" → "anthropic"
110    /// Returns `None` if the model ID is empty or has no provider prefix.
111    pub fn provider_from_model(model_id: &str) -> Option<&str> {
112        if model_id.is_empty() {
113            return None;
114        }
115        model_id.split_once('/').map(|(p, _)| p)
116    }
117}
118
119// ── Legacy auth.json migration ─────────────────────────────────────────────
120
121/// Legacy entry from `oxi-cli`: `{"type":"api_key","key":"..."}`.
122#[derive(serde::Deserialize)]
123struct LegacyEntry {
124    #[allow(dead_code)]
125    r#type: String,
126    key: String,
127}
128
129/// Try to load a legacy `oxi-cli` API key from auth.json.
130///
131/// Returns `Some(key)` if the provider entry exists in the legacy
132/// `{"type":"api_key","key":"..."}` format.
133fn try_load_legacy_key(provider: &str) -> Option<String> {
134    let raw = std::fs::read_to_string(auth_json_path().ok()?).ok()?;
135    let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&raw).ok()?;
136    let entry = map.get(provider)?;
137    let legacy: LegacyEntry = serde_json::from_value(entry.clone()).ok()?;
138    if legacy.key.is_empty() {
139        None
140    } else {
141        Some(legacy.key)
142    }
143}
144
145/// Check if an error is caused by a legacy-format auth.json.
146fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
147    matches!(err, oxi_sdk::OAuthError::Json(_))
148}
149
150/// Migrate a legacy auth.json to `TokenBundle` format, preserving entries that
151/// can be converted and writing the new token for `provider`.
152fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
153    let path = auth_json_path()?;
154    let raw = std::fs::read_to_string(&path)?;
155
156    // Parse as a flat JSON map.
157    let entries: serde_json::Map<String, serde_json::Value> =
158        serde_json::from_str(&raw).unwrap_or_default();
159
160    let mut migrated = HashMap::new();
161
162    for (key, value) in &entries {
163        if key == provider {
164            continue; // will be replaced with new_token below
165        }
166
167        // Try parsing as TokenBundle first.
168        if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
169            migrated.insert(key.clone(), bundle);
170            continue;
171        }
172
173        // Try parsing as legacy `{"type":"api_key","key":"..."}`.
174        if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
175            migrated.insert(
176                key.clone(),
177                oxi_sdk::TokenBundle {
178                    access_token: legacy.key,
179                    refresh_token: None,
180                    token_type: "Bearer".to_string(),
181                    obtained_at: chrono::Utc::now(),
182                    expires_in: 0,
183                    scope: None,
184                },
185            );
186            continue;
187        }
188
189        tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
190    }
191
192    // Insert the new token.
193    migrated.insert(provider.to_string(), new_token.clone());
194
195    // Write back as proper AuthStore.
196    let store = oxi_sdk::AuthStore { tokens: migrated };
197    oxi_sdk::save_auth_store(&store)?;
198    Ok(())
199}
200
201/// Resolve `~/.oxi/auth.json` path without depending on oxi_sdk's error type.
202fn auth_json_path() -> Result<PathBuf> {
203    let home = std::env::var("HOME")
204        .or_else(|_| std::env::var("USERPROFILE"))
205        .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
206    Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
207}
208
209/// Discover all provider names stored in `~/.oxi/auth.json`.
210///
211/// Returns a list of provider IDs (top-level keys in the JSON file).
212/// Special keys like `"version"` are filtered out. Used by `OxiosEngine::from_config`
213/// to ensure credentials from the auth store are always injected, even for
214/// providers not in the hardcoded known list.
215pub fn discover_auth_store_providers() -> Result<Vec<String>> {
216    let path = auth_json_path()?;
217    if !path.exists() {
218        return Ok(vec![]);
219    }
220    let raw = std::fs::read_to_string(&path)?;
221    let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&raw)?;
222    Ok(map
223        .keys()
224        .filter(|k| *k != "version" && !k.starts_with('_'))
225        .cloned()
226        .collect())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_provider_from_model() {
235        assert_eq!(
236            CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
237            Some("anthropic")
238        );
239        assert_eq!(
240            CredentialStore::provider_from_model("openai/gpt-4o"),
241            Some("openai")
242        );
243        assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
244        assert_eq!(CredentialStore::provider_from_model(""), None);
245    }
246
247    #[test]
248    fn test_config_key_takes_priority() {
249        // If config_key is set, it's always returned (even if other sources exist)
250        let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
251        assert!(result.is_some());
252        let (key, source) = result.unwrap();
253        assert_eq!(key, "sk-test-config-key");
254        assert!(matches!(source, CredentialSource::Config));
255    }
256
257    #[test]
258    fn test_empty_config_key_skipped() {
259        let result = CredentialStore::resolve("anthropic", Some(""));
260        // Empty string is treated as None — falls through to next source
261        // (result depends on whether auth.json or env vars exist)
262        // Just verify it doesn't panic
263        let _ = result;
264    }
265
266    #[test]
267    fn test_none_config_key_skipped() {
268        let result = CredentialStore::resolve("anthropic", None);
269        let _ = result; // depends on system state
270    }
271}