use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
pub fn eli_home() -> PathBuf {
std::env::var("ELI_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".eli")
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub provider: String,
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EliConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_profile: Option<String>,
#[serde(default)]
pub profiles: HashMap<String, Profile>,
}
impl EliConfig {
pub fn config_path() -> PathBuf {
eli_home().join("config.toml")
}
fn legacy_config_path() -> PathBuf {
eli_home().join("config.json")
}
pub fn load() -> Self {
let toml_path = Self::config_path();
if toml_path.exists() {
let contents = match std::fs::read_to_string(&toml_path) {
Ok(c) => c,
Err(_) => return Self::default(),
};
return toml::from_str(&contents).unwrap_or_default();
}
let legacy_path = Self::legacy_config_path();
if legacy_path.exists()
&& let Some(migrated) = Self::migrate_from_json(&legacy_path)
{
let _ = migrated.save();
return migrated;
}
Self::default()
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let toml_str = toml::to_string_pretty(self)? + "\n";
std::fs::write(&path, &toml_str)?;
Ok(())
}
pub fn active_profile(&self) -> Option<&Profile> {
let name = self.active_profile.as_deref()?;
self.profiles.get(name)
}
pub fn resolve_model(&self) -> Option<String> {
let name = self.active_profile.as_deref()?;
let p = self.profiles.get(name)?;
if p.model.contains(':') {
Some(p.model.clone())
} else {
Some(format!("{}:{}", p.provider, p.model))
}
}
pub fn resolve_provider(&self) -> Option<String> {
self.active_profile().map(|p| p.provider.clone())
}
pub fn set_active(&mut self, name: &str) -> bool {
if self.profiles.contains_key(name) {
self.active_profile = Some(name.to_string());
true
} else {
false
}
}
pub fn add_profile(&mut self, name: &str, profile: Profile) {
self.profiles.insert(name.to_string(), profile);
}
fn migrate_from_json(path: &std::path::Path) -> Option<Self> {
let contents = std::fs::read_to_string(path).ok()?;
let legacy: LegacyConfig = serde_json::from_str(&contents).ok()?;
let mut config = EliConfig::default();
if let (Some(provider), Some(model)) = (legacy.default_provider, legacy.default_model) {
let profile_name = provider.clone();
config.add_profile(
&profile_name,
Profile {
provider: provider.clone(),
model,
},
);
config.active_profile = Some(profile_name);
}
Some(config)
}
}
#[derive(Debug, Deserialize)]
struct LegacyConfig {
#[serde(default)]
default_provider: Option<String>,
#[serde(default)]
default_model: Option<String>,
}
pub fn load_anthropic_api_key() -> Option<String> {
let auth_path = eli_home().join("auth.json");
let contents = std::fs::read_to_string(&auth_path).ok()?;
let payload: Value = serde_json::from_str(&contents).ok()?;
let anthropic = payload.get("anthropic")?;
if let Some(access_token) = anthropic.get("access_token").and_then(|v| v.as_str())
&& !access_token.is_empty()
{
let expired = anthropic
.get("expires_at")
.and_then(|v| v.as_i64())
.map(|exp| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
now >= exp - 300 })
.unwrap_or(false);
if expired {
if let Some(refresh_token) = anthropic.get("refresh_token").and_then(|v| v.as_str())
&& let Some(new_token) = refresh_anthropic_token_sync(refresh_token)
{
return Some(new_token);
}
}
return Some(access_token.trim().to_string());
}
anthropic
.get("api_key")
.and_then(|k| k.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn refresh_anthropic_token_sync(refresh_token: &str) -> Option<String> {
let refresh_token = refresh_token.to_owned();
let run_refresh = move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(async { refresh_anthropic_token(&refresh_token).await })
};
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(run_refresh).join().ok().flatten()
} else {
run_refresh()
}
}
pub async fn refresh_anthropic_token(refresh_token: &str) -> Option<String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
});
let resp = client
.post("https://console.anthropic.com/v1/oauth/token")
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let token_resp: Value = resp.json().await.ok()?;
let access_token = token_resp.get("access_token")?.as_str()?.to_string();
let refresh_token_new = token_resp
.get("refresh_token")
.and_then(|v| v.as_str())
.unwrap_or(refresh_token)
.to_string();
let expires_in = token_resp
.get("expires_in")
.and_then(|v| v.as_i64())
.unwrap_or(28800);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let expires_at = now + expires_in;
let _ = save_anthropic_oauth_tokens(&access_token, &refresh_token_new, expires_at);
Some(access_token)
}
pub fn save_anthropic_oauth_tokens(
access_token: &str,
refresh_token: &str,
expires_at: i64,
) -> anyhow::Result<()> {
let home = eli_home();
std::fs::create_dir_all(&home)?;
let auth_path = home.join("auth.json");
let mut auth_data: serde_json::Map<String, Value> = if auth_path.exists() {
let contents = std::fs::read_to_string(&auth_path)?;
serde_json::from_str(&contents).unwrap_or_default()
} else {
serde_json::Map::new()
};
auth_data.insert(
"anthropic".to_string(),
serde_json::json!({
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at
}),
);
let json_str = serde_json::to_string_pretty(&Value::Object(auth_data))? + "\n";
std::fs::write(&auth_path, &json_str)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn load_auth_status() -> HashMap<String, String> {
let mut result = HashMap::new();
let auth_path = eli_home().join("auth.json");
if let Ok(contents) = std::fs::read_to_string(&auth_path)
&& let Ok(payload) = serde_json::from_str::<Value>(&contents)
&& let Some(obj) = payload.as_object()
{
for (provider, val) in obj {
if let Some(access_token) = val.get("access_token").and_then(|k| k.as_str()) {
let expired = val
.get("expires_at")
.and_then(|v| v.as_i64())
.map(|exp| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
now >= exp
})
.unwrap_or(false);
let status = if expired {
"(oauth token, expired)"
} else {
"(oauth token)"
};
result.insert(
provider.clone(),
format!("{} {}", redact_key(access_token), status),
);
} else if let Some(key) = val.get("api_key").and_then(|k| k.as_str()) {
result.insert(provider.clone(), redact_key(key));
} else if val.get("token").is_some() {
result.insert(provider.clone(), "(oauth token)".to_string());
} else {
result.insert(provider.clone(), "(configured)".to_string());
}
}
}
let codex_home = std::env::var("CODEX_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".codex")
});
let codex_auth = codex_home.join("auth.json");
if codex_auth.exists() && !result.contains_key("openai") {
result.insert("openai".to_string(), "(codex oauth token)".to_string());
}
let gh_home = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("github-copilot");
if gh_home.exists() && !result.contains_key("github-copilot") {
result.insert(
"github-copilot".to_string(),
"(copilot oauth token)".to_string(),
);
}
result
}
fn redact_key(key: &str) -> String {
if key.len() <= 12 {
return "****".to_string();
}
format!("{}...{}", &key[..7], &key[key.len() - 4..])
}
pub fn default_model_for_provider(provider: &str) -> &str {
match provider {
"openai" => "openai:gpt-5.4-mini",
"anthropic" | "claude" => "anthropic:claude-sonnet-4-6",
"github-copilot" | "copilot" => "github-copilot:gpt-5.4-mini",
_ => "openrouter:openai/gpt-5.4-mini",
}
}
pub fn normalize_provider(provider: &str) -> &str {
match provider {
"claude" | "anthropic" => "anthropic",
"copilot" | "github-copilot" => "github-copilot",
"openai" => "openai",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_toml_round_trip() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let mut config = EliConfig::default();
config.add_profile(
"openai",
Profile {
provider: "openai".to_string(),
model: "openai:gpt-5-codex-mini".to_string(),
},
);
config.active_profile = Some("openai".to_string());
let toml_str = toml::to_string_pretty(&config).unwrap() + "\n";
std::fs::write(&config_path, &toml_str).unwrap();
let contents = std::fs::read_to_string(&config_path).unwrap();
let loaded: EliConfig = toml::from_str(&contents).unwrap();
assert_eq!(loaded.active_profile.as_deref(), Some("openai"));
let profile = loaded.profiles.get("openai").unwrap();
assert_eq!(profile.provider, "openai");
assert_eq!(profile.model, "openai:gpt-5-codex-mini");
}
#[test]
fn test_config_default_is_empty() {
let config = EliConfig::default();
assert!(config.active_profile.is_none());
assert!(config.profiles.is_empty());
}
#[test]
fn test_resolve_model_from_active_profile() {
let mut config = EliConfig::default();
config.add_profile(
"anthropic",
Profile {
provider: "anthropic".to_string(),
model: "anthropic:claude-sonnet-4-20250514".to_string(),
},
);
config.active_profile = Some("anthropic".to_string());
assert_eq!(
config.resolve_model().as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert_eq!(config.resolve_provider().as_deref(), Some("anthropic"));
}
#[test]
fn test_resolve_model_none_when_no_active() {
let config = EliConfig::default();
assert!(config.resolve_model().is_none());
assert!(config.resolve_provider().is_none());
}
#[test]
fn test_set_active_returns_false_for_missing_profile() {
let mut config = EliConfig::default();
assert!(!config.set_active("nonexistent"));
assert!(config.active_profile.is_none());
}
#[test]
fn test_set_active_returns_true_for_existing_profile() {
let mut config = EliConfig::default();
config.add_profile(
"openai",
Profile {
provider: "openai".to_string(),
model: "openai:gpt-5-codex-mini".to_string(),
},
);
assert!(config.set_active("openai"));
assert_eq!(config.active_profile.as_deref(), Some("openai"));
}
#[test]
fn test_migrate_from_json() {
let tmp = TempDir::new().unwrap();
let json_path = tmp.path().join("config.json");
let content = serde_json::json!({
"default_provider": "openai",
"default_model": "openai:gpt-5-codex-mini"
});
std::fs::write(&json_path, serde_json::to_string(&content).unwrap()).unwrap();
let migrated = EliConfig::migrate_from_json(&json_path).unwrap();
assert_eq!(migrated.active_profile.as_deref(), Some("openai"));
let profile = migrated.profiles.get("openai").unwrap();
assert_eq!(profile.provider, "openai");
assert_eq!(profile.model, "openai:gpt-5-codex-mini");
}
#[test]
fn test_redact_key() {
assert_eq!(redact_key("sk-ant-api03-abcdefghij"), "sk-ant-...ghij");
assert_eq!(redact_key("short"), "****");
}
#[test]
fn test_default_model_for_provider() {
assert_eq!(default_model_for_provider("openai"), "openai:gpt-5.4-mini");
assert_eq!(
default_model_for_provider("anthropic"),
"anthropic:claude-sonnet-4-6"
);
assert_eq!(
default_model_for_provider("github-copilot"),
"github-copilot:gpt-5.4-mini"
);
}
#[test]
fn test_normalize_provider() {
assert_eq!(normalize_provider("claude"), "anthropic");
assert_eq!(normalize_provider("copilot"), "github-copilot");
assert_eq!(normalize_provider("openai"), "openai");
}
#[test]
fn test_load_anthropic_key_from_json() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
let content = serde_json::json!({
"anthropic": {
"api_key": "sk-ant-test123"
}
});
std::fs::write(&auth_path, serde_json::to_string(&content).unwrap()).unwrap();
let payload: Value =
serde_json::from_str(&std::fs::read_to_string(&auth_path).unwrap()).unwrap();
let key = payload
.get("anthropic")
.and_then(|a| a.get("api_key"))
.and_then(|k| k.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
assert_eq!(key.as_deref(), Some("sk-ant-test123"));
}
#[test]
fn test_multiple_profiles() {
let mut config = EliConfig::default();
config.add_profile(
"openai",
Profile {
provider: "openai".to_string(),
model: "openai:gpt-5-codex-mini".to_string(),
},
);
config.add_profile(
"anthropic",
Profile {
provider: "anthropic".to_string(),
model: "anthropic:claude-sonnet-4-20250514".to_string(),
},
);
config.add_profile(
"copilot",
Profile {
provider: "github-copilot".to_string(),
model: "github-copilot:gpt-4o".to_string(),
},
);
config.active_profile = Some("anthropic".to_string());
assert_eq!(config.profiles.len(), 3);
assert_eq!(
config.resolve_model().as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert!(config.set_active("openai"));
assert_eq!(
config.resolve_model().as_deref(),
Some("openai:gpt-5-codex-mini")
);
}
}