use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum CredentialSource {
Config,
OxiAuthStore,
EnvVar,
}
pub struct CredentialStore;
impl CredentialStore {
pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
let env_var = format!("OXIOS_{}_API_KEY", provider.to_uppercase());
if let Ok(key) = std::env::var(&env_var)
&& !key.is_empty()
{
return Some((key, CredentialSource::EnvVar));
}
if let Some(key) = config_key
&& !key.is_empty()
{
return Some((key.to_string(), CredentialSource::Config));
}
if let Ok(Some(token)) = oxi_sdk::load_token(provider)
&& !token.access_token.is_empty()
{
return Some((token.access_token, CredentialSource::OxiAuthStore));
}
if let Some(key) = load_from_shared_store(provider) {
return Some((key, CredentialSource::OxiAuthStore));
}
if let Some(key) = oxi_sdk::get_env_api_key(provider) {
return Some((key, CredentialSource::EnvVar));
}
None
}
pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
Self::resolve(provider, config_key).is_some()
}
pub fn store(provider: &str, api_key: &str) -> Result<()> {
let token = oxi_sdk::TokenBundle {
access_token: api_key.to_string(),
refresh_token: None,
token_type: "Bearer".to_string(),
obtained_at: chrono::Utc::now(),
expires_in: 0,
scope: None,
};
if let Err(e) = oxi_sdk::save_token(provider, &token) {
if is_legacy_auth_error(&e) {
tracing::info!("auth.json has legacy format, migrating to TokenBundle");
migrate_legacy_auth_store(provider, &token)?;
} else {
return Err(e.into());
}
}
tracing::info!(provider = %provider, "API key stored to oxios auth store");
Ok(())
}
pub fn delete(key: &str) -> Result<()> {
let _ = oxi_sdk::remove_token(key);
if let Ok(path) = shared_auth_json_path()
&& path.exists()
&& let Ok(raw) = std::fs::read_to_string(&path)
&& let Ok(mut map) =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
&& map.remove(key).is_some()
{
std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
tracing::info!(key = %key, "Credential deleted from shared oxi-cli auth store");
}
Ok(())
}
pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
if let Ok(val) = std::env::var(env_var)
&& !val.is_empty()
{
return Some((val, CredentialSource::EnvVar));
}
if let Ok(Some(token)) = oxi_sdk::load_token(key)
&& !token.access_token.is_empty()
{
return Some((token.access_token, CredentialSource::OxiAuthStore));
}
if let Some(val) = load_from_shared_store(key) {
return Some((val, CredentialSource::OxiAuthStore));
}
None
}
pub fn provider_from_model(model_id: &str) -> Option<&str> {
if model_id.is_empty() {
return None;
}
model_id.split_once('/').map(|(p, _)| p)
}
}
#[derive(serde::Deserialize)]
struct LegacyEntry {
#[allow(dead_code)]
r#type: String,
key: String,
}
fn extract_credential(provider: &str, raw: &str) -> Option<String> {
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(raw).ok()?;
let entry = map.get(provider)?;
if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(entry.clone())
&& !bundle.access_token.is_empty()
{
return Some(bundle.access_token);
}
if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(entry.clone())
&& !legacy.key.is_empty()
{
return Some(legacy.key);
}
None
}
fn load_from_shared_store(provider: &str) -> Option<String> {
let path = match shared_auth_json_path() {
Ok(p) => p,
Err(_) => return None,
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!(
provider = %provider,
path = %path.display(),
error = %e,
"shared auth.json exists but could not be read; skipping",
);
return None;
}
};
extract_credential(provider, &raw)
}
fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
matches!(err, oxi_sdk::OAuthError::Json(_))
}
fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
let path = shared_auth_json_path()?;
let raw = std::fs::read_to_string(&path)?;
let entries: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&raw).unwrap_or_default();
let mut migrated = HashMap::new();
for (key, value) in &entries {
if key == provider {
continue; }
if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
migrated.insert(key.clone(), bundle);
continue;
}
if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
migrated.insert(
key.clone(),
oxi_sdk::TokenBundle {
access_token: legacy.key,
refresh_token: None,
token_type: "Bearer".to_string(),
obtained_at: chrono::Utc::now(),
expires_in: 0,
scope: None,
},
);
continue;
}
tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
}
migrated.insert(provider.to_string(), new_token.clone());
let store = oxi_sdk::AuthStore { tokens: migrated };
oxi_sdk::save_auth_store(&store)?;
Ok(())
}
fn shared_auth_json_path() -> Result<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
}
pub fn discover_auth_store_providers() -> Result<Vec<String>> {
let mut providers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let keep = |k: &str| k != "version" && !k.starts_with('_');
if let Ok(store) = oxi_sdk::load_auth_store() {
for k in store.tokens.keys() {
if keep(k) {
providers.insert(k.clone());
}
}
}
if let Ok(path) = shared_auth_json_path()
&& let Ok(raw) = std::fs::read_to_string(&path)
&& let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
{
for k in map.keys() {
if keep(k) {
providers.insert(k.clone());
}
}
}
Ok(providers.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_from_model() {
assert_eq!(
CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
Some("anthropic")
);
assert_eq!(
CredentialStore::provider_from_model("openai/gpt-4o"),
Some("openai")
);
assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
assert_eq!(CredentialStore::provider_from_model(""), None);
}
#[test]
fn test_config_key_takes_priority() {
let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
assert!(result.is_some());
let (key, source) = result.unwrap();
assert_eq!(key, "sk-test-config-key");
assert!(matches!(source, CredentialSource::Config));
}
#[test]
fn test_empty_config_key_skipped() {
let _ = CredentialStore::resolve("anthropic", Some(""));
}
#[test]
fn test_none_config_key_skipped() {
let _ = CredentialStore::resolve("anthropic", None); }
#[test]
fn extract_credential_roundtrips_token_bundle() {
let bundle = oxi_sdk::TokenBundle {
access_token: "sk-bundle".into(),
refresh_token: None,
token_type: "Bearer".into(),
obtained_at: chrono::Utc::now(),
expires_in: 0,
scope: None,
};
let mut map = serde_json::Map::new();
map.insert("openai".into(), serde_json::to_value(&bundle).unwrap());
let raw = serde_json::to_string(&map).unwrap();
assert_eq!(
extract_credential("openai", &raw).as_deref(),
Some("sk-bundle")
);
}
#[test]
fn extract_credential_reads_legacy_shape() {
let raw = r#"{"anthropic":{"type":"api_key","key":"sk-legacy"}}"#;
assert_eq!(
extract_credential("anthropic", raw).as_deref(),
Some("sk-legacy")
);
}
#[test]
fn extract_credential_absent_provider_is_none() {
let raw = r#"{"openai":{"type":"api_key","key":"sk-x"}}"#;
assert!(extract_credential("anthropic", raw).is_none());
}
#[test]
fn extract_credential_empty_token_is_none() {
let raw = r#"{"openai":{"type":"api_key","key":""}}"#;
assert!(extract_credential("openai", raw).is_none());
}
}