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
397fn default_anthropic_base_url() -> String {
401 "https://api.anthropic.com".to_string()
402}
403
404#[cfg(unix)]
409use std::os::unix::fs::PermissionsExt;
410
411fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
414 if dir.exists() {
415 return Ok(());
416 }
417 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
418 path: dir.to_path_buf(),
419 source: e,
420 })?;
421 #[cfg(unix)]
422 {
423 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
424 }
425 Ok(())
426}
427
428fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
431 let dir = path
432 .parent()
433 .ok_or_else(|| ConfigError::Write {
434 path: path.to_path_buf(),
435 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
436 })?;
437 let tmp = dir.join(format!(
438 ".{}.tmp",
439 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
440 ));
441 std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
442 std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
443 path: path.to_path_buf(),
444 source: e,
445 })?;
446 Ok(())
447}
448
449fn set_owner_only(_path: &Path) {
452 #[cfg(unix)]
453 {
454 let _ = std::fs::set_permissions(
455 _path,
456 std::fs::Permissions::from_mode(0o600),
457 );
458 }
459}
460
461trait Pipe: Sized {
464 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
465 f(self)
466 }
467}
468impl<T> Pipe for T {}
469
470#[cfg(test)]
475pub(crate) mod test_support {
476 use std::sync::{Mutex, OnceLock};
482 pub(crate) fn env_lock() -> &'static Mutex<()> {
483 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
484 LOCK.get_or_init(|| Mutex::new(()))
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::config::test_support::env_lock;
492
493 struct TempConfig {
497 _guard: std::sync::MutexGuard<'static, ()>,
498 _tmp: tempfile::TempDir,
499 prev: Option<std::ffi::OsString>,
500 }
501 impl TempConfig {
502 fn new() -> Self {
503 let guard = env_lock().lock().unwrap();
504 let prev = std::env::var_os(CONFIG_DIR_ENV);
505 let tmp = tempfile::TempDir::new().unwrap();
506 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
507 Self { _guard: guard, _tmp: tmp, prev }
508 }
509 }
510 impl Drop for TempConfig {
511 fn drop(&mut self) {
512 restore_env(CONFIG_DIR_ENV, self.prev.take());
513 }
514 }
515
516 #[test]
517 fn read_auth_missing_file_is_empty() {
518 let _cfg = TempConfig::new();
519 let store = read_auth().unwrap();
520 assert!(store.is_empty());
521 }
522
523 #[test]
524 fn upsert_then_read_roundtrip() {
525 let _cfg = TempConfig::new();
526 upsert_credential(
527 "anthropic",
528 Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
529 )
530 .unwrap();
531 let store = read_auth().unwrap();
532 match store.get("anthropic") {
533 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
534 other => panic!("unexpected cred: {other:?}"),
535 }
536 let path = auth_path().unwrap();
538 assert!(path.exists(), "auth.json should exist after upsert");
539 let raw = std::fs::read_to_string(&path).unwrap();
540 assert!(raw.contains("\"anthropic\""));
541 assert!(raw.contains("api_key"));
542 }
543
544 #[test]
545 fn delete_credential_removes_entry() {
546 let _cfg = TempConfig::new();
547 upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
548 .unwrap();
549 assert!(delete_credential("anthropic").unwrap());
550 assert!(!delete_credential("anthropic").unwrap());
552 assert!(read_auth().unwrap().is_empty());
553 }
554
555 #[test]
556 fn load_models_config_missing_is_empty() {
557 let _cfg = TempConfig::new();
558 let c = load_models_config().unwrap();
559 assert!(c.providers.is_empty());
560 }
561
562 #[test]
563 fn load_models_config_parses_with_comments() {
564 let _cfg = TempConfig::new();
565 let json = r#"{
566 // a one-api style gateway
567 "providers": {
568 "gateway": {
569 "baseUrl": "https://gw.example.com",
570 "authHeader": true,
571 "apiKey": "gw-secret",
572 "models": [
573 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
574 ]
575 }
576 }
577}"#;
578 std::fs::write(models_path().unwrap(), json).unwrap();
579 let c = load_models_config().unwrap();
580 let gw = c.providers.get("gateway").expect("gateway provider present");
581 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
582 assert!(gw.auth_header.unwrap_or(false));
583 assert_eq!(gw.models.len(), 1);
584 assert_eq!(gw.models[0].id, "claude-sonnet-5");
585 }
586
587 #[test]
588 fn provider_to_models_merges_headers_without_synth_bearer() {
589 let cfg = ProviderConfig {
595 name: None,
596 base_url: Some("https://gw.example.com".into()),
597 api_key: Some("gw-secret".into()),
598 api: None,
599 headers: Some({
600 let mut h = BTreeMap::new();
601 h.insert("x-portkey-key".into(), "portkey-secret".into());
602 h
603 }),
604 auth_header: Some(true),
605 models: vec![ModelDefinition {
606 id: "claude-sonnet-5".into(),
607 name: None,
608 base_url: None,
609 reasoning: None,
610 context_window: None,
611 max_tokens: None,
612 input: None,
613 headers: None,
614 }],
615 };
616 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
617 assert_eq!(models.len(), 1);
618 let m = &models[0];
619 assert_eq!(m.id, "claude-sonnet-5");
620 assert_eq!(m.base_url, "https://gw.example.com");
621 assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
625 let headers = m.headers.as_ref().expect("provider headers merged");
626 assert_eq!(
628 headers.get("x-portkey-key").map(|s| s.as_str()),
629 Some("portkey-secret")
630 );
631 assert!(
636 headers.get("authorization").is_none(),
637 "provider_to_models must not synthesize the Bearer; resolve does"
638 );
639 }
640
641 #[test]
642 fn provider_to_models_ignores_non_anthropic_api() {
643 let cfg = ProviderConfig {
644 name: None,
645 base_url: None,
646 api_key: None,
647 api: Some("openai-completions".into()),
648 headers: None,
649 auth_header: None,
650 models: vec![],
651 };
652 assert!(provider_to_models("oai", &cfg).is_none());
653 }
654
655 #[test]
656 fn malformed_auth_json_is_an_error_not_silent_empty() {
657 let _cfg = TempConfig::new();
658 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
659 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
660 }
661
662 #[test]
663 fn agent_dir_respects_env_override() {
664 let _guard = env_lock().lock().unwrap();
665 let prev = std::env::var_os(CONFIG_DIR_ENV);
666 let tmp = tempfile::TempDir::new().unwrap();
667 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
668 let dir = agent_dir().unwrap();
669 restore_env(CONFIG_DIR_ENV, prev);
670 assert_eq!(dir, tmp.path());
671 }
672
673 #[test]
674 fn relative_override_is_rejected() {
675 let _guard = env_lock().lock().unwrap();
676 let prev = std::env::var_os(CONFIG_DIR_ENV);
677 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
678 let err = agent_dir().unwrap_err();
679 restore_env(CONFIG_DIR_ENV, prev);
680 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
681 }
682
683 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
685 match prev {
686 Some(v) => std::env::set_var(name, v),
687 None => std::env::remove_var(name),
688 }
689 }
690}