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 fn add_provider(
387 provider_id: &str,
388 api: &str,
389 base_url: &str,
390 api_key: &str,
391 model_id: &str,
392 model_name: Option<&str>,
393 context_window: Option<u64>,
394 max_tokens: Option<u64>,
395 supports_images: Option<bool>,
396) -> Result<(), String> {
397 const MAX_FIELD: usize = 4096;
398 let provider_id = provider_id.trim();
399 let api = api.trim();
400 let base_url = base_url.trim();
401 let api_key = api_key.trim();
402 let model_id = model_id.trim();
403 let model_name = model_name.map(str::trim).filter(|value| !value.is_empty());
404 if provider_id.is_empty()
405 || api.is_empty()
406 || base_url.is_empty()
407 || api_key.is_empty()
408 || model_id.is_empty()
409 {
410 return Err("provider, protocol, base URL, API key, and model ID are required".into());
411 }
412 for (label, value) in [
413 ("provider", provider_id),
414 ("protocol", api),
415 ("base URL", base_url),
416 ("API key", api_key),
417 ("model ID", model_id),
418 ] {
419 if value.len() > MAX_FIELD {
420 return Err(format!("{label} is too long"));
421 }
422 }
423 if model_name.is_some_and(|value| value.len() > MAX_FIELD) {
424 return Err("model name is too long".into());
425 }
426 if context_window == Some(0) {
427 return Err("context window must be greater than zero".into());
428 }
429 if max_tokens == Some(0) {
430 return Err("max output tokens must be greater than zero".into());
431 }
432 if !matches!(
433 api,
434 "anthropic-messages" | "openai-completions" | "openai-responses"
435 ) {
436 return Err("unsupported provider protocol".into());
437 }
438 if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
439 return Err("base URL must start with http:// or https://".into());
440 }
441 let mut config = load_models_config().map_err(|error| error.to_string())?;
442 if config.providers.contains_key(provider_id) {
443 return Err(format!(
444 "provider {provider_id:?} already has a models.json configuration"
445 ));
446 }
447 config.providers.insert(
448 provider_id.to_owned(),
449 ProviderConfig {
450 name: None,
451 base_url: Some(base_url.to_owned()),
452 api_key: Some(api_key.to_owned()),
453 api: Some(api.to_owned()),
454 headers: None,
455 auth_header: None,
456 models: vec![ModelDefinition {
457 id: model_id.to_owned(),
458 name: model_name.map(str::to_owned),
459 base_url: None,
460 reasoning: None,
461 thinking_level_map: None,
462 context_window,
463 max_tokens,
464 cost: None,
465 input: supports_images.map(|supports| {
466 if supports {
467 vec!["text".to_string(), "image".to_string()]
468 } else {
469 vec!["text".to_string()]
470 }
471 }),
472 headers: None,
473 compat: None,
474 }],
475 },
476 );
477 save_models_config(&config).map_err(|error| error.to_string())
478}
479
480pub fn update_provider(
484 provider_id: &str,
485 api: &str,
486 base_url: &str,
487 api_key: Option<&str>,
488 model_id: &str,
489 model_name: Option<&str>,
490 context_window: Option<u64>,
491 max_tokens: Option<u64>,
492 supports_images: bool,
493) -> Result<(), String> {
494 let provider_id = provider_id.trim();
495 let api = api.trim();
496 let base_url = base_url.trim();
497 let model_id = model_id.trim();
498 let model_name = model_name.map(str::trim).filter(|value| !value.is_empty());
499 if provider_id.is_empty() || api.is_empty() || base_url.is_empty() || model_id.is_empty() {
500 return Err("provider, protocol, base URL, and model ID are required".into());
501 }
502 if !matches!(
503 api,
504 "anthropic-messages" | "openai-completions" | "openai-responses"
505 ) {
506 return Err("unsupported provider protocol".into());
507 }
508 if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
509 return Err("base URL must start with http:// or https://".into());
510 }
511 if context_window == Some(0) {
512 return Err("context window must be greater than zero".into());
513 }
514 if max_tokens == Some(0) {
515 return Err("max output tokens must be greater than zero".into());
516 }
517 let mut config = load_models_config().map_err(|error| error.to_string())?;
518 let provider = config
519 .providers
520 .get_mut(provider_id)
521 .ok_or_else(|| format!("provider {provider_id:?} has no models.json configuration"))?;
522 provider.api = Some(api.to_owned());
523 provider.base_url = Some(base_url.to_owned());
524 if let Some(api_key) = api_key.map(str::trim).filter(|value| !value.is_empty()) {
525 provider.api_key = Some(api_key.to_owned());
526 }
527 let model = provider
528 .models
529 .first_mut()
530 .ok_or_else(|| format!("provider {provider_id:?} has no model to update"))?;
531 model.id = model_id.to_owned();
532 model.name = model_name.map(str::to_owned);
533 model.context_window = context_window;
534 model.max_tokens = max_tokens;
535 model.input = Some(if supports_images {
536 vec!["text".to_string(), "image".to_string()]
537 } else {
538 vec!["text".to_string()]
539 });
540 save_models_config(&config).map_err(|error| error.to_string())
541}
542
543pub fn delete_provider(provider_id: &str) -> Result<(), String> {
545 let provider_id = provider_id.trim();
546 if provider_id.is_empty() {
547 return Err("provider is required".into());
548 }
549 let mut config = load_models_config().map_err(|error| error.to_string())?;
550 if config.providers.shift_remove(provider_id).is_none() {
551 return Err(format!(
552 "provider {provider_id:?} has no models.json configuration"
553 ));
554 }
555 save_models_config(&config).map_err(|error| error.to_string())
556}
557
558pub type TrustStore = BTreeMap<String, Option<bool>>;
568
569pub fn read_trust() -> Result<TrustStore, ConfigError> {
573 let path = trust_path()?;
574 match std::fs::read_to_string(&path) {
575 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
576 path: path.clone(),
577 source: e,
578 }),
579 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
580 Err(e) => Err(ConfigError::Read { path, source: e }),
581 }
582}
583
584pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
587 let key = std::fs::canonicalize(cwd)
588 .unwrap_or_else(|_| cwd.to_path_buf())
589 .to_string_lossy()
590 .into_owned();
591 let mut store = read_trust()?;
592 if let Some(decision) = trusted {
593 store.insert(key, Some(decision));
594 } else {
595 store.remove(&key);
596 }
597 let path = trust_path()?;
598 ensure_dir(&agent_dir()?)?;
599 let json = serde_json::to_string_pretty(&store).unwrap();
600 atomic_write(&path, json.as_bytes())?;
601 set_owner_only(&path);
602 Ok(())
603}
604
605pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
608 let key = std::fs::canonicalize(cwd)
609 .unwrap_or_else(|_| cwd.to_path_buf())
610 .to_string_lossy()
611 .into_owned();
612 Ok(read_trust()?.get(&key).copied().flatten())
613}
614
615fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
619 match serde_json::from_str(text) {
620 Ok(c) => Ok(c),
621 Err(first) => {
622 let stripped = strip_line_comments(text);
627 serde_json::from_str(&stripped).map_err(|_| first)
628 }
629 }
630}
631
632pub(crate) fn strip_line_comments(text: &str) -> String {
637 text.lines()
638 .map(|line| match find_line_comment(line) {
639 Some(idx) => line[..idx].to_string(),
640 None => line.to_string(),
641 })
642 .collect::<Vec<_>>()
643 .join("\n")
644}
645
646fn find_line_comment(line: &str) -> Option<usize> {
648 let mut in_str = false;
649 let mut esc = false;
650 for (i, ch) in line.char_indices() {
651 if esc {
652 esc = false;
653 continue;
654 }
655 match ch {
656 '\\' if in_str => esc = true,
657 '"' => in_str = !in_str,
658 '/' if !in_str && line.as_bytes().get(i + 1) == Some(&b'/') => {
659 return Some(i);
660 }
661 _ => {}
662 }
663 }
664 None
665}
666
667fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
674 static CACHE: std::sync::OnceLock<
675 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
676 > = std::sync::OnceLock::new();
677 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
678}
679
680pub fn resolve_config_value(
695 config: &str,
696 env_overlay: Option<&BTreeMap<String, String>>,
697) -> Option<String> {
698 rpi_runtime::values::resolve_config_value(
699 config,
700 env_overlay,
701 &crate::provider::CliValues,
702 true,
703 )
704}
705
706pub fn resolve_config_value_uncached(
710 config: &str,
711 env_overlay: Option<&BTreeMap<String, String>>,
712) -> Option<String> {
713 rpi_runtime::values::resolve_config_value(
714 config,
715 env_overlay,
716 &crate::provider::CliValues,
717 false,
718 )
719}
720
721pub fn resolve_headers(
725 headers: &BTreeMap<String, String>,
726 env_overlay: Option<&BTreeMap<String, String>>,
727) -> BTreeMap<String, String> {
728 rpi_runtime::values::resolve_headers(headers, env_overlay, &crate::provider::CliValues)
729}
730
731pub(crate) fn resolve_command(cmd: &str) -> Option<String> {
735 let key = format!("!{cmd}");
736 if let Some(v) = command_cache().lock().ok()?.get(&key) {
737 return v.clone();
738 }
739 let result = resolve_command_uncached(cmd);
740 if let Ok(mut cache) = command_cache().lock() {
741 cache.insert(key, result.clone());
742 }
743 result
744}
745
746#[cfg(unix)]
747fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
748 std::process::Command::new("sh")
749 .arg("-c")
750 .arg(cmd)
751 .stdin(std::process::Stdio::null())
752 .stdout(std::process::Stdio::piped())
753 .stderr(std::process::Stdio::null())
754 .output()
755 .ok()
756}
757
758#[cfg(windows)]
759fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
760 use std::os::windows::process::CommandExt;
761 std::process::Command::new("cmd")
762 .arg("/C")
763 .arg(cmd)
764 .stdin(std::process::Stdio::null())
765 .stdout(std::process::Stdio::piped())
766 .stderr(std::process::Stdio::null())
767 .creation_flags(0x0800_0000) .output()
769 .ok()
770}
771
772pub(crate) fn resolve_command_uncached(cmd: &str) -> Option<String> {
774 let output = spawn_shell_command(cmd)?;
775 if !output.status.success() {
776 return None;
777 }
778 let stdout = String::from_utf8_lossy(&output.stdout);
779 let trimmed = stdout.trim();
780 if trimmed.is_empty() {
781 None
782 } else {
783 Some(trimmed.to_string())
784 }
785}
786
787pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
796 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
797 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
798 return Some(value);
799 }
800 }
801
802 let name = native_provider_api_key_env_var(provider_id)?;
803 std::env::var(name).ok().filter(|value| !value.is_empty())
804}
805
806fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
810 match provider_id {
811 "openai" => crate::credentials::builtin_api_key_env(provider_id),
812 _ => None,
813 }
814}
815
816#[cfg(unix)]
824use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
825
826fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
829 if dir.exists() {
830 return Ok(());
831 }
832 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
833 path: dir.to_path_buf(),
834 source: e,
835 })?;
836 #[cfg(unix)]
837 {
838 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
839 }
840 Ok(())
841}
842
843pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
846 let dir = path.parent().ok_or_else(|| ConfigError::Write {
847 path: path.to_path_buf(),
848 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
849 })?;
850 let tmp = dir.join(format!(
851 ".{}.{}.tmp",
852 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
853 uuid::Uuid::new_v4().simple()
854 ));
855 let mut options = std::fs::OpenOptions::new();
856 options.write(true).create_new(true);
857 #[cfg(unix)]
858 options.mode(0o600);
859 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
860 path: tmp.clone(),
861 source,
862 })?;
863 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
864 let _ = std::fs::remove_file(&tmp);
868 return Err(ConfigError::Write {
869 path: tmp,
870 source: error,
871 });
872 }
873 drop(file);
874 if let Err(error) = std::fs::rename(&tmp, path) {
875 let _ = std::fs::remove_file(&tmp);
878 return Err(ConfigError::Write {
879 path: path.to_path_buf(),
880 source: error,
881 });
882 }
883 Ok(())
884}
885
886fn set_owner_only(_path: &Path) {
889 #[cfg(unix)]
890 {
891 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
892 }
893}
894
895#[cfg(test)]
900pub(crate) mod test_support {
901 use std::sync::{Mutex, OnceLock};
907 pub(crate) fn env_lock() -> &'static Mutex<()> {
908 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
909 LOCK.get_or_init(|| Mutex::new(()))
910 }
911
912 pub(crate) struct SessionDirEnv {
915 _guard: std::sync::MutexGuard<'static, ()>,
916 previous: Option<std::ffi::OsString>,
917 }
918
919 impl SessionDirEnv {
920 pub(crate) fn new(directory: Option<&std::path::Path>) -> Self {
921 let guard = env_lock().lock().unwrap();
922 let previous = std::env::var_os("RPI_CODING_AGENT_SESSION_DIR");
923 match directory {
924 Some(directory) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", directory),
925 None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
926 }
927 Self {
928 _guard: guard,
929 previous,
930 }
931 }
932 }
933
934 impl Drop for SessionDirEnv {
935 fn drop(&mut self) {
936 match self.previous.take() {
937 Some(value) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", value),
938 None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
939 }
940 }
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use crate::config::test_support::env_lock;
948
949 struct TempConfig {
953 _guard: std::sync::MutexGuard<'static, ()>,
954 _tmp: tempfile::TempDir,
955 prev: Option<std::ffi::OsString>,
956 }
957 impl TempConfig {
958 fn new() -> Self {
959 let guard = env_lock().lock().unwrap();
960 let prev = std::env::var_os(CONFIG_DIR_ENV);
961 let tmp = tempfile::TempDir::new().unwrap();
962 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
963 Self {
964 _guard: guard,
965 _tmp: tmp,
966 prev,
967 }
968 }
969 }
970 impl Drop for TempConfig {
971 fn drop(&mut self) {
972 restore_env(CONFIG_DIR_ENV, self.prev.take());
973 }
974 }
975
976 #[test]
977 fn read_auth_missing_file_is_empty() {
978 let _cfg = TempConfig::new();
979 let store = read_auth().unwrap();
980 assert!(store.is_empty());
981 }
982
983 #[test]
984 fn upsert_then_read_roundtrip() {
985 let _cfg = TempConfig::new();
986 upsert_credential(
987 "anthropic",
988 Credential::ApiKey {
989 key: Some("sk-test-123".into()),
990 env: None,
991 },
992 )
993 .unwrap();
994 let store = read_auth().unwrap();
995 match store.get("anthropic") {
996 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
997 other => panic!("unexpected cred: {other:?}"),
998 }
999 let path = auth_path().unwrap();
1001 assert!(path.exists(), "auth.json should exist after upsert");
1002 let raw = std::fs::read_to_string(&path).unwrap();
1003 assert!(raw.contains("\"anthropic\""));
1004 assert!(raw.contains("api_key"));
1005 }
1006
1007 #[test]
1008 fn add_provider_writes_protocol_endpoint_key_and_model() {
1009 let _cfg = TempConfig::new();
1010 add_provider(
1011 "gateway",
1012 "openai-completions",
1013 "https://api.example.test/v1",
1014 "fixture-key",
1015 "model-a",
1016 Some("Model A"),
1017 Some(128_000),
1018 Some(4096),
1019 Some(true),
1020 )
1021 .unwrap();
1022 let config = load_models_config().unwrap();
1023 let provider = config.providers.get("gateway").unwrap();
1024 assert_eq!(provider.api.as_deref(), Some("openai-completions"));
1025 assert_eq!(
1026 provider.base_url.as_deref(),
1027 Some("https://api.example.test/v1")
1028 );
1029 assert_eq!(provider.api_key.as_deref(), Some("fixture-key"));
1030 assert_eq!(provider.models[0].id, "model-a");
1031 assert_eq!(provider.models[0].name.as_deref(), Some("Model A"));
1032 assert_eq!(provider.models[0].context_window, Some(128_000));
1033 assert_eq!(provider.models[0].max_tokens, Some(4096));
1034 assert_eq!(
1035 provider.models[0].input.as_deref(),
1036 Some(["text".to_string(), "image".to_string()].as_slice())
1037 );
1038 }
1039
1040 #[test]
1041 fn add_provider_rejects_duplicate_and_unsupported_protocol() {
1042 let _cfg = TempConfig::new();
1043 add_provider(
1044 "gateway",
1045 "openai-responses",
1046 "https://api.example.test/v1",
1047 "fixture-key",
1048 "model-a",
1049 None,
1050 None,
1051 None,
1052 None,
1053 )
1054 .unwrap();
1055 let duplicate = add_provider(
1056 "gateway",
1057 "openai-completions",
1058 "https://api.example.test/v1",
1059 "fixture-key-2",
1060 "model-b",
1061 None,
1062 None,
1063 None,
1064 None,
1065 )
1066 .unwrap_err();
1067 assert!(duplicate.contains("already"));
1068 let unsupported = add_provider(
1069 "other",
1070 "unsupported",
1071 "https://api.example.test/v1",
1072 "fixture-key",
1073 "model-a",
1074 None,
1075 None,
1076 None,
1077 None,
1078 )
1079 .unwrap_err();
1080 assert!(unsupported.contains("unsupported"));
1081 let invalid_context = add_provider(
1082 "context-zero",
1083 "openai-completions",
1084 "https://api.example.test/v1",
1085 "fixture-key",
1086 "model-a",
1087 None,
1088 Some(0),
1089 None,
1090 None,
1091 )
1092 .unwrap_err();
1093 assert!(invalid_context.contains("context window"));
1094 }
1095
1096 #[test]
1097 fn update_and_delete_provider_change_models_json() {
1098 let _cfg = TempConfig::new();
1099 add_provider(
1100 "gateway",
1101 "openai-completions",
1102 "https://api.example.test/v1",
1103 "fixture-key",
1104 "model-a",
1105 Some("Model A"),
1106 Some(128_000),
1107 Some(4096),
1108 Some(false),
1109 )
1110 .unwrap();
1111 update_provider(
1112 "gateway",
1113 "openai-responses",
1114 "https://new.example.test/v1",
1115 None,
1116 "model-b",
1117 Some("Model B"),
1118 Some(256_000),
1119 Some(8192),
1120 true,
1121 )
1122 .unwrap();
1123 let provider = load_models_config()
1124 .unwrap()
1125 .providers
1126 .get("gateway")
1127 .unwrap()
1128 .clone();
1129 assert_eq!(provider.api.as_deref(), Some("openai-responses"));
1130 assert_eq!(
1131 provider.base_url.as_deref(),
1132 Some("https://new.example.test/v1")
1133 );
1134 assert_eq!(provider.api_key.as_deref(), Some("fixture-key"));
1135 assert_eq!(provider.models[0].id, "model-b");
1136 assert_eq!(provider.models[0].max_tokens, Some(8192));
1137 assert_eq!(
1138 provider.models[0].input.as_deref(),
1139 Some(["text".to_string(), "image".to_string()].as_slice())
1140 );
1141 delete_provider("gateway").unwrap();
1142 assert!(!load_models_config()
1143 .unwrap()
1144 .providers
1145 .contains_key("gateway"));
1146 }
1147
1148 #[test]
1149 fn oauth_token_is_scoped_and_round_trips() {
1150 let _cfg = TempConfig::new();
1151 upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
1152 assert!(matches!(
1153 read_oauth_token("acme").unwrap(),
1154 Some(Credential::Oauth { access, refresh, expires })
1155 if access == "access" && refresh == "refresh" && expires == 123
1156 ));
1157 assert!(read_oauth_token("other").unwrap().is_none());
1158 }
1159
1160 #[test]
1161 fn delete_credential_removes_entry() {
1162 let _cfg = TempConfig::new();
1163 upsert_credential(
1164 "anthropic",
1165 Credential::ApiKey {
1166 key: Some("k".into()),
1167 env: None,
1168 },
1169 )
1170 .unwrap();
1171 assert!(delete_credential("anthropic").unwrap());
1172 assert!(!delete_credential("anthropic").unwrap());
1174 assert!(read_auth().unwrap().is_empty());
1175 }
1176
1177 #[test]
1178 fn load_models_config_missing_is_empty() {
1179 let _cfg = TempConfig::new();
1180 let c = load_models_config().unwrap();
1181 assert!(c.providers.is_empty());
1182 }
1183
1184 #[test]
1185 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1186 let tmp = tempfile::tempdir().unwrap();
1187 let primary = tmp.path().join("primary.json");
1188 let fallback = tmp.path().join("fallback.json");
1189 std::fs::write(&fallback, "fallback").unwrap();
1190
1191 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1192 .unwrap()
1193 .expect("fallback should be read");
1194 assert_eq!(text, "fallback");
1195
1196 std::fs::write(&primary, "primary").unwrap();
1197 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1198 .unwrap()
1199 .expect("primary should be read");
1200 assert_eq!(path, primary);
1201 assert_eq!(text, "primary");
1202 }
1203
1204 #[test]
1205 fn fallback_reader_does_not_mask_primary_read_errors() {
1206 let tmp = tempfile::tempdir().unwrap();
1207 let primary = tmp.path().join("primary.json");
1208 let fallback = tmp.path().join("fallback.json");
1209 std::fs::create_dir(&primary).unwrap();
1210 std::fs::write(&fallback, "fallback").unwrap();
1211
1212 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1213 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1214 }
1215
1216 #[test]
1217 fn load_models_config_parses_with_comments() {
1218 let _cfg = TempConfig::new();
1219 let json = r#"{
1220 // a one-api style gateway
1221 "providers": {
1222 "gateway": {
1223 "baseUrl": "https://gw.example.com",
1224 "authHeader": true,
1225 "apiKey": "gw-secret",
1226 "models": [
1227 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1228 ]
1229 }
1230 }
1231}"#;
1232 std::fs::write(models_path().unwrap(), json).unwrap();
1233 let c = load_models_config().unwrap();
1234 let gw = c
1235 .providers
1236 .get("gateway")
1237 .expect("gateway provider present");
1238 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1239 assert!(gw.auth_header.unwrap_or(false));
1240 assert_eq!(gw.models.len(), 1);
1241 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1242 }
1243
1244 #[test]
1245 fn provider_to_models_merges_headers_without_synth_bearer() {
1246 let cfg = ProviderConfig {
1252 name: None,
1253 base_url: Some("https://gw.example.com".into()),
1254 api_key: Some("gw-secret".into()),
1255 api: None,
1256 headers: Some({
1257 let mut h = BTreeMap::new();
1258 h.insert("x-portkey-key".into(), "portkey-secret".into());
1259 h
1260 }),
1261 auth_header: Some(true),
1262 models: vec![ModelDefinition {
1263 id: "claude-sonnet-5".into(),
1264 name: None,
1265 base_url: None,
1266 reasoning: None,
1267 thinking_level_map: None,
1268 context_window: None,
1269 max_tokens: None,
1270 cost: None,
1271 input: None,
1272 headers: None,
1273 compat: None,
1274 }],
1275 };
1276 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1277 assert_eq!(models.len(), 1);
1278 let m = &models[0];
1279 assert_eq!(m.id, "claude-sonnet-5");
1280 assert_eq!(m.base_url, "https://gw.example.com");
1281 assert_eq!(m.provider, "gateway");
1282 let headers = m.headers.as_ref().expect("provider headers merged");
1283 assert_eq!(
1285 headers.get("x-portkey-key").map(|s| s.as_str()),
1286 Some("portkey-secret")
1287 );
1288 assert!(
1293 headers.get("authorization").is_none(),
1294 "provider_to_models must not synthesize the Bearer; resolve does"
1295 );
1296 }
1297
1298 #[test]
1299 fn provider_to_models_supports_openai_completions() {
1300 let config: ModelsConfig = serde_json::from_str(
1301 r#"{
1302 "providers": {
1303 "oai": {
1304 "api": "openai-completions",
1305 "baseUrl": "https://gateway.example.com/v1",
1306 "apiKey": "secret",
1307 "models": [{"id":"gpt-test","maxTokens":4096}]
1308 }
1309 }
1310 }"#,
1311 )
1312 .unwrap();
1313 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1314 assert_eq!(models.len(), 1);
1315 assert_eq!(models[0].api, Api::OpenaiCompletions);
1316 assert_eq!(models[0].provider, "oai");
1317 assert_eq!(models[0].max_tokens, 4096);
1318 assert_eq!(models[0].cost, ModelCost::default());
1319 assert!(!models[0].has_pricing());
1320 assert!(
1321 models[0].headers.is_none(),
1322 "credentials are bound after provider resolution"
1323 );
1324 }
1325
1326 #[test]
1327 fn models_json_pricing_reaches_providers_and_survives_serialization() {
1328 let mut config = parse_models_json(
1329 r#"{
1330 "providers": {
1331 "pricing-fixture": {
1332 "api": "anthropic-messages",
1333 "models": [{
1334 "id": "priced-model",
1335 "cost": {
1336 "input": 2, "output": 8, "cacheRead": 0.2, "cacheWrite": 2.5,
1337 "tiers": [{
1338 "inputTokensAbove": 1000000,
1339 "input": 4, "output": 16, "cacheRead": 0.4, "cacheWrite": 5
1340 }]
1341 }
1342 }]
1343 }
1344 }
1345 }"#,
1346 )
1347 .unwrap();
1348 let expected = config.providers["pricing-fixture"].models[0]
1349 .cost
1350 .clone()
1351 .unwrap();
1352 for api in [
1353 "anthropic-messages",
1354 "openai-completions",
1355 "openai-responses",
1356 ] {
1357 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1358 cfg.api = Some(api.into());
1359 let models = provider_to_models("pricing-fixture", cfg).unwrap();
1360 assert_eq!(models[0].cost, expected);
1361 assert!(models[0].has_pricing());
1362 let mut usage = rpi_ai::Usage::zero();
1363 usage.input = 100_000;
1364 usage.output = 250_000;
1365 usage.cache_read = 300_000;
1366 usage.cache_write = 400_000;
1367 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1368 assert!((cost.total - 3.26).abs() < 1e-10, "base pricing for {api}");
1369 usage.input = 1_000_001;
1370 usage.output = 1_000_000;
1371 usage.cache_read = 0;
1372 usage.cache_write = 0;
1373 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1374 assert!(
1375 (cost.total - 20.000004).abs() < 1e-10,
1376 "tiered pricing for {api}"
1377 );
1378 }
1379 let saved = serde_json::to_string(&config).unwrap();
1380 let reloaded = parse_models_json(&saved).unwrap();
1381 assert_eq!(
1382 reloaded.providers["pricing-fixture"].models[0].cost,
1383 Some(expected)
1384 );
1385 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1386 cfg.models[0].cost = Some(ModelCost::default());
1387 let free = provider_to_models("pricing-fixture", cfg).unwrap();
1388 assert_eq!(free[0].cost, ModelCost::default());
1389 assert!(
1390 free[0].has_pricing(),
1391 "explicit zero prices are still configured"
1392 );
1393 }
1394
1395 #[test]
1396 fn models_json_compat_reaches_the_model_for_openai_completions() {
1397 let config: ModelsConfig = serde_json::from_str(
1404 r#"{
1405 "providers": {
1406 "qwen": {
1407 "api": "openai-completions",
1408 "baseUrl": "https://coding.example.com/v1",
1409 "apiKey": "secret",
1410 "models": [{
1411 "id": "qwen3.7-plus",
1412 "maxTokens": 131072,
1413 "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" },
1414 "compat": { "requiresThinkingAsText": true }
1415 }]
1416 }
1417 }
1418 }"#,
1419 )
1420 .unwrap();
1421 let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1422 assert_eq!(
1423 models[0]
1424 .thinking_level_map
1425 .as_ref()
1426 .unwrap()
1427 .get(&rpi_ai::ThinkingLevel::Xhigh),
1428 Some(&Some("xhigh".into()))
1429 );
1430 match models[0].compat.as_ref().expect("compat must survive") {
1431 StreamingProtocolCompat::OpenaiCompletions(compat) => {
1432 assert_eq!(compat.requires_thinking_as_text, Some(true));
1433 }
1434 other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1435 }
1436
1437 let plain: ModelsConfig = serde_json::from_str(
1439 r#"{
1440 "providers": {
1441 "qwen": {
1442 "api": "openai-completions",
1443 "baseUrl": "https://coding.example.com/v1",
1444 "apiKey": "secret",
1445 "models": [{"id":"qwen3.7-plus"}]
1446 }
1447 }
1448 }"#,
1449 )
1450 .unwrap();
1451 let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1452 assert!(plain_models[0].compat.is_none());
1453 }
1454
1455 #[test]
1456 fn models_json_accepts_pi_deepseek_compat_and_warns_on_unknown_or_invalid_fields() {
1457 let mut config: ModelsConfig = serde_json::from_value(serde_json::json!({
1458 "providers": { "deepseek": {
1459 "api": "openai-completions",
1460 "models": [{ "id": "reasoner", "reasoning": true, "compat": {
1461 "thinkingFormat": "deepseek",
1462 "requiresReasoningContentOnAssistantMessages": true
1463 }}]
1464 }}
1465 }))
1466 .unwrap();
1467 assert!(model_compat_warnings(&config).is_empty());
1468 let models = provider_to_models("deepseek", &config.providers["deepseek"]).unwrap();
1469 assert!(rpi_ai::model::prior_reasoning_is_replayed(&models[0]));
1470 let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1471 panic!("missing model compatibility flags");
1472 };
1473 assert_eq!(compat.requires_reasoning_content, Some(true));
1474 assert_eq!(
1475 compat.thinking_format,
1476 Some(rpi_ai::model::OpenaiThinkingFormat::Deepseek)
1477 );
1478
1479 config.providers["deepseek"].models[0]
1480 .compat
1481 .as_mut()
1482 .unwrap()["misspelledField"] = serde_json::json!("secret-fixture-value");
1483 let warnings = model_compat_warnings(&config);
1484 assert_eq!(warnings.len(), 1);
1485 assert!(warnings[0].contains("deepseek") && warnings[0].contains("reasoner"));
1486 assert!(warnings[0].contains("unknown field \"misspelledField\""));
1487 assert!(!warnings[0].contains("secret-fixture-value"));
1488
1489 config.providers["deepseek"].models[0].compat =
1490 Some(serde_json::json!({"requiresReasoningContent": "secret-fixture-token"}));
1491 let warning = &model_compat_warnings(&config)[0];
1492 assert!(warning.contains("invalid configuration"));
1493 assert!(!warning.contains("secret-fixture-token"));
1494 config.providers["deepseek"].models[0].compat =
1495 Some(serde_json::json!({"thinkingFormat": "unsupported"}));
1496 assert!(model_compat_warnings(&config)[0].contains("invalid configuration"));
1497 }
1498
1499 #[test]
1500 fn responses_compat_diagnostics_use_the_responses_schema() {
1501 let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1502 "providers": { "relay": {
1503 "api": "openai-responses", "models": [{ "id": "test", "compat": {
1504 "supportsOpenAIGrammarTools": true, "supportsStore": false
1505 }}]
1506 }}
1507 }))
1508 .unwrap();
1509 let warnings = model_compat_warnings(&config);
1510 assert_eq!(warnings.len(), 1);
1511 assert!(warnings[0].contains("supportsStore"));
1512 }
1513
1514 #[test]
1515 fn native_reasoning_content_key_wins_over_a_conflicting_pi_alias() {
1516 let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1517 "providers": { "relay": {
1518 "api": "openai-completions", "models": [{ "id": "test", "compat": {
1519 "requiresReasoningContent": false,
1520 "requiresReasoningContentOnAssistantMessages": true
1521 }}]
1522 }}
1523 }))
1524 .unwrap();
1525 let warnings = model_compat_warnings(&config);
1526 assert_eq!(warnings.len(), 1);
1527 assert!(warnings[0].contains("takes precedence"));
1528 let models = provider_to_models("relay", &config.providers["relay"]).unwrap();
1529 let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1530 panic!("valid native flag must survive an alias conflict");
1531 };
1532 assert_eq!(compat.requires_reasoning_content, Some(false));
1533 }
1534
1535 #[test]
1536 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1537 let _guard = env_lock().lock().unwrap();
1538 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1539 std::env::set_var(env_name, "env-secret");
1540 let cfg = ProviderConfig {
1541 name: None,
1542 base_url: Some("https://gateway.example.com/v1".into()),
1543 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1544 api: Some("openai-completions".into()),
1545 headers: None,
1546 auth_header: None,
1547 models: vec![ModelDefinition {
1548 id: "fake-model".into(),
1549 name: None,
1550 base_url: None,
1551 reasoning: None,
1552 thinking_level_map: None,
1553 context_window: None,
1554 max_tokens: None,
1555 cost: None,
1556 input: None,
1557 headers: None,
1558 compat: None,
1559 }],
1560 };
1561 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1562 assert_eq!(
1563 openai_provider_api_key("rpi-fake-provider", &cfg).as_deref(),
1564 Some("env-secret")
1565 );
1566 assert!(
1567 models[0].headers.is_none(),
1568 "discovery does not bind credentials"
1569 );
1570 std::env::remove_var(env_name);
1571 }
1572
1573 #[test]
1574 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1575 let _guard = env_lock().lock().unwrap();
1576 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1577 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1578 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1579 std::env::set_var("OPENAI_API_KEY", "official-key");
1580 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1581 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1582 let config = ProviderConfig {
1583 name: None,
1584 base_url: Some("https://gateway.example.com/v1".into()),
1585 api_key: None,
1586 api: Some("openai-responses".into()),
1587 headers: None,
1588 auth_header: None,
1589 models: Vec::new(),
1590 };
1591
1592 assert_eq!(
1593 openai_provider_api_key("openai", &config).as_deref(),
1594 Some("official-key")
1595 );
1596 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1597 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1598 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1599
1600 restore_env("OPENAI_API_KEY", prev_openai);
1601 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1602 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1603 }
1604
1605 #[test]
1606 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1607 assert_eq!(
1608 native_provider_api_key_env_var("openai"),
1609 Some("OPENAI_API_KEY")
1610 );
1611 for provider in [
1612 "deepseek",
1613 "openrouter",
1614 "google",
1615 "groq",
1616 "qwen",
1617 "nvidia",
1618 "OpenAI",
1619 "openai-completions",
1620 ] {
1621 assert_eq!(
1622 native_provider_api_key_env_var(provider),
1623 None,
1624 "external providers must declare auth in their package or models.json"
1625 );
1626 }
1627 }
1628
1629 #[test]
1630 fn malformed_auth_json_is_an_error_not_silent_empty() {
1631 let _cfg = TempConfig::new();
1632 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1633 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1634 }
1635
1636 #[test]
1637 fn agent_dir_nests_under_agent_by_default() {
1638 let _guard = env_lock().lock().unwrap();
1642 let prev = std::env::var_os(CONFIG_DIR_ENV);
1643 std::env::remove_var(CONFIG_DIR_ENV);
1644 let dir = agent_dir().unwrap();
1645 restore_env(CONFIG_DIR_ENV, prev);
1646 assert!(dir.ends_with("agent"));
1647 assert!(dir
1648 .parent()
1649 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1650 .unwrap_or(false));
1651 }
1652
1653 #[test]
1654 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1655 let tmp = tempfile::TempDir::new().unwrap();
1659 let root = tmp.path().to_path_buf();
1660 let agent = root.join("agent");
1661 std::fs::write(root.join("auth.json"), "{}").unwrap();
1662 std::fs::write(root.join("models.json"), "{}").unwrap();
1663 std::fs::write(root.join(".setup_done"), "1").unwrap();
1664 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1665 assert_eq!(moved, 3);
1666 assert!(agent.join("auth.json").exists());
1667 assert!(agent.join("models.json").exists());
1668 assert!(agent.join(".setup_done").exists());
1669 assert!(!root.join("auth.json").exists());
1670 }
1671
1672 #[test]
1673 fn migrate_legacy_layout_noop_when_agent_exists() {
1674 let tmp = tempfile::TempDir::new().unwrap();
1675 let root = tmp.path().to_path_buf();
1676 let agent = root.join("agent");
1677 std::fs::write(root.join("auth.json"), "{}").unwrap();
1678 std::fs::create_dir_all(&agent).unwrap();
1679 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1680 assert_eq!(moved, 0); }
1682
1683 #[test]
1684 fn migrate_legacy_layout_noop_when_no_flat_files() {
1685 let tmp = tempfile::TempDir::new().unwrap();
1686 let root = tmp.path().to_path_buf();
1687 let agent = root.join("agent");
1688 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1689 assert_eq!(moved, 0);
1690 }
1691
1692 #[test]
1693 fn migrate_legacy_layout_public_skips_env_override() {
1694 let _cfg = TempConfig::new();
1697 let moved = migrate_legacy_layout().unwrap();
1698 assert_eq!(moved, 0);
1699 }
1700
1701 #[test]
1702 fn read_trust_missing_file_is_empty() {
1703 let _cfg = TempConfig::new();
1704 assert!(read_trust().unwrap().is_empty());
1705 }
1706
1707 #[test]
1708 fn read_trust_parses_decisions() {
1709 let _cfg = TempConfig::new();
1710 let path = trust_path().unwrap();
1711 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1712 std::fs::write(
1713 &path,
1714 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1715 )
1716 .unwrap();
1717 let store = read_trust().unwrap();
1718 assert_eq!(store.len(), 3);
1719 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1720 assert_eq!(
1721 store.get("/home/me/untrusted").copied().flatten(),
1722 Some(false)
1723 );
1724 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1725 }
1726
1727 #[test]
1728 fn resolve_config_value_literal_passthrough() {
1729 assert_eq!(
1730 resolve_config_value("sk-literal-key", None),
1731 Some("sk-literal-key".into())
1732 );
1733 }
1734
1735 #[test]
1736 fn resolve_config_value_env_var() {
1737 let _guard = env_lock().lock().unwrap();
1738 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1739 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1740 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1741 assert_eq!(
1742 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1743 Some("secret-from-env".into())
1744 );
1745 assert_eq!(
1746 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1747 Some("prefix-secret-from-env-suffix".into())
1748 );
1749 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1751 assert_eq!(
1752 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1753 Some("a-secret-from-env-b-two-c".into())
1754 );
1755 let mut overlay = BTreeMap::new();
1757 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1758 assert_eq!(
1759 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1760 Some("overlay-value".into())
1761 );
1762 restore_env("RPI_TEST_CFG_KEY", prev);
1763 restore_env("RPI_TEST_CFG_KEY2", prev2);
1764 }
1765
1766 #[test]
1767 fn resolve_config_value_unset_env_is_none() {
1768 let _guard = env_lock().lock().unwrap();
1769 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1770 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1771 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1773 assert_eq!(
1774 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1775 None
1776 );
1777 restore_env("RPI_TEST_CFG_ABSENT", prev);
1778 }
1779
1780 #[test]
1781 fn resolve_config_value_dollar_dollar_escapes_literal() {
1782 assert_eq!(
1783 resolve_config_value("price-$$5", None),
1784 Some("price-$5".into())
1785 );
1786 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1787 }
1788
1789 #[test]
1790 fn resolve_config_value_command_runs_shell() {
1791 assert_eq!(
1793 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1794 Some("rpi-cfg-resolved".into())
1795 );
1796 assert_eq!(resolve_config_value_uncached("!false", None), None);
1798 }
1799
1800 #[test]
1801 fn resolve_headers_drops_unresolvable() {
1802 let _guard = env_lock().lock().unwrap();
1803 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1804 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1805 let mut h = BTreeMap::new();
1806 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1807 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1808 h.insert("x-literal".into(), "literal-value".into());
1809 let resolved = resolve_headers(&h, None);
1810 assert_eq!(resolved.len(), 2);
1811 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1812 assert_eq!(
1813 resolved.get("x-literal").map(|s| s.as_str()),
1814 Some("literal-value")
1815 );
1816 assert!(!resolved.contains_key("x-unset"));
1817 restore_env("RPI_TEST_HDR_SET", prev);
1818 }
1819
1820 #[test]
1821 fn agent_dir_respects_env_override() {
1822 let _guard = env_lock().lock().unwrap();
1823 let prev = std::env::var_os(CONFIG_DIR_ENV);
1824 let tmp = tempfile::TempDir::new().unwrap();
1825 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1826 let dir = agent_dir().unwrap();
1827 restore_env(CONFIG_DIR_ENV, prev);
1828 assert_eq!(dir, tmp.path());
1829 }
1830
1831 #[test]
1832 fn relative_override_is_rejected() {
1833 let _guard = env_lock().lock().unwrap();
1834 let prev = std::env::var_os(CONFIG_DIR_ENV);
1835 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1836 let err = agent_dir().unwrap_err();
1837 restore_env(CONFIG_DIR_ENV, prev);
1838 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1839 }
1840
1841 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1843 match prev {
1844 Some(v) => std::env::set_var(name, v),
1845 None => std::env::remove_var(name),
1846 }
1847 }
1848}
1849
1850pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
1852 rpi_runtime::config::provider_to_models(provider_id, cfg, &crate::provider::CliValues)
1853}