use std::collections::HashMap;
use std::path::Path;
use crate::backend::http::HttpConfig;
use crate::backend::roster::Provider;
use crate::error::ProserpinaError;
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
pub struct ProviderOverride {
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
pub oauth_access: Option<String>,
pub oauth_refresh: Option<String>,
pub oauth_expires: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
pub struct PanelConfig {
pub personas: Vec<PersonaSpec>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
pub struct RetryConfig {
pub max_attempts: Option<u32>,
pub timeout_secs: Option<u64>,
pub initial_backoff_ms: Option<u64>,
pub backoff_factor: Option<f64>,
pub max_backoff_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub struct PersonaSpec {
pub name: String,
pub framing: Option<String>,
pub focus: Option<String>,
}
impl PersonaSpec {
pub fn to_persona(&self) -> crate::persona::Persona {
let mut p = crate::persona::Persona::new(self.name.clone());
if let Some(f) = &self.framing {
p = p.with_framing(f.clone());
}
if let Some(f) = &self.focus {
p = p.with_focus(f.clone());
}
p
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Credentials {
providers: HashMap<String, ProviderOverride>,
panels: HashMap<String, PanelConfig>,
retry: RetryConfig,
exclude: Vec<String>,
}
impl Credentials {
pub fn from_toml(toml: &str) -> Result<Self, ProserpinaError> {
if toml.trim().is_empty() {
return Ok(Self::default());
}
#[derive(serde::Deserialize)]
struct Raw {
#[serde(default)]
panels: HashMap<String, PanelConfig>,
#[serde(default)]
retry: RetryConfig,
#[serde(default)]
exclude: Vec<String>,
#[serde(flatten)]
providers: HashMap<String, ProviderOverride>,
}
let parsed: Raw = ::toml::from_str(toml)
.map_err(|e| ProserpinaError::malformed_credentials("<str>", e))?;
Ok(Self {
providers: parsed.providers,
panels: parsed.panels,
retry: parsed.retry,
exclude: parsed.exclude,
})
}
pub fn from_path(path: &Path) -> Result<Self, ProserpinaError> {
let display = path.display().to_string();
let contents = std::fs::read_to_string(path)
.map_err(|e| ProserpinaError::malformed_credentials(&display, e))?;
Self::from_toml(&contents).map_err(|e| ProserpinaError::malformed_credentials(&display, e))
}
pub fn discover() -> Result<Self, ProserpinaError> {
for path in Self::candidate_paths() {
if path.exists() {
return Self::from_path(&path);
}
}
Ok(Self::default())
}
fn candidate_paths() -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
if let Ok(p) = std::env::var("PROSERPINA_CONFIG") {
paths.push(std::path::PathBuf::from(p));
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
paths.push(std::path::PathBuf::from(xdg).join("proserpina/credentials.toml"));
}
if let Ok(home) = std::env::var("HOME") {
paths.push(std::path::PathBuf::from(home).join(".config/proserpina/credentials.toml"));
}
paths
}
pub fn override_for(&self, name: &str) -> Option<&ProviderOverride> {
self.providers.get(name)
}
pub fn set_override(&mut self, name: &str, override_: ProviderOverride) {
self.providers.insert(name.to_owned(), override_);
}
pub fn merge_model(&mut self, name: &str, model: &str) {
self.providers.entry(name.to_owned()).or_default().model = Some(model.to_owned());
}
pub fn remove_override(&mut self, name: &str) {
self.providers.remove(name);
}
pub fn to_toml_string(&self) -> String {
let mut out = String::new();
for (name, ov) in &self.providers {
out.push_str(&format!("[{name}]\n"));
if let Some(k) = &ov.api_key {
out.push_str(&format!("api_key = \"{k}\"\n"));
}
if let Some(m) = &ov.model {
out.push_str(&format!("model = \"{m}\"\n"));
}
if let Some(u) = &ov.base_url {
out.push_str(&format!("base_url = \"{u}\"\n"));
}
if let Some(t) = &ov.oauth_access {
out.push_str(&format!("oauth_access = \"{t}\"\n"));
}
if let Some(r) = &ov.oauth_refresh {
out.push_str(&format!("oauth_refresh = \"{r}\"\n"));
}
if let Some(e) = ov.oauth_expires {
out.push_str(&format!("oauth_expires = {e}\n"));
}
out.push('\n');
}
if !self.panels.is_empty() {
}
if out.is_empty() {
out = "# Proserpina credentials\n".to_owned();
}
out
}
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &ProviderOverride)> {
self.providers.iter()
}
pub fn discover_or(path: Option<&std::path::Path>) -> Result<Self, ProserpinaError> {
match path {
Some(p) => Self::from_path(p),
None => Self::discover(),
}
}
pub fn panels(&self) -> &HashMap<String, PanelConfig> {
&self.panels
}
pub fn retry(&self) -> &RetryConfig {
&self.retry
}
pub fn exclude(&self) -> &[String] {
&self.exclude
}
}
fn env_var_name_for(provider_name: &str) -> String {
format!("{}_API_KEY", provider_name.replace('-', "_").to_uppercase())
}
pub fn resolve_configs(
registry: &[Provider],
credentials: &Credentials,
env_keys: &HashMap<String, String>,
) -> Result<Vec<HttpConfig>, ProserpinaError> {
resolve_configs_with_keyring(registry, credentials, env_keys, &HashMap::new())
}
pub fn resolve_configs_with_keyring(
registry: &[Provider],
credentials: &Credentials,
env_keys: &HashMap<String, String>,
keyring_keys: &HashMap<String, String>,
) -> Result<Vec<HttpConfig>, ProserpinaError> {
let registry_names: std::collections::HashSet<&str> =
registry.iter().map(|p| p.name()).collect();
let mut out = Vec::new();
for reg in registry {
let cfg = credentials.override_for(reg.name());
let key_var = reg.key_env_var();
let api_key = cfg
.and_then(|c| c.oauth_access.clone())
.or_else(|| keyring_keys.get(key_var).cloned())
.or_else(|| env_keys.get(key_var).cloned())
.or_else(|| cfg.and_then(|c| c.api_key.clone()));
let Some(api_key) = api_key else {
continue; };
let model = cfg
.and_then(|c| c.model.clone())
.unwrap_or_else(|| reg.model().to_owned());
let base_url = cfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| reg.base_url().to_owned());
out.push(HttpConfig {
base_url,
model,
api_key,
});
}
for (name, cfg) in credentials.iter() {
if registry_names.contains(name.as_str()) {
continue;
}
let env_var = env_var_name_for(name);
let api_key = keyring_keys
.get(&env_var)
.cloned()
.or_else(|| env_keys.get(&env_var).cloned())
.or(cfg.api_key.clone());
let model = cfg.model.clone();
let base_url = cfg.base_url.clone();
let mut missing: Vec<&'static str> = Vec::new();
if api_key.is_none() {
missing.push("api_key");
}
if model.is_none() {
missing.push("model");
}
if base_url.is_none() {
missing.push("base_url");
}
if !missing.is_empty() {
return Err(ProserpinaError::incomplete_custom_provider(name, missing));
}
out.push(HttpConfig {
base_url: base_url.unwrap(),
model: model.unwrap(),
api_key: api_key.unwrap(),
});
}
Ok(out)
}
pub fn authed_configs_with(
config_path: Option<&std::path::Path>,
) -> Result<Vec<HttpConfig>, ProserpinaError> {
authed_configs_with_excludes(config_path, &[])
}
pub fn authed_configs_with_excludes(
config_path: Option<&std::path::Path>,
cli_excludes: &[String],
) -> Result<Vec<HttpConfig>, ProserpinaError> {
let credentials = match config_path {
Some(path) => Credentials::from_path(path)?,
None => Credentials::discover()?,
};
let mut env_keys: HashMap<String, String> = HashMap::new();
for reg in Provider::registry() {
if let Ok(v) = std::env::var(reg.key_env_var()) {
env_keys.insert(reg.key_env_var().to_owned(), v);
}
}
for name in credentials.iter().map(|(n, _)| n.as_str()) {
let var = env_var_name_for(name);
if let Ok(v) = std::env::var(&var) {
env_keys.insert(var, v);
}
}
#[cfg(feature = "keyring")]
let keyring_keys = read_keyring_snapshot(&credentials);
#[cfg(not(feature = "keyring"))]
let keyring_keys: HashMap<String, String> = HashMap::new();
let mut configs =
resolve_configs_with_keyring(Provider::registry(), &credentials, &env_keys, &keyring_keys)?;
let pi_configs = discover_pi_configs();
let pi_hosts: std::collections::HashSet<String> = pi_configs
.iter()
.map(|c| extract_host(&c.base_url))
.collect();
configs.retain(|c| !pi_hosts.contains(&extract_host(&c.base_url)));
configs.extend(pi_configs);
let config_excludes = credentials.exclude();
configs.retain(|c| !config_excludes.contains(&c.model) && !cli_excludes.contains(&c.model));
Ok(configs)
}
pub fn discover_pi_configs() -> Vec<HttpConfig> {
let pi_home = std::env::var("PI_HOME")
.ok()
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.pi")));
let Some(pi_home) = pi_home else {
return Vec::new();
};
let models_path = std::path::Path::new(&pi_home).join("agent/models.json");
let Ok(contents) = std::fs::read_to_string(&models_path) else {
return Vec::new(); };
let Ok(parsed): Result<serde_json::Value, _> = serde_json::from_str(&contents) else {
return Vec::new(); };
let Some(providers) = parsed.get("providers").and_then(|p| p.as_object()) else {
return Vec::new();
};
let auth_keys = read_pi_auth_keys(&pi_home);
let mut configs = Vec::new();
for (_name, provider) in providers {
let base_url = provider.get("baseUrl").and_then(|v| v.as_str());
let api_key_ref = provider.get("apiKey").and_then(|v| v.as_str());
let models = provider.get("models").and_then(|m| m.as_array());
let api = provider.get("api").and_then(|v| v.as_str());
if api != Some("openai-completions") {
continue;
}
let (Some(base_url), Some(api_key_ref), Some(models)) = (base_url, api_key_ref, models)
else {
continue;
};
let key_var = api_key_ref.strip_prefix('$').unwrap_or(api_key_ref);
let api_key = auth_keys
.get(key_var)
.cloned()
.or_else(|| std::env::var(key_var).ok());
let Some(api_key) = api_key else {
continue; };
let model = models
.first()
.and_then(|m| m.get("id").and_then(|id| id.as_str()))
.unwrap_or("default");
configs.push(HttpConfig {
base_url: base_url.to_owned(),
model: model.to_owned(),
api_key,
});
}
configs
}
fn read_pi_auth_keys(pi_home: &str) -> HashMap<String, String> {
let auth_path = std::path::Path::new(pi_home).join("agent/auth.json");
let Ok(contents) = std::fs::read_to_string(&auth_path) else {
return HashMap::new();
};
let Ok(parsed): Result<serde_json::Value, _> = serde_json::from_str(&contents) else {
return HashMap::new();
};
let Some(entries) = parsed.as_object() else {
return HashMap::new();
};
let mut out = HashMap::new();
for (name, creds) in entries {
let cred_type = creds.get("type").and_then(|t| t.as_str());
if cred_type != Some("api_key") {
continue;
}
let Some(key) = creds.get("key").and_then(|k| k.as_str()) else {
continue;
};
let env_var = auth_entry_to_env_var(name);
out.insert(env_var, key.to_owned());
}
out
}
fn extract_host(url: &str) -> String {
let no_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let no_port = no_scheme.split(':').next().unwrap_or(no_scheme);
let no_path = no_port.split('/').next().unwrap_or(no_port);
no_path.to_owned()
}
fn auth_entry_to_env_var(name: &str) -> String {
let base = match name {
"zai-coding-cn" => "zai",
"openai-codex" => "openai",
other => other,
};
format!("{}_API_KEY", base.replace('-', "_").to_uppercase())
}
pub fn authed_configs() -> Result<Vec<HttpConfig>, ProserpinaError> {
authed_configs_with(None)
}
#[cfg(feature = "keyring")]
pub const KEYRING_SERVICE: &str = "proserpina";
#[cfg(feature = "keyring")]
pub fn read_keyring(key_env_var: &str) -> Result<Option<String>, ProserpinaError> {
let entry = keyring::Entry::new(KEYRING_SERVICE, key_env_var)
.map_err(|e| ProserpinaError::keyring_access(key_env_var, e))?;
match entry.get_password() {
Ok(p) => Ok(Some(p)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(ProserpinaError::keyring_access(key_env_var, e)),
}
}
#[cfg(feature = "keyring")]
fn read_keyring_snapshot(credentials: &Credentials) -> HashMap<String, String> {
let mut out = HashMap::new();
for reg in Provider::registry() {
let var = reg.key_env_var();
if let Ok(Some(v)) = read_keyring(var) {
out.insert(var.to_owned(), v);
}
}
for name in credentials.iter().map(|(n, _)| n.as_str()) {
let var = env_var_name_for(name);
if let Ok(Some(v)) = read_keyring(&var) {
out.insert(var, v);
}
}
out
}