1use std::collections::BTreeMap;
40use std::io::Write;
41use std::path::{Path, PathBuf};
42
43use rpi_ai::Model;
44#[cfg(test)]
45use rpi_ai::{Api, ModelCost, StreamingProtocolCompat};
46pub use rpi_runtime::config::{
47 default_anthropic_base_url, model_compat_warnings, provider_is_anthropic_compatible,
48 provider_is_openai_completions, provider_is_openai_responses, AuthStore, ConfigError,
49 Credential, ModelDefinition, ModelsConfig, ProviderConfig, ANTHROPIC_DEFAULT_BASE_URL,
50};
51
52pub const CONFIG_DIR_NAME: &str = ".rpi";
54
55pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
58
59pub use rpi_runtime::config::DEFAULT_PROVIDER_ID;
62
63pub fn agent_dir() -> Result<PathBuf, ConfigError> {
72 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
73 let p = PathBuf::from(&val);
74 if !p.is_absolute() {
75 return Err(ConfigError::RelativeOverride {
76 env: CONFIG_DIR_ENV,
77 val: val.to_string_lossy().into_owned(),
78 });
79 }
80 return Ok(p);
81 }
82 let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
83 env: CONFIG_DIR_ENV,
84 })?;
85 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
86}
87
88fn config_root_dir() -> Result<PathBuf, ConfigError> {
92 let agent = agent_dir()?;
93 agent
94 .parent()
95 .map(Path::to_path_buf)
96 .ok_or(ConfigError::NoHomeDir {
97 env: CONFIG_DIR_ENV,
98 })
99}
100
101pub fn auth_path() -> Result<PathBuf, ConfigError> {
103 Ok(agent_dir()?.join("auth.json"))
104}
105
106pub fn models_path() -> Result<PathBuf, ConfigError> {
108 Ok(agent_dir()?.join("models.json"))
109}
110
111pub fn settings_path() -> Result<PathBuf, ConfigError> {
113 Ok(agent_dir()?.join("settings.json"))
114}
115
116pub fn trust_path() -> Result<PathBuf, ConfigError> {
118 Ok(agent_dir()?.join("trust.json"))
119}
120
121fn read_text_with_fallback(
122 primary: PathBuf,
123 fallback: Option<PathBuf>,
124) -> Result<Option<(PathBuf, String)>, ConfigError> {
125 match std::fs::read_to_string(&primary) {
126 Ok(text) => Ok(Some((primary, text))),
127 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
128 let Some(fallback) = fallback.filter(|path| path != &primary) else {
129 return Ok(None);
130 };
131 match std::fs::read_to_string(&fallback) {
132 Ok(text) => Ok(Some((fallback, text))),
133 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
134 Err(source) => Err(ConfigError::Read {
135 path: fallback,
136 source,
137 }),
138 }
139 }
140 Err(source) => Err(ConfigError::Read {
141 path: primary,
142 source,
143 }),
144 }
145}
146
147pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
155 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
157 return Ok(0);
158 }
159 let root = match config_root_dir() {
160 Ok(p) => p,
161 Err(_) => return Ok(0),
162 };
163 let agent = agent_dir()?;
164 migrate_legacy_layout_in(&root, &agent)
165}
166
167fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
174 if agent.exists() {
176 return Ok(0);
177 }
178 let flat_auth = root.join("auth.json");
180 let flat_models = root.join("models.json");
181 if !flat_auth.exists() && !flat_models.exists() {
182 return Ok(0);
183 }
184 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
185 path: agent.to_path_buf(),
186 source: e,
187 })?;
188 let mut moved = 0usize;
189 for leaf in ["auth.json", "models.json", ".setup_done"] {
190 let from = root.join(leaf);
191 let to = agent.join(leaf);
192 if from.exists() && !to.exists() {
193 if let Err(_e) = std::fs::rename(&from, &to) {
196 if std::fs::copy(&from, &to).is_ok() {
197 let _ = std::fs::remove_file(&from);
198 }
199 }
200 moved += 1;
201 }
202 }
203 Ok(moved)
204}
205
206pub fn read_auth() -> Result<AuthStore, ConfigError> {
213 let path = auth_path()?;
214 match read_text_with_fallback(path, None)? {
215 Some((path, text)) => {
216 serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
217 }
218 None => Ok(AuthStore::new()),
219 }
220}
221
222fn auth_store_lock() -> &'static std::sync::Mutex<()> {
226 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
227 &LOCK
228}
229
230pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
234 let path = auth_path()?;
235 let dir = agent_dir()?;
236 ensure_dir(&dir)?;
237 let json = serde_json::to_string_pretty(store).unwrap();
238 atomic_write(&path, json.as_bytes())?;
239 set_owner_only(&path);
240 Ok(())
241}
242
243pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
246 let _store_guard = auth_store_lock()
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner);
249 let mut store = read_auth()?;
250 store.insert(provider_id.to_string(), cred);
251 write_auth(&store)
252}
253
254pub fn upsert_oauth_token(
257 provider_id: &str,
258 access: String,
259 refresh: String,
260 expires: i64,
261) -> Result<(), ConfigError> {
262 upsert_credential(
263 provider_id,
264 Credential::Oauth {
265 access,
266 refresh,
267 expires,
268 },
269 )
270}
271
272pub fn replace_oauth_token_if_unchanged(
277 provider_id: &str,
278 expected: (&str, &str, i64),
279 access: String,
280 refresh: String,
281 expires: i64,
282) -> Result<bool, ConfigError> {
283 let _store_guard = auth_store_lock()
284 .lock()
285 .unwrap_or_else(std::sync::PoisonError::into_inner);
286 let mut store = read_auth()?;
287 let unchanged = matches!(
288 store.get(provider_id),
289 Some(Credential::Oauth { access, refresh, expires })
290 if (access.as_str(), refresh.as_str(), *expires) == expected
291 );
292 if unchanged {
293 store.insert(
294 provider_id.to_string(),
295 Credential::Oauth {
296 access,
297 refresh,
298 expires,
299 },
300 );
301 write_auth(&store)?;
302 }
303 Ok(unchanged)
304}
305
306pub fn read_oauth_token(provider_id: &str) -> Result<Option<Credential>, ConfigError> {
308 Ok(match read_auth()?.get(provider_id) {
309 Some(Credential::Oauth {
310 access,
311 refresh,
312 expires,
313 }) => Some(Credential::Oauth {
314 access: access.clone(),
315 refresh: refresh.clone(),
316 expires: *expires,
317 }),
318 _ => None,
319 })
320}
321
322pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
327 let _store_guard = auth_store_lock()
328 .lock()
329 .unwrap_or_else(std::sync::PoisonError::into_inner);
330 let mut store = read_auth()?;
331 if store.remove(provider_id).is_some() {
332 write_auth(&store)?;
333 Ok(true)
334 } else {
335 Ok(false)
336 }
337}
338
339pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
345 let path = models_path()?;
346 match read_text_with_fallback(path, None)? {
347 Some((path, text)) => {
348 parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
349 }
350 None => Ok(ModelsConfig::default()),
351 }
352}
353
354pub fn save_models_config(cfg: &ModelsConfig) -> Result<(), ConfigError> {
362 let path = models_path()?;
363 let dir = agent_dir()?;
364 ensure_dir(&dir)?;
365 if let Ok(existing) = std::fs::read(&path) {
368 let stamp = std::time::SystemTime::now()
369 .duration_since(std::time::UNIX_EPOCH)
370 .map(|d| d.as_secs())
371 .unwrap_or(0);
372 let backup = dir.join(format!("models.json.bak-{stamp}"));
373 let _ = std::fs::write(&backup, existing);
374 }
375 let text = serde_json::to_string_pretty(cfg).map_err(|source| ConfigError::Json {
376 path: path.clone(),
377 source,
378 })?;
379 atomic_write(&path, text.as_bytes())
380}
381
382pub type TrustStore = BTreeMap<String, Option<bool>>;
392
393pub fn read_trust() -> Result<TrustStore, ConfigError> {
397 let path = trust_path()?;
398 match std::fs::read_to_string(&path) {
399 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
400 path: path.clone(),
401 source: e,
402 }),
403 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
404 Err(e) => Err(ConfigError::Read { path, source: e }),
405 }
406}
407
408pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
411 let key = std::fs::canonicalize(cwd)
412 .unwrap_or_else(|_| cwd.to_path_buf())
413 .to_string_lossy()
414 .into_owned();
415 let mut store = read_trust()?;
416 if let Some(decision) = trusted {
417 store.insert(key, Some(decision));
418 } else {
419 store.remove(&key);
420 }
421 let path = trust_path()?;
422 ensure_dir(&agent_dir()?)?;
423 let json = serde_json::to_string_pretty(&store).unwrap();
424 atomic_write(&path, json.as_bytes())?;
425 set_owner_only(&path);
426 Ok(())
427}
428
429pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
432 let key = std::fs::canonicalize(cwd)
433 .unwrap_or_else(|_| cwd.to_path_buf())
434 .to_string_lossy()
435 .into_owned();
436 Ok(read_trust()?.get(&key).copied().flatten())
437}
438
439fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
443 match serde_json::from_str(text) {
444 Ok(c) => Ok(c),
445 Err(first) => {
446 let stripped = strip_line_comments(text);
451 serde_json::from_str(&stripped).map_err(|_| first)
452 }
453 }
454}
455
456pub(crate) fn strip_line_comments(text: &str) -> String {
461 text.lines()
462 .map(|line| match find_line_comment(line) {
463 Some(idx) => line[..idx].to_string(),
464 None => line.to_string(),
465 })
466 .collect::<Vec<_>>()
467 .join("\n")
468}
469
470fn find_line_comment(line: &str) -> Option<usize> {
472 let mut in_str = false;
473 let mut esc = false;
474 for (i, ch) in line.char_indices() {
475 if esc {
476 esc = false;
477 continue;
478 }
479 match ch {
480 '\\' if in_str => esc = true,
481 '"' => in_str = !in_str,
482 '/' if !in_str && line.as_bytes().get(i + 1) == Some(&b'/') => {
483 return Some(i);
484 }
485 _ => {}
486 }
487 }
488 None
489}
490
491fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
498 static CACHE: std::sync::OnceLock<
499 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
500 > = std::sync::OnceLock::new();
501 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
502}
503
504pub fn resolve_config_value(
519 config: &str,
520 env_overlay: Option<&BTreeMap<String, String>>,
521) -> Option<String> {
522 rpi_runtime::values::resolve_config_value(
523 config,
524 env_overlay,
525 &crate::provider::CliValues,
526 true,
527 )
528}
529
530pub fn resolve_config_value_uncached(
534 config: &str,
535 env_overlay: Option<&BTreeMap<String, String>>,
536) -> Option<String> {
537 rpi_runtime::values::resolve_config_value(
538 config,
539 env_overlay,
540 &crate::provider::CliValues,
541 false,
542 )
543}
544
545pub fn resolve_headers(
549 headers: &BTreeMap<String, String>,
550 env_overlay: Option<&BTreeMap<String, String>>,
551) -> BTreeMap<String, String> {
552 rpi_runtime::values::resolve_headers(headers, env_overlay, &crate::provider::CliValues)
553}
554
555pub(crate) fn resolve_command(cmd: &str) -> Option<String> {
559 let key = format!("!{cmd}");
560 if let Some(v) = command_cache().lock().ok()?.get(&key) {
561 return v.clone();
562 }
563 let result = resolve_command_uncached(cmd);
564 if let Ok(mut cache) = command_cache().lock() {
565 cache.insert(key, result.clone());
566 }
567 result
568}
569
570#[cfg(unix)]
571fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
572 std::process::Command::new("sh")
573 .arg("-c")
574 .arg(cmd)
575 .stdin(std::process::Stdio::null())
576 .stdout(std::process::Stdio::piped())
577 .stderr(std::process::Stdio::null())
578 .output()
579 .ok()
580}
581
582#[cfg(windows)]
583fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
584 use std::os::windows::process::CommandExt;
585 std::process::Command::new("cmd")
586 .arg("/C")
587 .arg(cmd)
588 .stdin(std::process::Stdio::null())
589 .stdout(std::process::Stdio::piped())
590 .stderr(std::process::Stdio::null())
591 .creation_flags(0x0800_0000) .output()
593 .ok()
594}
595
596pub(crate) fn resolve_command_uncached(cmd: &str) -> Option<String> {
598 let output = spawn_shell_command(cmd)?;
599 if !output.status.success() {
600 return None;
601 }
602 let stdout = String::from_utf8_lossy(&output.stdout);
603 let trimmed = stdout.trim();
604 if trimmed.is_empty() {
605 None
606 } else {
607 Some(trimmed.to_string())
608 }
609}
610
611pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
620 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
621 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
622 return Some(value);
623 }
624 }
625
626 let name = native_provider_api_key_env_var(provider_id)?;
627 std::env::var(name).ok().filter(|value| !value.is_empty())
628}
629
630fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
634 match provider_id {
635 "openai" => crate::credentials::builtin_api_key_env(provider_id),
636 _ => None,
637 }
638}
639
640#[cfg(unix)]
648use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
649
650fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
653 if dir.exists() {
654 return Ok(());
655 }
656 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
657 path: dir.to_path_buf(),
658 source: e,
659 })?;
660 #[cfg(unix)]
661 {
662 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
663 }
664 Ok(())
665}
666
667pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
670 let dir = path.parent().ok_or_else(|| ConfigError::Write {
671 path: path.to_path_buf(),
672 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
673 })?;
674 let tmp = dir.join(format!(
675 ".{}.{}.tmp",
676 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
677 uuid::Uuid::new_v4().simple()
678 ));
679 let mut options = std::fs::OpenOptions::new();
680 options.write(true).create_new(true);
681 #[cfg(unix)]
682 options.mode(0o600);
683 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
684 path: tmp.clone(),
685 source,
686 })?;
687 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
688 let _ = std::fs::remove_file(&tmp);
692 return Err(ConfigError::Write {
693 path: tmp,
694 source: error,
695 });
696 }
697 drop(file);
698 if let Err(error) = std::fs::rename(&tmp, path) {
699 let _ = std::fs::remove_file(&tmp);
702 return Err(ConfigError::Write {
703 path: path.to_path_buf(),
704 source: error,
705 });
706 }
707 Ok(())
708}
709
710fn set_owner_only(_path: &Path) {
713 #[cfg(unix)]
714 {
715 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
716 }
717}
718
719#[cfg(test)]
724pub(crate) mod test_support {
725 use std::sync::{Mutex, OnceLock};
731 pub(crate) fn env_lock() -> &'static Mutex<()> {
732 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
733 LOCK.get_or_init(|| Mutex::new(()))
734 }
735
736 pub(crate) struct SessionDirEnv {
739 _guard: std::sync::MutexGuard<'static, ()>,
740 previous: Option<std::ffi::OsString>,
741 }
742
743 impl SessionDirEnv {
744 pub(crate) fn new(directory: Option<&std::path::Path>) -> Self {
745 let guard = env_lock().lock().unwrap();
746 let previous = std::env::var_os("RPI_CODING_AGENT_SESSION_DIR");
747 match directory {
748 Some(directory) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", directory),
749 None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
750 }
751 Self {
752 _guard: guard,
753 previous,
754 }
755 }
756 }
757
758 impl Drop for SessionDirEnv {
759 fn drop(&mut self) {
760 match self.previous.take() {
761 Some(value) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", value),
762 None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
763 }
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use crate::config::test_support::env_lock;
772
773 struct TempConfig {
777 _guard: std::sync::MutexGuard<'static, ()>,
778 _tmp: tempfile::TempDir,
779 prev: Option<std::ffi::OsString>,
780 }
781 impl TempConfig {
782 fn new() -> Self {
783 let guard = env_lock().lock().unwrap();
784 let prev = std::env::var_os(CONFIG_DIR_ENV);
785 let tmp = tempfile::TempDir::new().unwrap();
786 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
787 Self {
788 _guard: guard,
789 _tmp: tmp,
790 prev,
791 }
792 }
793 }
794 impl Drop for TempConfig {
795 fn drop(&mut self) {
796 restore_env(CONFIG_DIR_ENV, self.prev.take());
797 }
798 }
799
800 #[test]
801 fn read_auth_missing_file_is_empty() {
802 let _cfg = TempConfig::new();
803 let store = read_auth().unwrap();
804 assert!(store.is_empty());
805 }
806
807 #[test]
808 fn upsert_then_read_roundtrip() {
809 let _cfg = TempConfig::new();
810 upsert_credential(
811 "anthropic",
812 Credential::ApiKey {
813 key: Some("sk-test-123".into()),
814 env: None,
815 },
816 )
817 .unwrap();
818 let store = read_auth().unwrap();
819 match store.get("anthropic") {
820 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
821 other => panic!("unexpected cred: {other:?}"),
822 }
823 let path = auth_path().unwrap();
825 assert!(path.exists(), "auth.json should exist after upsert");
826 let raw = std::fs::read_to_string(&path).unwrap();
827 assert!(raw.contains("\"anthropic\""));
828 assert!(raw.contains("api_key"));
829 }
830
831 #[test]
832 fn oauth_token_is_scoped_and_round_trips() {
833 let _cfg = TempConfig::new();
834 upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
835 assert!(matches!(
836 read_oauth_token("acme").unwrap(),
837 Some(Credential::Oauth { access, refresh, expires })
838 if access == "access" && refresh == "refresh" && expires == 123
839 ));
840 assert!(read_oauth_token("other").unwrap().is_none());
841 }
842
843 #[test]
844 fn delete_credential_removes_entry() {
845 let _cfg = TempConfig::new();
846 upsert_credential(
847 "anthropic",
848 Credential::ApiKey {
849 key: Some("k".into()),
850 env: None,
851 },
852 )
853 .unwrap();
854 assert!(delete_credential("anthropic").unwrap());
855 assert!(!delete_credential("anthropic").unwrap());
857 assert!(read_auth().unwrap().is_empty());
858 }
859
860 #[test]
861 fn load_models_config_missing_is_empty() {
862 let _cfg = TempConfig::new();
863 let c = load_models_config().unwrap();
864 assert!(c.providers.is_empty());
865 }
866
867 #[test]
868 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
869 let tmp = tempfile::tempdir().unwrap();
870 let primary = tmp.path().join("primary.json");
871 let fallback = tmp.path().join("fallback.json");
872 std::fs::write(&fallback, "fallback").unwrap();
873
874 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
875 .unwrap()
876 .expect("fallback should be read");
877 assert_eq!(text, "fallback");
878
879 std::fs::write(&primary, "primary").unwrap();
880 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
881 .unwrap()
882 .expect("primary should be read");
883 assert_eq!(path, primary);
884 assert_eq!(text, "primary");
885 }
886
887 #[test]
888 fn fallback_reader_does_not_mask_primary_read_errors() {
889 let tmp = tempfile::tempdir().unwrap();
890 let primary = tmp.path().join("primary.json");
891 let fallback = tmp.path().join("fallback.json");
892 std::fs::create_dir(&primary).unwrap();
893 std::fs::write(&fallback, "fallback").unwrap();
894
895 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
896 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
897 }
898
899 #[test]
900 fn load_models_config_parses_with_comments() {
901 let _cfg = TempConfig::new();
902 let json = r#"{
903 // a one-api style gateway
904 "providers": {
905 "gateway": {
906 "baseUrl": "https://gw.example.com",
907 "authHeader": true,
908 "apiKey": "gw-secret",
909 "models": [
910 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
911 ]
912 }
913 }
914}"#;
915 std::fs::write(models_path().unwrap(), json).unwrap();
916 let c = load_models_config().unwrap();
917 let gw = c
918 .providers
919 .get("gateway")
920 .expect("gateway provider present");
921 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
922 assert!(gw.auth_header.unwrap_or(false));
923 assert_eq!(gw.models.len(), 1);
924 assert_eq!(gw.models[0].id, "claude-sonnet-5");
925 }
926
927 #[test]
928 fn provider_to_models_merges_headers_without_synth_bearer() {
929 let cfg = ProviderConfig {
935 name: None,
936 base_url: Some("https://gw.example.com".into()),
937 api_key: Some("gw-secret".into()),
938 api: None,
939 headers: Some({
940 let mut h = BTreeMap::new();
941 h.insert("x-portkey-key".into(), "portkey-secret".into());
942 h
943 }),
944 auth_header: Some(true),
945 models: vec![ModelDefinition {
946 id: "claude-sonnet-5".into(),
947 name: None,
948 base_url: None,
949 reasoning: None,
950 thinking_level_map: None,
951 context_window: None,
952 max_tokens: None,
953 cost: None,
954 input: None,
955 headers: None,
956 compat: None,
957 }],
958 };
959 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
960 assert_eq!(models.len(), 1);
961 let m = &models[0];
962 assert_eq!(m.id, "claude-sonnet-5");
963 assert_eq!(m.base_url, "https://gw.example.com");
964 assert_eq!(m.provider, "gateway");
965 let headers = m.headers.as_ref().expect("provider headers merged");
966 assert_eq!(
968 headers.get("x-portkey-key").map(|s| s.as_str()),
969 Some("portkey-secret")
970 );
971 assert!(
976 headers.get("authorization").is_none(),
977 "provider_to_models must not synthesize the Bearer; resolve does"
978 );
979 }
980
981 #[test]
982 fn provider_to_models_supports_openai_completions() {
983 let config: ModelsConfig = serde_json::from_str(
984 r#"{
985 "providers": {
986 "oai": {
987 "api": "openai-completions",
988 "baseUrl": "https://gateway.example.com/v1",
989 "apiKey": "secret",
990 "models": [{"id":"gpt-test","maxTokens":4096}]
991 }
992 }
993 }"#,
994 )
995 .unwrap();
996 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
997 assert_eq!(models.len(), 1);
998 assert_eq!(models[0].api, Api::OpenaiCompletions);
999 assert_eq!(models[0].provider, "oai");
1000 assert_eq!(models[0].max_tokens, 4096);
1001 assert_eq!(models[0].cost, ModelCost::default());
1002 assert!(!models[0].has_pricing());
1003 assert!(
1004 models[0].headers.is_none(),
1005 "credentials are bound after provider resolution"
1006 );
1007 }
1008
1009 #[test]
1010 fn models_json_pricing_reaches_providers_and_survives_serialization() {
1011 let mut config = parse_models_json(
1012 r#"{
1013 "providers": {
1014 "pricing-fixture": {
1015 "api": "anthropic-messages",
1016 "models": [{
1017 "id": "priced-model",
1018 "cost": {
1019 "input": 2, "output": 8, "cacheRead": 0.2, "cacheWrite": 2.5,
1020 "tiers": [{
1021 "inputTokensAbove": 1000000,
1022 "input": 4, "output": 16, "cacheRead": 0.4, "cacheWrite": 5
1023 }]
1024 }
1025 }]
1026 }
1027 }
1028 }"#,
1029 )
1030 .unwrap();
1031 let expected = config.providers["pricing-fixture"].models[0]
1032 .cost
1033 .clone()
1034 .unwrap();
1035 for api in [
1036 "anthropic-messages",
1037 "openai-completions",
1038 "openai-responses",
1039 ] {
1040 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1041 cfg.api = Some(api.into());
1042 let models = provider_to_models("pricing-fixture", cfg).unwrap();
1043 assert_eq!(models[0].cost, expected);
1044 assert!(models[0].has_pricing());
1045 let mut usage = rpi_ai::Usage::zero();
1046 usage.input = 100_000;
1047 usage.output = 250_000;
1048 usage.cache_read = 300_000;
1049 usage.cache_write = 400_000;
1050 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1051 assert!((cost.total - 3.26).abs() < 1e-10, "base pricing for {api}");
1052 usage.input = 1_000_001;
1053 usage.output = 1_000_000;
1054 usage.cache_read = 0;
1055 usage.cache_write = 0;
1056 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1057 assert!(
1058 (cost.total - 20.000004).abs() < 1e-10,
1059 "tiered pricing for {api}"
1060 );
1061 }
1062 let saved = serde_json::to_string(&config).unwrap();
1063 let reloaded = parse_models_json(&saved).unwrap();
1064 assert_eq!(
1065 reloaded.providers["pricing-fixture"].models[0].cost,
1066 Some(expected)
1067 );
1068 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1069 cfg.models[0].cost = Some(ModelCost::default());
1070 let free = provider_to_models("pricing-fixture", cfg).unwrap();
1071 assert_eq!(free[0].cost, ModelCost::default());
1072 assert!(
1073 free[0].has_pricing(),
1074 "explicit zero prices are still configured"
1075 );
1076 }
1077
1078 #[test]
1079 fn models_json_compat_reaches_the_model_for_openai_completions() {
1080 let config: ModelsConfig = serde_json::from_str(
1087 r#"{
1088 "providers": {
1089 "qwen": {
1090 "api": "openai-completions",
1091 "baseUrl": "https://coding.example.com/v1",
1092 "apiKey": "secret",
1093 "models": [{
1094 "id": "qwen3.7-plus",
1095 "maxTokens": 131072,
1096 "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" },
1097 "compat": { "requiresThinkingAsText": true }
1098 }]
1099 }
1100 }
1101 }"#,
1102 )
1103 .unwrap();
1104 let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1105 assert_eq!(
1106 models[0]
1107 .thinking_level_map
1108 .as_ref()
1109 .unwrap()
1110 .get(&rpi_ai::ThinkingLevel::Xhigh),
1111 Some(&Some("xhigh".into()))
1112 );
1113 match models[0].compat.as_ref().expect("compat must survive") {
1114 StreamingProtocolCompat::OpenaiCompletions(compat) => {
1115 assert_eq!(compat.requires_thinking_as_text, Some(true));
1116 }
1117 other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1118 }
1119
1120 let plain: ModelsConfig = serde_json::from_str(
1122 r#"{
1123 "providers": {
1124 "qwen": {
1125 "api": "openai-completions",
1126 "baseUrl": "https://coding.example.com/v1",
1127 "apiKey": "secret",
1128 "models": [{"id":"qwen3.7-plus"}]
1129 }
1130 }
1131 }"#,
1132 )
1133 .unwrap();
1134 let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1135 assert!(plain_models[0].compat.is_none());
1136 }
1137
1138 #[test]
1139 fn models_json_accepts_pi_deepseek_compat_and_warns_on_unknown_or_invalid_fields() {
1140 let mut config: ModelsConfig = serde_json::from_value(serde_json::json!({
1141 "providers": { "deepseek": {
1142 "api": "openai-completions",
1143 "models": [{ "id": "reasoner", "reasoning": true, "compat": {
1144 "thinkingFormat": "deepseek",
1145 "requiresReasoningContentOnAssistantMessages": true
1146 }}]
1147 }}
1148 }))
1149 .unwrap();
1150 assert!(model_compat_warnings(&config).is_empty());
1151 let models = provider_to_models("deepseek", &config.providers["deepseek"]).unwrap();
1152 assert!(rpi_ai::model::prior_reasoning_is_replayed(&models[0]));
1153 let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1154 panic!("missing model compatibility flags");
1155 };
1156 assert_eq!(compat.requires_reasoning_content, Some(true));
1157 assert_eq!(
1158 compat.thinking_format,
1159 Some(rpi_ai::model::OpenaiThinkingFormat::Deepseek)
1160 );
1161
1162 config.providers["deepseek"].models[0]
1163 .compat
1164 .as_mut()
1165 .unwrap()["misspelledField"] = serde_json::json!("secret-fixture-value");
1166 let warnings = model_compat_warnings(&config);
1167 assert_eq!(warnings.len(), 1);
1168 assert!(warnings[0].contains("deepseek") && warnings[0].contains("reasoner"));
1169 assert!(warnings[0].contains("unknown field \"misspelledField\""));
1170 assert!(!warnings[0].contains("secret-fixture-value"));
1171
1172 config.providers["deepseek"].models[0].compat =
1173 Some(serde_json::json!({"requiresReasoningContent": "secret-fixture-token"}));
1174 let warning = &model_compat_warnings(&config)[0];
1175 assert!(warning.contains("invalid configuration"));
1176 assert!(!warning.contains("secret-fixture-token"));
1177 config.providers["deepseek"].models[0].compat =
1178 Some(serde_json::json!({"thinkingFormat": "unsupported"}));
1179 assert!(model_compat_warnings(&config)[0].contains("invalid configuration"));
1180 }
1181
1182 #[test]
1183 fn responses_compat_diagnostics_use_the_responses_schema() {
1184 let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1185 "providers": { "relay": {
1186 "api": "openai-responses", "models": [{ "id": "test", "compat": {
1187 "supportsOpenAIGrammarTools": true, "supportsStore": false
1188 }}]
1189 }}
1190 }))
1191 .unwrap();
1192 let warnings = model_compat_warnings(&config);
1193 assert_eq!(warnings.len(), 1);
1194 assert!(warnings[0].contains("supportsStore"));
1195 }
1196
1197 #[test]
1198 fn native_reasoning_content_key_wins_over_a_conflicting_pi_alias() {
1199 let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1200 "providers": { "relay": {
1201 "api": "openai-completions", "models": [{ "id": "test", "compat": {
1202 "requiresReasoningContent": false,
1203 "requiresReasoningContentOnAssistantMessages": true
1204 }}]
1205 }}
1206 }))
1207 .unwrap();
1208 let warnings = model_compat_warnings(&config);
1209 assert_eq!(warnings.len(), 1);
1210 assert!(warnings[0].contains("takes precedence"));
1211 let models = provider_to_models("relay", &config.providers["relay"]).unwrap();
1212 let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1213 panic!("valid native flag must survive an alias conflict");
1214 };
1215 assert_eq!(compat.requires_reasoning_content, Some(false));
1216 }
1217
1218 #[test]
1219 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1220 let _guard = env_lock().lock().unwrap();
1221 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1222 std::env::set_var(env_name, "env-secret");
1223 let cfg = ProviderConfig {
1224 name: None,
1225 base_url: Some("https://gateway.example.com/v1".into()),
1226 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1227 api: Some("openai-completions".into()),
1228 headers: None,
1229 auth_header: None,
1230 models: vec![ModelDefinition {
1231 id: "fake-model".into(),
1232 name: None,
1233 base_url: None,
1234 reasoning: None,
1235 thinking_level_map: None,
1236 context_window: None,
1237 max_tokens: None,
1238 cost: None,
1239 input: None,
1240 headers: None,
1241 compat: None,
1242 }],
1243 };
1244 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1245 assert_eq!(
1246 openai_provider_api_key("rpi-fake-provider", &cfg).as_deref(),
1247 Some("env-secret")
1248 );
1249 assert!(
1250 models[0].headers.is_none(),
1251 "discovery does not bind credentials"
1252 );
1253 std::env::remove_var(env_name);
1254 }
1255
1256 #[test]
1257 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1258 let _guard = env_lock().lock().unwrap();
1259 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1260 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1261 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1262 std::env::set_var("OPENAI_API_KEY", "official-key");
1263 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1264 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1265 let config = ProviderConfig {
1266 name: None,
1267 base_url: Some("https://gateway.example.com/v1".into()),
1268 api_key: None,
1269 api: Some("openai-responses".into()),
1270 headers: None,
1271 auth_header: None,
1272 models: Vec::new(),
1273 };
1274
1275 assert_eq!(
1276 openai_provider_api_key("openai", &config).as_deref(),
1277 Some("official-key")
1278 );
1279 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1280 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1281 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1282
1283 restore_env("OPENAI_API_KEY", prev_openai);
1284 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1285 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1286 }
1287
1288 #[test]
1289 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1290 assert_eq!(
1291 native_provider_api_key_env_var("openai"),
1292 Some("OPENAI_API_KEY")
1293 );
1294 for provider in [
1295 "deepseek",
1296 "openrouter",
1297 "google",
1298 "groq",
1299 "qwen",
1300 "nvidia",
1301 "OpenAI",
1302 "openai-completions",
1303 ] {
1304 assert_eq!(
1305 native_provider_api_key_env_var(provider),
1306 None,
1307 "external providers must declare auth in their package or models.json"
1308 );
1309 }
1310 }
1311
1312 #[test]
1313 fn malformed_auth_json_is_an_error_not_silent_empty() {
1314 let _cfg = TempConfig::new();
1315 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1316 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1317 }
1318
1319 #[test]
1320 fn agent_dir_nests_under_agent_by_default() {
1321 let _guard = env_lock().lock().unwrap();
1325 let prev = std::env::var_os(CONFIG_DIR_ENV);
1326 std::env::remove_var(CONFIG_DIR_ENV);
1327 let dir = agent_dir().unwrap();
1328 restore_env(CONFIG_DIR_ENV, prev);
1329 assert!(dir.ends_with("agent"));
1330 assert!(dir
1331 .parent()
1332 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1333 .unwrap_or(false));
1334 }
1335
1336 #[test]
1337 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1338 let tmp = tempfile::TempDir::new().unwrap();
1342 let root = tmp.path().to_path_buf();
1343 let agent = root.join("agent");
1344 std::fs::write(root.join("auth.json"), "{}").unwrap();
1345 std::fs::write(root.join("models.json"), "{}").unwrap();
1346 std::fs::write(root.join(".setup_done"), "1").unwrap();
1347 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1348 assert_eq!(moved, 3);
1349 assert!(agent.join("auth.json").exists());
1350 assert!(agent.join("models.json").exists());
1351 assert!(agent.join(".setup_done").exists());
1352 assert!(!root.join("auth.json").exists());
1353 }
1354
1355 #[test]
1356 fn migrate_legacy_layout_noop_when_agent_exists() {
1357 let tmp = tempfile::TempDir::new().unwrap();
1358 let root = tmp.path().to_path_buf();
1359 let agent = root.join("agent");
1360 std::fs::write(root.join("auth.json"), "{}").unwrap();
1361 std::fs::create_dir_all(&agent).unwrap();
1362 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1363 assert_eq!(moved, 0); }
1365
1366 #[test]
1367 fn migrate_legacy_layout_noop_when_no_flat_files() {
1368 let tmp = tempfile::TempDir::new().unwrap();
1369 let root = tmp.path().to_path_buf();
1370 let agent = root.join("agent");
1371 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1372 assert_eq!(moved, 0);
1373 }
1374
1375 #[test]
1376 fn migrate_legacy_layout_public_skips_env_override() {
1377 let _cfg = TempConfig::new();
1380 let moved = migrate_legacy_layout().unwrap();
1381 assert_eq!(moved, 0);
1382 }
1383
1384 #[test]
1385 fn read_trust_missing_file_is_empty() {
1386 let _cfg = TempConfig::new();
1387 assert!(read_trust().unwrap().is_empty());
1388 }
1389
1390 #[test]
1391 fn read_trust_parses_decisions() {
1392 let _cfg = TempConfig::new();
1393 let path = trust_path().unwrap();
1394 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1395 std::fs::write(
1396 &path,
1397 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1398 )
1399 .unwrap();
1400 let store = read_trust().unwrap();
1401 assert_eq!(store.len(), 3);
1402 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1403 assert_eq!(
1404 store.get("/home/me/untrusted").copied().flatten(),
1405 Some(false)
1406 );
1407 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1408 }
1409
1410 #[test]
1411 fn resolve_config_value_literal_passthrough() {
1412 assert_eq!(
1413 resolve_config_value("sk-literal-key", None),
1414 Some("sk-literal-key".into())
1415 );
1416 }
1417
1418 #[test]
1419 fn resolve_config_value_env_var() {
1420 let _guard = env_lock().lock().unwrap();
1421 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1422 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1423 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1424 assert_eq!(
1425 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1426 Some("secret-from-env".into())
1427 );
1428 assert_eq!(
1429 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1430 Some("prefix-secret-from-env-suffix".into())
1431 );
1432 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1434 assert_eq!(
1435 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1436 Some("a-secret-from-env-b-two-c".into())
1437 );
1438 let mut overlay = BTreeMap::new();
1440 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1441 assert_eq!(
1442 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1443 Some("overlay-value".into())
1444 );
1445 restore_env("RPI_TEST_CFG_KEY", prev);
1446 restore_env("RPI_TEST_CFG_KEY2", prev2);
1447 }
1448
1449 #[test]
1450 fn resolve_config_value_unset_env_is_none() {
1451 let _guard = env_lock().lock().unwrap();
1452 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1453 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1454 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1456 assert_eq!(
1457 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1458 None
1459 );
1460 restore_env("RPI_TEST_CFG_ABSENT", prev);
1461 }
1462
1463 #[test]
1464 fn resolve_config_value_dollar_dollar_escapes_literal() {
1465 assert_eq!(
1466 resolve_config_value("price-$$5", None),
1467 Some("price-$5".into())
1468 );
1469 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1470 }
1471
1472 #[test]
1473 fn resolve_config_value_command_runs_shell() {
1474 assert_eq!(
1476 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1477 Some("rpi-cfg-resolved".into())
1478 );
1479 assert_eq!(resolve_config_value_uncached("!false", None), None);
1481 }
1482
1483 #[test]
1484 fn resolve_headers_drops_unresolvable() {
1485 let _guard = env_lock().lock().unwrap();
1486 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1487 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1488 let mut h = BTreeMap::new();
1489 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1490 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1491 h.insert("x-literal".into(), "literal-value".into());
1492 let resolved = resolve_headers(&h, None);
1493 assert_eq!(resolved.len(), 2);
1494 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1495 assert_eq!(
1496 resolved.get("x-literal").map(|s| s.as_str()),
1497 Some("literal-value")
1498 );
1499 assert!(!resolved.contains_key("x-unset"));
1500 restore_env("RPI_TEST_HDR_SET", prev);
1501 }
1502
1503 #[test]
1504 fn agent_dir_respects_env_override() {
1505 let _guard = env_lock().lock().unwrap();
1506 let prev = std::env::var_os(CONFIG_DIR_ENV);
1507 let tmp = tempfile::TempDir::new().unwrap();
1508 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1509 let dir = agent_dir().unwrap();
1510 restore_env(CONFIG_DIR_ENV, prev);
1511 assert_eq!(dir, tmp.path());
1512 }
1513
1514 #[test]
1515 fn relative_override_is_rejected() {
1516 let _guard = env_lock().lock().unwrap();
1517 let prev = std::env::var_os(CONFIG_DIR_ENV);
1518 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1519 let err = agent_dir().unwrap_err();
1520 restore_env(CONFIG_DIR_ENV, prev);
1521 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1522 }
1523
1524 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1526 match prev {
1527 Some(v) => std::env::set_var(name, v),
1528 None => std::env::remove_var(name),
1529 }
1530 }
1531}
1532
1533pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
1535 rpi_runtime::config::provider_to_models(provider_id, cfg, &crate::provider::CliValues)
1536}