1use std::collections::BTreeMap;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39
40use rpi_ai::{Api, InputModality, Model, ModelCost, StreamingProtocolCompat, ThinkingLevelMap};
41
42pub const CONFIG_DIR_NAME: &str = ".rpi";
44
45pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
48
49pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
52
53#[derive(Debug, thiserror::Error)]
60pub enum ConfigError {
61 #[error("could not resolve home directory (set {env} to override)")]
62 NoHomeDir { env: &'static str },
63 #[error("config dir override {env}={val:?} is not an absolute path")]
64 RelativeOverride { env: &'static str, val: String },
65 #[error("could not read {path}: {source}")]
66 Read {
67 path: PathBuf,
68 #[source]
69 source: std::io::Error,
70 },
71 #[error("could not write {path}: {source}")]
72 Write {
73 path: PathBuf,
74 #[source]
75 source: std::io::Error,
76 },
77 #[error("invalid JSON in {path}: {source}")]
78 Json {
79 path: PathBuf,
80 #[source]
81 source: serde_json::Error,
82 },
83}
84
85pub fn agent_dir() -> Result<PathBuf, ConfigError> {
94 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
95 let p = PathBuf::from(&val);
96 if !p.is_absolute() {
97 return Err(ConfigError::RelativeOverride {
98 env: CONFIG_DIR_ENV,
99 val: val.to_string_lossy().into_owned(),
100 });
101 }
102 return Ok(p);
103 }
104 let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
105 env: CONFIG_DIR_ENV,
106 })?;
107 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
108}
109
110fn config_root_dir() -> Result<PathBuf, ConfigError> {
114 let agent = agent_dir()?;
115 agent
116 .parent()
117 .map(Path::to_path_buf)
118 .ok_or(ConfigError::NoHomeDir {
119 env: CONFIG_DIR_ENV,
120 })
121}
122
123pub fn auth_path() -> Result<PathBuf, ConfigError> {
125 Ok(agent_dir()?.join("auth.json"))
126}
127
128pub fn models_path() -> Result<PathBuf, ConfigError> {
130 Ok(agent_dir()?.join("models.json"))
131}
132
133pub fn settings_path() -> Result<PathBuf, ConfigError> {
135 Ok(agent_dir()?.join("settings.json"))
136}
137
138pub fn trust_path() -> Result<PathBuf, ConfigError> {
140 Ok(agent_dir()?.join("trust.json"))
141}
142
143fn read_text_with_fallback(
144 primary: PathBuf,
145 fallback: Option<PathBuf>,
146) -> Result<Option<(PathBuf, String)>, ConfigError> {
147 match std::fs::read_to_string(&primary) {
148 Ok(text) => Ok(Some((primary, text))),
149 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
150 let Some(fallback) = fallback.filter(|path| path != &primary) else {
151 return Ok(None);
152 };
153 match std::fs::read_to_string(&fallback) {
154 Ok(text) => Ok(Some((fallback, text))),
155 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
156 Err(source) => Err(ConfigError::Read {
157 path: fallback,
158 source,
159 }),
160 }
161 }
162 Err(source) => Err(ConfigError::Read {
163 path: primary,
164 source,
165 }),
166 }
167}
168
169pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
177 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
179 return Ok(0);
180 }
181 let root = match config_root_dir() {
182 Ok(p) => p,
183 Err(_) => return Ok(0),
184 };
185 let agent = agent_dir()?;
186 migrate_legacy_layout_in(&root, &agent)
187}
188
189fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
196 if agent.exists() {
198 return Ok(0);
199 }
200 let flat_auth = root.join("auth.json");
202 let flat_models = root.join("models.json");
203 if !flat_auth.exists() && !flat_models.exists() {
204 return Ok(0);
205 }
206 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
207 path: agent.to_path_buf(),
208 source: e,
209 })?;
210 let mut moved = 0usize;
211 for leaf in ["auth.json", "models.json", ".setup_done"] {
212 let from = root.join(leaf);
213 let to = agent.join(leaf);
214 if from.exists() && !to.exists() {
215 if let Err(_e) = std::fs::rename(&from, &to) {
218 if std::fs::copy(&from, &to).is_ok() {
219 let _ = std::fs::remove_file(&from);
220 }
221 }
222 moved += 1;
223 }
224 }
225 Ok(moved)
226}
227
228#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
236#[serde(rename_all = "snake_case", tag = "type")]
237pub enum Credential {
238 ApiKey {
241 key: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 env: Option<BTreeMap<String, String>>,
244 },
245 Oauth {
247 access: String,
248 #[serde(default)]
249 refresh: String,
250 #[serde(default)]
252 expires: i64,
253 },
254}
255
256pub type AuthStore = BTreeMap<String, Credential>;
259
260pub fn read_auth() -> Result<AuthStore, ConfigError> {
263 let path = auth_path()?;
264 match read_text_with_fallback(path, None)? {
265 Some((path, text)) => {
266 serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
267 }
268 None => Ok(AuthStore::new()),
269 }
270}
271
272pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
275 let path = auth_path()?;
276 let dir = agent_dir()?;
277 ensure_dir(&dir)?;
278 let json = serde_json::to_string_pretty(store).unwrap();
279 atomic_write(&path, json.as_bytes())?;
280 set_owner_only(&path);
281 Ok(())
282}
283
284pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
286 let mut store = read_auth()?;
287 store.insert(provider_id.to_string(), cred);
288 write_auth(&store)
289}
290
291pub fn upsert_oauth_token(
294 provider_id: &str,
295 access: String,
296 refresh: String,
297 expires: i64,
298) -> Result<(), ConfigError> {
299 upsert_credential(
300 provider_id,
301 Credential::Oauth {
302 access,
303 refresh,
304 expires,
305 },
306 )
307}
308
309pub fn read_oauth_token(provider_id: &str) -> Result<Option<Credential>, ConfigError> {
311 Ok(match read_auth()?.get(provider_id) {
312 Some(Credential::Oauth {
313 access,
314 refresh,
315 expires,
316 }) => Some(Credential::Oauth {
317 access: access.clone(),
318 refresh: refresh.clone(),
319 expires: *expires,
320 }),
321 _ => None,
322 })
323}
324
325pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
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
339#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
346#[serde(rename_all = "camelCase")]
347pub struct ModelsConfig {
348 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
349 pub providers: indexmap::IndexMap<String, ProviderConfig>,
352}
353
354#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
358#[serde(rename_all = "camelCase")]
359pub struct ProviderConfig {
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub name: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub base_url: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub api_key: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub api: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub headers: Option<BTreeMap<String, String>>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub auth_header: Option<bool>,
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub models: Vec<ModelDefinition>,
376}
377
378#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
380#[serde(rename_all = "camelCase")]
381pub struct ModelDefinition {
382 pub id: String,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub name: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub base_url: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub reasoning: Option<bool>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub thinking_level_map: Option<ThinkingLevelMap>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub context_window: Option<u64>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub max_tokens: Option<u64>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub cost: Option<ModelCost>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub input: Option<Vec<String>>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub headers: Option<BTreeMap<String, String>>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub compat: Option<serde_json::Value>,
406}
407
408pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
410 let path = models_path()?;
411 match read_text_with_fallback(path, None)? {
412 Some((path, text)) => {
413 parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
414 }
415 None => Ok(ModelsConfig::default()),
416 }
417}
418
419pub fn model_compat_warnings(config: &ModelsConfig) -> Vec<String> {
422 let mut warnings = Vec::new();
423 for (provider, cfg) in &config.providers {
424 let defaults = match cfg.api.as_deref() {
425 Some("openai-completions") => {
426 serde_json::to_value(rpi_ai::model::OpenaiCompletionsCompat::default())
427 .expect("compat defaults serialize")
428 }
429 Some("openai-responses") => {
430 serde_json::to_value(rpi_ai::model::OpenaiResponsesCompat::default())
431 .expect("compat defaults serialize")
432 }
433 _ => continue,
434 };
435 for model in &cfg.models {
436 let Some(value) = &model.compat else { continue };
437 let context = format!("provider {provider:?}, model {:?}, compat", model.id);
438 if let Some(fields) = value.as_object() {
439 if provider_is_openai_completions(cfg) {
440 if let (Some(native), Some(alias)) = (
441 fields.get("requiresReasoningContent"),
442 fields.get("requiresReasoningContentOnAssistantMessages"),
443 ) {
444 if native != alias {
445 warnings.push(format!("{context}: conflicting reasoning-content flags; requiresReasoningContent takes precedence"));
446 }
447 }
448 }
449 for key in fields.keys() {
450 let alias = provider_is_openai_completions(cfg)
451 && key == "requiresReasoningContentOnAssistantMessages";
452 if defaults.get(key).is_none() && !alias {
453 warnings.push(format!("{context}: unknown field {key:?}; ignored"));
454 }
455 }
456 }
457 let parsed = if provider_is_openai_completions(cfg) {
458 serde_json::from_value::<rpi_ai::model::OpenaiCompletionsCompat>(
459 normalize_completions_compat(value.clone()),
460 )
461 .map(|_| ())
462 } else {
463 serde_json::from_value::<rpi_ai::model::OpenaiResponsesCompat>(value.clone())
464 .map(|_| ())
465 };
466 if let Err(error) = parsed {
467 let error = error.to_string();
470 let expected = error
471 .rsplit_once(", expected ")
472 .map_or("a valid compat object", |(_, expected)| expected);
473 warnings.push(format!(
474 "{context}: invalid configuration; expected {expected}; compat ignored"
475 ));
476 }
477 }
478 }
479 warnings
480}
481
482fn normalize_completions_compat(mut value: serde_json::Value) -> serde_json::Value {
483 if let Some(fields) = value.as_object_mut() {
484 if let Some(alias) = fields.remove("requiresReasoningContentOnAssistantMessages") {
485 fields.entry("requiresReasoningContent").or_insert(alias);
486 }
487 }
488 value
489}
490
491pub fn save_models_config(cfg: &ModelsConfig) -> Result<(), ConfigError> {
497 let path = models_path()?;
498 let dir = agent_dir()?;
499 ensure_dir(&dir)?;
500 if let Ok(existing) = std::fs::read(&path) {
503 let stamp = std::time::SystemTime::now()
504 .duration_since(std::time::UNIX_EPOCH)
505 .map(|d| d.as_secs())
506 .unwrap_or(0);
507 let backup = dir.join(format!("models.json.bak-{stamp}"));
508 let _ = std::fs::write(&backup, existing);
509 }
510 let text = serde_json::to_string_pretty(cfg).map_err(|source| ConfigError::Json {
511 path: path.clone(),
512 source,
513 })?;
514 atomic_write(&path, text.as_bytes())
515}
516
517pub type TrustStore = BTreeMap<String, Option<bool>>;
527
528pub fn read_trust() -> Result<TrustStore, ConfigError> {
532 let path = trust_path()?;
533 match std::fs::read_to_string(&path) {
534 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
535 path: path.clone(),
536 source: e,
537 }),
538 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
539 Err(e) => Err(ConfigError::Read { path, source: e }),
540 }
541}
542
543pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
546 let key = std::fs::canonicalize(cwd)
547 .unwrap_or_else(|_| cwd.to_path_buf())
548 .to_string_lossy()
549 .into_owned();
550 let mut store = read_trust()?;
551 if let Some(decision) = trusted {
552 store.insert(key, Some(decision));
553 } else {
554 store.remove(&key);
555 }
556 let path = trust_path()?;
557 ensure_dir(&agent_dir()?)?;
558 let json = serde_json::to_string_pretty(&store).unwrap();
559 atomic_write(&path, json.as_bytes())?;
560 set_owner_only(&path);
561 Ok(())
562}
563
564pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
567 let key = std::fs::canonicalize(cwd)
568 .unwrap_or_else(|_| cwd.to_path_buf())
569 .to_string_lossy()
570 .into_owned();
571 Ok(read_trust()?.get(&key).copied().flatten())
572}
573
574fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
578 match serde_json::from_str(text) {
579 Ok(c) => Ok(c),
580 Err(first) => {
581 let stripped = strip_line_comments(text);
586 serde_json::from_str(&stripped).map_err(|_| first)
587 }
588 }
589}
590
591pub(crate) fn strip_line_comments(text: &str) -> String {
596 text.lines()
597 .map(|line| match find_line_comment(line) {
598 Some(idx) => line[..idx].to_string(),
599 None => line.to_string(),
600 })
601 .collect::<Vec<_>>()
602 .join("\n")
603}
604
605fn find_line_comment(line: &str) -> Option<usize> {
607 let mut in_str = false;
608 let mut esc = false;
609 for (i, ch) in line.char_indices() {
610 if esc {
611 esc = false;
612 continue;
613 }
614 match ch {
615 '\\' if in_str => esc = true,
616 '"' => in_str = !in_str,
617 '/' if !in_str && line.as_bytes().get(i + 1) == Some(&b'/') => {
618 return Some(i);
619 }
620 _ => {}
621 }
622 }
623 None
624}
625
626fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
633 static CACHE: std::sync::OnceLock<
634 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
635 > = std::sync::OnceLock::new();
636 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
637}
638
639pub fn resolve_config_value(
654 config: &str,
655 env_overlay: Option<&BTreeMap<String, String>>,
656) -> Option<String> {
657 if let Some(cmd) = config.strip_prefix('!') {
658 return resolve_command(cmd);
659 }
660 resolve_template(config, env_overlay)
661}
662
663pub fn resolve_config_value_uncached(
667 config: &str,
668 env_overlay: Option<&BTreeMap<String, String>>,
669) -> Option<String> {
670 if let Some(cmd) = config.strip_prefix('!') {
671 return resolve_command_uncached(cmd);
672 }
673 resolve_template(config, env_overlay)
674}
675
676pub fn resolve_headers(
680 headers: &BTreeMap<String, String>,
681 env_overlay: Option<&BTreeMap<String, String>>,
682) -> BTreeMap<String, String> {
683 let mut out = BTreeMap::new();
684 for (k, v) in headers {
685 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
686 out.insert(k.clone(), resolved);
687 }
688 }
689 out
690}
691
692fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
695 if let Some(overlay) = env_overlay {
696 if let Some(v) = overlay.get(name) {
697 return Some(v.clone());
698 }
699 }
700 std::env::var(name).ok()
701}
702
703enum TemplatePart {
705 Literal(String),
706 Env(String),
707}
708
709fn parse_template(config: &str) -> Vec<TemplatePart> {
714 let mut parts: Vec<TemplatePart> = Vec::new();
715 let bytes = config.as_bytes();
716 let mut i = 0usize;
717 while i < bytes.len() {
718 match config[i..].find('$') {
720 None => {
721 push_literal(&mut parts, &config[i..]);
722 break;
723 }
724 Some(offset) => {
725 let dollar = i + offset;
726 push_literal(&mut parts, &config[i..dollar]);
727 let after = dollar + 1;
728 let next = bytes.get(after).copied();
729 if next == Some(b'$') || next == Some(b'!') {
730 push_literal(&mut parts, &config[after..after + 1]);
731 i = after + 1;
732 continue;
733 }
734 if next == Some(b'{') {
735 if let Some(end_rel) = config[after + 1..].find('}') {
737 let end = after + 1 + end_rel;
738 let name = &config[after + 1..end];
739 if is_env_name(name) {
740 parts.push(TemplatePart::Env(name.to_string()));
741 } else {
742 push_literal(&mut parts, &config[dollar..=end]);
744 }
745 i = end + 1;
746 continue;
747 }
748 push_literal(&mut parts, "$");
750 i = after;
751 continue;
752 }
753 if let Some(name) = env_name_prefix(&config[after..]) {
755 parts.push(TemplatePart::Env(name.to_string()));
756 i = after + name.len();
757 } else {
758 push_literal(&mut parts, "$");
759 i = after;
760 }
761 }
762 }
763 }
764 parts
765}
766
767fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
768 if value.is_empty() {
769 return;
770 }
771 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
772 s.push_str(value);
773 } else {
774 parts.push(TemplatePart::Literal(value.to_string()));
775 }
776}
777
778fn is_env_name(s: &str) -> bool {
779 let mut chars = s.chars();
780 match chars.next() {
781 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
782 _ => return false,
783 }
784 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
785}
786
787fn env_name_prefix(s: &str) -> Option<&str> {
790 let mut chars = s.char_indices();
791 match chars.next() {
792 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
793 _ => return None,
794 }
795 let end = chars
796 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
797 .map(|(idx, _)| idx)
798 .unwrap_or(s.len());
799 Some(&s[..end])
800}
801
802fn resolve_template(
805 config: &str,
806 env_overlay: Option<&BTreeMap<String, String>>,
807) -> Option<String> {
808 let parts = parse_template(config);
809 let mut out = String::with_capacity(config.len());
810 for part in parts {
811 match part {
812 TemplatePart::Literal(s) => out.push_str(&s),
813 TemplatePart::Env(name) => {
814 let v = env_lookup(&name, env_overlay)?;
815 out.push_str(&v)
816 }
817 }
818 }
819 Some(out)
820}
821
822fn resolve_command(cmd: &str) -> Option<String> {
826 let key = format!("!{cmd}");
827 if let Some(v) = command_cache().lock().ok()?.get(&key) {
828 return v.clone();
829 }
830 let result = resolve_command_uncached(cmd);
831 if let Ok(mut cache) = command_cache().lock() {
832 cache.insert(key, result.clone());
833 }
834 result
835}
836
837#[cfg(unix)]
838fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
839 std::process::Command::new("sh")
840 .arg("-c")
841 .arg(cmd)
842 .stdin(std::process::Stdio::null())
843 .stdout(std::process::Stdio::piped())
844 .stderr(std::process::Stdio::null())
845 .output()
846 .ok()
847}
848
849#[cfg(windows)]
850fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
851 use std::os::windows::process::CommandExt;
852 std::process::Command::new("cmd")
853 .arg("/C")
854 .arg(cmd)
855 .stdin(std::process::Stdio::null())
856 .stdout(std::process::Stdio::piped())
857 .stderr(std::process::Stdio::null())
858 .creation_flags(0x0800_0000) .output()
860 .ok()
861}
862
863fn resolve_command_uncached(cmd: &str) -> Option<String> {
865 let output = spawn_shell_command(cmd)?;
866 if !output.status.success() {
867 return None;
868 }
869 let stdout = String::from_utf8_lossy(&output.stdout);
870 let trimmed = stdout.trim();
871 if trimmed.is_empty() {
872 None
873 } else {
874 Some(trimmed.to_string())
875 }
876}
877
878pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
883 match cfg.api.as_deref() {
884 None | Some("") | Some("anthropic-messages") => true,
885 _ => false,
886 }
887}
888
889pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
891 matches!(cfg.api.as_deref(), Some("openai-completions"))
892}
893
894pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
895 matches!(cfg.api.as_deref(), Some("openai-responses"))
896}
897
898pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
903 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
904 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
905 return Some(value);
906 }
907 }
908
909 let name = native_provider_api_key_env_var(provider_id)?;
910 std::env::var(name).ok().filter(|value| !value.is_empty())
911}
912
913fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
917 match provider_id {
918 "openai" => crate::credentials::builtin_api_key_env(provider_id),
919 _ => None,
920 }
921}
922
923pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
927 let api = if provider_is_anthropic_compatible(cfg) {
928 Api::AnthropicMessages
929 } else if provider_is_openai_completions(cfg) {
930 Api::OpenaiCompletions
931 } else if provider_is_openai_responses(cfg) {
932 Api::OpenaiResponses
933 } else {
934 return None;
935 };
936 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
937 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
938 _ => default_anthropic_base_url(),
939 });
940 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
941 for def in &cfg.models {
942 let base_url = def
943 .base_url
944 .clone()
945 .unwrap_or_else(|| provider_base.clone());
946 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
947 let mut m = Model::new(
948 def.id.clone(),
949 name,
950 api.clone(),
951 provider_id.to_string(),
952 base_url,
953 );
954 m.reasoning = def.reasoning.unwrap_or(false);
955 m.thinking_level_map = def.thinking_level_map.clone();
956 m.context_window = def.context_window.unwrap_or(0);
957 m.max_tokens = def.max_tokens.unwrap_or(0);
958 if let Some(cost) = &def.cost {
959 m.cost = cost.clone();
960 m.cost_configured = true;
961 }
962 m.input = parse_input_modalities(def.input.as_deref());
963 let mut headers: BTreeMap<String, String> = BTreeMap::new();
977 if let Some(h) = def.headers.clone() {
978 for (k, v) in resolve_headers(&h, None) {
979 headers.insert(k, v);
980 }
981 }
982 if let Some(h) = cfg.headers.clone() {
983 for (k, v) in resolve_headers(&h, None) {
984 headers.insert(k, v);
985 }
986 }
987 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
988 if let Some(value) = def.compat.clone() {
989 if matches!(api, Api::OpenaiResponses) {
990 if let Ok(compat) = serde_json::from_value(value) {
991 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
992 }
993 } else if let Ok(compat) =
994 serde_json::from_value(normalize_completions_compat(value))
995 {
996 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
997 }
998 }
999 }
1000 if !headers.is_empty() {
1001 m.headers = Some(headers);
1002 }
1003 merged.push(m);
1004 }
1005 Some(merged)
1006}
1007
1008fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
1011 match input {
1012 None => vec![InputModality::Text],
1013 Some(list) if list.is_empty() => vec![InputModality::Text],
1014 Some(list) => list
1015 .iter()
1016 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
1017 "text" => Some(InputModality::Text),
1018 "image" => Some(InputModality::Image),
1019 _ => None,
1020 })
1021 .collect::<Vec<_>>()
1022 .pipe(|v| {
1023 if v.is_empty() {
1024 vec![InputModality::Text]
1025 } else {
1026 v
1027 }
1028 }),
1029 }
1030}
1031
1032pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
1038
1039pub fn default_anthropic_base_url() -> String {
1043 ANTHROPIC_DEFAULT_BASE_URL.to_string()
1044}
1045
1046#[cfg(unix)]
1051use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1052
1053fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
1056 if dir.exists() {
1057 return Ok(());
1058 }
1059 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
1060 path: dir.to_path_buf(),
1061 source: e,
1062 })?;
1063 #[cfg(unix)]
1064 {
1065 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
1066 }
1067 Ok(())
1068}
1069
1070pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1073 let dir = path.parent().ok_or_else(|| ConfigError::Write {
1074 path: path.to_path_buf(),
1075 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
1076 })?;
1077 let tmp = dir.join(format!(
1078 ".{}.{}.tmp",
1079 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
1080 uuid::Uuid::new_v4().simple()
1081 ));
1082 let mut options = std::fs::OpenOptions::new();
1083 options.write(true).create_new(true);
1084 #[cfg(unix)]
1085 options.mode(0o600);
1086 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
1087 path: tmp.clone(),
1088 source,
1089 })?;
1090 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1091 let _ = std::fs::remove_file(&tmp);
1095 return Err(ConfigError::Write {
1096 path: tmp,
1097 source: error,
1098 });
1099 }
1100 drop(file);
1101 if let Err(error) = std::fs::rename(&tmp, path) {
1102 let _ = std::fs::remove_file(&tmp);
1105 return Err(ConfigError::Write {
1106 path: path.to_path_buf(),
1107 source: error,
1108 });
1109 }
1110 Ok(())
1111}
1112
1113fn set_owner_only(_path: &Path) {
1116 #[cfg(unix)]
1117 {
1118 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
1119 }
1120}
1121
1122trait Pipe: Sized {
1125 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
1126 f(self)
1127 }
1128}
1129impl<T> Pipe for T {}
1130
1131#[cfg(test)]
1136pub(crate) mod test_support {
1137 use std::sync::{Mutex, OnceLock};
1143 pub(crate) fn env_lock() -> &'static Mutex<()> {
1144 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1145 LOCK.get_or_init(|| Mutex::new(()))
1146 }
1147
1148 pub(crate) struct SessionDirEnv {
1151 _guard: std::sync::MutexGuard<'static, ()>,
1152 previous: Option<std::ffi::OsString>,
1153 }
1154
1155 impl SessionDirEnv {
1156 pub(crate) fn new(directory: Option<&std::path::Path>) -> Self {
1157 let guard = env_lock().lock().unwrap();
1158 let previous = std::env::var_os("RPI_CODING_AGENT_SESSION_DIR");
1159 match directory {
1160 Some(directory) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", directory),
1161 None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
1162 }
1163 Self {
1164 _guard: guard,
1165 previous,
1166 }
1167 }
1168 }
1169
1170 impl Drop for SessionDirEnv {
1171 fn drop(&mut self) {
1172 match self.previous.take() {
1173 Some(value) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", value),
1174 None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
1175 }
1176 }
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use crate::config::test_support::env_lock;
1184
1185 struct TempConfig {
1189 _guard: std::sync::MutexGuard<'static, ()>,
1190 _tmp: tempfile::TempDir,
1191 prev: Option<std::ffi::OsString>,
1192 }
1193 impl TempConfig {
1194 fn new() -> Self {
1195 let guard = env_lock().lock().unwrap();
1196 let prev = std::env::var_os(CONFIG_DIR_ENV);
1197 let tmp = tempfile::TempDir::new().unwrap();
1198 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1199 Self {
1200 _guard: guard,
1201 _tmp: tmp,
1202 prev,
1203 }
1204 }
1205 }
1206 impl Drop for TempConfig {
1207 fn drop(&mut self) {
1208 restore_env(CONFIG_DIR_ENV, self.prev.take());
1209 }
1210 }
1211
1212 #[test]
1213 fn read_auth_missing_file_is_empty() {
1214 let _cfg = TempConfig::new();
1215 let store = read_auth().unwrap();
1216 assert!(store.is_empty());
1217 }
1218
1219 #[test]
1220 fn upsert_then_read_roundtrip() {
1221 let _cfg = TempConfig::new();
1222 upsert_credential(
1223 "anthropic",
1224 Credential::ApiKey {
1225 key: Some("sk-test-123".into()),
1226 env: None,
1227 },
1228 )
1229 .unwrap();
1230 let store = read_auth().unwrap();
1231 match store.get("anthropic") {
1232 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
1233 other => panic!("unexpected cred: {other:?}"),
1234 }
1235 let path = auth_path().unwrap();
1237 assert!(path.exists(), "auth.json should exist after upsert");
1238 let raw = std::fs::read_to_string(&path).unwrap();
1239 assert!(raw.contains("\"anthropic\""));
1240 assert!(raw.contains("api_key"));
1241 }
1242
1243 #[test]
1244 fn oauth_token_is_scoped_and_round_trips() {
1245 let _cfg = TempConfig::new();
1246 upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
1247 assert!(matches!(
1248 read_oauth_token("acme").unwrap(),
1249 Some(Credential::Oauth { access, refresh, expires })
1250 if access == "access" && refresh == "refresh" && expires == 123
1251 ));
1252 assert!(read_oauth_token("other").unwrap().is_none());
1253 }
1254
1255 #[test]
1256 fn delete_credential_removes_entry() {
1257 let _cfg = TempConfig::new();
1258 upsert_credential(
1259 "anthropic",
1260 Credential::ApiKey {
1261 key: Some("k".into()),
1262 env: None,
1263 },
1264 )
1265 .unwrap();
1266 assert!(delete_credential("anthropic").unwrap());
1267 assert!(!delete_credential("anthropic").unwrap());
1269 assert!(read_auth().unwrap().is_empty());
1270 }
1271
1272 #[test]
1273 fn load_models_config_missing_is_empty() {
1274 let _cfg = TempConfig::new();
1275 let c = load_models_config().unwrap();
1276 assert!(c.providers.is_empty());
1277 }
1278
1279 #[test]
1280 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1281 let tmp = tempfile::tempdir().unwrap();
1282 let primary = tmp.path().join("primary.json");
1283 let fallback = tmp.path().join("fallback.json");
1284 std::fs::write(&fallback, "fallback").unwrap();
1285
1286 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1287 .unwrap()
1288 .expect("fallback should be read");
1289 assert_eq!(text, "fallback");
1290
1291 std::fs::write(&primary, "primary").unwrap();
1292 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1293 .unwrap()
1294 .expect("primary should be read");
1295 assert_eq!(path, primary);
1296 assert_eq!(text, "primary");
1297 }
1298
1299 #[test]
1300 fn fallback_reader_does_not_mask_primary_read_errors() {
1301 let tmp = tempfile::tempdir().unwrap();
1302 let primary = tmp.path().join("primary.json");
1303 let fallback = tmp.path().join("fallback.json");
1304 std::fs::create_dir(&primary).unwrap();
1305 std::fs::write(&fallback, "fallback").unwrap();
1306
1307 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1308 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1309 }
1310
1311 #[test]
1312 fn load_models_config_parses_with_comments() {
1313 let _cfg = TempConfig::new();
1314 let json = r#"{
1315 // a one-api style gateway
1316 "providers": {
1317 "gateway": {
1318 "baseUrl": "https://gw.example.com",
1319 "authHeader": true,
1320 "apiKey": "gw-secret",
1321 "models": [
1322 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1323 ]
1324 }
1325 }
1326}"#;
1327 std::fs::write(models_path().unwrap(), json).unwrap();
1328 let c = load_models_config().unwrap();
1329 let gw = c
1330 .providers
1331 .get("gateway")
1332 .expect("gateway provider present");
1333 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1334 assert!(gw.auth_header.unwrap_or(false));
1335 assert_eq!(gw.models.len(), 1);
1336 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1337 }
1338
1339 #[test]
1340 fn provider_to_models_merges_headers_without_synth_bearer() {
1341 let cfg = ProviderConfig {
1347 name: None,
1348 base_url: Some("https://gw.example.com".into()),
1349 api_key: Some("gw-secret".into()),
1350 api: None,
1351 headers: Some({
1352 let mut h = BTreeMap::new();
1353 h.insert("x-portkey-key".into(), "portkey-secret".into());
1354 h
1355 }),
1356 auth_header: Some(true),
1357 models: vec![ModelDefinition {
1358 id: "claude-sonnet-5".into(),
1359 name: None,
1360 base_url: None,
1361 reasoning: None,
1362 thinking_level_map: None,
1363 context_window: None,
1364 max_tokens: None,
1365 cost: None,
1366 input: None,
1367 headers: None,
1368 compat: None,
1369 }],
1370 };
1371 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1372 assert_eq!(models.len(), 1);
1373 let m = &models[0];
1374 assert_eq!(m.id, "claude-sonnet-5");
1375 assert_eq!(m.base_url, "https://gw.example.com");
1376 assert_eq!(m.provider, "gateway");
1377 let headers = m.headers.as_ref().expect("provider headers merged");
1378 assert_eq!(
1380 headers.get("x-portkey-key").map(|s| s.as_str()),
1381 Some("portkey-secret")
1382 );
1383 assert!(
1388 headers.get("authorization").is_none(),
1389 "provider_to_models must not synthesize the Bearer; resolve does"
1390 );
1391 }
1392
1393 #[test]
1394 fn provider_to_models_supports_openai_completions() {
1395 let config: ModelsConfig = serde_json::from_str(
1396 r#"{
1397 "providers": {
1398 "oai": {
1399 "api": "openai-completions",
1400 "baseUrl": "https://gateway.example.com/v1",
1401 "apiKey": "secret",
1402 "models": [{"id":"gpt-test","maxTokens":4096}]
1403 }
1404 }
1405 }"#,
1406 )
1407 .unwrap();
1408 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1409 assert_eq!(models.len(), 1);
1410 assert_eq!(models[0].api, Api::OpenaiCompletions);
1411 assert_eq!(models[0].provider, "oai");
1412 assert_eq!(models[0].max_tokens, 4096);
1413 assert_eq!(models[0].cost, ModelCost::default());
1414 assert!(!models[0].has_pricing());
1415 assert!(
1416 models[0].headers.is_none(),
1417 "credentials are bound after provider resolution"
1418 );
1419 }
1420
1421 #[test]
1422 fn models_json_pricing_reaches_providers_and_survives_serialization() {
1423 let mut config = parse_models_json(
1424 r#"{
1425 "providers": {
1426 "pricing-fixture": {
1427 "api": "anthropic-messages",
1428 "models": [{
1429 "id": "priced-model",
1430 "cost": {
1431 "input": 2, "output": 8, "cacheRead": 0.2, "cacheWrite": 2.5,
1432 "tiers": [{
1433 "inputTokensAbove": 1000000,
1434 "input": 4, "output": 16, "cacheRead": 0.4, "cacheWrite": 5
1435 }]
1436 }
1437 }]
1438 }
1439 }
1440 }"#,
1441 )
1442 .unwrap();
1443 let expected = config.providers["pricing-fixture"].models[0]
1444 .cost
1445 .clone()
1446 .unwrap();
1447 for api in [
1448 "anthropic-messages",
1449 "openai-completions",
1450 "openai-responses",
1451 ] {
1452 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1453 cfg.api = Some(api.into());
1454 let models = provider_to_models("pricing-fixture", cfg).unwrap();
1455 assert_eq!(models[0].cost, expected);
1456 assert!(models[0].has_pricing());
1457 let mut usage = rpi_ai::Usage::zero();
1458 usage.input = 100_000;
1459 usage.output = 250_000;
1460 usage.cache_read = 300_000;
1461 usage.cache_write = 400_000;
1462 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1463 assert!((cost.total - 3.26).abs() < 1e-10, "base pricing for {api}");
1464 usage.input = 1_000_001;
1465 usage.output = 1_000_000;
1466 usage.cache_read = 0;
1467 usage.cache_write = 0;
1468 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1469 assert!(
1470 (cost.total - 20.000004).abs() < 1e-10,
1471 "tiered pricing for {api}"
1472 );
1473 }
1474 let saved = serde_json::to_string(&config).unwrap();
1475 let reloaded = parse_models_json(&saved).unwrap();
1476 assert_eq!(
1477 reloaded.providers["pricing-fixture"].models[0].cost,
1478 Some(expected)
1479 );
1480 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1481 cfg.models[0].cost = Some(ModelCost::default());
1482 let free = provider_to_models("pricing-fixture", cfg).unwrap();
1483 assert_eq!(free[0].cost, ModelCost::default());
1484 assert!(
1485 free[0].has_pricing(),
1486 "explicit zero prices are still configured"
1487 );
1488 }
1489
1490 #[test]
1491 fn models_json_compat_reaches_the_model_for_openai_completions() {
1492 let config: ModelsConfig = serde_json::from_str(
1499 r#"{
1500 "providers": {
1501 "qwen": {
1502 "api": "openai-completions",
1503 "baseUrl": "https://coding.example.com/v1",
1504 "apiKey": "secret",
1505 "models": [{
1506 "id": "qwen3.7-plus",
1507 "maxTokens": 131072,
1508 "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" },
1509 "compat": { "requiresThinkingAsText": true }
1510 }]
1511 }
1512 }
1513 }"#,
1514 )
1515 .unwrap();
1516 let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1517 assert_eq!(
1518 models[0]
1519 .thinking_level_map
1520 .as_ref()
1521 .unwrap()
1522 .get(&rpi_ai::ThinkingLevel::Xhigh),
1523 Some(&Some("xhigh".into()))
1524 );
1525 match models[0].compat.as_ref().expect("compat must survive") {
1526 StreamingProtocolCompat::OpenaiCompletions(compat) => {
1527 assert_eq!(compat.requires_thinking_as_text, Some(true));
1528 }
1529 other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1530 }
1531
1532 let plain: ModelsConfig = serde_json::from_str(
1534 r#"{
1535 "providers": {
1536 "qwen": {
1537 "api": "openai-completions",
1538 "baseUrl": "https://coding.example.com/v1",
1539 "apiKey": "secret",
1540 "models": [{"id":"qwen3.7-plus"}]
1541 }
1542 }
1543 }"#,
1544 )
1545 .unwrap();
1546 let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1547 assert!(plain_models[0].compat.is_none());
1548 }
1549
1550 #[test]
1551 fn models_json_accepts_pi_deepseek_compat_and_warns_on_unknown_or_invalid_fields() {
1552 let mut config: ModelsConfig = serde_json::from_value(serde_json::json!({
1553 "providers": { "deepseek": {
1554 "api": "openai-completions",
1555 "models": [{ "id": "reasoner", "reasoning": true, "compat": {
1556 "thinkingFormat": "deepseek",
1557 "requiresReasoningContentOnAssistantMessages": true
1558 }}]
1559 }}
1560 }))
1561 .unwrap();
1562 assert!(model_compat_warnings(&config).is_empty());
1563 let models = provider_to_models("deepseek", &config.providers["deepseek"]).unwrap();
1564 assert!(rpi_ai::model::prior_reasoning_is_replayed(&models[0]));
1565 let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1566 panic!("missing model compatibility flags");
1567 };
1568 assert_eq!(compat.requires_reasoning_content, Some(true));
1569 assert_eq!(
1570 compat.thinking_format,
1571 Some(rpi_ai::model::OpenaiThinkingFormat::Deepseek)
1572 );
1573
1574 config.providers["deepseek"].models[0]
1575 .compat
1576 .as_mut()
1577 .unwrap()["misspelledField"] = serde_json::json!("secret-fixture-value");
1578 let warnings = model_compat_warnings(&config);
1579 assert_eq!(warnings.len(), 1);
1580 assert!(warnings[0].contains("deepseek") && warnings[0].contains("reasoner"));
1581 assert!(warnings[0].contains("unknown field \"misspelledField\""));
1582 assert!(!warnings[0].contains("secret-fixture-value"));
1583
1584 config.providers["deepseek"].models[0].compat =
1585 Some(serde_json::json!({"requiresReasoningContent": "secret-fixture-token"}));
1586 let warning = &model_compat_warnings(&config)[0];
1587 assert!(warning.contains("invalid configuration"));
1588 assert!(!warning.contains("secret-fixture-token"));
1589 config.providers["deepseek"].models[0].compat =
1590 Some(serde_json::json!({"thinkingFormat": "unsupported"}));
1591 assert!(model_compat_warnings(&config)[0].contains("invalid configuration"));
1592 }
1593
1594 #[test]
1595 fn responses_compat_diagnostics_use_the_responses_schema() {
1596 let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1597 "providers": { "relay": {
1598 "api": "openai-responses", "models": [{ "id": "test", "compat": {
1599 "supportsOpenAIGrammarTools": true, "supportsStore": false
1600 }}]
1601 }}
1602 }))
1603 .unwrap();
1604 let warnings = model_compat_warnings(&config);
1605 assert_eq!(warnings.len(), 1);
1606 assert!(warnings[0].contains("supportsStore"));
1607 }
1608
1609 #[test]
1610 fn native_reasoning_content_key_wins_over_a_conflicting_pi_alias() {
1611 let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1612 "providers": { "relay": {
1613 "api": "openai-completions", "models": [{ "id": "test", "compat": {
1614 "requiresReasoningContent": false,
1615 "requiresReasoningContentOnAssistantMessages": true
1616 }}]
1617 }}
1618 }))
1619 .unwrap();
1620 let warnings = model_compat_warnings(&config);
1621 assert_eq!(warnings.len(), 1);
1622 assert!(warnings[0].contains("takes precedence"));
1623 let models = provider_to_models("relay", &config.providers["relay"]).unwrap();
1624 let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1625 panic!("valid native flag must survive an alias conflict");
1626 };
1627 assert_eq!(compat.requires_reasoning_content, Some(false));
1628 }
1629
1630 #[test]
1631 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1632 let _guard = env_lock().lock().unwrap();
1633 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1634 std::env::set_var(env_name, "env-secret");
1635 let cfg = ProviderConfig {
1636 name: None,
1637 base_url: Some("https://gateway.example.com/v1".into()),
1638 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1639 api: Some("openai-completions".into()),
1640 headers: None,
1641 auth_header: None,
1642 models: vec![ModelDefinition {
1643 id: "fake-model".into(),
1644 name: None,
1645 base_url: None,
1646 reasoning: None,
1647 thinking_level_map: None,
1648 context_window: None,
1649 max_tokens: None,
1650 cost: None,
1651 input: None,
1652 headers: None,
1653 compat: None,
1654 }],
1655 };
1656 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1657 assert_eq!(
1658 openai_provider_api_key("rpi-fake-provider", &cfg).as_deref(),
1659 Some("env-secret")
1660 );
1661 assert!(
1662 models[0].headers.is_none(),
1663 "discovery does not bind credentials"
1664 );
1665 std::env::remove_var(env_name);
1666 }
1667
1668 #[test]
1669 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1670 let _guard = env_lock().lock().unwrap();
1671 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1672 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1673 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1674 std::env::set_var("OPENAI_API_KEY", "official-key");
1675 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1676 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1677 let config = ProviderConfig {
1678 name: None,
1679 base_url: Some("https://gateway.example.com/v1".into()),
1680 api_key: None,
1681 api: Some("openai-responses".into()),
1682 headers: None,
1683 auth_header: None,
1684 models: Vec::new(),
1685 };
1686
1687 assert_eq!(
1688 openai_provider_api_key("openai", &config).as_deref(),
1689 Some("official-key")
1690 );
1691 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1692 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1693 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1694
1695 restore_env("OPENAI_API_KEY", prev_openai);
1696 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1697 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1698 }
1699
1700 #[test]
1701 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1702 assert_eq!(
1703 native_provider_api_key_env_var("openai"),
1704 Some("OPENAI_API_KEY")
1705 );
1706 for provider in [
1707 "deepseek",
1708 "openrouter",
1709 "google",
1710 "groq",
1711 "qwen",
1712 "nvidia",
1713 "OpenAI",
1714 "openai-completions",
1715 ] {
1716 assert_eq!(
1717 native_provider_api_key_env_var(provider),
1718 None,
1719 "external providers must declare auth in their package or models.json"
1720 );
1721 }
1722 }
1723
1724 #[test]
1725 fn malformed_auth_json_is_an_error_not_silent_empty() {
1726 let _cfg = TempConfig::new();
1727 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1728 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1729 }
1730
1731 #[test]
1732 fn agent_dir_nests_under_agent_by_default() {
1733 let _guard = env_lock().lock().unwrap();
1737 let prev = std::env::var_os(CONFIG_DIR_ENV);
1738 std::env::remove_var(CONFIG_DIR_ENV);
1739 let dir = agent_dir().unwrap();
1740 restore_env(CONFIG_DIR_ENV, prev);
1741 assert!(dir.ends_with("agent"));
1742 assert!(dir
1743 .parent()
1744 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1745 .unwrap_or(false));
1746 }
1747
1748 #[test]
1749 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1750 let tmp = tempfile::TempDir::new().unwrap();
1754 let root = tmp.path().to_path_buf();
1755 let agent = root.join("agent");
1756 std::fs::write(root.join("auth.json"), "{}").unwrap();
1757 std::fs::write(root.join("models.json"), "{}").unwrap();
1758 std::fs::write(root.join(".setup_done"), "1").unwrap();
1759 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1760 assert_eq!(moved, 3);
1761 assert!(agent.join("auth.json").exists());
1762 assert!(agent.join("models.json").exists());
1763 assert!(agent.join(".setup_done").exists());
1764 assert!(!root.join("auth.json").exists());
1765 }
1766
1767 #[test]
1768 fn migrate_legacy_layout_noop_when_agent_exists() {
1769 let tmp = tempfile::TempDir::new().unwrap();
1770 let root = tmp.path().to_path_buf();
1771 let agent = root.join("agent");
1772 std::fs::write(root.join("auth.json"), "{}").unwrap();
1773 std::fs::create_dir_all(&agent).unwrap();
1774 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1775 assert_eq!(moved, 0); }
1777
1778 #[test]
1779 fn migrate_legacy_layout_noop_when_no_flat_files() {
1780 let tmp = tempfile::TempDir::new().unwrap();
1781 let root = tmp.path().to_path_buf();
1782 let agent = root.join("agent");
1783 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1784 assert_eq!(moved, 0);
1785 }
1786
1787 #[test]
1788 fn migrate_legacy_layout_public_skips_env_override() {
1789 let _cfg = TempConfig::new();
1792 let moved = migrate_legacy_layout().unwrap();
1793 assert_eq!(moved, 0);
1794 }
1795
1796 #[test]
1797 fn read_trust_missing_file_is_empty() {
1798 let _cfg = TempConfig::new();
1799 assert!(read_trust().unwrap().is_empty());
1800 }
1801
1802 #[test]
1803 fn read_trust_parses_decisions() {
1804 let _cfg = TempConfig::new();
1805 let path = trust_path().unwrap();
1806 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1807 std::fs::write(
1808 &path,
1809 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1810 )
1811 .unwrap();
1812 let store = read_trust().unwrap();
1813 assert_eq!(store.len(), 3);
1814 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1815 assert_eq!(
1816 store.get("/home/me/untrusted").copied().flatten(),
1817 Some(false)
1818 );
1819 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1820 }
1821
1822 #[test]
1823 fn resolve_config_value_literal_passthrough() {
1824 assert_eq!(
1825 resolve_config_value("sk-literal-key", None),
1826 Some("sk-literal-key".into())
1827 );
1828 }
1829
1830 #[test]
1831 fn resolve_config_value_env_var() {
1832 let _guard = env_lock().lock().unwrap();
1833 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1834 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1835 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1836 assert_eq!(
1837 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1838 Some("secret-from-env".into())
1839 );
1840 assert_eq!(
1841 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1842 Some("prefix-secret-from-env-suffix".into())
1843 );
1844 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1846 assert_eq!(
1847 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1848 Some("a-secret-from-env-b-two-c".into())
1849 );
1850 let mut overlay = BTreeMap::new();
1852 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1853 assert_eq!(
1854 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1855 Some("overlay-value".into())
1856 );
1857 restore_env("RPI_TEST_CFG_KEY", prev);
1858 restore_env("RPI_TEST_CFG_KEY2", prev2);
1859 }
1860
1861 #[test]
1862 fn resolve_config_value_unset_env_is_none() {
1863 let _guard = env_lock().lock().unwrap();
1864 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1865 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1866 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1868 assert_eq!(
1869 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1870 None
1871 );
1872 restore_env("RPI_TEST_CFG_ABSENT", prev);
1873 }
1874
1875 #[test]
1876 fn resolve_config_value_dollar_dollar_escapes_literal() {
1877 assert_eq!(
1878 resolve_config_value("price-$$5", None),
1879 Some("price-$5".into())
1880 );
1881 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1882 }
1883
1884 #[test]
1885 fn resolve_config_value_command_runs_shell() {
1886 assert_eq!(
1888 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1889 Some("rpi-cfg-resolved".into())
1890 );
1891 assert_eq!(resolve_config_value_uncached("!false", None), None);
1893 }
1894
1895 #[test]
1896 fn resolve_headers_drops_unresolvable() {
1897 let _guard = env_lock().lock().unwrap();
1898 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1899 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1900 let mut h = BTreeMap::new();
1901 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1902 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1903 h.insert("x-literal".into(), "literal-value".into());
1904 let resolved = resolve_headers(&h, None);
1905 assert_eq!(resolved.len(), 2);
1906 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1907 assert_eq!(
1908 resolved.get("x-literal").map(|s| s.as_str()),
1909 Some("literal-value")
1910 );
1911 assert!(!resolved.contains_key("x-unset"));
1912 restore_env("RPI_TEST_HDR_SET", prev);
1913 }
1914
1915 #[test]
1916 fn agent_dir_respects_env_override() {
1917 let _guard = env_lock().lock().unwrap();
1918 let prev = std::env::var_os(CONFIG_DIR_ENV);
1919 let tmp = tempfile::TempDir::new().unwrap();
1920 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1921 let dir = agent_dir().unwrap();
1922 restore_env(CONFIG_DIR_ENV, prev);
1923 assert_eq!(dir, tmp.path());
1924 }
1925
1926 #[test]
1927 fn relative_override_is_rejected() {
1928 let _guard = env_lock().lock().unwrap();
1929 let prev = std::env::var_os(CONFIG_DIR_ENV);
1930 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1931 let err = agent_dir().unwrap_err();
1932 restore_env(CONFIG_DIR_ENV, prev);
1933 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1934 }
1935
1936 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1938 match prev {
1939 Some(v) => std::env::set_var(name, v),
1940 None => std::env::remove_var(name),
1941 }
1942 }
1943}