Skip to main content

rpi_cli/
config.rs

1//! # Layout
2//!
3//! Uses a nested layout: a `~/.rpi/agent/` directory (or one pointed at via
4//! `RPI_CODING_AGENT_DIR`) "just works":
5//!
6//! ```text
7//! ~/.rpi/                 (RPI_CODING_AGENT_DIR env overrides the agent/ dir)
8//! └── agent/
9//!     ├── auth.json       # persisted credentials (mode 0o600 on Unix)
10//!     ├── models.json     # user-defined provider/model catalog (hand-edited)
11//!     ├── settings.json   # saved default provider/model/thinking + theme
12//!     ├── trust.json      # per-cwd project trust decisions (read-only parity)
13//!     ├── .setup_done     # first-time-setup sentinel (extras.rs)
14//! ```
15//!
16//! Flat-installed `~/.rpi/{auth.json,models.json}` from older rpi releases are
17//! migrated under `agent/` on the next launch by [`migrate_legacy_layout`]
18//! (best-effort, idempotent; only when the env override is unset).
19//!
20//! # Concurrency
21//!
22//! v1 is a single-process CLI, so we use **atomic rename** instead of upstream's
23//! `proper-lockfile`: write a sibling temp file, `fs::rename` over the target,
24//! then `chmod 0o600` on Unix (Windows chmod is a no-op, matching Node).
25//! Within one process, auth.json read-modify-write updates (upsert and delete)
26//! run under a short in-memory lock, so concurrent provider renewals cannot
27//! drop each other's entries. Across processes, two `rpi auth login` runs from
28//! two shells could still lose one update — that's accepted and documented;
29//! adding a file lock is deferred.
30//!
31//! # Config-value expansion
32//!
33//! [`resolve_config_value`] expands `$ENV`/`${ENV}`/`!command` inside
34//! `apiKey`/`headers` exactly like upstream's `resolve-config-value.ts` — so a
35//! copied pi `models.json`/`auth.json` that references env vars or shell
36//! commands resolves the same way. Applied where rpi consumes those values
37//! (auth.json key, models.json bearer apiKey, provider/model `headers`).
38
39use std::collections::BTreeMap;
40use std::io::Write;
41use std::path::{Path, PathBuf};
42
43use rpi_ai::Model;
44#[cfg(test)]
45use rpi_ai::{Api, ModelCost, StreamingProtocolCompat};
46pub use rpi_runtime::config::{
47    default_anthropic_base_url, model_compat_warnings, provider_is_anthropic_compatible,
48    provider_is_openai_completions, provider_is_openai_responses, AuthStore, ConfigError,
49    Credential, ModelDefinition, ModelsConfig, ProviderConfig, ANTHROPIC_DEFAULT_BASE_URL,
50};
51
52/// The config directory name under the home dir.
53pub const CONFIG_DIR_NAME: &str = ".rpi";
54
55/// Env var that overrides the whole config dir. Absolute path; relative
56/// values are rejected.
57pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
58
59/// The provider id under which `rpi auth login` stores the Anthropic key.
60/// Mirrors upstream's fixed `anthropic` provider id.
61pub use rpi_runtime::config::DEFAULT_PROVIDER_ID;
62
63// ---------------------------------------------------------------------------
64// Path resolution
65// ---------------------------------------------------------------------------
66
67/// The rpi config directory (`~/.rpi/agent` by default, `RPI_CODING_AGENT_DIR`
68/// override). Creates nothing — purely a path computation. The `agent/` layer
69/// is `join(homedir(), CONFIG_DIR_NAME, "agent")`. The env override points at
70/// the agent dir itself.
71pub fn agent_dir() -> Result<PathBuf, ConfigError> {
72    if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
73        let p = PathBuf::from(&val);
74        if !p.is_absolute() {
75            return Err(ConfigError::RelativeOverride {
76                env: CONFIG_DIR_ENV,
77                val: val.to_string_lossy().into_owned(),
78            });
79        }
80        return Ok(p);
81    }
82    let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
83        env: CONFIG_DIR_ENV,
84    })?;
85    Ok(home.join(CONFIG_DIR_NAME).join("agent"))
86}
87
88/// The config dir one level above the agent dir (`~/.rpi`, or the parent of an
89/// env override). Used by [`migrate_legacy_layout`] to locate the old flat
90/// layout. Returns `None` when the env override has no parent (a root path).
91fn config_root_dir() -> Result<PathBuf, ConfigError> {
92    let agent = agent_dir()?;
93    agent
94        .parent()
95        .map(Path::to_path_buf)
96        .ok_or(ConfigError::NoHomeDir {
97            env: CONFIG_DIR_ENV,
98        })
99}
100
101/// `~/.rpi/agent/auth.json`.
102pub fn auth_path() -> Result<PathBuf, ConfigError> {
103    Ok(agent_dir()?.join("auth.json"))
104}
105
106/// `~/.rpi/agent/models.json`.
107pub fn models_path() -> Result<PathBuf, ConfigError> {
108    Ok(agent_dir()?.join("models.json"))
109}
110
111/// `~/.rpi/agent/settings.json` (saved default provider/model/thinking + theme).
112pub fn settings_path() -> Result<PathBuf, ConfigError> {
113    Ok(agent_dir()?.join("settings.json"))
114}
115
116/// `~/.rpi/agent/trust.json` (per-cwd project trust decisions).
117pub fn trust_path() -> Result<PathBuf, ConfigError> {
118    Ok(agent_dir()?.join("trust.json"))
119}
120
121fn read_text_with_fallback(
122    primary: PathBuf,
123    fallback: Option<PathBuf>,
124) -> Result<Option<(PathBuf, String)>, ConfigError> {
125    match std::fs::read_to_string(&primary) {
126        Ok(text) => Ok(Some((primary, text))),
127        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
128            let Some(fallback) = fallback.filter(|path| path != &primary) else {
129                return Ok(None);
130            };
131            match std::fs::read_to_string(&fallback) {
132                Ok(text) => Ok(Some((fallback, text))),
133                Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
134                Err(source) => Err(ConfigError::Read {
135                    path: fallback,
136                    source,
137                }),
138            }
139        }
140        Err(source) => Err(ConfigError::Read {
141            path: primary,
142            source,
143        }),
144    }
145}
146
147/// One-time best-effort migration of a pre-nesting flat layout
148/// (`~/.rpi/{auth.json,models.json,.setup_done}`) into the
149/// nested `~/.rpi/agent/` layout. **No-op when `RPI_CODING_AGENT_DIR` is set**
150/// (never touch an explicit override), when the agent dir already exists, or
151/// when no flat files are present. Idempotent: a partial move resumes. Errors
152/// are swallowed (logged via the returned `Result` only so tests can observe);
153/// `app::run` ignores them so a migration hiccup never blocks startup.
154pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
155    // Only migrate the default home-backed layout — never an env override.
156    if std::env::var_os(CONFIG_DIR_ENV).is_some() {
157        return Ok(0);
158    }
159    let root = match config_root_dir() {
160        Ok(p) => p,
161        Err(_) => return Ok(0),
162    };
163    let agent = agent_dir()?;
164    migrate_legacy_layout_in(&root, &agent)
165}
166
167/// The core migration (no env gate): if `agent/` is absent but flat files exist
168/// under `root`, move `{auth.json,models.json,.setup_done}` into
169/// `agent/`. Idempotent. Factored out so tests can drive it against a temp
170/// root/agent pair without touching the env (the public
171/// [`migrate_legacy_layout`] short-circuits on an env override, which tests
172/// can't unset portably while other tests run).
173fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
174    // If the agent dir already exists with any content, assume already migrated.
175    if agent.exists() {
176        return Ok(0);
177    }
178    // Probe for a flat file. If none, nothing to migrate.
179    let flat_auth = root.join("auth.json");
180    let flat_models = root.join("models.json");
181    if !flat_auth.exists() && !flat_models.exists() {
182        return Ok(0);
183    }
184    std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
185        path: agent.to_path_buf(),
186        source: e,
187    })?;
188    let mut moved = 0usize;
189    for leaf in ["auth.json", "models.json", ".setup_done"] {
190        let from = root.join(leaf);
191        let to = agent.join(leaf);
192        if from.exists() && !to.exists() {
193            // `rename` across the same filesystem is atomic; fall back to copy
194            // + remove on cross-device (rare for a home dir).
195            if let Err(_e) = std::fs::rename(&from, &to) {
196                if std::fs::copy(&from, &to).is_ok() {
197                    let _ = std::fs::remove_file(&from);
198                }
199            }
200            moved += 1;
201        }
202    }
203    Ok(moved)
204}
205
206// ---------------------------------------------------------------------------
207// auth.json — Credential store
208// ---------------------------------------------------------------------------
209
210/// Read the auth store. Missing file ⇒ empty store (not an error). Malformed
211/// JSON ⇒ `ConfigError::Json` (we do not silently swallow a corrupt auth file).
212pub fn read_auth() -> Result<AuthStore, ConfigError> {
213    let path = auth_path()?;
214    match read_text_with_fallback(path, None)? {
215        Some((path, text)) => {
216            serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
217        }
218        None => Ok(AuthStore::new()),
219    }
220}
221
222/// Serializes this process's read-modify-write updates of the auth store. It is
223/// held only for the read and the write, never while calling a provider
224/// package, so two providers committing at once cannot drop each other's entry.
225fn auth_store_lock() -> &'static std::sync::Mutex<()> {
226    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
227    &LOCK
228}
229
230/// Atomically write the whole auth store (ensures the dir exists, writes a
231/// temp sibling, `rename`s over the target, then `chmod 0o600` on Unix).
232/// Callers that read-modify-write must hold the internal store lock.
233pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
234    let path = auth_path()?;
235    let dir = agent_dir()?;
236    ensure_dir(&dir)?;
237    let json = serde_json::to_string_pretty(store).unwrap();
238    atomic_write(&path, json.as_bytes())?;
239    set_owner_only(&path);
240    Ok(())
241}
242
243/// Read-modify-write: upsert a credential for `provider_id`. Re-reads the
244/// latest store under the lock, so other providers' entries are preserved.
245pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
246    let _store_guard = auth_store_lock()
247        .lock()
248        .unwrap_or_else(std::sync::PoisonError::into_inner);
249    let mut store = read_auth()?;
250    store.insert(provider_id.to_string(), cred);
251    write_auth(&store)
252}
253
254/// Store an OAuth token under exactly one provider id using the atomic auth
255/// store writer. Token values never enter the event or session layer.
256pub fn upsert_oauth_token(
257    provider_id: &str,
258    access: String,
259    refresh: String,
260    expires: i64,
261) -> Result<(), ConfigError> {
262    upsert_credential(
263        provider_id,
264        Credential::Oauth {
265            access,
266            refresh,
267            expires,
268        },
269    )
270}
271
272/// Replace `provider_id`'s OAuth token only if the stored one still has the
273/// `expected` access, refresh and expiry. Returns `false`, writing nothing, when
274/// a login or logout replaced or removed it meanwhile, so a late refresh result
275/// never undoes that user action.
276pub fn replace_oauth_token_if_unchanged(
277    provider_id: &str,
278    expected: (&str, &str, i64),
279    access: String,
280    refresh: String,
281    expires: i64,
282) -> Result<bool, ConfigError> {
283    let _store_guard = auth_store_lock()
284        .lock()
285        .unwrap_or_else(std::sync::PoisonError::into_inner);
286    let mut store = read_auth()?;
287    let unchanged = matches!(
288        store.get(provider_id),
289        Some(Credential::Oauth { access, refresh, expires })
290            if (access.as_str(), refresh.as_str(), *expires) == expected
291    );
292    if unchanged {
293        store.insert(
294            provider_id.to_string(),
295            Credential::Oauth {
296                access,
297                refresh,
298                expires,
299            },
300        );
301        write_auth(&store)?;
302    }
303    Ok(unchanged)
304}
305
306/// Read only the OAuth credential for one provider.
307pub fn read_oauth_token(provider_id: &str) -> Result<Option<Credential>, ConfigError> {
308    Ok(match read_auth()?.get(provider_id) {
309        Some(Credential::Oauth {
310            access,
311            refresh,
312            expires,
313        }) => Some(Credential::Oauth {
314            access: access.clone(),
315            refresh: refresh.clone(),
316            expires: *expires,
317        }),
318        _ => None,
319    })
320}
321
322/// Remove `provider_id` from the store. Returns `true` if a credential was
323/// present (and is now gone), `false` if it was already absent. Always rewrites
324/// the file when the provider existed (so `auth logout` reflects the new state
325/// on disk even if the map isn't empty).
326pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
327    let _store_guard = auth_store_lock()
328        .lock()
329        .unwrap_or_else(std::sync::PoisonError::into_inner);
330    let mut store = read_auth()?;
331    if store.remove(provider_id).is_some() {
332        write_auth(&store)?;
333        Ok(true)
334    } else {
335        Ok(false)
336    }
337}
338
339// ---------------------------------------------------------------------------
340// models.json — provider/model catalog
341// ---------------------------------------------------------------------------
342
343/// Load `~/.rpi/models.json`. Missing file ⇒ empty config (no error).
344pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
345    let path = models_path()?;
346    match read_text_with_fallback(path, None)? {
347        Some((path, text)) => {
348            parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
349        }
350        None => Ok(ModelsConfig::default()),
351    }
352}
353
354/// Diagnose unsupported compat keys and malformed values without rejecting
355/// unrelated providers.
356/// Atomically write the whole `~/.rpi/models.json` (used by the interactive
357/// provider form). Note: unlike [`crate::settings::save_settings`], this
358/// serializes the modeled config directly, so hand-written comments and unknown
359/// fields in `models.json` are not preserved on rewrite. A timestamped
360/// `models.json.bak-*` snapshot is written first so a mistake can be reverted.
361pub fn save_models_config(cfg: &ModelsConfig) -> Result<(), ConfigError> {
362    let path = models_path()?;
363    let dir = agent_dir()?;
364    ensure_dir(&dir)?;
365    // Back up the existing file before rewriting it (best-effort; a missing
366    // file is normal on first save).
367    if let Ok(existing) = std::fs::read(&path) {
368        let stamp = std::time::SystemTime::now()
369            .duration_since(std::time::UNIX_EPOCH)
370            .map(|d| d.as_secs())
371            .unwrap_or(0);
372        let backup = dir.join(format!("models.json.bak-{stamp}"));
373        let _ = std::fs::write(&backup, existing);
374    }
375    let text = serde_json::to_string_pretty(cfg).map_err(|source| ConfigError::Json {
376        path: path.clone(),
377        source,
378    })?;
379    atomic_write(&path, text.as_bytes())
380}
381
382/// Add one user-configured provider and its first model to `models.json`.
383///
384/// This is shared by the interactive provider form and RPC clients so both
385/// surfaces enforce the same protocol, URL, duplicate, and size checks.
386pub fn add_provider(
387    provider_id: &str,
388    api: &str,
389    base_url: &str,
390    api_key: &str,
391    model_id: &str,
392    model_name: Option<&str>,
393    context_window: Option<u64>,
394    max_tokens: Option<u64>,
395    supports_images: Option<bool>,
396) -> Result<(), String> {
397    const MAX_FIELD: usize = 4096;
398    let provider_id = provider_id.trim();
399    let api = api.trim();
400    let base_url = base_url.trim();
401    let api_key = api_key.trim();
402    let model_id = model_id.trim();
403    let model_name = model_name.map(str::trim).filter(|value| !value.is_empty());
404    if provider_id.is_empty()
405        || api.is_empty()
406        || base_url.is_empty()
407        || api_key.is_empty()
408        || model_id.is_empty()
409    {
410        return Err("provider, protocol, base URL, API key, and model ID are required".into());
411    }
412    for (label, value) in [
413        ("provider", provider_id),
414        ("protocol", api),
415        ("base URL", base_url),
416        ("API key", api_key),
417        ("model ID", model_id),
418    ] {
419        if value.len() > MAX_FIELD {
420            return Err(format!("{label} is too long"));
421        }
422    }
423    if model_name.is_some_and(|value| value.len() > MAX_FIELD) {
424        return Err("model name is too long".into());
425    }
426    if context_window == Some(0) {
427        return Err("context window must be greater than zero".into());
428    }
429    if max_tokens == Some(0) {
430        return Err("max output tokens must be greater than zero".into());
431    }
432    if !matches!(
433        api,
434        "anthropic-messages" | "openai-completions" | "openai-responses"
435    ) {
436        return Err("unsupported provider protocol".into());
437    }
438    if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
439        return Err("base URL must start with http:// or https://".into());
440    }
441    let mut config = load_models_config().map_err(|error| error.to_string())?;
442    if config.providers.contains_key(provider_id) {
443        return Err(format!(
444            "provider {provider_id:?} already has a models.json configuration"
445        ));
446    }
447    config.providers.insert(
448        provider_id.to_owned(),
449        ProviderConfig {
450            name: None,
451            base_url: Some(base_url.to_owned()),
452            api_key: Some(api_key.to_owned()),
453            api: Some(api.to_owned()),
454            headers: None,
455            auth_header: None,
456            models: vec![ModelDefinition {
457                id: model_id.to_owned(),
458                name: model_name.map(str::to_owned),
459                base_url: None,
460                reasoning: None,
461                thinking_level_map: None,
462                context_window,
463                max_tokens,
464                cost: None,
465                input: supports_images.map(|supports| {
466                    if supports {
467                        vec!["text".to_string(), "image".to_string()]
468                    } else {
469                        vec!["text".to_string()]
470                    }
471                }),
472                headers: None,
473                compat: None,
474            }],
475        },
476    );
477    save_models_config(&config).map_err(|error| error.to_string())
478}
479
480/// Update an existing models.json provider and its first model. An omitted API
481/// key preserves the existing key, so editing metadata does not require
482/// exposing or re-entering a secret.
483pub fn update_provider(
484    provider_id: &str,
485    api: &str,
486    base_url: &str,
487    api_key: Option<&str>,
488    model_id: &str,
489    model_name: Option<&str>,
490    context_window: Option<u64>,
491    max_tokens: Option<u64>,
492    supports_images: bool,
493) -> Result<(), String> {
494    let provider_id = provider_id.trim();
495    let api = api.trim();
496    let base_url = base_url.trim();
497    let model_id = model_id.trim();
498    let model_name = model_name.map(str::trim).filter(|value| !value.is_empty());
499    if provider_id.is_empty() || api.is_empty() || base_url.is_empty() || model_id.is_empty() {
500        return Err("provider, protocol, base URL, and model ID are required".into());
501    }
502    if !matches!(
503        api,
504        "anthropic-messages" | "openai-completions" | "openai-responses"
505    ) {
506        return Err("unsupported provider protocol".into());
507    }
508    if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
509        return Err("base URL must start with http:// or https://".into());
510    }
511    if context_window == Some(0) {
512        return Err("context window must be greater than zero".into());
513    }
514    if max_tokens == Some(0) {
515        return Err("max output tokens must be greater than zero".into());
516    }
517    let mut config = load_models_config().map_err(|error| error.to_string())?;
518    let provider = config
519        .providers
520        .get_mut(provider_id)
521        .ok_or_else(|| format!("provider {provider_id:?} has no models.json configuration"))?;
522    provider.api = Some(api.to_owned());
523    provider.base_url = Some(base_url.to_owned());
524    if let Some(api_key) = api_key.map(str::trim).filter(|value| !value.is_empty()) {
525        provider.api_key = Some(api_key.to_owned());
526    }
527    let model = provider
528        .models
529        .first_mut()
530        .ok_or_else(|| format!("provider {provider_id:?} has no model to update"))?;
531    model.id = model_id.to_owned();
532    model.name = model_name.map(str::to_owned);
533    model.context_window = context_window;
534    model.max_tokens = max_tokens;
535    model.input = Some(if supports_images {
536        vec!["text".to_string(), "image".to_string()]
537    } else {
538        vec!["text".to_string()]
539    });
540    save_models_config(&config).map_err(|error| error.to_string())
541}
542
543/// Delete a user-configured provider from models.json.
544pub fn delete_provider(provider_id: &str) -> Result<(), String> {
545    let provider_id = provider_id.trim();
546    if provider_id.is_empty() {
547        return Err("provider is required".into());
548    }
549    let mut config = load_models_config().map_err(|error| error.to_string())?;
550    if config.providers.shift_remove(provider_id).is_none() {
551        return Err(format!(
552            "provider {provider_id:?} has no models.json configuration"
553        ));
554    }
555    save_models_config(&config).map_err(|error| error.to_string())
556}
557
558// ---------------------------------------------------------------------------
559// trust.json — project-trust store (read-only layout parity with pi)
560// ---------------------------------------------------------------------------
561
562/// The trust store: `canonicalCwd -> decision` (`true`/`false`/`null`). Mirrors
563/// pi's `TrustFile = Record<string, boolean | null | undefined>`
564/// (`trust-manager.ts`). rpi reads this for layout parity (a copied pi
565/// `trust.json` parses + is located correctly) but does **not** gate any
566/// project resources behind trust in v1 — there is no trust prompt. Deferred.
567pub type TrustStore = BTreeMap<String, Option<bool>>;
568
569/// Read `~/.rpi/agent/trust.json`. Missing file ⇒ empty store (not an error).
570/// Malformed JSON ⇒ `ConfigError::Json`. `null` decisions deserialize as
571/// `None`; absent entries are simply not present.
572pub fn read_trust() -> Result<TrustStore, ConfigError> {
573    let path = trust_path()?;
574    match std::fs::read_to_string(&path) {
575        Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
576            path: path.clone(),
577            source: e,
578        }),
579        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
580        Err(e) => Err(ConfigError::Read { path, source: e }),
581    }
582}
583
584/// Persist the trust decision for a project directory. The path is canonical
585/// when it exists, with an absolute fallback for a project being created.
586pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
587    let key = std::fs::canonicalize(cwd)
588        .unwrap_or_else(|_| cwd.to_path_buf())
589        .to_string_lossy()
590        .into_owned();
591    let mut store = read_trust()?;
592    if let Some(decision) = trusted {
593        store.insert(key, Some(decision));
594    } else {
595        store.remove(&key);
596    }
597    let path = trust_path()?;
598    ensure_dir(&agent_dir()?)?;
599    let json = serde_json::to_string_pretty(&store).unwrap();
600    atomic_write(&path, json.as_bytes())?;
601    set_owner_only(&path);
602    Ok(())
603}
604
605/// Return the stored trust decision for `cwd`. A missing entry (or an entry
606/// explicitly set to `null`) returns `None`; callers choose their safe default.
607pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
608    let key = std::fs::canonicalize(cwd)
609        .unwrap_or_else(|_| cwd.to_path_buf())
610        .to_string_lossy()
611        .into_owned();
612    Ok(read_trust()?.get(&key).copied().flatten())
613}
614
615/// Parse the models JSON, tolerating `//` line comments (a minimal subset of
616/// upstream's `stripJsonComments`). Tries strict JSON first; on failure, strips
617/// `//…` to end-of-line and retries.
618fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
619    match serde_json::from_str(text) {
620        Ok(c) => Ok(c),
621        Err(first) => {
622            // Best-effort comment strip — only `//` to EOL, never inside strings
623            // (a `//` inside a JSON string would already have made the strict
624            // parse fail for a *different* reason; stripping naively is an
625            // acceptable v1 trade-off, documented as a limitation).
626            let stripped = strip_line_comments(text);
627            serde_json::from_str(&stripped).map_err(|_| first)
628        }
629    }
630}
631
632/// Strip `//` line comments (to end-of-line), skipping `//` that appears inside
633/// a double-quoted string. A minimal subset of upstream's `stripJsonComments`,
634/// shared by [`parse_models_json`] and [`crate::settings::load_settings`] so a
635/// copied pi `models.json`/`settings.json` (which pi allows comments in) parses.
636pub(crate) fn strip_line_comments(text: &str) -> String {
637    text.lines()
638        .map(|line| match find_line_comment(line) {
639            Some(idx) => line[..idx].to_string(),
640            None => line.to_string(),
641        })
642        .collect::<Vec<_>>()
643        .join("\n")
644}
645
646/// Index of a `//` line comment that is *not* inside a double-quoted string.
647fn find_line_comment(line: &str) -> Option<usize> {
648    let mut in_str = false;
649    let mut esc = false;
650    for (i, ch) in line.char_indices() {
651        if esc {
652            esc = false;
653            continue;
654        }
655        match ch {
656            '\\' if in_str => esc = true,
657            '"' => in_str = !in_str,
658            '/' if !in_str && line.as_bytes().get(i + 1) == Some(&b'/') => {
659                return Some(i);
660            }
661            _ => {}
662        }
663    }
664    None
665}
666
667// ---------------------------------------------------------------------------
668// Config-value expansion (mirrors pi `resolve-config-value.ts`)
669// ---------------------------------------------------------------------------
670
671/// A process-lifetime cache for `!command` resolutions, mirroring pi's
672/// `commandResultCache`. Keyed by the raw `!cmd` string (including the `!`).
673fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
674    static CACHE: std::sync::OnceLock<
675        std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
676    > = std::sync::OnceLock::new();
677    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
678}
679
680/// Resolve a config value (API key, header value) that may be a shell command,
681/// an env-var template, or a literal — mirroring pi's `resolveConfigValue`.
682///
683/// - `!command` → run the rest as a shell command (`sh -c` on Unix, `cmd /C` on
684///   Windows), return trimmed stdout (cached per process). Missing shell or
685///   non-zero exit ⇒ `None`.
686/// - `$VAR` / `${VAR}` templates: interpolate from `env_overlay` (winning) then
687///   the process env. `$$`→`$`, `$!`→`!` escapes. Any referenced var that is
688///   unset ⇒ the **whole** value resolves to `None` (pi semantics).
689/// - Otherwise the literal string (returned as-is).
690///
691/// `env_overlay` is the `credential.env` map for auth.json keys (pi passes the
692/// same). `None` (or an empty overlay) means process-env only — used for
693/// models.json apiKey/headers, which have no env overlay.
694pub fn resolve_config_value(
695    config: &str,
696    env_overlay: Option<&BTreeMap<String, String>>,
697) -> Option<String> {
698    rpi_runtime::values::resolve_config_value(
699        config,
700        env_overlay,
701        &crate::provider::CliValues,
702        true,
703    )
704}
705
706/// Like [`resolve_config_value`] but **uncached** — mirrors pi's
707/// `resolveConfigValueUncached`, used when a fresh resolution is required
708/// (e.g. headers, which pi resolves uncached so a rotating token is re-read).
709pub fn resolve_config_value_uncached(
710    config: &str,
711    env_overlay: Option<&BTreeMap<String, String>>,
712) -> Option<String> {
713    rpi_runtime::values::resolve_config_value(
714        config,
715        env_overlay,
716        &crate::provider::CliValues,
717        false,
718    )
719}
720
721/// Resolve every header value via [`resolve_config_value_uncached`]; drop
722/// entries that resolve to `None` (mirrors pi `resolveHeaders`). Used on
723/// models.json `headers` maps before folding onto a model.
724pub fn resolve_headers(
725    headers: &BTreeMap<String, String>,
726    env_overlay: Option<&BTreeMap<String, String>>,
727) -> BTreeMap<String, String> {
728    rpi_runtime::values::resolve_headers(headers, env_overlay, &crate::provider::CliValues)
729}
730
731/// Run `cmd` (without the leading `!`), returning trimmed stdout. Cached per
732/// process (mirrors pi `executeCommand`). 10s timeout; non-zero exit / missing
733/// shell ⇒ `None`.
734pub(crate) fn resolve_command(cmd: &str) -> Option<String> {
735    let key = format!("!{cmd}");
736    if let Some(v) = command_cache().lock().ok()?.get(&key) {
737        return v.clone();
738    }
739    let result = resolve_command_uncached(cmd);
740    if let Ok(mut cache) = command_cache().lock() {
741        cache.insert(key, result.clone());
742    }
743    result
744}
745
746#[cfg(unix)]
747fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
748    std::process::Command::new("sh")
749        .arg("-c")
750        .arg(cmd)
751        .stdin(std::process::Stdio::null())
752        .stdout(std::process::Stdio::piped())
753        .stderr(std::process::Stdio::null())
754        .output()
755        .ok()
756}
757
758#[cfg(windows)]
759fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
760    use std::os::windows::process::CommandExt;
761    std::process::Command::new("cmd")
762        .arg("/C")
763        .arg(cmd)
764        .stdin(std::process::Stdio::null())
765        .stdout(std::process::Stdio::piped())
766        .stderr(std::process::Stdio::null())
767        .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
768        .output()
769        .ok()
770}
771
772/// Uncached `!command` execution (mirrors pi `executeCommandUncached`).
773pub(crate) fn resolve_command_uncached(cmd: &str) -> Option<String> {
774    let output = spawn_shell_command(cmd)?;
775    if !output.status.success() {
776        return None;
777    }
778    let stdout = String::from_utf8_lossy(&output.stdout);
779    let trimmed = stdout.trim();
780    if trimmed.is_empty() {
781        None
782    } else {
783        Some(trimmed.to_string())
784    }
785}
786
787/// (`anthropic-messages`, or omitted/unknown). Unknown `api` is allowed through
788/// for forward-compat but flagged ignored-in-v1 in the docs. Public so
789/// [`crate::provider`] can scan models.json providers for an `authHeader:true`
790/// gateway bearer source.
791/// Resolve an OpenAI-compatible provider key from its explicit config or a
792/// known provider's canonical environment variable. Arbitrary custom provider
793/// ids must declare `apiKey: "$ENV"`: deriving an env name by normalizing the
794/// id would collapse distinct native-Pi identities such as `a-b` and `a_b`.
795pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
796    if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
797        if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
798            return Some(value);
799        }
800    }
801
802    let name = native_provider_api_key_env_var(provider_id)?;
803    std::env::var(name).ok().filter(|value| !value.is_empty())
804}
805
806/// Provider-id-to-environment-variable mapping. Provider ids are
807/// identities, so aliases and case variants must not inherit another
808/// provider's credential.
809fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
810    match provider_id {
811        "openai" => crate::credentials::builtin_api_key_env(provider_id),
812        _ => None,
813    }
814}
815
816/// Convert a `(provider_id, ProviderConfig)` pair into a list of library
817/// [`Model`]s. Provider-level `base_url`/`headers`/`auth_header` fold into each
818/// model. Returns `None` for protocols that do not have a runtime provider.
819// ---------------------------------------------------------------------------
820// Internals: dir ensure, atomic write, chmod
821// ---------------------------------------------------------------------------
822
823#[cfg(unix)]
824use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
825
826/// Create the config dir if missing. Mode 0o700 on Unix (mkdir default on
827/// Windows, where the sticky-permission concept doesn't apply).
828fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
829    if dir.exists() {
830        return Ok(());
831    }
832    std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
833        path: dir.to_path_buf(),
834        source: e,
835    })?;
836    #[cfg(unix)]
837    {
838        let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
839    }
840    Ok(())
841}
842
843/// Write `bytes` to `path` atomically: a temp sibling → `rename`. The temp
844/// file lives next to the target so the rename stays on one filesystem.
845pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
846    let dir = path.parent().ok_or_else(|| ConfigError::Write {
847        path: path.to_path_buf(),
848        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
849    })?;
850    let tmp = dir.join(format!(
851        ".{}.{}.tmp",
852        path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
853        uuid::Uuid::new_v4().simple()
854    ));
855    let mut options = std::fs::OpenOptions::new();
856    options.write(true).create_new(true);
857    #[cfg(unix)]
858    options.mode(0o600);
859    let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
860        path: tmp.clone(),
861        source,
862    })?;
863    if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
864        // A partial staging file is never useful to a later caller. Best
865        // effort cleanup also avoids leaving a misleading stale temp behind
866        // when the target itself was left untouched.
867        let _ = std::fs::remove_file(&tmp);
868        return Err(ConfigError::Write {
869            path: tmp,
870            source: error,
871        });
872    }
873    drop(file);
874    if let Err(error) = std::fs::rename(&tmp, path) {
875        // `rename` is the publish point; on failure the original target is
876        // still intact. Remove the staging file before returning the error.
877        let _ = std::fs::remove_file(&tmp);
878        return Err(ConfigError::Write {
879            path: path.to_path_buf(),
880            source: error,
881        });
882    }
883    Ok(())
884}
885
886/// Best-effort tighten to owner-only (0o600). No-op on Windows (the Node
887/// upstream applies no ACL either).
888fn set_owner_only(_path: &Path) {
889    #[cfg(unix)]
890    {
891        let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
892    }
893}
894
895// ---------------------------------------------------------------------------
896// Tests
897// ---------------------------------------------------------------------------
898
899#[cfg(test)]
900pub(crate) mod test_support {
901    /// A shared workspace lock for tests that touch process-global env vars
902    /// (`RPI_CODING_AGENT_DIR`, `ANTHROPIC_*`). All env-mutating tests across
903    /// the crate (config / provider / auth) share this ONE mutex so they can't
904    /// race on the shared environment. Hold the returned guard for the whole
905    /// test (store it in a RAII struct).
906    use std::sync::{Mutex, OnceLock};
907    pub(crate) fn env_lock() -> &'static Mutex<()> {
908        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
909        LOCK.get_or_init(|| Mutex::new(()))
910    }
911
912    /// Isolate session-directory overrides while holding the shared env lock.
913    /// Restore the inherited value before releasing the lock, even on unwind.
914    pub(crate) struct SessionDirEnv {
915        _guard: std::sync::MutexGuard<'static, ()>,
916        previous: Option<std::ffi::OsString>,
917    }
918
919    impl SessionDirEnv {
920        pub(crate) fn new(directory: Option<&std::path::Path>) -> Self {
921            let guard = env_lock().lock().unwrap();
922            let previous = std::env::var_os("RPI_CODING_AGENT_SESSION_DIR");
923            match directory {
924                Some(directory) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", directory),
925                None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
926            }
927            Self {
928                _guard: guard,
929                previous,
930            }
931        }
932    }
933
934    impl Drop for SessionDirEnv {
935        fn drop(&mut self) {
936            match self.previous.take() {
937                Some(value) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", value),
938                None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
939            }
940        }
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use crate::config::test_support::env_lock;
948
949    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for the duration of the
950    /// test (cleaned up on drop). Holds the prior value of the env var to
951    /// restore it. Hold the env lock for its whole lifetime.
952    struct TempConfig {
953        _guard: std::sync::MutexGuard<'static, ()>,
954        _tmp: tempfile::TempDir,
955        prev: Option<std::ffi::OsString>,
956    }
957    impl TempConfig {
958        fn new() -> Self {
959            let guard = env_lock().lock().unwrap();
960            let prev = std::env::var_os(CONFIG_DIR_ENV);
961            let tmp = tempfile::TempDir::new().unwrap();
962            std::env::set_var(CONFIG_DIR_ENV, tmp.path());
963            Self {
964                _guard: guard,
965                _tmp: tmp,
966                prev,
967            }
968        }
969    }
970    impl Drop for TempConfig {
971        fn drop(&mut self) {
972            restore_env(CONFIG_DIR_ENV, self.prev.take());
973        }
974    }
975
976    #[test]
977    fn read_auth_missing_file_is_empty() {
978        let _cfg = TempConfig::new();
979        let store = read_auth().unwrap();
980        assert!(store.is_empty());
981    }
982
983    #[test]
984    fn upsert_then_read_roundtrip() {
985        let _cfg = TempConfig::new();
986        upsert_credential(
987            "anthropic",
988            Credential::ApiKey {
989                key: Some("sk-test-123".into()),
990                env: None,
991            },
992        )
993        .unwrap();
994        let store = read_auth().unwrap();
995        match store.get("anthropic") {
996            Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
997            other => panic!("unexpected cred: {other:?}"),
998        }
999        // The file should exist and be JSON.
1000        let path = auth_path().unwrap();
1001        assert!(path.exists(), "auth.json should exist after upsert");
1002        let raw = std::fs::read_to_string(&path).unwrap();
1003        assert!(raw.contains("\"anthropic\""));
1004        assert!(raw.contains("api_key"));
1005    }
1006
1007    #[test]
1008    fn add_provider_writes_protocol_endpoint_key_and_model() {
1009        let _cfg = TempConfig::new();
1010        add_provider(
1011            "gateway",
1012            "openai-completions",
1013            "https://api.example.test/v1",
1014            "fixture-key",
1015            "model-a",
1016            Some("Model A"),
1017            Some(128_000),
1018            Some(4096),
1019            Some(true),
1020        )
1021        .unwrap();
1022        let config = load_models_config().unwrap();
1023        let provider = config.providers.get("gateway").unwrap();
1024        assert_eq!(provider.api.as_deref(), Some("openai-completions"));
1025        assert_eq!(
1026            provider.base_url.as_deref(),
1027            Some("https://api.example.test/v1")
1028        );
1029        assert_eq!(provider.api_key.as_deref(), Some("fixture-key"));
1030        assert_eq!(provider.models[0].id, "model-a");
1031        assert_eq!(provider.models[0].name.as_deref(), Some("Model A"));
1032        assert_eq!(provider.models[0].context_window, Some(128_000));
1033        assert_eq!(provider.models[0].max_tokens, Some(4096));
1034        assert_eq!(
1035            provider.models[0].input.as_deref(),
1036            Some(["text".to_string(), "image".to_string()].as_slice())
1037        );
1038    }
1039
1040    #[test]
1041    fn add_provider_rejects_duplicate_and_unsupported_protocol() {
1042        let _cfg = TempConfig::new();
1043        add_provider(
1044            "gateway",
1045            "openai-responses",
1046            "https://api.example.test/v1",
1047            "fixture-key",
1048            "model-a",
1049            None,
1050            None,
1051            None,
1052            None,
1053        )
1054        .unwrap();
1055        let duplicate = add_provider(
1056            "gateway",
1057            "openai-completions",
1058            "https://api.example.test/v1",
1059            "fixture-key-2",
1060            "model-b",
1061            None,
1062            None,
1063            None,
1064            None,
1065        )
1066        .unwrap_err();
1067        assert!(duplicate.contains("already"));
1068        let unsupported = add_provider(
1069            "other",
1070            "unsupported",
1071            "https://api.example.test/v1",
1072            "fixture-key",
1073            "model-a",
1074            None,
1075            None,
1076            None,
1077            None,
1078        )
1079        .unwrap_err();
1080        assert!(unsupported.contains("unsupported"));
1081        let invalid_context = add_provider(
1082            "context-zero",
1083            "openai-completions",
1084            "https://api.example.test/v1",
1085            "fixture-key",
1086            "model-a",
1087            None,
1088            Some(0),
1089            None,
1090            None,
1091        )
1092        .unwrap_err();
1093        assert!(invalid_context.contains("context window"));
1094    }
1095
1096    #[test]
1097    fn update_and_delete_provider_change_models_json() {
1098        let _cfg = TempConfig::new();
1099        add_provider(
1100            "gateway",
1101            "openai-completions",
1102            "https://api.example.test/v1",
1103            "fixture-key",
1104            "model-a",
1105            Some("Model A"),
1106            Some(128_000),
1107            Some(4096),
1108            Some(false),
1109        )
1110        .unwrap();
1111        update_provider(
1112            "gateway",
1113            "openai-responses",
1114            "https://new.example.test/v1",
1115            None,
1116            "model-b",
1117            Some("Model B"),
1118            Some(256_000),
1119            Some(8192),
1120            true,
1121        )
1122        .unwrap();
1123        let provider = load_models_config()
1124            .unwrap()
1125            .providers
1126            .get("gateway")
1127            .unwrap()
1128            .clone();
1129        assert_eq!(provider.api.as_deref(), Some("openai-responses"));
1130        assert_eq!(
1131            provider.base_url.as_deref(),
1132            Some("https://new.example.test/v1")
1133        );
1134        assert_eq!(provider.api_key.as_deref(), Some("fixture-key"));
1135        assert_eq!(provider.models[0].id, "model-b");
1136        assert_eq!(provider.models[0].max_tokens, Some(8192));
1137        assert_eq!(
1138            provider.models[0].input.as_deref(),
1139            Some(["text".to_string(), "image".to_string()].as_slice())
1140        );
1141        delete_provider("gateway").unwrap();
1142        assert!(!load_models_config()
1143            .unwrap()
1144            .providers
1145            .contains_key("gateway"));
1146    }
1147
1148    #[test]
1149    fn oauth_token_is_scoped_and_round_trips() {
1150        let _cfg = TempConfig::new();
1151        upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
1152        assert!(matches!(
1153            read_oauth_token("acme").unwrap(),
1154            Some(Credential::Oauth { access, refresh, expires })
1155                if access == "access" && refresh == "refresh" && expires == 123
1156        ));
1157        assert!(read_oauth_token("other").unwrap().is_none());
1158    }
1159
1160    #[test]
1161    fn delete_credential_removes_entry() {
1162        let _cfg = TempConfig::new();
1163        upsert_credential(
1164            "anthropic",
1165            Credential::ApiKey {
1166                key: Some("k".into()),
1167                env: None,
1168            },
1169        )
1170        .unwrap();
1171        assert!(delete_credential("anthropic").unwrap());
1172        // Second delete is a no-op.
1173        assert!(!delete_credential("anthropic").unwrap());
1174        assert!(read_auth().unwrap().is_empty());
1175    }
1176
1177    #[test]
1178    fn load_models_config_missing_is_empty() {
1179        let _cfg = TempConfig::new();
1180        let c = load_models_config().unwrap();
1181        assert!(c.providers.is_empty());
1182    }
1183
1184    #[test]
1185    fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1186        let tmp = tempfile::tempdir().unwrap();
1187        let primary = tmp.path().join("primary.json");
1188        let fallback = tmp.path().join("fallback.json");
1189        std::fs::write(&fallback, "fallback").unwrap();
1190
1191        let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1192            .unwrap()
1193            .expect("fallback should be read");
1194        assert_eq!(text, "fallback");
1195
1196        std::fs::write(&primary, "primary").unwrap();
1197        let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1198            .unwrap()
1199            .expect("primary should be read");
1200        assert_eq!(path, primary);
1201        assert_eq!(text, "primary");
1202    }
1203
1204    #[test]
1205    fn fallback_reader_does_not_mask_primary_read_errors() {
1206        let tmp = tempfile::tempdir().unwrap();
1207        let primary = tmp.path().join("primary.json");
1208        let fallback = tmp.path().join("fallback.json");
1209        std::fs::create_dir(&primary).unwrap();
1210        std::fs::write(&fallback, "fallback").unwrap();
1211
1212        let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1213        assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1214    }
1215
1216    #[test]
1217    fn load_models_config_parses_with_comments() {
1218        let _cfg = TempConfig::new();
1219        let json = r#"{
1220  // a one-api style gateway
1221  "providers": {
1222    "gateway": {
1223      "baseUrl": "https://gw.example.com",
1224      "authHeader": true,
1225      "apiKey": "gw-secret",
1226      "models": [
1227        { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1228      ]
1229    }
1230  }
1231}"#;
1232        std::fs::write(models_path().unwrap(), json).unwrap();
1233        let c = load_models_config().unwrap();
1234        let gw = c
1235            .providers
1236            .get("gateway")
1237            .expect("gateway provider present");
1238        assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1239        assert!(gw.auth_header.unwrap_or(false));
1240        assert_eq!(gw.models.len(), 1);
1241        assert_eq!(gw.models[0].id, "claude-sonnet-5");
1242    }
1243
1244    #[test]
1245    fn provider_to_models_merges_headers_without_synth_bearer() {
1246        // `provider_to_models` merges model-level then provider-level headers,
1247        // but does NOT synthesize the `authHeader:true` Bearer itself — that
1248        // happens centrally in `crate::provider::resolve` (via
1249        // `models_json_bearer_token`) so it can be skipped on the x-api-key
1250        // path. Here the model carries only what the file declared.
1251        let cfg = ProviderConfig {
1252            name: None,
1253            base_url: Some("https://gw.example.com".into()),
1254            api_key: Some("gw-secret".into()),
1255            api: None,
1256            headers: Some({
1257                let mut h = BTreeMap::new();
1258                h.insert("x-portkey-key".into(), "portkey-secret".into());
1259                h
1260            }),
1261            auth_header: Some(true),
1262            models: vec![ModelDefinition {
1263                id: "claude-sonnet-5".into(),
1264                name: None,
1265                base_url: None,
1266                reasoning: None,
1267                thinking_level_map: None,
1268                context_window: None,
1269                max_tokens: None,
1270                cost: None,
1271                input: None,
1272                headers: None,
1273                compat: None,
1274            }],
1275        };
1276        let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1277        assert_eq!(models.len(), 1);
1278        let m = &models[0];
1279        assert_eq!(m.id, "claude-sonnet-5");
1280        assert_eq!(m.base_url, "https://gw.example.com");
1281        assert_eq!(m.provider, "gateway");
1282        let headers = m.headers.as_ref().expect("provider headers merged");
1283        // Declared provider header folds in…
1284        assert_eq!(
1285            headers.get("x-portkey-key").map(|s| s.as_str()),
1286            Some("portkey-secret")
1287        );
1288        // …but no Bearer is synthesized here. The bearer-from-authHeader path
1289        // is exercised end-to-end by the provider.rs `resolve` tests
1290        // (`models_json_auth_header_satisfies_auth_without_env`,
1291        // `api_key_flag_beats_models_json_bearer`).
1292        assert!(
1293            headers.get("authorization").is_none(),
1294            "provider_to_models must not synthesize the Bearer; resolve does"
1295        );
1296    }
1297
1298    #[test]
1299    fn provider_to_models_supports_openai_completions() {
1300        let config: ModelsConfig = serde_json::from_str(
1301            r#"{
1302                "providers": {
1303                    "oai": {
1304                        "api": "openai-completions",
1305                        "baseUrl": "https://gateway.example.com/v1",
1306                        "apiKey": "secret",
1307                        "models": [{"id":"gpt-test","maxTokens":4096}]
1308                    }
1309                }
1310            }"#,
1311        )
1312        .unwrap();
1313        let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1314        assert_eq!(models.len(), 1);
1315        assert_eq!(models[0].api, Api::OpenaiCompletions);
1316        assert_eq!(models[0].provider, "oai");
1317        assert_eq!(models[0].max_tokens, 4096);
1318        assert_eq!(models[0].cost, ModelCost::default());
1319        assert!(!models[0].has_pricing());
1320        assert!(
1321            models[0].headers.is_none(),
1322            "credentials are bound after provider resolution"
1323        );
1324    }
1325
1326    #[test]
1327    fn models_json_pricing_reaches_providers_and_survives_serialization() {
1328        let mut config = parse_models_json(
1329            r#"{
1330                "providers": {
1331                    "pricing-fixture": {
1332                        "api": "anthropic-messages",
1333                        "models": [{
1334                            "id": "priced-model",
1335                            "cost": {
1336                                "input": 2, "output": 8, "cacheRead": 0.2, "cacheWrite": 2.5,
1337                                "tiers": [{
1338                                    "inputTokensAbove": 1000000,
1339                                    "input": 4, "output": 16, "cacheRead": 0.4, "cacheWrite": 5
1340                                }]
1341                            }
1342                        }]
1343                    }
1344                }
1345            }"#,
1346        )
1347        .unwrap();
1348        let expected = config.providers["pricing-fixture"].models[0]
1349            .cost
1350            .clone()
1351            .unwrap();
1352        for api in [
1353            "anthropic-messages",
1354            "openai-completions",
1355            "openai-responses",
1356        ] {
1357            let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1358            cfg.api = Some(api.into());
1359            let models = provider_to_models("pricing-fixture", cfg).unwrap();
1360            assert_eq!(models[0].cost, expected);
1361            assert!(models[0].has_pricing());
1362            let mut usage = rpi_ai::Usage::zero();
1363            usage.input = 100_000;
1364            usage.output = 250_000;
1365            usage.cache_read = 300_000;
1366            usage.cache_write = 400_000;
1367            let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1368            assert!((cost.total - 3.26).abs() < 1e-10, "base pricing for {api}");
1369            usage.input = 1_000_001;
1370            usage.output = 1_000_000;
1371            usage.cache_read = 0;
1372            usage.cache_write = 0;
1373            let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1374            assert!(
1375                (cost.total - 20.000004).abs() < 1e-10,
1376                "tiered pricing for {api}"
1377            );
1378        }
1379        let saved = serde_json::to_string(&config).unwrap();
1380        let reloaded = parse_models_json(&saved).unwrap();
1381        assert_eq!(
1382            reloaded.providers["pricing-fixture"].models[0].cost,
1383            Some(expected)
1384        );
1385        let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1386        cfg.models[0].cost = Some(ModelCost::default());
1387        let free = provider_to_models("pricing-fixture", cfg).unwrap();
1388        assert_eq!(free[0].cost, ModelCost::default());
1389        assert!(
1390            free[0].has_pricing(),
1391            "explicit zero prices are still configured"
1392        );
1393    }
1394
1395    #[test]
1396    fn models_json_compat_reaches_the_model_for_openai_completions() {
1397        // The `compat` block is what lets a custom openai-completions provider
1398        // describe its quirks. `requiresThinkingAsText` is the one that decides
1399        // whether the model's own reasoning is sent back or dropped — see
1400        // `docs/debugging/llm-repetition-forensics.md` §八. If this hop ever breaks, the
1401        // setting silently does nothing (the provider falls back to
1402        // `Content::text_only`, which drops every thinking block).
1403        let config: ModelsConfig = serde_json::from_str(
1404            r#"{
1405                "providers": {
1406                    "qwen": {
1407                        "api": "openai-completions",
1408                        "baseUrl": "https://coding.example.com/v1",
1409                        "apiKey": "secret",
1410                        "models": [{
1411                            "id": "qwen3.7-plus",
1412                            "maxTokens": 131072,
1413                            "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" },
1414                            "compat": { "requiresThinkingAsText": true }
1415                        }]
1416                    }
1417                }
1418            }"#,
1419        )
1420        .unwrap();
1421        let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1422        assert_eq!(
1423            models[0]
1424                .thinking_level_map
1425                .as_ref()
1426                .unwrap()
1427                .get(&rpi_ai::ThinkingLevel::Xhigh),
1428            Some(&Some("xhigh".into()))
1429        );
1430        match models[0].compat.as_ref().expect("compat must survive") {
1431            StreamingProtocolCompat::OpenaiCompletions(compat) => {
1432                assert_eq!(compat.requires_thinking_as_text, Some(true));
1433            }
1434            other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1435        }
1436
1437        // An entry without `compat` stays on the default (thinking dropped).
1438        let plain: ModelsConfig = serde_json::from_str(
1439            r#"{
1440                "providers": {
1441                    "qwen": {
1442                        "api": "openai-completions",
1443                        "baseUrl": "https://coding.example.com/v1",
1444                        "apiKey": "secret",
1445                        "models": [{"id":"qwen3.7-plus"}]
1446                    }
1447                }
1448            }"#,
1449        )
1450        .unwrap();
1451        let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1452        assert!(plain_models[0].compat.is_none());
1453    }
1454
1455    #[test]
1456    fn models_json_accepts_pi_deepseek_compat_and_warns_on_unknown_or_invalid_fields() {
1457        let mut config: ModelsConfig = serde_json::from_value(serde_json::json!({
1458            "providers": { "deepseek": {
1459                "api": "openai-completions",
1460                "models": [{ "id": "reasoner", "reasoning": true, "compat": {
1461                    "thinkingFormat": "deepseek",
1462                    "requiresReasoningContentOnAssistantMessages": true
1463                }}]
1464            }}
1465        }))
1466        .unwrap();
1467        assert!(model_compat_warnings(&config).is_empty());
1468        let models = provider_to_models("deepseek", &config.providers["deepseek"]).unwrap();
1469        assert!(rpi_ai::model::prior_reasoning_is_replayed(&models[0]));
1470        let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1471            panic!("missing model compatibility flags");
1472        };
1473        assert_eq!(compat.requires_reasoning_content, Some(true));
1474        assert_eq!(
1475            compat.thinking_format,
1476            Some(rpi_ai::model::OpenaiThinkingFormat::Deepseek)
1477        );
1478
1479        config.providers["deepseek"].models[0]
1480            .compat
1481            .as_mut()
1482            .unwrap()["misspelledField"] = serde_json::json!("secret-fixture-value");
1483        let warnings = model_compat_warnings(&config);
1484        assert_eq!(warnings.len(), 1);
1485        assert!(warnings[0].contains("deepseek") && warnings[0].contains("reasoner"));
1486        assert!(warnings[0].contains("unknown field \"misspelledField\""));
1487        assert!(!warnings[0].contains("secret-fixture-value"));
1488
1489        config.providers["deepseek"].models[0].compat =
1490            Some(serde_json::json!({"requiresReasoningContent": "secret-fixture-token"}));
1491        let warning = &model_compat_warnings(&config)[0];
1492        assert!(warning.contains("invalid configuration"));
1493        assert!(!warning.contains("secret-fixture-token"));
1494        config.providers["deepseek"].models[0].compat =
1495            Some(serde_json::json!({"thinkingFormat": "unsupported"}));
1496        assert!(model_compat_warnings(&config)[0].contains("invalid configuration"));
1497    }
1498
1499    #[test]
1500    fn responses_compat_diagnostics_use_the_responses_schema() {
1501        let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1502            "providers": { "relay": {
1503                "api": "openai-responses", "models": [{ "id": "test", "compat": {
1504                    "supportsOpenAIGrammarTools": true, "supportsStore": false
1505                }}]
1506            }}
1507        }))
1508        .unwrap();
1509        let warnings = model_compat_warnings(&config);
1510        assert_eq!(warnings.len(), 1);
1511        assert!(warnings[0].contains("supportsStore"));
1512    }
1513
1514    #[test]
1515    fn native_reasoning_content_key_wins_over_a_conflicting_pi_alias() {
1516        let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1517            "providers": { "relay": {
1518                "api": "openai-completions", "models": [{ "id": "test", "compat": {
1519                    "requiresReasoningContent": false,
1520                    "requiresReasoningContentOnAssistantMessages": true
1521                }}]
1522            }}
1523        }))
1524        .unwrap();
1525        let warnings = model_compat_warnings(&config);
1526        assert_eq!(warnings.len(), 1);
1527        assert!(warnings[0].contains("takes precedence"));
1528        let models = provider_to_models("relay", &config.providers["relay"]).unwrap();
1529        let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1530            panic!("valid native flag must survive an alias conflict");
1531        };
1532        assert_eq!(compat.requires_reasoning_content, Some(false));
1533    }
1534
1535    #[test]
1536    fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1537        let _guard = env_lock().lock().unwrap();
1538        let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1539        std::env::set_var(env_name, "env-secret");
1540        let cfg = ProviderConfig {
1541            name: None,
1542            base_url: Some("https://gateway.example.com/v1".into()),
1543            api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1544            api: Some("openai-completions".into()),
1545            headers: None,
1546            auth_header: None,
1547            models: vec![ModelDefinition {
1548                id: "fake-model".into(),
1549                name: None,
1550                base_url: None,
1551                reasoning: None,
1552                thinking_level_map: None,
1553                context_window: None,
1554                max_tokens: None,
1555                cost: None,
1556                input: None,
1557                headers: None,
1558                compat: None,
1559            }],
1560        };
1561        let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1562        assert_eq!(
1563            openai_provider_api_key("rpi-fake-provider", &cfg).as_deref(),
1564            Some("env-secret")
1565        );
1566        assert!(
1567            models[0].headers.is_none(),
1568            "discovery does not bind credentials"
1569        );
1570        std::env::remove_var(env_name);
1571    }
1572
1573    #[test]
1574    fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1575        let _guard = env_lock().lock().unwrap();
1576        let prev_openai = std::env::var_os("OPENAI_API_KEY");
1577        let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1578        let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1579        std::env::set_var("OPENAI_API_KEY", "official-key");
1580        std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1581        std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1582        let config = ProviderConfig {
1583            name: None,
1584            base_url: Some("https://gateway.example.com/v1".into()),
1585            api_key: None,
1586            api: Some("openai-responses".into()),
1587            headers: None,
1588            auth_header: None,
1589            models: Vec::new(),
1590        };
1591
1592        assert_eq!(
1593            openai_provider_api_key("openai", &config).as_deref(),
1594            Some("official-key")
1595        );
1596        assert!(openai_provider_api_key("openai-completions", &config).is_none());
1597        assert!(openai_provider_api_key("openai-responses", &config).is_none());
1598        assert!(openai_provider_api_key("OpenAI", &config).is_none());
1599
1600        restore_env("OPENAI_API_KEY", prev_openai);
1601        restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1602        restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1603    }
1604
1605    #[test]
1606    fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1607        assert_eq!(
1608            native_provider_api_key_env_var("openai"),
1609            Some("OPENAI_API_KEY")
1610        );
1611        for provider in [
1612            "deepseek",
1613            "openrouter",
1614            "google",
1615            "groq",
1616            "qwen",
1617            "nvidia",
1618            "OpenAI",
1619            "openai-completions",
1620        ] {
1621            assert_eq!(
1622                native_provider_api_key_env_var(provider),
1623                None,
1624                "external providers must declare auth in their package or models.json"
1625            );
1626        }
1627    }
1628
1629    #[test]
1630    fn malformed_auth_json_is_an_error_not_silent_empty() {
1631        let _cfg = TempConfig::new();
1632        std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1633        assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1634    }
1635
1636    #[test]
1637    fn agent_dir_nests_under_agent_by_default() {
1638        // With no env override, agent_dir() must end in `.../.rpi/agent`
1639        // (mirrors pi's `getAgentDir`). We can't assertion the home prefix
1640        // portably, but the leaf two segments are stable.
1641        let _guard = env_lock().lock().unwrap();
1642        let prev = std::env::var_os(CONFIG_DIR_ENV);
1643        std::env::remove_var(CONFIG_DIR_ENV);
1644        let dir = agent_dir().unwrap();
1645        restore_env(CONFIG_DIR_ENV, prev);
1646        assert!(dir.ends_with("agent"));
1647        assert!(dir
1648            .parent()
1649            .map(|p| p.ends_with(CONFIG_DIR_NAME))
1650            .unwrap_or(false));
1651    }
1652
1653    #[test]
1654    fn migrate_legacy_layout_moves_flat_files_into_agent() {
1655        // Drive the core migration directly against a temp root/agent so the
1656        // result is independent of whatever RPI_CODING_AGENT_DIR the parallel
1657        // TempConfig tests happen to set.
1658        let tmp = tempfile::TempDir::new().unwrap();
1659        let root = tmp.path().to_path_buf();
1660        let agent = root.join("agent");
1661        std::fs::write(root.join("auth.json"), "{}").unwrap();
1662        std::fs::write(root.join("models.json"), "{}").unwrap();
1663        std::fs::write(root.join(".setup_done"), "1").unwrap();
1664        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1665        assert_eq!(moved, 3);
1666        assert!(agent.join("auth.json").exists());
1667        assert!(agent.join("models.json").exists());
1668        assert!(agent.join(".setup_done").exists());
1669        assert!(!root.join("auth.json").exists());
1670    }
1671
1672    #[test]
1673    fn migrate_legacy_layout_noop_when_agent_exists() {
1674        let tmp = tempfile::TempDir::new().unwrap();
1675        let root = tmp.path().to_path_buf();
1676        let agent = root.join("agent");
1677        std::fs::write(root.join("auth.json"), "{}").unwrap();
1678        std::fs::create_dir_all(&agent).unwrap();
1679        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1680        assert_eq!(moved, 0); // agent/ already present — leave flat file alone
1681    }
1682
1683    #[test]
1684    fn migrate_legacy_layout_noop_when_no_flat_files() {
1685        let tmp = tempfile::TempDir::new().unwrap();
1686        let root = tmp.path().to_path_buf();
1687        let agent = root.join("agent");
1688        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1689        assert_eq!(moved, 0);
1690    }
1691
1692    #[test]
1693    fn migrate_legacy_layout_public_skips_env_override() {
1694        // When RPI_CODING_AGENT_DIR is set, the public entry point is a no-op
1695        // (it must never touch an explicit override). TempConfig sets it.
1696        let _cfg = TempConfig::new();
1697        let moved = migrate_legacy_layout().unwrap();
1698        assert_eq!(moved, 0);
1699    }
1700
1701    #[test]
1702    fn read_trust_missing_file_is_empty() {
1703        let _cfg = TempConfig::new();
1704        assert!(read_trust().unwrap().is_empty());
1705    }
1706
1707    #[test]
1708    fn read_trust_parses_decisions() {
1709        let _cfg = TempConfig::new();
1710        let path = trust_path().unwrap();
1711        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1712        std::fs::write(
1713            &path,
1714            r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1715        )
1716        .unwrap();
1717        let store = read_trust().unwrap();
1718        assert_eq!(store.len(), 3);
1719        assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1720        assert_eq!(
1721            store.get("/home/me/untrusted").copied().flatten(),
1722            Some(false)
1723        );
1724        assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1725    }
1726
1727    #[test]
1728    fn resolve_config_value_literal_passthrough() {
1729        assert_eq!(
1730            resolve_config_value("sk-literal-key", None),
1731            Some("sk-literal-key".into())
1732        );
1733    }
1734
1735    #[test]
1736    fn resolve_config_value_env_var() {
1737        let _guard = env_lock().lock().unwrap();
1738        let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1739        let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1740        std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1741        assert_eq!(
1742            resolve_config_value("$RPI_TEST_CFG_KEY", None),
1743            Some("secret-from-env".into())
1744        );
1745        assert_eq!(
1746            resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1747            Some("prefix-secret-from-env-suffix".into())
1748        );
1749        // Two vars in one template.
1750        std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1751        assert_eq!(
1752            resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1753            Some("a-secret-from-env-b-two-c".into())
1754        );
1755        // Env overlay wins over process env.
1756        let mut overlay = BTreeMap::new();
1757        overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1758        assert_eq!(
1759            resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1760            Some("overlay-value".into())
1761        );
1762        restore_env("RPI_TEST_CFG_KEY", prev);
1763        restore_env("RPI_TEST_CFG_KEY2", prev2);
1764    }
1765
1766    #[test]
1767    fn resolve_config_value_unset_env_is_none() {
1768        let _guard = env_lock().lock().unwrap();
1769        let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1770        std::env::remove_var("RPI_TEST_CFG_ABSENT");
1771        // Any referenced unset var ⇒ the whole value is None (pi semantics).
1772        assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1773        assert_eq!(
1774            resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1775            None
1776        );
1777        restore_env("RPI_TEST_CFG_ABSENT", prev);
1778    }
1779
1780    #[test]
1781    fn resolve_config_value_dollar_dollar_escapes_literal() {
1782        assert_eq!(
1783            resolve_config_value("price-$$5", None),
1784            Some("price-$5".into())
1785        );
1786        assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1787    }
1788
1789    #[test]
1790    fn resolve_config_value_command_runs_shell() {
1791        // `!echo resolved` → "resolved" (sh on Unix; `echo` works under cmd too).
1792        assert_eq!(
1793            resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1794            Some("rpi-cfg-resolved".into())
1795        );
1796        // Non-zero exit ⇒ None.
1797        assert_eq!(resolve_config_value_uncached("!false", None), None);
1798    }
1799
1800    #[test]
1801    fn resolve_headers_drops_unresolvable() {
1802        let _guard = env_lock().lock().unwrap();
1803        let prev = std::env::var_os("RPI_TEST_HDR_SET");
1804        std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1805        let mut h = BTreeMap::new();
1806        h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1807        h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1808        h.insert("x-literal".into(), "literal-value".into());
1809        let resolved = resolve_headers(&h, None);
1810        assert_eq!(resolved.len(), 2);
1811        assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1812        assert_eq!(
1813            resolved.get("x-literal").map(|s| s.as_str()),
1814            Some("literal-value")
1815        );
1816        assert!(!resolved.contains_key("x-unset"));
1817        restore_env("RPI_TEST_HDR_SET", prev);
1818    }
1819
1820    #[test]
1821    fn agent_dir_respects_env_override() {
1822        let _guard = env_lock().lock().unwrap();
1823        let prev = std::env::var_os(CONFIG_DIR_ENV);
1824        let tmp = tempfile::TempDir::new().unwrap();
1825        std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1826        let dir = agent_dir().unwrap();
1827        restore_env(CONFIG_DIR_ENV, prev);
1828        assert_eq!(dir, tmp.path());
1829    }
1830
1831    #[test]
1832    fn relative_override_is_rejected() {
1833        let _guard = env_lock().lock().unwrap();
1834        let prev = std::env::var_os(CONFIG_DIR_ENV);
1835        std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1836        let err = agent_dir().unwrap_err();
1837        restore_env(CONFIG_DIR_ENV, prev);
1838        assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1839    }
1840
1841    /// Restore/remove an env var based on its prior `OsString` value.
1842    fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1843        match prev {
1844            Some(v) => std::env::set_var(name, v),
1845            None => std::env::remove_var(name),
1846        }
1847    }
1848}
1849
1850/// Convert host configuration with the same rules as the embeddable runtime.
1851pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
1852    rpi_runtime::config::provider_to_models(provider_id, cfg, &crate::provider::CliValues)
1853}