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            if !key.is_empty() {
39                return Some((key, CredentialSource::EnvVar));
40            }
41        }
42
43        // 2. config.toml explicit key
44        if let Some(key) = config_key {
45            if !key.is_empty() {
46                return Some((key.to_string(), CredentialSource::Config));
47            }
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#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_provider_from_model() {
215        assert_eq!(
216            CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
217            Some("anthropic")
218        );
219        assert_eq!(
220            CredentialStore::provider_from_model("openai/gpt-4o"),
221            Some("openai")
222        );
223        assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
224        assert_eq!(CredentialStore::provider_from_model(""), None);
225    }
226
227    #[test]
228    fn test_config_key_takes_priority() {
229        // If config_key is set, it's always returned (even if other sources exist)
230        let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
231        assert!(result.is_some());
232        let (key, source) = result.unwrap();
233        assert_eq!(key, "sk-test-config-key");
234        assert!(matches!(source, CredentialSource::Config));
235    }
236
237    #[test]
238    fn test_empty_config_key_skipped() {
239        let result = CredentialStore::resolve("anthropic", Some(""));
240        // Empty string is treated as None — falls through to next source
241        // (result depends on whether auth.json or env vars exist)
242        // Just verify it doesn't panic
243        let _ = result;
244    }
245
246    #[test]
247    fn test_none_config_key_skipped() {
248        let result = CredentialStore::resolve("anthropic", None);
249        let _ = result; // depends on system state
250    }
251}