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>,
}
#[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 {
name: String,
framing: Option<String>,
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,
}
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(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,
})
}
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 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
}
}
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 = 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> {
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();
resolve_configs_with_keyring(Provider::registry(), &credentials, &env_keys, &keyring_keys)
}
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
}