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 save_models_config(cfg: &ModelsConfig) -> Result<(), ConfigError> {
425 let path = models_path()?;
426 let dir = agent_dir()?;
427 ensure_dir(&dir)?;
428 if let Ok(existing) = std::fs::read(&path) {
431 let stamp = std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH)
433 .map(|d| d.as_secs())
434 .unwrap_or(0);
435 let backup = dir.join(format!("models.json.bak-{stamp}"));
436 let _ = std::fs::write(&backup, existing);
437 }
438 let text = serde_json::to_string_pretty(cfg).map_err(|source| ConfigError::Json {
439 path: path.clone(),
440 source,
441 })?;
442 atomic_write(&path, text.as_bytes())
443}
444
445pub type TrustStore = BTreeMap<String, Option<bool>>;
455
456pub fn read_trust() -> Result<TrustStore, ConfigError> {
460 let path = trust_path()?;
461 match std::fs::read_to_string(&path) {
462 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
463 path: path.clone(),
464 source: e,
465 }),
466 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
467 Err(e) => Err(ConfigError::Read { path, source: e }),
468 }
469}
470
471pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
474 let key = std::fs::canonicalize(cwd)
475 .unwrap_or_else(|_| cwd.to_path_buf())
476 .to_string_lossy()
477 .into_owned();
478 let mut store = read_trust()?;
479 if let Some(decision) = trusted {
480 store.insert(key, Some(decision));
481 } else {
482 store.remove(&key);
483 }
484 let path = trust_path()?;
485 ensure_dir(&agent_dir()?)?;
486 let json = serde_json::to_string_pretty(&store).unwrap();
487 atomic_write(&path, json.as_bytes())?;
488 set_owner_only(&path);
489 Ok(())
490}
491
492pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
495 let key = std::fs::canonicalize(cwd)
496 .unwrap_or_else(|_| cwd.to_path_buf())
497 .to_string_lossy()
498 .into_owned();
499 Ok(read_trust()?.get(&key).copied().flatten())
500}
501
502fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
506 match serde_json::from_str(text) {
507 Ok(c) => Ok(c),
508 Err(first) => {
509 let stripped = strip_line_comments(text);
514 serde_json::from_str(&stripped).map_err(|_| first)
515 }
516 }
517}
518
519pub(crate) fn strip_line_comments(text: &str) -> String {
524 text.lines()
525 .map(|line| match find_line_comment(line) {
526 Some(idx) => line[..idx].to_string(),
527 None => line.to_string(),
528 })
529 .collect::<Vec<_>>()
530 .join("\n")
531}
532
533fn find_line_comment(line: &str) -> Option<usize> {
535 let mut in_str = false;
536 let mut esc = false;
537 for (i, ch) in line.char_indices() {
538 if esc {
539 esc = false;
540 continue;
541 }
542 match ch {
543 '\\' if in_str => esc = true,
544 '"' => in_str = !in_str,
545 '/' if !in_str => {
546 if line.as_bytes().get(i + 1) == Some(&b'/') {
547 return Some(i);
548 }
549 }
550 _ => {}
551 }
552 }
553 None
554}
555
556fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
563 static CACHE: std::sync::OnceLock<
564 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
565 > = std::sync::OnceLock::new();
566 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
567}
568
569pub fn resolve_config_value(
584 config: &str,
585 env_overlay: Option<&BTreeMap<String, String>>,
586) -> Option<String> {
587 if let Some(cmd) = config.strip_prefix('!') {
588 return resolve_command(cmd);
589 }
590 resolve_template(config, env_overlay)
591}
592
593pub fn resolve_config_value_uncached(
597 config: &str,
598 env_overlay: Option<&BTreeMap<String, String>>,
599) -> Option<String> {
600 if let Some(cmd) = config.strip_prefix('!') {
601 return resolve_command_uncached(cmd);
602 }
603 resolve_template(config, env_overlay)
604}
605
606pub fn resolve_headers(
610 headers: &BTreeMap<String, String>,
611 env_overlay: Option<&BTreeMap<String, String>>,
612) -> BTreeMap<String, String> {
613 let mut out = BTreeMap::new();
614 for (k, v) in headers {
615 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
616 out.insert(k.clone(), resolved);
617 }
618 }
619 out
620}
621
622fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
625 if let Some(overlay) = env_overlay {
626 if let Some(v) = overlay.get(name) {
627 return Some(v.clone());
628 }
629 }
630 std::env::var(name).ok()
631}
632
633enum TemplatePart {
635 Literal(String),
636 Env(String),
637}
638
639fn parse_template(config: &str) -> Vec<TemplatePart> {
644 let mut parts: Vec<TemplatePart> = Vec::new();
645 let bytes = config.as_bytes();
646 let mut i = 0usize;
647 while i < bytes.len() {
648 match config[i..].find('$') {
650 None => {
651 push_literal(&mut parts, &config[i..]);
652 break;
653 }
654 Some(offset) => {
655 let dollar = i + offset;
656 push_literal(&mut parts, &config[i..dollar]);
657 let after = dollar + 1;
658 let next = bytes.get(after).copied();
659 if next == Some(b'$') || next == Some(b'!') {
660 push_literal(&mut parts, &config[after..after + 1]);
661 i = after + 1;
662 continue;
663 }
664 if next == Some(b'{') {
665 if let Some(end_rel) = config[after + 1..].find('}') {
667 let end = after + 1 + end_rel;
668 let name = &config[after + 1..end];
669 if is_env_name(name) {
670 parts.push(TemplatePart::Env(name.to_string()));
671 } else {
672 push_literal(&mut parts, &config[dollar..=end]);
674 }
675 i = end + 1;
676 continue;
677 }
678 push_literal(&mut parts, "$");
680 i = after;
681 continue;
682 }
683 if let Some(name) = env_name_prefix(&config[after..]) {
685 parts.push(TemplatePart::Env(name.to_string()));
686 i = after + name.len();
687 } else {
688 push_literal(&mut parts, "$");
689 i = after;
690 }
691 }
692 }
693 }
694 parts
695}
696
697fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
698 if value.is_empty() {
699 return;
700 }
701 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
702 s.push_str(value);
703 } else {
704 parts.push(TemplatePart::Literal(value.to_string()));
705 }
706}
707
708fn is_env_name(s: &str) -> bool {
709 let mut chars = s.chars();
710 match chars.next() {
711 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
712 _ => return false,
713 }
714 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
715}
716
717fn env_name_prefix(s: &str) -> Option<&str> {
720 let mut chars = s.char_indices();
721 match chars.next() {
722 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
723 _ => return None,
724 }
725 let end = chars
726 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
727 .map(|(idx, _)| idx)
728 .unwrap_or(s.len());
729 Some(&s[..end])
730}
731
732fn resolve_template(
735 config: &str,
736 env_overlay: Option<&BTreeMap<String, String>>,
737) -> Option<String> {
738 let parts = parse_template(config);
739 let mut out = String::with_capacity(config.len());
740 for part in parts {
741 match part {
742 TemplatePart::Literal(s) => out.push_str(&s),
743 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
744 Some(v) => out.push_str(&v),
745 None => return None,
746 },
747 }
748 }
749 Some(out)
750}
751
752fn resolve_command(cmd: &str) -> Option<String> {
756 let key = format!("!{cmd}");
757 if let Some(v) = command_cache().lock().ok()?.get(&key) {
758 return v.clone();
759 }
760 let result = resolve_command_uncached(cmd);
761 if let Ok(mut cache) = command_cache().lock() {
762 cache.insert(key, result.clone());
763 }
764 result
765}
766
767#[cfg(unix)]
768fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
769 std::process::Command::new("sh")
770 .arg("-c")
771 .arg(cmd)
772 .stdin(std::process::Stdio::null())
773 .stdout(std::process::Stdio::piped())
774 .stderr(std::process::Stdio::null())
775 .output()
776 .ok()
777}
778
779#[cfg(windows)]
780fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
781 use std::os::windows::process::CommandExt;
782 std::process::Command::new("cmd")
783 .arg("/C")
784 .arg(cmd)
785 .stdin(std::process::Stdio::null())
786 .stdout(std::process::Stdio::piped())
787 .stderr(std::process::Stdio::null())
788 .creation_flags(0x0800_0000) .output()
790 .ok()
791}
792
793fn resolve_command_uncached(cmd: &str) -> Option<String> {
795 let output = spawn_shell_command(cmd)?;
796 if !output.status.success() {
797 return None;
798 }
799 let stdout = String::from_utf8_lossy(&output.stdout);
800 let trimmed = stdout.trim();
801 if trimmed.is_empty() {
802 None
803 } else {
804 Some(trimmed.to_string())
805 }
806}
807
808pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
813 match cfg.api.as_deref() {
814 None | Some("") | Some("anthropic-messages") => true,
815 _ => false,
816 }
817}
818
819pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
821 matches!(cfg.api.as_deref(), Some("openai-completions"))
822}
823
824pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
825 matches!(cfg.api.as_deref(), Some("openai-responses"))
826}
827
828pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
833 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
834 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
835 return Some(value);
836 }
837 }
838
839 let name = native_provider_api_key_env_var(provider_id)?;
840 std::env::var(name).ok().filter(|value| !value.is_empty())
841}
842
843fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
847 match provider_id {
848 "ant-ling" => Some("ANT_LING_API_KEY"),
849 "qwen-token-plan" | "qwen-token-plan-individual" => Some("QWEN_TOKEN_PLAN_API_KEY"),
850 "qwen-token-plan-cn" => Some("QWEN_TOKEN_PLAN_CN_API_KEY"),
851 "openai" => Some("OPENAI_API_KEY"),
852 "azure-openai-responses" => Some("AZURE_OPENAI_API_KEY"),
853 "nvidia" => Some("NVIDIA_API_KEY"),
854 "deepseek" => Some("DEEPSEEK_API_KEY"),
855 "google" => Some("GEMINI_API_KEY"),
856 "google-vertex" => Some("GOOGLE_CLOUD_API_KEY"),
857 "groq" => Some("GROQ_API_KEY"),
858 "cerebras" => Some("CEREBRAS_API_KEY"),
859 "xai" => Some("XAI_API_KEY"),
860 "radius" => Some("RADIUS_API_KEY"),
861 "openrouter" => Some("OPENROUTER_API_KEY"),
862 "vercel-ai-gateway" => Some("AI_GATEWAY_API_KEY"),
863 "zai" => Some("ZAI_API_KEY"),
864 "zai-coding-cn" => Some("ZAI_CODING_CN_API_KEY"),
865 "mistral" => Some("MISTRAL_API_KEY"),
866 "minimax" => Some("MINIMAX_API_KEY"),
867 "minimax-cn" => Some("MINIMAX_CN_API_KEY"),
868 "moonshotai" | "moonshotai-cn" => Some("MOONSHOT_API_KEY"),
869 "huggingface" => Some("HF_TOKEN"),
870 "fireworks" => Some("FIREWORKS_API_KEY"),
871 "together" => Some("TOGETHER_API_KEY"),
872 "baseten" => Some("BASETEN_API_KEY"),
873 "opencode" | "opencode-go" => Some("OPENCODE_API_KEY"),
874 "kimi-coding" => Some("KIMI_API_KEY"),
875 "cloudflare-workers-ai" | "cloudflare-ai-gateway" => Some("CLOUDFLARE_API_KEY"),
876 "xiaomi" => Some("XIAOMI_API_KEY"),
877 "xiaomi-token-plan-cn" => Some("XIAOMI_TOKEN_PLAN_CN_API_KEY"),
878 "xiaomi-token-plan-ams" => Some("XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
879 "xiaomi-token-plan-sgp" => Some("XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
880 _ => None,
881 }
882}
883
884pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
888 let api = if provider_is_anthropic_compatible(cfg) {
889 Api::AnthropicMessages
890 } else if provider_is_openai_completions(cfg) {
891 Api::OpenaiCompletions
892 } else if provider_is_openai_responses(cfg) {
893 Api::OpenaiResponses
894 } else {
895 return None;
896 };
897 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
898 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
899 _ => default_anthropic_base_url(),
900 });
901 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
902 for def in &cfg.models {
903 let base_url = def
904 .base_url
905 .clone()
906 .unwrap_or_else(|| provider_base.clone());
907 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
908 let mut m = Model::new(
909 def.id.clone(),
910 name,
911 api.clone(),
912 provider_id.to_string(),
913 base_url,
914 );
915 m.reasoning = def.reasoning.unwrap_or(false);
916 m.thinking_level_map = def.thinking_level_map.clone();
917 m.context_window = def.context_window.unwrap_or(0);
918 m.max_tokens = def.max_tokens.unwrap_or(0);
919 if let Some(cost) = &def.cost {
920 m.cost = cost.clone();
921 m.cost_configured = true;
922 }
923 m.input = parse_input_modalities(def.input.as_deref());
924 let mut headers: BTreeMap<String, String> = BTreeMap::new();
938 if let Some(h) = def.headers.clone() {
939 for (k, v) in resolve_headers(&h, None) {
940 headers.insert(k, v);
941 }
942 }
943 if let Some(h) = cfg.headers.clone() {
944 for (k, v) in resolve_headers(&h, None) {
945 headers.insert(k, v);
946 }
947 }
948 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
949 if let Some(key) = openai_provider_api_key(provider_id, cfg) {
950 headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
951 headers.insert("authorization".to_string(), format!("Bearer {key}"));
952 }
953 if let Some(value) = def.compat.clone() {
954 if matches!(api, Api::OpenaiResponses) {
955 if let Ok(compat) = serde_json::from_value(value) {
956 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
957 }
958 } else if let Ok(compat) = serde_json::from_value(value) {
959 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
960 }
961 }
962 }
963 if !headers.is_empty() {
964 m.headers = Some(headers);
965 }
966 merged.push(m);
967 }
968 Some(merged)
969}
970
971fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
974 match input {
975 None => vec![InputModality::Text],
976 Some(list) if list.is_empty() => vec![InputModality::Text],
977 Some(list) => list
978 .iter()
979 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
980 "text" => Some(InputModality::Text),
981 "image" => Some(InputModality::Image),
982 _ => None,
983 })
984 .collect::<Vec<_>>()
985 .pipe(|v| {
986 if v.is_empty() {
987 vec![InputModality::Text]
988 } else {
989 v
990 }
991 }),
992 }
993}
994
995pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
1001
1002pub fn default_anthropic_base_url() -> String {
1006 ANTHROPIC_DEFAULT_BASE_URL.to_string()
1007}
1008
1009#[cfg(unix)]
1014use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1015
1016fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
1019 if dir.exists() {
1020 return Ok(());
1021 }
1022 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
1023 path: dir.to_path_buf(),
1024 source: e,
1025 })?;
1026 #[cfg(unix)]
1027 {
1028 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
1029 }
1030 Ok(())
1031}
1032
1033pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1036 let dir = path.parent().ok_or_else(|| ConfigError::Write {
1037 path: path.to_path_buf(),
1038 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
1039 })?;
1040 let tmp = dir.join(format!(
1041 ".{}.{}.tmp",
1042 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
1043 uuid::Uuid::new_v4().simple()
1044 ));
1045 let mut options = std::fs::OpenOptions::new();
1046 options.write(true).create_new(true);
1047 #[cfg(unix)]
1048 options.mode(0o600);
1049 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
1050 path: tmp.clone(),
1051 source,
1052 })?;
1053 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1054 let _ = std::fs::remove_file(&tmp);
1058 return Err(ConfigError::Write {
1059 path: tmp,
1060 source: error,
1061 });
1062 }
1063 drop(file);
1064 if let Err(error) = std::fs::rename(&tmp, path) {
1065 let _ = std::fs::remove_file(&tmp);
1068 return Err(ConfigError::Write {
1069 path: path.to_path_buf(),
1070 source: error,
1071 });
1072 }
1073 Ok(())
1074}
1075
1076fn set_owner_only(_path: &Path) {
1079 #[cfg(unix)]
1080 {
1081 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
1082 }
1083}
1084
1085trait Pipe: Sized {
1088 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
1089 f(self)
1090 }
1091}
1092impl<T> Pipe for T {}
1093
1094#[cfg(test)]
1099pub(crate) mod test_support {
1100 use std::sync::{Mutex, OnceLock};
1106 pub(crate) fn env_lock() -> &'static Mutex<()> {
1107 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1108 LOCK.get_or_init(|| Mutex::new(()))
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use crate::config::test_support::env_lock;
1116
1117 struct TempConfig {
1121 _guard: std::sync::MutexGuard<'static, ()>,
1122 _tmp: tempfile::TempDir,
1123 prev: Option<std::ffi::OsString>,
1124 }
1125 impl TempConfig {
1126 fn new() -> Self {
1127 let guard = env_lock().lock().unwrap();
1128 let prev = std::env::var_os(CONFIG_DIR_ENV);
1129 let tmp = tempfile::TempDir::new().unwrap();
1130 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1131 Self {
1132 _guard: guard,
1133 _tmp: tmp,
1134 prev,
1135 }
1136 }
1137 }
1138 impl Drop for TempConfig {
1139 fn drop(&mut self) {
1140 restore_env(CONFIG_DIR_ENV, self.prev.take());
1141 }
1142 }
1143
1144 #[test]
1145 fn read_auth_missing_file_is_empty() {
1146 let _cfg = TempConfig::new();
1147 let store = read_auth().unwrap();
1148 assert!(store.is_empty());
1149 }
1150
1151 #[test]
1152 fn upsert_then_read_roundtrip() {
1153 let _cfg = TempConfig::new();
1154 upsert_credential(
1155 "anthropic",
1156 Credential::ApiKey {
1157 key: Some("sk-test-123".into()),
1158 env: None,
1159 },
1160 )
1161 .unwrap();
1162 let store = read_auth().unwrap();
1163 match store.get("anthropic") {
1164 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
1165 other => panic!("unexpected cred: {other:?}"),
1166 }
1167 let path = auth_path().unwrap();
1169 assert!(path.exists(), "auth.json should exist after upsert");
1170 let raw = std::fs::read_to_string(&path).unwrap();
1171 assert!(raw.contains("\"anthropic\""));
1172 assert!(raw.contains("api_key"));
1173 }
1174
1175 #[test]
1176 fn oauth_token_is_scoped_and_round_trips() {
1177 let _cfg = TempConfig::new();
1178 upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
1179 assert!(matches!(
1180 read_oauth_token("acme").unwrap(),
1181 Some(Credential::Oauth { access, refresh, expires })
1182 if access == "access" && refresh == "refresh" && expires == 123
1183 ));
1184 assert!(read_oauth_token("other").unwrap().is_none());
1185 }
1186
1187 #[test]
1188 fn delete_credential_removes_entry() {
1189 let _cfg = TempConfig::new();
1190 upsert_credential(
1191 "anthropic",
1192 Credential::ApiKey {
1193 key: Some("k".into()),
1194 env: None,
1195 },
1196 )
1197 .unwrap();
1198 assert!(delete_credential("anthropic").unwrap());
1199 assert!(!delete_credential("anthropic").unwrap());
1201 assert!(read_auth().unwrap().is_empty());
1202 }
1203
1204 #[test]
1205 fn load_models_config_missing_is_empty() {
1206 let _cfg = TempConfig::new();
1207 let c = load_models_config().unwrap();
1208 assert!(c.providers.is_empty());
1209 }
1210
1211 #[test]
1212 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1213 let tmp = tempfile::tempdir().unwrap();
1214 let primary = tmp.path().join("primary.json");
1215 let fallback = tmp.path().join("fallback.json");
1216 std::fs::write(&fallback, "fallback").unwrap();
1217
1218 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1219 .unwrap()
1220 .expect("fallback should be read");
1221 assert_eq!(text, "fallback");
1222
1223 std::fs::write(&primary, "primary").unwrap();
1224 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1225 .unwrap()
1226 .expect("primary should be read");
1227 assert_eq!(path, primary);
1228 assert_eq!(text, "primary");
1229 }
1230
1231 #[test]
1232 fn fallback_reader_does_not_mask_primary_read_errors() {
1233 let tmp = tempfile::tempdir().unwrap();
1234 let primary = tmp.path().join("primary.json");
1235 let fallback = tmp.path().join("fallback.json");
1236 std::fs::create_dir(&primary).unwrap();
1237 std::fs::write(&fallback, "fallback").unwrap();
1238
1239 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1240 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1241 }
1242
1243 #[test]
1244 fn load_models_config_parses_with_comments() {
1245 let _cfg = TempConfig::new();
1246 let json = r#"{
1247 // a one-api style gateway
1248 "providers": {
1249 "gateway": {
1250 "baseUrl": "https://gw.example.com",
1251 "authHeader": true,
1252 "apiKey": "gw-secret",
1253 "models": [
1254 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1255 ]
1256 }
1257 }
1258}"#;
1259 std::fs::write(models_path().unwrap(), json).unwrap();
1260 let c = load_models_config().unwrap();
1261 let gw = c
1262 .providers
1263 .get("gateway")
1264 .expect("gateway provider present");
1265 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1266 assert!(gw.auth_header.unwrap_or(false));
1267 assert_eq!(gw.models.len(), 1);
1268 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1269 }
1270
1271 #[test]
1272 fn provider_to_models_merges_headers_without_synth_bearer() {
1273 let cfg = ProviderConfig {
1279 name: None,
1280 base_url: Some("https://gw.example.com".into()),
1281 api_key: Some("gw-secret".into()),
1282 api: None,
1283 headers: Some({
1284 let mut h = BTreeMap::new();
1285 h.insert("x-portkey-key".into(), "portkey-secret".into());
1286 h
1287 }),
1288 auth_header: Some(true),
1289 models: vec![ModelDefinition {
1290 id: "claude-sonnet-5".into(),
1291 name: None,
1292 base_url: None,
1293 reasoning: None,
1294 thinking_level_map: None,
1295 context_window: None,
1296 max_tokens: None,
1297 cost: None,
1298 input: None,
1299 headers: None,
1300 compat: None,
1301 }],
1302 };
1303 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1304 assert_eq!(models.len(), 1);
1305 let m = &models[0];
1306 assert_eq!(m.id, "claude-sonnet-5");
1307 assert_eq!(m.base_url, "https://gw.example.com");
1308 assert_eq!(m.provider, "gateway");
1309 let headers = m.headers.as_ref().expect("provider headers merged");
1310 assert_eq!(
1312 headers.get("x-portkey-key").map(|s| s.as_str()),
1313 Some("portkey-secret")
1314 );
1315 assert!(
1320 headers.get("authorization").is_none(),
1321 "provider_to_models must not synthesize the Bearer; resolve does"
1322 );
1323 }
1324
1325 #[test]
1326 fn provider_to_models_supports_openai_completions() {
1327 let config: ModelsConfig = serde_json::from_str(
1328 r#"{
1329 "providers": {
1330 "oai": {
1331 "api": "openai-completions",
1332 "baseUrl": "https://gateway.example.com/v1",
1333 "apiKey": "secret",
1334 "models": [{"id":"gpt-test","maxTokens":4096}]
1335 }
1336 }
1337 }"#,
1338 )
1339 .unwrap();
1340 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1341 assert_eq!(models.len(), 1);
1342 assert_eq!(models[0].api, Api::OpenaiCompletions);
1343 assert_eq!(models[0].provider, "oai");
1344 assert_eq!(models[0].max_tokens, 4096);
1345 assert_eq!(models[0].cost, ModelCost::default());
1346 assert!(!models[0].has_pricing());
1347 assert_eq!(
1348 models[0]
1349 .headers
1350 .as_ref()
1351 .and_then(|headers| headers.get("authorization"))
1352 .map(String::as_str),
1353 Some("Bearer secret")
1354 );
1355 }
1356
1357 #[test]
1358 fn models_json_pricing_reaches_providers_and_survives_serialization() {
1359 let mut config = parse_models_json(
1360 r#"{
1361 "providers": {
1362 "pricing-fixture": {
1363 "api": "anthropic-messages",
1364 "models": [{
1365 "id": "priced-model",
1366 "cost": {
1367 "input": 2, "output": 8, "cacheRead": 0.2, "cacheWrite": 2.5,
1368 "tiers": [{
1369 "inputTokensAbove": 1000000,
1370 "input": 4, "output": 16, "cacheRead": 0.4, "cacheWrite": 5
1371 }]
1372 }
1373 }]
1374 }
1375 }
1376 }"#,
1377 )
1378 .unwrap();
1379 let expected = config.providers["pricing-fixture"].models[0]
1380 .cost
1381 .clone()
1382 .unwrap();
1383 for api in [
1384 "anthropic-messages",
1385 "openai-completions",
1386 "openai-responses",
1387 ] {
1388 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1389 cfg.api = Some(api.into());
1390 let models = provider_to_models("pricing-fixture", cfg).unwrap();
1391 assert_eq!(models[0].cost, expected);
1392 assert!(models[0].has_pricing());
1393 let mut usage = rpi_ai::Usage::zero();
1394 usage.input = 100_000;
1395 usage.output = 250_000;
1396 usage.cache_read = 300_000;
1397 usage.cache_write = 400_000;
1398 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1399 assert!((cost.total - 3.26).abs() < 1e-10, "base pricing for {api}");
1400 usage.input = 1_000_001;
1401 usage.output = 1_000_000;
1402 usage.cache_read = 0;
1403 usage.cache_write = 0;
1404 let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1405 assert!(
1406 (cost.total - 20.000004).abs() < 1e-10,
1407 "tiered pricing for {api}"
1408 );
1409 }
1410 let saved = serde_json::to_string(&config).unwrap();
1411 let reloaded = parse_models_json(&saved).unwrap();
1412 assert_eq!(
1413 reloaded.providers["pricing-fixture"].models[0].cost,
1414 Some(expected)
1415 );
1416 let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1417 cfg.models[0].cost = Some(ModelCost::default());
1418 let free = provider_to_models("pricing-fixture", cfg).unwrap();
1419 assert_eq!(free[0].cost, ModelCost::default());
1420 assert!(
1421 free[0].has_pricing(),
1422 "explicit zero prices are still configured"
1423 );
1424 }
1425
1426 #[test]
1427 fn models_json_compat_reaches_the_model_for_openai_completions() {
1428 let config: ModelsConfig = serde_json::from_str(
1435 r#"{
1436 "providers": {
1437 "qwen": {
1438 "api": "openai-completions",
1439 "baseUrl": "https://coding.example.com/v1",
1440 "apiKey": "secret",
1441 "models": [{
1442 "id": "qwen3.7-plus",
1443 "maxTokens": 131072,
1444 "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" },
1445 "compat": { "requiresThinkingAsText": true }
1446 }]
1447 }
1448 }
1449 }"#,
1450 )
1451 .unwrap();
1452 let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1453 assert_eq!(
1454 models[0]
1455 .thinking_level_map
1456 .as_ref()
1457 .unwrap()
1458 .get(&rpi_ai::ThinkingLevel::Xhigh),
1459 Some(&Some("xhigh".into()))
1460 );
1461 match models[0].compat.as_ref().expect("compat must survive") {
1462 StreamingProtocolCompat::OpenaiCompletions(compat) => {
1463 assert_eq!(compat.requires_thinking_as_text, Some(true));
1464 }
1465 other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1466 }
1467
1468 let plain: ModelsConfig = serde_json::from_str(
1470 r#"{
1471 "providers": {
1472 "qwen": {
1473 "api": "openai-completions",
1474 "baseUrl": "https://coding.example.com/v1",
1475 "apiKey": "secret",
1476 "models": [{"id":"qwen3.7-plus"}]
1477 }
1478 }
1479 }"#,
1480 )
1481 .unwrap();
1482 let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1483 assert!(plain_models[0].compat.is_none());
1484 }
1485
1486 #[test]
1487 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1488 let _guard = env_lock().lock().unwrap();
1489 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1490 std::env::set_var(env_name, "env-secret");
1491 let cfg = ProviderConfig {
1492 name: None,
1493 base_url: Some("https://gateway.example.com/v1".into()),
1494 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1495 api: Some("openai-completions".into()),
1496 headers: None,
1497 auth_header: None,
1498 models: vec![ModelDefinition {
1499 id: "fake-model".into(),
1500 name: None,
1501 base_url: None,
1502 reasoning: None,
1503 thinking_level_map: None,
1504 context_window: None,
1505 max_tokens: None,
1506 cost: None,
1507 input: None,
1508 headers: None,
1509 compat: None,
1510 }],
1511 };
1512 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1513 assert_eq!(
1514 models[0]
1515 .headers
1516 .as_ref()
1517 .and_then(|headers| headers.get("authorization"))
1518 .map(String::as_str),
1519 Some("Bearer env-secret")
1520 );
1521 std::env::remove_var(env_name);
1522 }
1523
1524 #[test]
1525 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1526 let _guard = env_lock().lock().unwrap();
1527 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1528 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1529 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1530 std::env::set_var("OPENAI_API_KEY", "official-key");
1531 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1532 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1533 let config = ProviderConfig {
1534 name: None,
1535 base_url: Some("https://gateway.example.com/v1".into()),
1536 api_key: None,
1537 api: Some("openai-responses".into()),
1538 headers: None,
1539 auth_header: None,
1540 models: Vec::new(),
1541 };
1542
1543 assert_eq!(
1544 openai_provider_api_key("openai", &config).as_deref(),
1545 Some("official-key")
1546 );
1547 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1548 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1549 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1550
1551 restore_env("OPENAI_API_KEY", prev_openai);
1552 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1553 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1554 }
1555
1556 #[test]
1557 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1558 for (provider, env_var) in [
1559 ("ant-ling", "ANT_LING_API_KEY"),
1560 ("qwen-token-plan", "QWEN_TOKEN_PLAN_API_KEY"),
1561 ("qwen-token-plan-cn", "QWEN_TOKEN_PLAN_CN_API_KEY"),
1562 ("qwen-token-plan-individual", "QWEN_TOKEN_PLAN_API_KEY"),
1563 ("openai", "OPENAI_API_KEY"),
1564 ("azure-openai-responses", "AZURE_OPENAI_API_KEY"),
1565 ("nvidia", "NVIDIA_API_KEY"),
1566 ("deepseek", "DEEPSEEK_API_KEY"),
1567 ("google", "GEMINI_API_KEY"),
1568 ("google-vertex", "GOOGLE_CLOUD_API_KEY"),
1569 ("groq", "GROQ_API_KEY"),
1570 ("cerebras", "CEREBRAS_API_KEY"),
1571 ("xai", "XAI_API_KEY"),
1572 ("radius", "RADIUS_API_KEY"),
1573 ("openrouter", "OPENROUTER_API_KEY"),
1574 ("vercel-ai-gateway", "AI_GATEWAY_API_KEY"),
1575 ("zai", "ZAI_API_KEY"),
1576 ("zai-coding-cn", "ZAI_CODING_CN_API_KEY"),
1577 ("mistral", "MISTRAL_API_KEY"),
1578 ("minimax", "MINIMAX_API_KEY"),
1579 ("minimax-cn", "MINIMAX_CN_API_KEY"),
1580 ("moonshotai", "MOONSHOT_API_KEY"),
1581 ("moonshotai-cn", "MOONSHOT_API_KEY"),
1582 ("huggingface", "HF_TOKEN"),
1583 ("fireworks", "FIREWORKS_API_KEY"),
1584 ("together", "TOGETHER_API_KEY"),
1585 ("baseten", "BASETEN_API_KEY"),
1586 ("opencode", "OPENCODE_API_KEY"),
1587 ("opencode-go", "OPENCODE_API_KEY"),
1588 ("kimi-coding", "KIMI_API_KEY"),
1589 ("cloudflare-workers-ai", "CLOUDFLARE_API_KEY"),
1590 ("cloudflare-ai-gateway", "CLOUDFLARE_API_KEY"),
1591 ("xiaomi", "XIAOMI_API_KEY"),
1592 ("xiaomi-token-plan-cn", "XIAOMI_TOKEN_PLAN_CN_API_KEY"),
1593 ("xiaomi-token-plan-ams", "XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
1594 ("xiaomi-token-plan-sgp", "XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
1595 ] {
1596 assert_eq!(
1597 native_provider_api_key_env_var(provider),
1598 Some(env_var),
1599 "unexpected env mapping for {provider}"
1600 );
1601 }
1602
1603 for alias in [
1604 "togetherai",
1605 "perplexity",
1606 "moonshot",
1607 "kimi",
1608 "qwen",
1609 "zhipu",
1610 "OpenAI",
1611 ] {
1612 assert_eq!(
1613 native_provider_api_key_env_var(alias),
1614 None,
1615 "non-native alias {alias} must not inherit credentials"
1616 );
1617 }
1618 }
1619
1620 #[test]
1621 fn malformed_auth_json_is_an_error_not_silent_empty() {
1622 let _cfg = TempConfig::new();
1623 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1624 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1625 }
1626
1627 #[test]
1628 fn agent_dir_nests_under_agent_by_default() {
1629 let _guard = env_lock().lock().unwrap();
1633 let prev = std::env::var_os(CONFIG_DIR_ENV);
1634 std::env::remove_var(CONFIG_DIR_ENV);
1635 let dir = agent_dir().unwrap();
1636 restore_env(CONFIG_DIR_ENV, prev);
1637 assert!(dir.ends_with("agent"));
1638 assert!(dir
1639 .parent()
1640 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1641 .unwrap_or(false));
1642 }
1643
1644 #[test]
1645 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1646 let tmp = tempfile::TempDir::new().unwrap();
1650 let root = tmp.path().to_path_buf();
1651 let agent = root.join("agent");
1652 std::fs::write(root.join("auth.json"), "{}").unwrap();
1653 std::fs::write(root.join("models.json"), "{}").unwrap();
1654 std::fs::write(root.join(".setup_done"), "1").unwrap();
1655 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1656 assert_eq!(moved, 3);
1657 assert!(agent.join("auth.json").exists());
1658 assert!(agent.join("models.json").exists());
1659 assert!(agent.join(".setup_done").exists());
1660 assert!(!root.join("auth.json").exists());
1661 }
1662
1663 #[test]
1664 fn migrate_legacy_layout_noop_when_agent_exists() {
1665 let tmp = tempfile::TempDir::new().unwrap();
1666 let root = tmp.path().to_path_buf();
1667 let agent = root.join("agent");
1668 std::fs::write(root.join("auth.json"), "{}").unwrap();
1669 std::fs::create_dir_all(&agent).unwrap();
1670 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1671 assert_eq!(moved, 0); }
1673
1674 #[test]
1675 fn migrate_legacy_layout_noop_when_no_flat_files() {
1676 let tmp = tempfile::TempDir::new().unwrap();
1677 let root = tmp.path().to_path_buf();
1678 let agent = root.join("agent");
1679 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1680 assert_eq!(moved, 0);
1681 }
1682
1683 #[test]
1684 fn migrate_legacy_layout_public_skips_env_override() {
1685 let _cfg = TempConfig::new();
1688 let moved = migrate_legacy_layout().unwrap();
1689 assert_eq!(moved, 0);
1690 }
1691
1692 #[test]
1693 fn read_trust_missing_file_is_empty() {
1694 let _cfg = TempConfig::new();
1695 assert!(read_trust().unwrap().is_empty());
1696 }
1697
1698 #[test]
1699 fn read_trust_parses_decisions() {
1700 let _cfg = TempConfig::new();
1701 let path = trust_path().unwrap();
1702 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1703 std::fs::write(
1704 &path,
1705 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1706 )
1707 .unwrap();
1708 let store = read_trust().unwrap();
1709 assert_eq!(store.len(), 3);
1710 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1711 assert_eq!(
1712 store.get("/home/me/untrusted").copied().flatten(),
1713 Some(false)
1714 );
1715 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1716 }
1717
1718 #[test]
1719 fn resolve_config_value_literal_passthrough() {
1720 assert_eq!(
1721 resolve_config_value("sk-literal-key", None),
1722 Some("sk-literal-key".into())
1723 );
1724 }
1725
1726 #[test]
1727 fn resolve_config_value_env_var() {
1728 let _guard = env_lock().lock().unwrap();
1729 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1730 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1731 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1732 assert_eq!(
1733 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1734 Some("secret-from-env".into())
1735 );
1736 assert_eq!(
1737 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1738 Some("prefix-secret-from-env-suffix".into())
1739 );
1740 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1742 assert_eq!(
1743 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1744 Some("a-secret-from-env-b-two-c".into())
1745 );
1746 let mut overlay = BTreeMap::new();
1748 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1749 assert_eq!(
1750 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1751 Some("overlay-value".into())
1752 );
1753 restore_env("RPI_TEST_CFG_KEY", prev);
1754 restore_env("RPI_TEST_CFG_KEY2", prev2);
1755 }
1756
1757 #[test]
1758 fn resolve_config_value_unset_env_is_none() {
1759 let _guard = env_lock().lock().unwrap();
1760 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1761 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1762 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1764 assert_eq!(
1765 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1766 None
1767 );
1768 restore_env("RPI_TEST_CFG_ABSENT", prev);
1769 }
1770
1771 #[test]
1772 fn resolve_config_value_dollar_dollar_escapes_literal() {
1773 assert_eq!(
1774 resolve_config_value("price-$$5", None),
1775 Some("price-$5".into())
1776 );
1777 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1778 }
1779
1780 #[test]
1781 fn resolve_config_value_command_runs_shell() {
1782 assert_eq!(
1784 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1785 Some("rpi-cfg-resolved".into())
1786 );
1787 assert_eq!(resolve_config_value_uncached("!false", None), None);
1789 }
1790
1791 #[test]
1792 fn resolve_headers_drops_unresolvable() {
1793 let _guard = env_lock().lock().unwrap();
1794 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1795 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1796 let mut h = BTreeMap::new();
1797 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1798 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1799 h.insert("x-literal".into(), "literal-value".into());
1800 let resolved = resolve_headers(&h, None);
1801 assert_eq!(resolved.len(), 2);
1802 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1803 assert_eq!(
1804 resolved.get("x-literal").map(|s| s.as_str()),
1805 Some("literal-value")
1806 );
1807 assert!(!resolved.contains_key("x-unset"));
1808 restore_env("RPI_TEST_HDR_SET", prev);
1809 }
1810
1811 #[test]
1812 fn agent_dir_respects_env_override() {
1813 let _guard = env_lock().lock().unwrap();
1814 let prev = std::env::var_os(CONFIG_DIR_ENV);
1815 let tmp = tempfile::TempDir::new().unwrap();
1816 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1817 let dir = agent_dir().unwrap();
1818 restore_env(CONFIG_DIR_ENV, prev);
1819 assert_eq!(dir, tmp.path());
1820 }
1821
1822 #[test]
1823 fn relative_override_is_rejected() {
1824 let _guard = env_lock().lock().unwrap();
1825 let prev = std::env::var_os(CONFIG_DIR_ENV);
1826 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1827 let err = agent_dir().unwrap_err();
1828 restore_env(CONFIG_DIR_ENV, prev);
1829 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1830 }
1831
1832 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1834 match prev {
1835 Some(v) => std::env::set_var(name, v),
1836 None => std::env::remove_var(name),
1837 }
1838 }
1839}