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