Skip to main content

rpi_cli/
config.rs

1//! `~/.rpi/` persistent configuration — auth + model catalog. Mirrors (a
2//! Rust-flattened slice of) the TS `packages/coding-agent/src/config.ts`
3//! (`getAgentDir`/`getAuthPath`/`getModelsPath`) + `core/auth-storage.ts`
4//! (`FileAuthStorageBackend`) + `core/model-config.ts` (`ModelConfig`).
5//!
6//! # Layout (divergence from upstream — documented in `docs/m6-cli-open-questions.md`)
7//!
8//! Upstream uses `~/.pi/agent/{auth.json, models.json, …}` because the same dir
9//! also hosts themes/bin/prompts/sessions. rpi v1 has only two files, so it
10//! drops the `agent/` layer and goes flat:
11//!
12//! ```text
13//! ~/.rpi/                 (RPI_CODING_AGENT_DIR env overrides this)
14//! ├── auth.json          # persisted credentials (mode 0o600 on Unix)
15//! └── models.json        # user-defined provider/model catalog (hand-edited)
16//! ```
17//!
18//! # Concurrency
19//!
20//! v1 is a single-process CLI, so we use **atomic rename** instead of upstream's
21//! `proper-lockfile`: write a sibling temp file, `fs::rename` over the target,
22//! then `chmod 0o600` on Unix (Windows chmod is a no-op, matching Node).
23//! Concurrent `rpi auth login` from two shells could lose one update — that's
24//! accepted and documented; adding a file lock is deferred.
25//!
26//! # models.json credential expansion
27//!
28//! Upstream `resolveConfigValue` expands `$ENV`/`!command`/`${ENV}` inside
29//! `apiKey`/`headers`. **v1 does not** — only literal strings are accepted
30//! (use the `ANTHROPIC_*` env vars for dynamic secrets). Documented divergence.
31
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34
35use rpi_ai::{Api, InputModality, Model};
36
37/// The config directory name under the home dir. Upstream is `.pi`; rpi uses
38/// `.rpi` to avoid colliding with a native `pi` install on the same machine.
39pub const CONFIG_DIR_NAME: &str = ".rpi";
40
41/// Env var that overrides the whole config dir (mirrors upstream
42/// `PI_CODING_AGENT_DIR`). Absolute path; relative values are rejected.
43pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
44
45/// The provider id under which `rpi auth login` stores the Anthropic key.
46/// Mirrors upstream's fixed `anthropic` provider id.
47pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
48
49// ---------------------------------------------------------------------------
50// Errors
51// ---------------------------------------------------------------------------
52
53/// A config-layer error (path resolution, IO, JSON). Surfaced to the user by
54/// the `auth` subcommand / `provider::resolve`.
55#[derive(Debug, thiserror::Error)]
56pub enum ConfigError {
57    #[error("could not resolve home directory (set {env} to override)")]
58    NoHomeDir { env: &'static str },
59    #[error("config dir override {env}={val:?} is not an absolute path")]
60    RelativeOverride { env: &'static str, val: String },
61    #[error("could not read {path}: {source}")]
62    Read { path: PathBuf, #[source] source: std::io::Error },
63    #[error("could not write {path}: {source}")]
64    Write { path: PathBuf, #[source] source: std::io::Error },
65    #[error("invalid JSON in {path}: {source}")]
66    Json { path: PathBuf, #[source] source: serde_json::Error },
67}
68
69// ---------------------------------------------------------------------------
70// Path resolution
71// ---------------------------------------------------------------------------
72
73/// The rpi config directory (`~/.rpi` by default, `RPI_CODING_AGENT_DIR`
74/// override). Creates nothing — purely a path computation.
75pub fn agent_dir() -> Result<PathBuf, ConfigError> {
76    if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
77        let p = PathBuf::from(&val);
78        if !p.is_absolute() {
79            return Err(ConfigError::RelativeOverride {
80                env: CONFIG_DIR_ENV,
81                val: val.to_string_lossy().into_owned(),
82            });
83        }
84        return Ok(p);
85    }
86    let home = dirs::home_dir()
87        .ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })?;
88    Ok(home.join(CONFIG_DIR_NAME))
89}
90
91/// `~/.rpi/auth.json`.
92pub fn auth_path() -> Result<PathBuf, ConfigError> {
93    Ok(agent_dir()?.join("auth.json"))
94}
95
96/// `~/.rpi/models.json`.
97pub fn models_path() -> Result<PathBuf, ConfigError> {
98    Ok(agent_dir()?.join("models.json"))
99}
100
101// ---------------------------------------------------------------------------
102// auth.json — Credential store
103// ---------------------------------------------------------------------------
104
105/// A stored credential. Mirrors the TS `Credential` union
106/// (`packages/ai/src/auth/types.ts`). The `Oauth` variant exists for forward
107/// compatibility but v1 never writes it (no OAuth device-code flow); `resolve`
108/// does not consume it.
109#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
110#[serde(rename_all = "snake_case", tag = "type")]
111pub enum Credential {
112    /// An API key, optionally sourced from an env var map. v1 stores only the
113    /// literal `key` (the `env` field is kept for upstream-shape compatibility).
114    ApiKey {
115        key: Option<String>,
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        env: Option<BTreeMap<String, String>>,
118    },
119    /// OAuth tokens (access + refresh + expiry). v1 does not write this.
120    Oauth {
121        access: String,
122        refresh: String,
123        /// Unix epoch seconds.
124        expires: i64,
125    },
126}
127
128/// The auth store: `providerId -> Credential`. Mirrors upstream
129/// `Record<providerId, Credential>`.
130pub type AuthStore = BTreeMap<String, Credential>;
131
132/// Read the auth store. Missing file ⇒ empty store (not an error). Malformed
133/// JSON ⇒ `ConfigError::Json` (we do not silently swallow a corrupt auth file).
134pub fn read_auth() -> Result<AuthStore, ConfigError> {
135    let path = auth_path()?;
136    match std::fs::read_to_string(&path) {
137        Ok(text) => Ok(serde_json::from_str(&text).map_err(|e| ConfigError::Json {
138            path: path.clone(),
139            source: e,
140        })?),
141        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
142        Err(e) => Err(ConfigError::Read { path, source: e }),
143    }
144}
145
146/// Atomically write the whole auth store (ensures the dir exists, writes a
147/// temp sibling, `rename`s over the target, then `chmod 0o600` on Unix).
148pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
149    let path = auth_path()?;
150    let dir = agent_dir()?;
151    ensure_dir(&dir)?;
152    let json = serde_json::to_string_pretty(store).unwrap();
153    atomic_write(&path, json.as_bytes())?;
154    set_owner_only(&path);
155    Ok(())
156}
157
158/// Read-modify-write: upsert a credential for `provider_id`.
159pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
160    let mut store = read_auth()?;
161    store.insert(provider_id.to_string(), cred);
162    write_auth(&store)
163}
164
165/// Remove `provider_id` from the store. Returns `true` if a credential was
166/// present (and is now gone), `false` if it was already absent. Always rewrites
167/// the file when the provider existed (so `auth logout` reflects the new state
168/// on disk even if the map isn't empty).
169pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
170    let mut store = read_auth()?;
171    if store.remove(provider_id).is_some() {
172        write_auth(&store)?;
173        Ok(true)
174    } else {
175        Ok(false)
176    }
177}
178
179// ---------------------------------------------------------------------------
180// models.json — provider/model catalog
181// ---------------------------------------------------------------------------
182
183/// The `models.json` document. Mirrors TS `{ providers: Record<id, ProviderConfig> }`
184/// (`core/model-config.ts` `ModelsConfigSchema`).
185#[derive(serde::Deserialize, Default, Debug, Clone)]
186#[serde(rename_all = "camelCase")]
187pub struct ModelsConfig {
188    #[serde(default)]
189    pub providers: BTreeMap<String, ProviderConfig>,
190}
191
192/// A provider entry in `models.json`. The fields mirror the TS `ProviderConfig`
193/// one-for-one; v1 honors `base_url`/`api_key`/`headers`/`auth_header`/`models`,
194/// and **ignores** `api` values other than `anthropic-messages` (documented).
195#[derive(serde::Deserialize, Debug, Clone)]
196#[serde(rename_all = "camelCase")]
197pub struct ProviderConfig {
198    #[serde(default)]
199    pub name: Option<String>,
200    #[serde(default)]
201    pub base_url: Option<String>,
202    #[serde(default)]
203    pub api_key: Option<String>,
204    #[serde(default)]
205    pub api: Option<String>,
206    #[serde(default)]
207    pub headers: Option<BTreeMap<String, String>>,
208    /// `true` ⇒ wrap `api_key` as `Authorization: Bearer <key>` (mirrors
209    /// upstream `provider-composer.ts` `authHeader`).
210    #[serde(default)]
211    pub auth_header: Option<bool>,
212    #[serde(default)]
213    pub models: Vec<ModelDefinition>,
214}
215
216/// One model under a provider. `id` is required (mirrors TS `ModelDefinition`).
217#[derive(serde::Deserialize, Debug, Clone)]
218#[serde(rename_all = "camelCase")]
219pub struct ModelDefinition {
220    pub id: String,
221    #[serde(default)]
222    pub name: Option<String>,
223    #[serde(default)]
224    pub base_url: Option<String>,
225    #[serde(default)]
226    pub reasoning: Option<bool>,
227    #[serde(default)]
228    pub context_window: Option<u64>,
229    #[serde(default)]
230    pub max_tokens: Option<u64>,
231    /// Free-form modality strings ("text"/"image"); unknown values fall back
232    /// to text-only.
233    #[serde(default)]
234    pub input: Option<Vec<String>>,
235    #[serde(default)]
236    pub headers: Option<BTreeMap<String, String>>,
237}
238
239/// Load `~/.rpi/models.json`. Missing file ⇒ empty config (no error).
240pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
241    let path = models_path()?;
242    match std::fs::read_to_string(&path) {
243        Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
244            path: path.clone(),
245            source: e,
246        }),
247        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
248        Err(e) => Err(ConfigError::Read { path, source: e }),
249    }
250}
251
252/// Parse the models JSON, tolerating `//` line comments (a minimal subset of
253/// upstream's `stripJsonComments`). Tries strict JSON first; on failure, strips
254/// `//…` to end-of-line and retries.
255fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
256    match serde_json::from_str(text) {
257        Ok(c) => Ok(c),
258        Err(first) => {
259            // Best-effort comment strip — only `//` to EOL, never inside strings
260            // (a `//` inside a JSON string would already have made the strict
261            // parse fail for a *different* reason; stripping naively is an
262            // acceptable v1 trade-off, documented as a limitation).
263            let stripped: String = text
264                .lines()
265                .map(|line| {
266                    if let Some(idx) = find_line_comment(line) {
267                        line[..idx].to_string()
268                    } else {
269                        line.to_string()
270                    }
271                })
272                .collect::<Vec<_>>()
273                .join("\n");
274            serde_json::from_str(&stripped).map_err(|_| first)
275        }
276    }
277}
278
279/// Index of a `//` line comment that is *not* inside a double-quoted string.
280fn find_line_comment(line: &str) -> Option<usize> {
281    let mut in_str = false;
282    let mut esc = false;
283    for (i, ch) in line.char_indices() {
284        if esc {
285            esc = false;
286            continue;
287        }
288        match ch {
289            '\\' if in_str => esc = true,
290            '"' => in_str = !in_str,
291            '/' if !in_str => {
292                if line.as_bytes().get(i + 1) == Some(&b'/') {
293                    return Some(i);
294                }
295            }
296            _ => {}
297        }
298    }
299    None
300}
301
302/// Whether the entry under `provider_id` speaks the v1-honored protocol
303/// (`anthropic-messages`, or omitted/unknown). Unknown `api` is allowed through
304/// for forward-compat but flagged ignored-in-v1 in the docs. Public so
305/// [`crate::provider`] can scan models.json providers for an `authHeader:true`
306/// gateway bearer source.
307pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
308    match cfg.api.as_deref() {
309        None | Some("") | Some("anthropic-messages") => true,
310        _ => false,
311    }
312}
313
314/// Convert a `(provider_id, ProviderConfig)` pair into a list of library
315/// [`Model`]s. Provider-level `base_url`/`headers`/`auth_header` fold into each
316/// model. Returns `None` for non-anthropic providers (v1 ignores them).
317pub fn provider_to_models(
318    provider_id: &str,
319    cfg: &ProviderConfig,
320) -> Option<Vec<Model>> {
321    let _ = provider_id; // config-namespacing only; v1 routes via the single AnthropicProvider.
322    if !provider_is_anthropic_compatible(cfg) {
323        return None;
324    }
325    let provider_base = cfg.base_url.clone().unwrap_or_else(default_anthropic_base_url);
326    let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
327    for def in &cfg.models {
328        let base_url = def
329            .base_url
330            .clone()
331            .unwrap_or_else(|| provider_base.clone());
332        let name = def.name.clone().unwrap_or_else(|| def.id.clone());
333        // v1 routes EVERY anthropic-messages model through the single
334        // `AnthropicProvider` (whose `id()` is "anthropic"). Upstream
335        // `registerProvider(providerName, …)` registers a distinct provider per
336        // models.json key and routes by that key; v1 has no multi-provider
337        // registry, so the models.json provider id is config-namespacing only
338        // — the per-model `base_url` + `headers` carry the actual endpoint/auth
339        // differentiation. Stamping `provider = "anthropic"` here lets the
340        // harness's `resolve_provider` (`provider.id() == model.provider`)
341        // match. Without this, a `gateway/custom-claude` model would carry
342        // `provider = "gateway"` and the run would fail with "No provider
343        // registered for 'gateway'". Divergence documented in
344        // `docs/m6-cli-open-questions.md`.
345        let mut m = Model::new(
346            def.id.clone(),
347            name,
348            Api::AnthropicMessages,
349            DEFAULT_PROVIDER_ID.to_string(),
350            base_url,
351        );
352        m.reasoning = def.reasoning.unwrap_or(false);
353        m.context_window = def.context_window.unwrap_or(0);
354        m.max_tokens = def.max_tokens.unwrap_or(0);
355        m.input = parse_input_modalities(def.input.as_deref());
356        // Merge: model-level headers, then provider-level headers (provider wins
357        // on conflict — it's the more specific-to-this-endpoint declaration).
358        // NOTE: the `authHeader:true` Bearer synthesis is NOT done here —
359        // [`crate::provider::resolve`] applies it centrally so it can skip it
360        // when a higher-priority x-api-key source (`--api-key` / auth.json /
361        // `ANTHROPIC_API_KEY`) wins. Folding it here unconditionally would put a
362        // Bearer on the model even on the x-api-key path. See
363        // `models_json_bearer_token` + the fold loop in `resolve`.
364        let mut headers: BTreeMap<String, String> = BTreeMap::new();
365        if let Some(h) = def.headers.clone() {
366            headers.extend(h);
367        }
368        if let Some(h) = cfg.headers.clone() {
369            headers.extend(h);
370        }
371        if !headers.is_empty() {
372            m.headers = Some(headers);
373        }
374        merged.push(m);
375    }
376    Some(merged)
377}
378
379/// Parse `["text","image"]`-style modality strings into [`InputModality`]s;
380/// unknown values drop to text-only. `None` ⇒ text (the [`Model::new`] default).
381fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
382    match input {
383        None => vec![InputModality::Text],
384        Some(list) if list.is_empty() => vec![InputModality::Text],
385        Some(list) => list
386            .iter()
387            .filter_map(|s| match s.to_ascii_lowercase().as_str() {
388                "text" => Some(InputModality::Text),
389                "image" => Some(InputModality::Image),
390                _ => None,
391            })
392            .collect::<Vec<_>>()
393            .pipe(|v| if v.is_empty() { vec![InputModality::Text] } else { v }),
394    }
395}
396
397/// The first-party Anthropic endpoint — used as the fallback `base_url` when a
398/// models.json provider omits it. Kept here (not imported from `rpi_ai`) so the
399/// config layer never depends on the provider's private `models` module.
400/// Public so [`crate::provider::resolve`] can tell a gateway model (whose
401/// `base_url` differs from this) from a built-in Anthropic model.
402pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
403
404/// Same value as [`ANTHROPIC_DEFAULT_BASE_URL`], as an owned `String` for the
405/// `unwrap_or_else` ergonomic used by [`provider_to_models`].
406fn default_anthropic_base_url() -> String {
407    ANTHROPIC_DEFAULT_BASE_URL.to_string()
408}
409
410// ---------------------------------------------------------------------------
411// Internals: dir ensure, atomic write, chmod
412// ---------------------------------------------------------------------------
413
414#[cfg(unix)]
415use std::os::unix::fs::PermissionsExt;
416
417/// Create the config dir if missing. Mode 0o700 on Unix (mkdir default on
418/// Windows, where the sticky-permission concept doesn't apply).
419fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
420    if dir.exists() {
421        return Ok(());
422    }
423    std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
424        path: dir.to_path_buf(),
425        source: e,
426    })?;
427    #[cfg(unix)]
428    {
429        let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
430    }
431    Ok(())
432}
433
434/// Write `bytes` to `path` atomically: a temp sibling → `rename`. The temp
435/// file lives next to the target so the rename stays on one filesystem.
436fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
437    let dir = path
438        .parent()
439        .ok_or_else(|| ConfigError::Write {
440            path: path.to_path_buf(),
441            source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
442        })?;
443    let tmp = dir.join(format!(
444        ".{}.tmp",
445        path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
446    ));
447    std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
448    std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
449        path: path.to_path_buf(),
450        source: e,
451    })?;
452    Ok(())
453}
454
455/// Best-effort tighten to owner-only (0o600). No-op on Windows (the Node
456/// upstream applies no ACL either).
457fn set_owner_only(_path: &Path) {
458    #[cfg(unix)]
459    {
460        let _ = std::fs::set_permissions(
461            _path,
462            std::fs::Permissions::from_mode(0o600),
463        );
464    }
465}
466
467// A tiny `.pipe`-shim so the `parse_input_modalities` chain reads top-to-bottom
468// without pulling itertools. Kept private to this module.
469trait Pipe: Sized {
470    fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
471        f(self)
472    }
473}
474impl<T> Pipe for T {}
475
476// ---------------------------------------------------------------------------
477// Tests
478// ---------------------------------------------------------------------------
479
480#[cfg(test)]
481pub(crate) mod test_support {
482    /// A shared workspace lock for tests that touch process-global env vars
483    /// (`RPI_CODING_AGENT_DIR`, `ANTHROPIC_*`). All env-mutating tests across
484    /// the crate (config / provider / auth) share this ONE mutex so they can't
485    /// race on the shared environment. Hold the returned guard for the whole
486    /// test (store it in a RAII struct).
487    use std::sync::{Mutex, OnceLock};
488    pub(crate) fn env_lock() -> &'static Mutex<()> {
489        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
490        LOCK.get_or_init(|| Mutex::new(()))
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::config::test_support::env_lock;
498
499    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for the duration of the
500    /// test (cleaned up on drop). Holds the prior value of the env var to
501    /// restore it. Hold the env lock for its whole lifetime.
502    struct TempConfig {
503        _guard: std::sync::MutexGuard<'static, ()>,
504        _tmp: tempfile::TempDir,
505        prev: Option<std::ffi::OsString>,
506    }
507    impl TempConfig {
508        fn new() -> Self {
509            let guard = env_lock().lock().unwrap();
510            let prev = std::env::var_os(CONFIG_DIR_ENV);
511            let tmp = tempfile::TempDir::new().unwrap();
512            std::env::set_var(CONFIG_DIR_ENV, tmp.path());
513            Self { _guard: guard, _tmp: tmp, prev }
514        }
515    }
516    impl Drop for TempConfig {
517        fn drop(&mut self) {
518            restore_env(CONFIG_DIR_ENV, self.prev.take());
519        }
520    }
521
522    #[test]
523    fn read_auth_missing_file_is_empty() {
524        let _cfg = TempConfig::new();
525        let store = read_auth().unwrap();
526        assert!(store.is_empty());
527    }
528
529    #[test]
530    fn upsert_then_read_roundtrip() {
531        let _cfg = TempConfig::new();
532        upsert_credential(
533            "anthropic",
534            Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
535        )
536        .unwrap();
537        let store = read_auth().unwrap();
538        match store.get("anthropic") {
539            Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
540            other => panic!("unexpected cred: {other:?}"),
541        }
542        // The file should exist and be JSON.
543        let path = auth_path().unwrap();
544        assert!(path.exists(), "auth.json should exist after upsert");
545        let raw = std::fs::read_to_string(&path).unwrap();
546        assert!(raw.contains("\"anthropic\""));
547        assert!(raw.contains("api_key"));
548    }
549
550    #[test]
551    fn delete_credential_removes_entry() {
552        let _cfg = TempConfig::new();
553        upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
554            .unwrap();
555        assert!(delete_credential("anthropic").unwrap());
556        // Second delete is a no-op.
557        assert!(!delete_credential("anthropic").unwrap());
558        assert!(read_auth().unwrap().is_empty());
559    }
560
561    #[test]
562    fn load_models_config_missing_is_empty() {
563        let _cfg = TempConfig::new();
564        let c = load_models_config().unwrap();
565        assert!(c.providers.is_empty());
566    }
567
568    #[test]
569    fn load_models_config_parses_with_comments() {
570        let _cfg = TempConfig::new();
571        let json = r#"{
572  // a one-api style gateway
573  "providers": {
574    "gateway": {
575      "baseUrl": "https://gw.example.com",
576      "authHeader": true,
577      "apiKey": "gw-secret",
578      "models": [
579        { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
580      ]
581    }
582  }
583}"#;
584        std::fs::write(models_path().unwrap(), json).unwrap();
585        let c = load_models_config().unwrap();
586        let gw = c.providers.get("gateway").expect("gateway provider present");
587        assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
588        assert!(gw.auth_header.unwrap_or(false));
589        assert_eq!(gw.models.len(), 1);
590        assert_eq!(gw.models[0].id, "claude-sonnet-5");
591    }
592
593    #[test]
594    fn provider_to_models_merges_headers_without_synth_bearer() {
595        // `provider_to_models` merges model-level then provider-level headers,
596        // but does NOT synthesize the `authHeader:true` Bearer itself — that
597        // happens centrally in `crate::provider::resolve` (via
598        // `models_json_bearer_token`) so it can be skipped on the x-api-key
599        // path. Here the model carries only what the file declared.
600        let cfg = ProviderConfig {
601            name: None,
602            base_url: Some("https://gw.example.com".into()),
603            api_key: Some("gw-secret".into()),
604            api: None,
605            headers: Some({
606                let mut h = BTreeMap::new();
607                h.insert("x-portkey-key".into(), "portkey-secret".into());
608                h
609            }),
610            auth_header: Some(true),
611            models: vec![ModelDefinition {
612                id: "claude-sonnet-5".into(),
613                name: None,
614                base_url: None,
615                reasoning: None,
616                context_window: None,
617                max_tokens: None,
618                input: None,
619                headers: None,
620            }],
621        };
622        let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
623        assert_eq!(models.len(), 1);
624        let m = &models[0];
625        assert_eq!(m.id, "claude-sonnet-5");
626        assert_eq!(m.base_url, "https://gw.example.com");
627        // v1 stamps `provider = "anthropic"` on every models.json model so the
628        // single AnthropicProvider routes it (the models.json provider id is
629        // config-namespacing only).
630        assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
631        let headers = m.headers.as_ref().expect("provider headers merged");
632        // Declared provider header folds in…
633        assert_eq!(
634            headers.get("x-portkey-key").map(|s| s.as_str()),
635            Some("portkey-secret")
636        );
637        // …but no Bearer is synthesized here. The bearer-from-authHeader path
638        // is exercised end-to-end by the provider.rs `resolve` tests
639        // (`models_json_auth_header_satisfies_auth_without_env`,
640        // `api_key_flag_beats_models_json_bearer`).
641        assert!(
642            headers.get("authorization").is_none(),
643            "provider_to_models must not synthesize the Bearer; resolve does"
644        );
645    }
646
647    #[test]
648    fn provider_to_models_ignores_non_anthropic_api() {
649        let cfg = ProviderConfig {
650            name: None,
651            base_url: None,
652            api_key: None,
653            api: Some("openai-completions".into()),
654            headers: None,
655            auth_header: None,
656            models: vec![],
657        };
658        assert!(provider_to_models("oai", &cfg).is_none());
659    }
660
661    #[test]
662    fn malformed_auth_json_is_an_error_not_silent_empty() {
663        let _cfg = TempConfig::new();
664        std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
665        assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
666    }
667
668    #[test]
669    fn agent_dir_respects_env_override() {
670        let _guard = env_lock().lock().unwrap();
671        let prev = std::env::var_os(CONFIG_DIR_ENV);
672        let tmp = tempfile::TempDir::new().unwrap();
673        std::env::set_var(CONFIG_DIR_ENV, tmp.path());
674        let dir = agent_dir().unwrap();
675        restore_env(CONFIG_DIR_ENV, prev);
676        assert_eq!(dir, tmp.path());
677    }
678
679    #[test]
680    fn relative_override_is_rejected() {
681        let _guard = env_lock().lock().unwrap();
682        let prev = std::env::var_os(CONFIG_DIR_ENV);
683        std::env::set_var(CONFIG_DIR_ENV, "relative/path");
684        let err = agent_dir().unwrap_err();
685        restore_env(CONFIG_DIR_ENV, prev);
686        assert!(matches!(err, ConfigError::RelativeOverride { .. }));
687    }
688
689    /// Restore/remove an env var based on its prior `OsString` value.
690    fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
691        match prev {
692            Some(v) => std::env::set_var(name, v),
693            None => std::env::remove_var(name),
694        }
695    }
696}