1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34
35use rpi_ai::{Api, InputModality, Model};
36
37pub const CONFIG_DIR_NAME: &str = ".rpi";
40
41pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
44
45pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
48
49#[derive(Debug, thiserror::Error)]
56pub enum ConfigError {
57 #[error("could not resolve home directory (set {env} to override)")]
58 NoHomeDir { env: &'static str },
59 #[error("config dir override {env}={val:?} is not an absolute path")]
60 RelativeOverride { env: &'static str, val: String },
61 #[error("could not read {path}: {source}")]
62 Read { path: PathBuf, #[source] source: std::io::Error },
63 #[error("could not write {path}: {source}")]
64 Write { path: PathBuf, #[source] source: std::io::Error },
65 #[error("invalid JSON in {path}: {source}")]
66 Json { path: PathBuf, #[source] source: serde_json::Error },
67}
68
69pub fn agent_dir() -> Result<PathBuf, ConfigError> {
76 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
77 let p = PathBuf::from(&val);
78 if !p.is_absolute() {
79 return Err(ConfigError::RelativeOverride {
80 env: CONFIG_DIR_ENV,
81 val: val.to_string_lossy().into_owned(),
82 });
83 }
84 return Ok(p);
85 }
86 let home = dirs::home_dir()
87 .ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })?;
88 Ok(home.join(CONFIG_DIR_NAME))
89}
90
91pub fn auth_path() -> Result<PathBuf, ConfigError> {
93 Ok(agent_dir()?.join("auth.json"))
94}
95
96pub fn models_path() -> Result<PathBuf, ConfigError> {
98 Ok(agent_dir()?.join("models.json"))
99}
100
101#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
110#[serde(rename_all = "snake_case", tag = "type")]
111pub enum Credential {
112 ApiKey {
115 key: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 env: Option<BTreeMap<String, String>>,
118 },
119 Oauth {
121 access: String,
122 refresh: String,
123 expires: i64,
125 },
126}
127
128pub type AuthStore = BTreeMap<String, Credential>;
131
132pub fn read_auth() -> Result<AuthStore, ConfigError> {
135 let path = auth_path()?;
136 match std::fs::read_to_string(&path) {
137 Ok(text) => Ok(serde_json::from_str(&text).map_err(|e| ConfigError::Json {
138 path: path.clone(),
139 source: e,
140 })?),
141 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
142 Err(e) => Err(ConfigError::Read { path, source: e }),
143 }
144}
145
146pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
149 let path = auth_path()?;
150 let dir = agent_dir()?;
151 ensure_dir(&dir)?;
152 let json = serde_json::to_string_pretty(store).unwrap();
153 atomic_write(&path, json.as_bytes())?;
154 set_owner_only(&path);
155 Ok(())
156}
157
158pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
160 let mut store = read_auth()?;
161 store.insert(provider_id.to_string(), cred);
162 write_auth(&store)
163}
164
165pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
170 let mut store = read_auth()?;
171 if store.remove(provider_id).is_some() {
172 write_auth(&store)?;
173 Ok(true)
174 } else {
175 Ok(false)
176 }
177}
178
179#[derive(serde::Deserialize, Default, Debug, Clone)]
186#[serde(rename_all = "camelCase")]
187pub struct ModelsConfig {
188 #[serde(default)]
189 pub providers: BTreeMap<String, ProviderConfig>,
190}
191
192#[derive(serde::Deserialize, Debug, Clone)]
196#[serde(rename_all = "camelCase")]
197pub struct ProviderConfig {
198 #[serde(default)]
199 pub name: Option<String>,
200 #[serde(default)]
201 pub base_url: Option<String>,
202 #[serde(default)]
203 pub api_key: Option<String>,
204 #[serde(default)]
205 pub api: Option<String>,
206 #[serde(default)]
207 pub headers: Option<BTreeMap<String, String>>,
208 #[serde(default)]
211 pub auth_header: Option<bool>,
212 #[serde(default)]
213 pub models: Vec<ModelDefinition>,
214}
215
216#[derive(serde::Deserialize, Debug, Clone)]
218#[serde(rename_all = "camelCase")]
219pub struct ModelDefinition {
220 pub id: String,
221 #[serde(default)]
222 pub name: Option<String>,
223 #[serde(default)]
224 pub base_url: Option<String>,
225 #[serde(default)]
226 pub reasoning: Option<bool>,
227 #[serde(default)]
228 pub context_window: Option<u64>,
229 #[serde(default)]
230 pub max_tokens: Option<u64>,
231 #[serde(default)]
234 pub input: Option<Vec<String>>,
235 #[serde(default)]
236 pub headers: Option<BTreeMap<String, String>>,
237}
238
239pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
241 let path = models_path()?;
242 match std::fs::read_to_string(&path) {
243 Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
244 path: path.clone(),
245 source: e,
246 }),
247 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
248 Err(e) => Err(ConfigError::Read { path, source: e }),
249 }
250}
251
252fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
256 match serde_json::from_str(text) {
257 Ok(c) => Ok(c),
258 Err(first) => {
259 let stripped: String = text
264 .lines()
265 .map(|line| {
266 if let Some(idx) = find_line_comment(line) {
267 line[..idx].to_string()
268 } else {
269 line.to_string()
270 }
271 })
272 .collect::<Vec<_>>()
273 .join("\n");
274 serde_json::from_str(&stripped).map_err(|_| first)
275 }
276 }
277}
278
279fn find_line_comment(line: &str) -> Option<usize> {
281 let mut in_str = false;
282 let mut esc = false;
283 for (i, ch) in line.char_indices() {
284 if esc {
285 esc = false;
286 continue;
287 }
288 match ch {
289 '\\' if in_str => esc = true,
290 '"' => in_str = !in_str,
291 '/' if !in_str => {
292 if line.as_bytes().get(i + 1) == Some(&b'/') {
293 return Some(i);
294 }
295 }
296 _ => {}
297 }
298 }
299 None
300}
301
302pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
308 match cfg.api.as_deref() {
309 None | Some("") | Some("anthropic-messages") => true,
310 _ => false,
311 }
312}
313
314pub fn provider_to_models(
318 provider_id: &str,
319 cfg: &ProviderConfig,
320) -> Option<Vec<Model>> {
321 let _ = provider_id; if !provider_is_anthropic_compatible(cfg) {
323 return None;
324 }
325 let provider_base = cfg.base_url.clone().unwrap_or_else(default_anthropic_base_url);
326 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
327 for def in &cfg.models {
328 let base_url = def
329 .base_url
330 .clone()
331 .unwrap_or_else(|| provider_base.clone());
332 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
333 let mut m = Model::new(
346 def.id.clone(),
347 name,
348 Api::AnthropicMessages,
349 DEFAULT_PROVIDER_ID.to_string(),
350 base_url,
351 );
352 m.reasoning = def.reasoning.unwrap_or(false);
353 m.context_window = def.context_window.unwrap_or(0);
354 m.max_tokens = def.max_tokens.unwrap_or(0);
355 m.input = parse_input_modalities(def.input.as_deref());
356 let mut headers: BTreeMap<String, String> = BTreeMap::new();
365 if let Some(h) = def.headers.clone() {
366 headers.extend(h);
367 }
368 if let Some(h) = cfg.headers.clone() {
369 headers.extend(h);
370 }
371 if !headers.is_empty() {
372 m.headers = Some(headers);
373 }
374 merged.push(m);
375 }
376 Some(merged)
377}
378
379fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
382 match input {
383 None => vec![InputModality::Text],
384 Some(list) if list.is_empty() => vec![InputModality::Text],
385 Some(list) => list
386 .iter()
387 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
388 "text" => Some(InputModality::Text),
389 "image" => Some(InputModality::Image),
390 _ => None,
391 })
392 .collect::<Vec<_>>()
393 .pipe(|v| if v.is_empty() { vec![InputModality::Text] } else { v }),
394 }
395}
396
397pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
403
404fn default_anthropic_base_url() -> String {
407 ANTHROPIC_DEFAULT_BASE_URL.to_string()
408}
409
410#[cfg(unix)]
415use std::os::unix::fs::PermissionsExt;
416
417fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
420 if dir.exists() {
421 return Ok(());
422 }
423 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
424 path: dir.to_path_buf(),
425 source: e,
426 })?;
427 #[cfg(unix)]
428 {
429 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
430 }
431 Ok(())
432}
433
434fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
437 let dir = path
438 .parent()
439 .ok_or_else(|| ConfigError::Write {
440 path: path.to_path_buf(),
441 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
442 })?;
443 let tmp = dir.join(format!(
444 ".{}.tmp",
445 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
446 ));
447 std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
448 std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
449 path: path.to_path_buf(),
450 source: e,
451 })?;
452 Ok(())
453}
454
455fn set_owner_only(_path: &Path) {
458 #[cfg(unix)]
459 {
460 let _ = std::fs::set_permissions(
461 _path,
462 std::fs::Permissions::from_mode(0o600),
463 );
464 }
465}
466
467trait Pipe: Sized {
470 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
471 f(self)
472 }
473}
474impl<T> Pipe for T {}
475
476#[cfg(test)]
481pub(crate) mod test_support {
482 use std::sync::{Mutex, OnceLock};
488 pub(crate) fn env_lock() -> &'static Mutex<()> {
489 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
490 LOCK.get_or_init(|| Mutex::new(()))
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::config::test_support::env_lock;
498
499 struct TempConfig {
503 _guard: std::sync::MutexGuard<'static, ()>,
504 _tmp: tempfile::TempDir,
505 prev: Option<std::ffi::OsString>,
506 }
507 impl TempConfig {
508 fn new() -> Self {
509 let guard = env_lock().lock().unwrap();
510 let prev = std::env::var_os(CONFIG_DIR_ENV);
511 let tmp = tempfile::TempDir::new().unwrap();
512 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
513 Self { _guard: guard, _tmp: tmp, prev }
514 }
515 }
516 impl Drop for TempConfig {
517 fn drop(&mut self) {
518 restore_env(CONFIG_DIR_ENV, self.prev.take());
519 }
520 }
521
522 #[test]
523 fn read_auth_missing_file_is_empty() {
524 let _cfg = TempConfig::new();
525 let store = read_auth().unwrap();
526 assert!(store.is_empty());
527 }
528
529 #[test]
530 fn upsert_then_read_roundtrip() {
531 let _cfg = TempConfig::new();
532 upsert_credential(
533 "anthropic",
534 Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
535 )
536 .unwrap();
537 let store = read_auth().unwrap();
538 match store.get("anthropic") {
539 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
540 other => panic!("unexpected cred: {other:?}"),
541 }
542 let path = auth_path().unwrap();
544 assert!(path.exists(), "auth.json should exist after upsert");
545 let raw = std::fs::read_to_string(&path).unwrap();
546 assert!(raw.contains("\"anthropic\""));
547 assert!(raw.contains("api_key"));
548 }
549
550 #[test]
551 fn delete_credential_removes_entry() {
552 let _cfg = TempConfig::new();
553 upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
554 .unwrap();
555 assert!(delete_credential("anthropic").unwrap());
556 assert!(!delete_credential("anthropic").unwrap());
558 assert!(read_auth().unwrap().is_empty());
559 }
560
561 #[test]
562 fn load_models_config_missing_is_empty() {
563 let _cfg = TempConfig::new();
564 let c = load_models_config().unwrap();
565 assert!(c.providers.is_empty());
566 }
567
568 #[test]
569 fn load_models_config_parses_with_comments() {
570 let _cfg = TempConfig::new();
571 let json = r#"{
572 // a one-api style gateway
573 "providers": {
574 "gateway": {
575 "baseUrl": "https://gw.example.com",
576 "authHeader": true,
577 "apiKey": "gw-secret",
578 "models": [
579 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
580 ]
581 }
582 }
583}"#;
584 std::fs::write(models_path().unwrap(), json).unwrap();
585 let c = load_models_config().unwrap();
586 let gw = c.providers.get("gateway").expect("gateway provider present");
587 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
588 assert!(gw.auth_header.unwrap_or(false));
589 assert_eq!(gw.models.len(), 1);
590 assert_eq!(gw.models[0].id, "claude-sonnet-5");
591 }
592
593 #[test]
594 fn provider_to_models_merges_headers_without_synth_bearer() {
595 let cfg = ProviderConfig {
601 name: None,
602 base_url: Some("https://gw.example.com".into()),
603 api_key: Some("gw-secret".into()),
604 api: None,
605 headers: Some({
606 let mut h = BTreeMap::new();
607 h.insert("x-portkey-key".into(), "portkey-secret".into());
608 h
609 }),
610 auth_header: Some(true),
611 models: vec![ModelDefinition {
612 id: "claude-sonnet-5".into(),
613 name: None,
614 base_url: None,
615 reasoning: None,
616 context_window: None,
617 max_tokens: None,
618 input: None,
619 headers: None,
620 }],
621 };
622 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
623 assert_eq!(models.len(), 1);
624 let m = &models[0];
625 assert_eq!(m.id, "claude-sonnet-5");
626 assert_eq!(m.base_url, "https://gw.example.com");
627 assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
631 let headers = m.headers.as_ref().expect("provider headers merged");
632 assert_eq!(
634 headers.get("x-portkey-key").map(|s| s.as_str()),
635 Some("portkey-secret")
636 );
637 assert!(
642 headers.get("authorization").is_none(),
643 "provider_to_models must not synthesize the Bearer; resolve does"
644 );
645 }
646
647 #[test]
648 fn provider_to_models_ignores_non_anthropic_api() {
649 let cfg = ProviderConfig {
650 name: None,
651 base_url: None,
652 api_key: None,
653 api: Some("openai-completions".into()),
654 headers: None,
655 auth_header: None,
656 models: vec![],
657 };
658 assert!(provider_to_models("oai", &cfg).is_none());
659 }
660
661 #[test]
662 fn malformed_auth_json_is_an_error_not_silent_empty() {
663 let _cfg = TempConfig::new();
664 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
665 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
666 }
667
668 #[test]
669 fn agent_dir_respects_env_override() {
670 let _guard = env_lock().lock().unwrap();
671 let prev = std::env::var_os(CONFIG_DIR_ENV);
672 let tmp = tempfile::TempDir::new().unwrap();
673 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
674 let dir = agent_dir().unwrap();
675 restore_env(CONFIG_DIR_ENV, prev);
676 assert_eq!(dir, tmp.path());
677 }
678
679 #[test]
680 fn relative_override_is_rejected() {
681 let _guard = env_lock().lock().unwrap();
682 let prev = std::env::var_os(CONFIG_DIR_ENV);
683 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
684 let err = agent_dir().unwrap_err();
685 restore_env(CONFIG_DIR_ENV, prev);
686 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
687 }
688
689 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
691 match prev {
692 Some(v) => std::env::set_var(name, v),
693 None => std::env::remove_var(name),
694 }
695 }
696}