oxios_kernel/
credential.rs1use anyhow::Result;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15#[derive(Debug, Clone)]
17pub enum CredentialSource {
18 Config,
20 OxiAuthStore,
22 EnvVar,
24}
25
26pub struct CredentialStore;
28
29impl CredentialStore {
30 pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
35 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 if let Some(key) = config_key {
45 if !key.is_empty() {
46 return Some((key.to_string(), CredentialSource::Config));
47 }
48 }
49
50 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 if let Some(key) = oxi_sdk::get_env_api_key(provider) {
63 return Some((key, CredentialSource::EnvVar));
64 }
65
66 None
67 }
68
69 pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
71 Self::resolve(provider, config_key).is_some()
72 }
73
74 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 if let Err(e) = oxi_sdk::save_token(provider, &token) {
93 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 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#[derive(serde::Deserialize)]
123struct LegacyEntry {
124 #[allow(dead_code)]
125 r#type: String,
126 key: String,
127}
128
129fn 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
145fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
147 matches!(err, oxi_sdk::OAuthError::Json(_))
148}
149
150fn 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 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; }
166
167 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
169 migrated.insert(key.clone(), bundle);
170 continue;
171 }
172
173 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 migrated.insert(provider.to_string(), new_token.clone());
194
195 let store = oxi_sdk::AuthStore { tokens: migrated };
197 oxi_sdk::save_auth_store(&store)?;
198 Ok(())
199}
200
201fn 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 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 let _ = result;
244 }
245
246 #[test]
247 fn test_none_config_key_skipped() {
248 let result = CredentialStore::resolve("anthropic", None);
249 let _ = result; }
251}