use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use rpi_ai::{Api, InputModality, Model};
pub const CONFIG_DIR_NAME: &str = ".rpi";
pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not resolve home directory (set {env} to override)")]
NoHomeDir { env: &'static str },
#[error("config dir override {env}={val:?} is not an absolute path")]
RelativeOverride { env: &'static str, val: String },
#[error("could not read {path}: {source}")]
Read { path: PathBuf, #[source] source: std::io::Error },
#[error("could not write {path}: {source}")]
Write { path: PathBuf, #[source] source: std::io::Error },
#[error("invalid JSON in {path}: {source}")]
Json { path: PathBuf, #[source] source: serde_json::Error },
}
pub fn agent_dir() -> Result<PathBuf, ConfigError> {
if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
let p = PathBuf::from(&val);
if !p.is_absolute() {
return Err(ConfigError::RelativeOverride {
env: CONFIG_DIR_ENV,
val: val.to_string_lossy().into_owned(),
});
}
return Ok(p);
}
let home = dirs::home_dir()
.ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })?;
Ok(home.join(CONFIG_DIR_NAME))
}
pub fn auth_path() -> Result<PathBuf, ConfigError> {
Ok(agent_dir()?.join("auth.json"))
}
pub fn models_path() -> Result<PathBuf, ConfigError> {
Ok(agent_dir()?.join("models.json"))
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum Credential {
ApiKey {
key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
env: Option<BTreeMap<String, String>>,
},
Oauth {
access: String,
refresh: String,
expires: i64,
},
}
pub type AuthStore = BTreeMap<String, Credential>;
pub fn read_auth() -> Result<AuthStore, ConfigError> {
let path = auth_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => Ok(serde_json::from_str(&text).map_err(|e| ConfigError::Json {
path: path.clone(),
source: e,
})?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
Err(e) => Err(ConfigError::Read { path, source: e }),
}
}
pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
let path = auth_path()?;
let dir = agent_dir()?;
ensure_dir(&dir)?;
let json = serde_json::to_string_pretty(store).unwrap();
atomic_write(&path, json.as_bytes())?;
set_owner_only(&path);
Ok(())
}
pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
let mut store = read_auth()?;
store.insert(provider_id.to_string(), cred);
write_auth(&store)
}
pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
let mut store = read_auth()?;
if store.remove(provider_id).is_some() {
write_auth(&store)?;
Ok(true)
} else {
Ok(false)
}
}
#[derive(serde::Deserialize, Default, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelsConfig {
#[serde(default)]
pub providers: BTreeMap<String, ProviderConfig>,
}
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub api: Option<String>,
#[serde(default)]
pub headers: Option<BTreeMap<String, String>>,
#[serde(default)]
pub auth_header: Option<bool>,
#[serde(default)]
pub models: Vec<ModelDefinition>,
}
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelDefinition {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub reasoning: Option<bool>,
#[serde(default)]
pub context_window: Option<u64>,
#[serde(default)]
pub max_tokens: Option<u64>,
#[serde(default)]
pub input: Option<Vec<String>>,
#[serde(default)]
pub headers: Option<BTreeMap<String, String>>,
}
pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
let path = models_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
path: path.clone(),
source: e,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
Err(e) => Err(ConfigError::Read { path, source: e }),
}
}
fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
match serde_json::from_str(text) {
Ok(c) => Ok(c),
Err(first) => {
let stripped: String = text
.lines()
.map(|line| {
if let Some(idx) = find_line_comment(line) {
line[..idx].to_string()
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
serde_json::from_str(&stripped).map_err(|_| first)
}
}
}
fn find_line_comment(line: &str) -> Option<usize> {
let mut in_str = false;
let mut esc = false;
for (i, ch) in line.char_indices() {
if esc {
esc = false;
continue;
}
match ch {
'\\' if in_str => esc = true,
'"' => in_str = !in_str,
'/' if !in_str => {
if line.as_bytes().get(i + 1) == Some(&b'/') {
return Some(i);
}
}
_ => {}
}
}
None
}
pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
match cfg.api.as_deref() {
None | Some("") | Some("anthropic-messages") => true,
_ => false,
}
}
pub fn provider_to_models(
provider_id: &str,
cfg: &ProviderConfig,
) -> Option<Vec<Model>> {
let _ = provider_id; if !provider_is_anthropic_compatible(cfg) {
return None;
}
let provider_base = cfg.base_url.clone().unwrap_or_else(default_anthropic_base_url);
let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
for def in &cfg.models {
let base_url = def
.base_url
.clone()
.unwrap_or_else(|| provider_base.clone());
let name = def.name.clone().unwrap_or_else(|| def.id.clone());
let mut m = Model::new(
def.id.clone(),
name,
Api::AnthropicMessages,
DEFAULT_PROVIDER_ID.to_string(),
base_url,
);
m.reasoning = def.reasoning.unwrap_or(false);
m.context_window = def.context_window.unwrap_or(0);
m.max_tokens = def.max_tokens.unwrap_or(0);
m.input = parse_input_modalities(def.input.as_deref());
let mut headers: BTreeMap<String, String> = BTreeMap::new();
if let Some(h) = def.headers.clone() {
headers.extend(h);
}
if let Some(h) = cfg.headers.clone() {
headers.extend(h);
}
if !headers.is_empty() {
m.headers = Some(headers);
}
merged.push(m);
}
Some(merged)
}
fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
match input {
None => vec![InputModality::Text],
Some(list) if list.is_empty() => vec![InputModality::Text],
Some(list) => list
.iter()
.filter_map(|s| match s.to_ascii_lowercase().as_str() {
"text" => Some(InputModality::Text),
"image" => Some(InputModality::Image),
_ => None,
})
.collect::<Vec<_>>()
.pipe(|v| if v.is_empty() { vec![InputModality::Text] } else { v }),
}
}
fn default_anthropic_base_url() -> String {
"https://api.anthropic.com".to_string()
}
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
if dir.exists() {
return Ok(());
}
std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
path: dir.to_path_buf(),
source: e,
})?;
#[cfg(unix)]
{
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
let dir = path
.parent()
.ok_or_else(|| ConfigError::Write {
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
})?;
let tmp = dir.join(format!(
".{}.tmp",
path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
));
std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
fn set_owner_only(_path: &Path) {
#[cfg(unix)]
{
let _ = std::fs::set_permissions(
_path,
std::fs::Permissions::from_mode(0o600),
);
}
}
trait Pipe: Sized {
fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
f(self)
}
}
impl<T> Pipe for T {}
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, OnceLock};
pub(crate) fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_support::env_lock;
struct TempConfig {
_guard: std::sync::MutexGuard<'static, ()>,
_tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
}
impl TempConfig {
fn new() -> Self {
let guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(CONFIG_DIR_ENV, tmp.path());
Self { _guard: guard, _tmp: tmp, prev }
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
restore_env(CONFIG_DIR_ENV, self.prev.take());
}
}
#[test]
fn read_auth_missing_file_is_empty() {
let _cfg = TempConfig::new();
let store = read_auth().unwrap();
assert!(store.is_empty());
}
#[test]
fn upsert_then_read_roundtrip() {
let _cfg = TempConfig::new();
upsert_credential(
"anthropic",
Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
)
.unwrap();
let store = read_auth().unwrap();
match store.get("anthropic") {
Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
other => panic!("unexpected cred: {other:?}"),
}
let path = auth_path().unwrap();
assert!(path.exists(), "auth.json should exist after upsert");
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("\"anthropic\""));
assert!(raw.contains("api_key"));
}
#[test]
fn delete_credential_removes_entry() {
let _cfg = TempConfig::new();
upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
.unwrap();
assert!(delete_credential("anthropic").unwrap());
assert!(!delete_credential("anthropic").unwrap());
assert!(read_auth().unwrap().is_empty());
}
#[test]
fn load_models_config_missing_is_empty() {
let _cfg = TempConfig::new();
let c = load_models_config().unwrap();
assert!(c.providers.is_empty());
}
#[test]
fn load_models_config_parses_with_comments() {
let _cfg = TempConfig::new();
let json = r#"{
// a one-api style gateway
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"authHeader": true,
"apiKey": "gw-secret",
"models": [
{ "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
]
}
}
}"#;
std::fs::write(models_path().unwrap(), json).unwrap();
let c = load_models_config().unwrap();
let gw = c.providers.get("gateway").expect("gateway provider present");
assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
assert!(gw.auth_header.unwrap_or(false));
assert_eq!(gw.models.len(), 1);
assert_eq!(gw.models[0].id, "claude-sonnet-5");
}
#[test]
fn provider_to_models_merges_headers_without_synth_bearer() {
let cfg = ProviderConfig {
name: None,
base_url: Some("https://gw.example.com".into()),
api_key: Some("gw-secret".into()),
api: None,
headers: Some({
let mut h = BTreeMap::new();
h.insert("x-portkey-key".into(), "portkey-secret".into());
h
}),
auth_header: Some(true),
models: vec![ModelDefinition {
id: "claude-sonnet-5".into(),
name: None,
base_url: None,
reasoning: None,
context_window: None,
max_tokens: None,
input: None,
headers: None,
}],
};
let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m.id, "claude-sonnet-5");
assert_eq!(m.base_url, "https://gw.example.com");
assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
let headers = m.headers.as_ref().expect("provider headers merged");
assert_eq!(
headers.get("x-portkey-key").map(|s| s.as_str()),
Some("portkey-secret")
);
assert!(
headers.get("authorization").is_none(),
"provider_to_models must not synthesize the Bearer; resolve does"
);
}
#[test]
fn provider_to_models_ignores_non_anthropic_api() {
let cfg = ProviderConfig {
name: None,
base_url: None,
api_key: None,
api: Some("openai-completions".into()),
headers: None,
auth_header: None,
models: vec![],
};
assert!(provider_to_models("oai", &cfg).is_none());
}
#[test]
fn malformed_auth_json_is_an_error_not_silent_empty() {
let _cfg = TempConfig::new();
std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
}
#[test]
fn agent_dir_respects_env_override() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(CONFIG_DIR_ENV, tmp.path());
let dir = agent_dir().unwrap();
restore_env(CONFIG_DIR_ENV, prev);
assert_eq!(dir, tmp.path());
}
#[test]
fn relative_override_is_rejected() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
std::env::set_var(CONFIG_DIR_ENV, "relative/path");
let err = agent_dir().unwrap_err();
restore_env(CONFIG_DIR_ENV, prev);
assert!(matches!(err, ConfigError::RelativeOverride { .. }));
}
fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
match prev {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}