Skip to main content

rpi_cli/
provider.rs

1//! Provider + model resolution. Mirrors the *Anthropic-protocol* slice of the
2//! TS `packages/coding-agent/src/core/model-resolver.ts` (`resolveCliModel` +
3//! the `provider/id[:thinking]` parsing in [`crate::args`]).
4//!
5//! v1 is Anthropic-protocol only (plan §5.16: "OAuth/Copilot skipped v1;
6//! API-key auth only" — now extended to include third-party Anthropic-compatible
7//! endpoints via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` and a
8//! `~/.rpi/models.json` catalog; OAuth is still deferred). The TS
9//! `ModelRuntime`/`ModelRegistry` multi-provider machinery is not ported; this
10//! module builds a single [`AnthropicProvider`] from a resolved credential and
11//! resolves a [`Model`] + [`ThinkingLevel`] against the catalog.
12//!
13//! # Auth resolution precedence (mirrors upstream `anthropic.ts:resolve`)
14//!
15//! 1. `--api-key` → provider default key (sent as `x-api-key`).
16//! 2. `~/.rpi/auth.json` `anthropic.api_key.key` — the persistent `rpi auth
17//!    login` credential (sent as `x-api-key`). This is the "logged-in" path.
18//! 3. `~/.rpi/models.json` provider with `authHeader: true` + `apiKey` →
19//!    `Authorization: Bearer <key>` (a static gateway credential — the models.json
20//!    file alone is a complete third-party-endpoint setup, no env var needed).
21//! 4. `ANTHROPIC_AUTH_TOKEN` env → `Authorization: Bearer <token>` (folded into
22//!    each model's `headers`; the provider's `has_header_auth` recognizes it and
23//!    skips `x-api-key`, so a token-only setup does not error on a missing key).
24//! 5. `ANTHROPIC_API_KEY` env → provider default key (`x-api-key`).
25//! 6. None of the above ⇒ [`ResolveError::NoApiKey`].
26//!
27//! When a Bearer source (item 3 or 4) wins, the provider is built with
28//! `api_key = None` — the header on each model carries the auth. When a key
29//! source wins (1, 2, or 5), the provider carries the key as `x-api-key`.
30//!
31//! # Endpoint + catalog
32//!
33//! - `--base-url` / `ANTHROPIC_BASE_URL` overrides `model.base_url` at resolve
34//!   time (the request URL is built from it per-request in rpi-ai).
35//! - `~/.rpi/models.json` (if present) merges/overrides the built-in catalog:
36//!   each `anthropic-messages` provider contributes its models, with
37//!   provider-level `base_url`/`headers`/`authHeader` folded in. The models.json
38//!   provider id (e.g. `gateway`) is **config-namespacing only** in v1: every
39//!   models.json model is stamped `provider = "anthropic"` so it routes through
40//!   the single `AnthropicProvider` (the per-model `base_url` + `headers` carry
41//!   the endpoint/auth differentiation). A `--model gateway/custom-claude` just
42//!   strips the `gateway/` prefix and matches the `custom-claude` id.
43//!
44//! # Model pattern precedence (mirrors `resolveCliModel`)
45//!
46//! 1. `--model` may carry `provider/id[:thinking]`. A leading `anthropic/`
47//!    (case-insensitive) is stripped; any other `foo/` prefix is also stripped
48//!    so a `models.json` provider id (e.g. `gateway/…`) addresses its model.
49//! 2. Otherwise treat `--model` as `id[:thinking]`: if a trailing `:level` is a
50//!    valid thinking level, strip it and apply it (overriding `--thinking`);
51//!    else the whole string is the id.
52//! 3. A `--provider` that isn't `anthropic` is a hard error (v1 has no other
53//!    provider). `--provider anthropic` is accepted and just confirms the
54//!    default.
55//! 4. The model id is matched **exactly, case-insensitively** against the
56//!    catalog. The TS resolver additionally does fuzzy/partial matching; v1
57//!    keeps it exact to avoid surprising model picks (partial match is a common
58//!    source of "got the wrong model" bugs — documented as a divergence in
59//!    `docs/m6-cli-open-questions.md`).
60//! 5. No `--model` ⇒ [`pick_default_model`]:
61//!    (a) the built-in default ([`DEFAULT_MODEL_ID`] = `claude-sonnet-5`) if it
62//!    is already authenticated (has a folded Bearer, or the provider holds an
63//!    `x-api-key`); otherwise (b) the **first authenticated model** in the
64//!    catalog — mirroring the TS `findInitialModel` step-4 fallback
65//!    `availableModels[0]` over the auth-filtered snapshot. This lets a
66//!    `models.json`-only gateway config "just work": the built-in Anthropic
67//!    models carry no auth, so the gateway model (the only authenticated one)
68//!    is picked. The all-builtin/no-custom-code default (`ANTHROPIC_API_KEY`
69//!    path) still selects `claude-sonnet-5`. Last resort falls back to
70//!    [`DEFAULT_MODEL_ID`] (or the catalog head) — unreachable in practice
71//!    because the auth gate refuses an unauthed catalog earlier.
72//!
73//! [`AnthropicProvider`]: rpi_ai::providers::anthropic::AnthropicProvider
74
75use std::collections::BTreeMap;
76use std::sync::Arc;
77
78use rpi_ai::providers::anthropic::models::anthropic_models;
79use rpi_ai::providers::anthropic::AnthropicProvider;
80use rpi_ai::{Model, Provider, ThinkingLevel};
81
82use crate::args::parse_thinking_level;
83use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
84use crate::settings;
85
86/// The v1-default model id when `--model` is absent. Mirrors the TS
87/// `defaultModelPerProvider["anthropic"]` (the first current-generation
88/// reasoning model in the catalog).
89pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
90
91/// The default thinking level when neither `--thinking` nor a `:level` suffix
92/// is present. Mirrors the TS `DEFAULT_THINKING_LEVEL` (`"medium"`, clamped to
93/// model capabilities by the harness's provider build_params).
94pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
95
96/// The resolved run configuration: the provider handle, the chosen model, and
97/// the effective thinking level (after `--thinking` / `:level` / model-clamp).
98#[derive(Clone)]
99pub struct ResolvedModel {
100    /// The Anthropic provider (carries the API key, or `None` when Bearer
101    /// headers carry the auth). Cheap to clone (`Arc` internally via the
102    /// `Provider` trait object).
103    pub provider: Arc<dyn Provider>,
104    /// The chosen model from the catalog.
105    pub model: Model,
106    /// Effective thinking level (the requested level, before model-clamp — the
107    /// harness/provider clamps to the model's supported set).
108    pub thinking_level: ThinkingLevel,
109    /// Whether the x-api-key path was taken (`--api-key` / auth.json /
110    /// `ANTHROPIC_API_KEY` ⇒ the provider carries a default key that
111    /// `assemble_headers` attaches to EVERY model out-of-band). When `false`,
112    /// auth rides only on model headers (Bearer fold / models.json `apiKey`
113    /// fold) — so only header-authed models can actually run.
114    ///
115    /// Kept so [`available_catalog`] can reproduce the auth-filtered snapshot
116    /// (pi `getAvailableSnapshot`: `available = all.filter(m =>
117    /// configuredProviders.has(m.provider))`) and surface only models that
118    /// won't fail at request time with "No API key for provider".
119    pub has_provider_key: bool,
120    /// Saved theme name from `~/.rpi/agent/settings.json`, if any. Best-effort:
121    /// the TUI applies it at startup when it matches a known preset
122    /// (dark/light/monochrome); otherwise ignored.
123    pub theme: Option<String>,
124}
125
126impl std::fmt::Debug for ResolvedModel {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("ResolvedModel")
129            .field("provider", &self.provider.id())
130            .field("model", &self.model.id)
131            .field("thinking_level", &self.thinking_level)
132            .field("has_provider_key", &self.has_provider_key)
133            .field("theme", &self.theme)
134            .finish()
135    }
136}
137
138/// The env var consulted for the API key. Mirrors TS `ANTHROPIC_API_KEY`.
139pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
140
141/// The env var consulted for a bearer token (routed as
142/// `Authorization: Bearer`). Mirrors TS `ANTHROPIC_AUTH_TOKEN` — used by
143/// third-party Anthropic-compatible gateways (one-api/new-api/claude-code-router
144/// and private reverse proxies) that authenticate via `Authorization` rather
145/// than `x-api-key`.
146pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
147
148/// The env var that overrides the Anthropic endpoint base URL. Mirrors TS
149/// `ANTHROPIC_BASE_URL` — point this at a gateway/proxy that speaks the
150/// `/v1/messages` protocol.
151pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
152
153/// Hint text surfaced when no credential source is available. Lists every
154/// accepted source so the user can pick the one that fits their setup.
155pub const NO_API_KEY_HINT: &str =
156    "ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login` (writes ~/.rpi/auth.json)";
157
158/// A resolution error. The TS resolver returns `{ error, warning }`; v1 folds
159/// both into a single enum since the CLI treats them the same (print + non-zero
160/// exit) except `NoApiKey`, which prints guidance then exits.
161#[derive(Debug, thiserror::Error)]
162pub enum ResolveError {
163    #[error("Unknown provider \"{0}\". v1 supports: anthropic")]
164    UnknownProvider(String),
165    #[error("No model matches \"{pattern}\". Available: {available}")]
166    NoMatch { pattern: String, available: String },
167    #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
168    InvalidThinkingLevel(String, String),
169    #[error("No API key. Set one of: {hint}")]
170    NoApiKey { hint: &'static str },
171    #[error("Could not read config: {0}")]
172    Config(#[from] config::ConfigError),
173}
174
175/// Resolve the provider + model + thinking level from the CLI flags + env +
176/// `~/.rpi/` config.
177///
178/// `cli_provider` is the `--provider` value (optional). `cli_model` is the
179/// `--model` value (optional; may be `provider/id[:thinking]` or `id[:thinking]`).
180/// `cli_thinking` is the `--thinking` value (optional). `cli_api_key` is the
181/// `--api-key` value (optional; highest-priority `x-api-key` source).
182/// `cli_base_url` is the `--base-url` value (optional; overrides
183/// `ANTHROPIC_BASE_URL` + each model's `base_url`).
184pub fn resolve(
185    cli_provider: Option<&str>,
186    cli_model: Option<&str>,
187    cli_thinking: Option<ThinkingLevel>,
188    cli_api_key: Option<&str>,
189    cli_base_url: Option<&str>,
190) -> Result<ResolvedModel, ResolveError> {
191    // ---- Provider selection (v1: Anthropic protocol only) ----
192    if let Some(req) = cli_provider {
193        if !req.eq_ignore_ascii_case("anthropic") {
194            return Err(ResolveError::UnknownProvider(req.to_string()));
195        }
196    }
197
198    // ---- Auth resolution: provider_key (x-api-key) OR auth_headers (Bearer) ----
199    let mut provider_key: Option<String> = None;
200    let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
201    // Whether the resolved header auth came from a `~/.rpi/models.json` gateway
202    // (endpoint-specific — fold onto gateway models only) vs `ANTHROPIC_AUTH_TOKEN`
203    // env (a global credential — fold onto every model). Covers BOTH models.json
204    // auth sources: the `authHeader:true` Bearer AND the bare-`apiKey` `x-api-key`
205    // (`composeApiKeyAuth` arm) — both are endpoint-specific. See the fold below.
206    let mut auth_from_models_json = false;
207
208    // Load the models.json config ONCE — it is consulted both as an auth source
209    // (a provider with `authHeader: true` + `apiKey` supplies a Bearer token,
210    // OR a bare `apiKey` supplies an `x-api-key`, mirroring upstream
211    // `provider-composer.ts` `withConfiguredAuth`/`composeApiKeyAuth`) and as the
212    // model catalog merge source (below). Loading here (before the auth gate)
213    // means a static `~/.rpi/models.json` gateway credential can satisfy auth
214    // without any env var or `rpi auth login` — the models.json file alone is a
215    // complete third-party-endpoint setup.
216    let models_cfg = config::load_models_config()?;
217
218    // 1. --api-key (highest-priority x-api-key source).
219    if let Some(k) = cli_api_key.filter(|s| !s.is_empty()) {
220        provider_key = Some(k.to_string());
221    }
222    // 2. ~/.rpi/auth.json anthropic.api_key.key (persistent login). The key may
223    //    be a `$ENV`/`!command` template (mirrors pi auth-storage.ts:267, which
224    //    runs `resolveConfigValue(credential.key, credential.env)`); the
225    //    credential's `env` map is the overlay. A key that resolves to `None`
226    //    (e.g. references an unset env var) is skipped, exactly as pi skips an
227    //    unresolvable key.
228    if provider_key.is_none() {
229        if let Ok(store) = config::read_auth() {
230            if let Some(Credential::ApiKey { key: Some(k), env }) = store.get(DEFAULT_PROVIDER_ID) {
231                if let Some(resolved) = config::resolve_config_value(k, env.as_ref()) {
232                    if !resolved.is_empty() {
233                        provider_key = Some(resolved);
234                    }
235                }
236            }
237        }
238    }
239    // 3. ~/.rpi/models.json provider keys — ONE auth entry PER provider, keyed
240    //    by that provider's `base_url`. Each provider's credential folds onto
241    //    ITS OWN models only (upstream `composeApiKeyAuth` is per-provider:
242    //    provider-composer.ts routes a provider's `apiKey` as the auth for
243    //    that provider's models). The old code collapsed this to a single
244    //    "first provider's key" and stamped it onto EVERY gateway model — with
245    //    two gateways the 2nd gateway's models received the 1st gateway's key
246    //    → 401 at request time. This is the multi-gateway case this
247    //    restructure fixes. Both models.json auth shapes are covered: an
248    //    `authHeader:true` key becomes `Authorization: Bearer`, a bare
249    //    `apiKey` becomes `x-api-key` (`composeApiKeyAuth` arm).
250    let models_json_auth = models_json_provider_auth(&models_cfg);
251    if provider_key.is_none() && auth_headers.is_empty() && !models_json_auth.is_empty() {
252        // The models.json file alone is a complete third-party-endpoint setup:
253        // each gateway model is stamped with its own provider's credential in
254        // the fold below, so auth is satisfied without any env var / stored
255        // cred / `--api-key`. Mark the auth as endpoint-specific so the fold
256        // targets gateway models only (NOT the built-in Anthropic catalog).
257        auth_from_models_json = true;
258    }
259    // 4. ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (third-party gateways).
260    if provider_key.is_none() && auth_headers.is_empty() {
261        if let Ok(tok) = std::env::var(ANTHROPIC_AUTH_TOKEN_ENV) {
262            if !tok.is_empty() {
263                auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
264            }
265        }
266    }
267    // 5. ANTHROPIC_API_KEY → x-api-key (fallback).
268    if provider_key.is_none() && auth_headers.is_empty() {
269        if let Ok(k) = std::env::var(ANTHROPIC_API_KEY_ENV) {
270            if !k.is_empty() {
271                provider_key = Some(k);
272            }
273        }
274    }
275    // 6. Nothing → clear error listing every accepted source.
276    //    `models_json_auth` counts as a source: per-provider gateway keys were
277    //    moved out of the single `auth_headers` map (they now ride on each
278    //    gateway model's own headers), so the gate must see them here.
279    if provider_key.is_none() && auth_headers.is_empty() && models_json_auth.is_empty() {
280        return Err(ResolveError::NoApiKey { hint: NO_API_KEY_HINT });
281    }
282
283    // ---- Endpoint override (--base-url → ANTHROPIC_BASE_URL) ----
284    let base_url_override = cli_base_url
285        .map(|s| s.to_string())
286        .or_else(|| {
287            std::env::var(ANTHROPIC_BASE_URL_ENV)
288                .ok()
289                .filter(|s| !s.is_empty())
290        });
291
292    // Load saved settings once — `defaultProvider`/`defaultModel`/
293    // `defaultThinkingLevel`/`theme` (pi `findInitialModel` step 3 + the theme
294    // the TUI applies at startup). Missing file ⇒ defaults (no error).
295    let settings = settings::load_settings().unwrap_or_default();
296
297    // ---- Catalog: built-in + ~/.rpi/models.json (merged, reusing the
298    // already-loaded config) ----
299    let mut catalog = anthropic_models();
300    merge_user_catalog(&mut catalog, &models_cfg);
301
302    // Apply the endpoint override to every model (the request URL is built from
303    // `model.base_url` per-request in rpi-ai).
304    if let Some(base) = &base_url_override {
305        for m in catalog.iter_mut() {
306            m.base_url = base.clone();
307        }
308    }
309
310    // Fold the resolved header auth (if any) into the catalog — but only onto
311    // models the auth is actually meant for. Upstream `withConfiguredAuth`
312    // synthesizes the header per-provider: a models.json gateway's auth rides
313    // only on that gateway's models, NOT the built-in Anthropic claude-* catalog
314    // (whose `base_url` is `api.anthropic.com`). Folding it onto every model —
315    // the old behavior — meant the *default* model (`claude-sonnet-5`, whose
316    // base_url is Anthropic) carried a gateway Bearer to the wrong endpoint →
317    // 401 "Invalid bearer token". The same misrouting applies to a bare-`apiKey`
318    // `x-api-key`: stamped onto a built-in claude-* model it would send a
319    // gateway key to api.anthropic.com → 401, and a global `provider_key` would
320    // do the same (see `assemble_headers`, which applies `provider_key` to every
321    // model). Both models.json auth sources are therefore folded
322    // endpoint-specifically via model headers.
323    //
324    // Two header-auth sources, two fold scopes:
325    //  - `~/.rpi/models.json` gateway (`auth_from_models_json`): endpoint-
326    //    specific. Fold onto gateway models only — a model counts as a "gateway
327    //    model" when either (a) a `--base-url`/`ANTHROPIC_BASE_URL` override
328    //    rewrote every model's `base_url`, or (b) the model's own `base_url` was
329    //    set to a non-Anthropic URL by `provider_to_models` (i.e. it came from
330    //    `models.json`). Built-in `claude-*` keeps `api.anthropic.com` → stays
331    //    header-auth-less. This is what lets `pick_default_model` pick the
332    //    gateway model (the only authed one) in a gateway-only setup. Covers
333    //    both the `authHeader:true` Bearer and the bare-`apiKey` `x-api-key`.
334    //  - `ANTHROPIC_AUTH_TOKEN` env: a global credential the user intends for the
335    //    configured endpoint (either the built-in Anthropic endpoint or a
336    //    `--base-url` override). Fold onto EVERY model so the default
337    //    `claude-sonnet-5` carries it — matching the pre-gateway behavior and
338    //    the TS behavior where an env Bearer is a provider-level credential.
339    if !auth_headers.is_empty() && !auth_from_models_json {
340        // ANTHROPIC_AUTH_TOKEN: global — stamp onto every model.
341        for m in catalog.iter_mut() {
342            let headers = m.headers.get_or_insert_with(BTreeMap::new);
343            for (k, v) in &auth_headers {
344                headers.insert(k.clone(), v.clone());
345            }
346        }
347    } else if auth_from_models_json {
348        // models.json gateway auth: per-provider — each gateway model carries
349        // the credential of the models.json provider whose `base_url` matches
350        // its own (the `composeApiKeyAuth` per-provider contract). With a
351        // `--base-url`/`ANTHROPIC_BASE_URL` override (single endpoint) fall
352        // back to the first keyed provider for all gateway models.
353        let override_active = base_url_override.is_some();
354        for m in catalog.iter_mut() {
355            let is_gateway =
356                override_active || m.base_url != config::ANTHROPIC_DEFAULT_BASE_URL;
357            if !is_gateway {
358                continue;
359            }
360            let provider_auth = if override_active {
361                models_json_auth.values().next()
362            } else {
363                models_json_auth.get(&m.base_url)
364            };
365            let Some(provider_auth) = provider_auth else { continue };
366            let headers = m.headers.get_or_insert_with(BTreeMap::new);
367            for (k, v) in provider_auth {
368                headers.insert(k.clone(), v.clone());
369            }
370        }
371    }
372
373    let available = catalog
374        .iter()
375        .map(|m| m.id.clone())
376        .collect::<Vec<_>>()
377        .join(", ");
378
379    // ---- Model selection ----
380    // With `--model`: parse the pattern (`provider/id[:thinking]`), match it
381    // exactly against the catalog (TS fuzzy/partial match is a deliberate v1
382    // omission — see module docs §5). Without `--model`: pi `findInitialModel`
383    // precedence — (3) the saved default from settings (when present + authed),
384    // then (4) `pick_default_model` (built-in default if authed, else first
385    // authed). The saved default mirrors `findInitialModel` step 3 and lets a
386    // copied pi `settings.json`'s `defaultModel` come alive on launch.
387    let (model, thinking_level) = match cli_model {
388        Some(raw) => {
389            let (pattern, pattern_thinking) = split_model_pattern(raw);
390            // `--thinking` wins over a `:level` suffix; else default.
391            let thinking_level = cli_thinking
392                .or(pattern_thinking)
393                .unwrap_or(DEFAULT_THINKING_LEVEL);
394            let model = match find_model(&pattern, &catalog) {
395                Some(m) => m,
396                None => {
397                    return Err(ResolveError::NoMatch {
398                        pattern: pattern.clone(),
399                        available,
400                    });
401                }
402            };
403            (model, thinking_level)
404        }
405        None => {
406            // `--thinking` > settings `defaultThinkingLevel` > built-in default.
407            // The settings level is honored only when its model is also the
408            // saved default (matches pi, which applies `defaultThinkingLevel`
409            // inside the step-3 branch). For the fallback default, keep
410            // `DEFAULT_THINKING_LEVEL`.
411            let settings_thinking = settings
412                .default_thinking_level
413                .as_deref()
414                .and_then(parse_thinking_level);
415
416            // (3) Saved default from settings, when the provider is anthropic
417            // (or absent — v1 is anthropic-only) OR names a configured
418            // models.json gateway (config-namespacing: the saved
419            // `defaultProvider` id matches a `~/.rpi/models.json` provider
420            // key), and the saved model is authed. Without the gateway arm a
421            // copied pi settings.json (`defaultProvider:
422            // "cc-switch-deep-seek-copy-2"`) is ignored and the default falls
423            // to first-authed — which, once a second gateway is enabled, may
424            // NOT be the user's saved choice (BTreeMap provider order).
425            let saved_provider_ok = settings.default_provider.as_deref().map_or(true, |p| {
426                p.eq_ignore_ascii_case("anthropic")
427                    || models_cfg.providers.contains_key(p)
428            });
429            if saved_provider_ok {
430                if let Some(id) = settings.default_model.as_deref() {
431                    // Clone the match to release the catalog borrow before
432                    // moving `catalog` into the provider below.
433                    let found = catalog
434                        .iter()
435                        .find(|m| m.id.eq_ignore_ascii_case(id))
436                        .filter(|m| model_is_authed(m, provider_key.is_some()))
437                        .cloned();
438                    if let Some(m) = found {
439                        let thinking_level = cli_thinking
440                            .or(settings_thinking)
441                            .unwrap_or(DEFAULT_THINKING_LEVEL);
442                        let has_provider_key = provider_key.is_some();
443                        return Ok(ResolvedModel {
444                            provider: Arc::new(AnthropicProvider::with_models(
445                                provider_key,
446                                reqwest::Client::new(),
447                                catalog,
448                            )),
449                            model: m,
450                            thinking_level,
451                            has_provider_key,
452                            theme: settings.theme.clone(),
453                        });
454                    }
455                }
456            }
457
458            // (4) Fallback: built-in default if authed, else first authed.
459            let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
460            let model = pick_default_model(&catalog, provider_key.is_some());
461            (model, thinking_level)
462        }
463    };
464
465    // ---- Provider build ----
466    // Bearer path: `provider_key = None` — the model headers carry the auth
467    // (`has_header_auth` skips x-api-key). x-api-key path: pass the key.
468    let has_provider_key = provider_key.is_some();
469    let provider: Arc<dyn Provider> = Arc::new(AnthropicProvider::with_models(
470        provider_key,
471        reqwest::Client::new(),
472        catalog,
473    ));
474
475    Ok(ResolvedModel { provider, model, thinking_level, has_provider_key, theme: settings.theme.clone() })
476}
477
478/// The catalog the TUI's `/model` selector displays (read-only). Re-derives the
479/// **auth-filtered** snapshot the provider was built from so the selector shows
480/// exactly the models that can actually run (mirrors pi `getAvailableSnapshot`:
481/// `available = all.filter(m => configuredProviders.has(m.provider))` — v1's
482/// single-provider equivalent of "configured" is [`model_is_authed`]).
483///
484/// Why the filter matters: in a models.json-gateway-only setup the gateway's
485/// `apiKey` folds onto the gateway models only — the built-in Anthropic models
486/// stay header-less and the provider carries no default key (`has_provider_key
487/// == false`). Without the filter the `/model` selector / Ctrl+M cycle would
488/// offer those built-ins, and selecting one would fail at request time with
489/// "No API key for provider: anthropic" (rpi-ai's `assertRequestAuth`). pi
490/// avoids this by only listing configured providers; this filter is the same
491/// guarantee on the v1 single-provider world.
492///
493/// On any config read error it falls back to the built-in Anthropic catalog —
494/// the selector is non-critical and must never block the TUI from starting.
495pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
496    resolved
497        .provider
498        .models()
499        .iter()
500        .filter(|m| model_is_authed(m, resolved.has_provider_key))
501        .cloned()
502        .collect()
503}
504
505/// Merge `~/.rpi/models.json` providers into the built-in catalog. Models from
506/// the user file replace any built-in entry with the same id (custom
507/// definitions win); brand-new ids are appended. Non-`anthropic-messages`
508/// providers are skipped (ignored in v1, documented). Takes the already-loaded
509/// config so the file is read once per `resolve`.
510fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
511    for (provider_id, provider_cfg) in &cfg.providers {
512        let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
513            // Non-anthropic protocol — ignored in v1 (documented).
514            continue;
515        };
516        for m in models {
517            if let Some(existing) = catalog.iter_mut().find(|c| c.id.eq_ignore_ascii_case(&m.id)) {
518                *existing = m;
519            } else {
520                catalog.push(m);
521            }
522        }
523    }
524}
525
526/// Extract a static gateway Bearer token from the first anthropic-compatible
527/// models.json provider that declares `authHeader: true` + a non-empty
528/// `apiKey`. The `apiKey` is resolved via [`config::resolve_config_value`]
529/// (`$ENV`/`!command` expansion, mirroring pi provider-composer.ts:351) — a
530/// copied pi models.json referencing an env var resolves the same way. Returns
531/// `None` when no such provider exists (the env/stored-cred/cli-flag sources
532/// Build the per-provider auth headers from `~/.rpi/models.json`: a map of
533/// provider `base_url` → the auth headers that provider's models should carry.
534/// Each anthropic-compatible provider with a non-empty, resolvable `apiKey`
535/// contributes one entry (`authHeader:true` ⇒ `Authorization: Bearer <key>`, a
536/// bare `apiKey` ⇒ `x-api-key: <key>` — the upstream `composeApiKeyAuth`
537/// arms). The `apiKey` is resolved via [`config::resolve_config_value`]
538/// (`$ENV`/`!command` expansion, mirroring pi provider-composer.ts:351) so a
539/// copied pi models.json referencing an env var resolves the same way.
540///
541/// The map is keyed by `base_url` (falling back to the Anthropic default when
542/// omitted) so [`resolve`]'s fold can stamp each gateway model with the
543/// credential of ITS endpoint — a per-provider contract. Several providers
544/// sharing one `base_url` collapse to the first keyed entry (same endpoint ⇒
545/// one credential per endpoint is the sane contract). Returns an empty map when
546/// no keyed anthropic-compatible provider exists (the env/stored-cred/
547/// cli-flag sources still apply).
548fn models_json_provider_auth(
549    cfg: &config::ModelsConfig,
550) -> BTreeMap<String, BTreeMap<String, String>> {
551    let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
552    for (_provider_id, provider_cfg) in &cfg.providers {
553        if !config::provider_is_anthropic_compatible(provider_cfg) {
554            continue;
555        }
556        let Some(raw) = provider_cfg.api_key.as_deref().filter(|s| !s.is_empty()) else {
557            continue;
558        };
559        // models.json providers have no credential env overlay — env-only.
560        let Some(resolved) = config::resolve_config_value(raw, None) else {
561            continue;
562        };
563        if resolved.is_empty() {
564            continue;
565        }
566        let base = provider_cfg
567            .base_url
568            .clone()
569            .unwrap_or_else(config::default_anthropic_base_url);
570        let mut headers = BTreeMap::new();
571        if provider_cfg.auth_header.unwrap_or(false) {
572            headers.insert("authorization".to_string(), format!("Bearer {resolved}"));
573        } else {
574            headers.insert("x-api-key".to_string(), resolved);
575        }
576        out.entry(base).or_insert(headers);
577    }
578    out
579}
580
581/// Split a `--model` value into `(id_pattern, optional_thinking_level)`.
582///
583/// Handles `provider/id[:thinking]` (strips a leading `anthropic/` or any other
584/// `foo/` prefix so a `models.json` provider id addresses its model) and
585/// `id[:thinking]`. A trailing `:level` is parsed as a thinking level only if
586/// it is a valid level string; otherwise the whole tail is kept in the id
587/// pattern (some model ids legitimately contain colons — none do in the v1
588/// Anthropic catalog, but the parser stays conservative).
589///
590/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
591fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
592    // Strip a leading `provider/` prefix. `anthropic/` is the common case; any
593    // other `foo/` prefix is also stripped so a `models.json` provider id (e.g.
594    // `gateway/custom-claude`) resolves to the `custom-claude` catalog entry.
595    let trimmed = value
596        .strip_prefix("anthropic/")
597        .or_else(|| value.strip_prefix("Anthropic/"))
598        .or_else(|| {
599            if let Some(idx) = value.find('/') {
600                Some(&value[idx + 1..])
601            } else {
602                None
603            }
604        })
605        .unwrap_or(value);
606
607    // Last-colon split: if the suffix is a valid thinking level, peel it.
608    if let Some(idx) = trimmed.rfind(':') {
609        let (head, tail) = trimmed.split_at(idx);
610        let suffix = &tail[1..]; // drop the ':'
611        if let Some(level) = parse_thinking_level(suffix) {
612            return (head.to_string(), Some(level));
613        }
614    }
615    (trimmed.to_string(), None)
616}
617
618/// Case-insensitive exact id match against the catalog. The TS resolver also
619/// does partial/fuzzy match; v1 keeps it exact (see module docs).
620fn find_model(pattern: &str, catalog: &[Model]) -> Option<Model> {
621    catalog
622        .iter()
623        .find(|m| m.id.eq_ignore_ascii_case(pattern))
624        .cloned()
625}
626
627/// Whether a catalog model is "configured-auth" — i.e. the request built for it
628/// would pass `assertRequestAuth` and not return "No API key". Mirrors the TS
629/// `hasConfiguredAuth(providerId)` filter that `getAvailableSnapshot()` applies
630/// (`available = all.filter(m => configuredProviders.has(m.provider))`).
631///
632/// In v1's single-provider world, "configured auth" is decided statically after
633/// the Bearer fold: a model counts as authed when EITHER
634/// (a) it carries an auth-owned header (`authorization`/`x-api-key`/`cf-aig-…`)
635///     — the Bearer fold has stamped a gateway/env Bearer onto it — OR
636/// (b) the provider holds a resolved `provider_key` (the x-api-key path:
637///     `--api-key`/auth.json/`ANTHROPIC_API_KEY`), which `assemble_headers`
638///     attaches out-of-band to every model regardless of `headers`.
639///
640/// This is called *after* the Bearer fold, so `has_header_auth(&m.headers)`
641/// truthfully reflects whether a Bearer was folded onto *this* model (gateway
642/// models only — see the fold's `is_gateway` gate; built-in claude-* without an
643/// override stay Bearer-less).
644fn model_is_authed(m: &Model, has_provider_key: bool) -> bool {
645    model_has_header_auth(m) || has_provider_key
646}
647
648/// Same three-name check as rpi-ai's `has_header_auth`, but called from the
649/// CLI layer (rpi-ai's `has_header_auth` is private to the provider module, so
650/// we mirror it here over the model's `headers` map).
651fn model_has_header_auth(m: &Model) -> bool {
652    let Some(h) = &m.headers else { return false };
653    const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
654    h.keys()
655        .any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
656}
657
658/// Choose the default model when `--model` is absent. Mirrors upstream
659/// `findInitialModel` [`packages/coding-agent/src/core/model-resolver.ts`]:
660/// the built-in default (`claude-sonnet-5`) wins *if it has configured auth*;
661/// otherwise fall back to the first authed model in the catalog (the TS
662/// `availableModels[0]` when no `defaultModelPerProvider` entry matches — e.g.
663/// a `~/.rpi/models.json` gateway is the only configured endpoint). This fixes
664/// the gateway-only case where the old hard-coded `claude-sonnet-5` default
665/// carried a gateway Bearer to `api.anthropic.com` and 401'd.
666///
667/// `provider_key` is the resolved x-api-key (`Some` on the `--api-key`/
668/// auth.json/`ANTHROPIC_API_KEY` path; `None` on the Bearer path). It is passed
669/// in (not read from a field) because the auth decision is local to `resolve`.
670fn pick_default_model(catalog: &[Model], has_provider_key: bool) -> Model {
671    // 1. Built-in default, when it is authed — preserves the standard
672    //    `ANTHROPIC_API_KEY`/`auth.json` behavior (claude-sonnet-5).
673    if let Some(m) = catalog
674        .iter()
675        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
676        .filter(|m| model_is_authed(m, has_provider_key))
677    {
678        return m.clone();
679    }
680    // 2. First authed model (TS `availableModels[0]`). In a gateway-only setup
681    //    this is the gateway model (Bearer folded onto it, base_url = gateway).
682    if let Some(m) = catalog.iter().find(|m| model_is_authed(m, has_provider_key)) {
683        return m.clone();
684    }
685    // 3. Last resort: the built-in default, authed or not. The auth gate above
686    //    already errored when no source resolved, so reaching here means *some*
687    //    auth exists but none folded/attached to a model we can see — keep the
688    //    historical default to avoid a NoMatch surprise.
689    catalog
690        .iter()
691        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
692        .or_else(|| catalog.first())
693        .expect("catalog is never empty (built-in anthropic_models)")
694        .clone()
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
701    use crate::config::test_support::env_lock;
702
703    /// Scope a test to a throwaway config dir + clear the `ANTHROPIC_*` env
704    /// vars, restoring both on drop. Holds the shared env lock for its whole
705    /// lifetime so parallel env-mutating tests across config/provider/auth all
706    /// serialize on one mutex.
707    struct TestEnv {
708        _guard: std::sync::MutexGuard<'static, ()>,
709        prev_key: Option<std::ffi::OsString>,
710        prev_tok: Option<std::ffi::OsString>,
711        prev_base: Option<std::ffi::OsString>,
712        prev_dir: Option<std::ffi::OsString>,
713        _tmp: tempfile::TempDir,
714    }
715    impl TestEnv {
716        fn new() -> Self {
717            let guard = env_lock().lock().unwrap();
718            let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
719            let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
720            let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
721            let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
722            std::env::remove_var(ANTHROPIC_API_KEY_ENV);
723            std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
724            std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
725            let tmp = tempfile::TempDir::new().unwrap();
726            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
727            Self {
728                _guard: guard,
729                prev_key,
730                prev_tok,
731                prev_base,
732                prev_dir,
733                _tmp: tmp,
734            }
735        }
736    }
737    impl Drop for TestEnv {
738        fn drop(&mut self) {
739            restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
740            restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
741            restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
742            restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
743        }
744    }
745    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
746        match prev {
747            Some(v) => std::env::set_var(name, v),
748            None => std::env::remove_var(name),
749        }
750    }
751
752    // These tests hit the network-free resolution path only (provider/model
753    // selection). They set a throwaway credential so `resolve` clears the
754    // `NoApiKey` gate, then assert the model + thinking choice — never making
755    // a real request.
756
757    fn resolve_with_key(
758        provider: Option<&str>,
759        model: Option<&str>,
760        thinking: Option<ThinkingLevel>,
761    ) -> Result<ResolvedModel, ResolveError> {
762        let _env = TestEnv::new();
763        std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
764        resolve(provider, model, thinking, None, None)
765    }
766
767    #[test]
768    fn default_model_is_sonnet_5() {
769        let r = resolve_with_key(None, None, None).unwrap();
770        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
771        assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
772        assert_eq!(r.provider.id(), "anthropic");
773    }
774
775    #[test]
776    fn settings_default_model_wins_when_authed() {
777        // A copied pi `settings.json` carrying `defaultModel` (step 3 of pi's
778        // `findInitialModel`) overrides the built-in `claude-sonnet-5` default
779        // when that model is in the catalog and authed. Mirrors the on-disk-
780        // parity goal: drop a `.pi/agent/` dir at `~/.rpi/agent/` and the saved
781        // default comes alive on launch (no `--model` needed).
782        let _env = TestEnv::new();
783        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
784        let path = config::settings_path().unwrap();
785        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
786        std::fs::write(
787            &path,
788            r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
789        )
790        .unwrap();
791        let r = resolve(None, None, None, None, None).unwrap();
792        assert_eq!(r.model.id, "claude-haiku-4-5");
793        assert_eq!(r.thinking_level, ThinkingLevel::High);
794        // An unauthed saved default (unknown id) falls through to the built-in.
795        std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
796        let r = resolve(None, None, None, None, None).unwrap();
797        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
798    }
799
800    #[test]
801    fn explicit_id_match() {
802        let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
803        assert_eq!(r.model.id, "claude-haiku-4-5");
804    }
805
806    #[test]
807    fn case_insensitive_id() {
808        let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
809        assert_eq!(r.model.id, "claude-opus-5");
810    }
811
812    #[test]
813    fn provider_prefix_stripped() {
814        let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
815        assert_eq!(r.model.id, "claude-sonnet-5");
816    }
817
818    #[test]
819    fn custom_provider_prefix_stripped() {
820        // `gateway/custom-claude` resolves to the catalog id `custom-claude`
821        // after the `foo/` prefix is stripped.
822        let _env = TestEnv::new();
823        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
824        std::fs::write(
825            config::models_path().unwrap(),
826            r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "models": [{"id":"custom-claude"}] } } }"#,
827        )
828        .unwrap();
829        let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
830        assert_eq!(r.model.id, "custom-claude");
831    }
832
833    #[test]
834    fn thinking_suffix_in_model() {
835        let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
836        assert_eq!(r.model.id, "claude-sonnet-5");
837        assert_eq!(r.thinking_level, ThinkingLevel::High);
838    }
839
840    #[test]
841    fn thinking_flag_overrides_suffix() {
842        // `--thinking low` wins over a `:high` suffix.
843        let r =
844            resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
845        assert_eq!(r.thinking_level, ThinkingLevel::Low);
846    }
847
848    #[test]
849    fn explicit_provider_anthropic_ok() {
850        let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
851        assert_eq!(r.model.id, "claude-sonnet-5");
852    }
853
854    #[test]
855    fn unknown_provider_rejected() {
856        let err = resolve_with_key(Some("openai"), None, None).unwrap_err();
857        assert!(matches!(err, ResolveError::UnknownProvider(_)));
858    }
859
860    #[test]
861    fn no_match_lists_available() {
862        let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
863        match err {
864            ResolveError::NoMatch { pattern, available } => {
865                assert_eq!(pattern, "claude-does-not-exist");
866                assert!(available.contains("claude-sonnet-5"));
867            }
868            other => panic!("expected NoMatch, got {other:?}"),
869        }
870    }
871
872    #[test]
873    fn colon_not_a_thinking_level_kept_in_id() {
874        // A trailing `:foo` that isn't a thinking level stays part of the id
875        // pattern → no match (no model id contains `:foo`).
876        let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
877        assert!(matches!(err, ResolveError::NoMatch { .. }));
878    }
879
880    #[test]
881    fn parse_thinking_level_roundtrip() {
882        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
883        assert_eq!(parse_thinking_level("bogus"), None);
884        // Sanity: the valid set matches what help advertises.
885        for lvl in VALID_THINKING_LEVELS {
886            assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
887        }
888    }
889
890    #[test]
891    fn no_api_key_errors_with_hint() {
892        let _env = TestEnv::new();
893        let err = resolve(None, None, None, None, None).unwrap_err();
894        match err {
895            ResolveError::NoApiKey { hint } => {
896                assert!(hint.contains("ANTHROPIC_API_KEY"));
897                assert!(hint.contains("auth login"));
898            }
899            other => panic!("expected NoApiKey, got {other:?}"),
900        }
901    }
902
903    #[test]
904    fn stored_credential_satisfies_auth() {
905        let _env = TestEnv::new();
906        config::upsert_credential(
907            DEFAULT_PROVIDER_ID,
908            Credential::ApiKey { key: Some("stored-key".into()), env: None },
909        )
910        .unwrap();
911        let r = resolve(None, None, None, None, None).unwrap();
912        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
913        // x-api-key path: no Bearer header folded onto the model (auth rides on
914        // the provider's default key, surfaced to the provider at build time).
915        assert!(
916            r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
917            "x-api-key path should not synthesize a Bearer header"
918        );
919    }
920
921    #[test]
922    fn auth_token_routes_via_bearer_header() {
923        let _env = TestEnv::new();
924        std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
925        let r = resolve(None, None, None, None, None).unwrap();
926        // No provider key carries auth — it lives on the model header.
927        let headers = r.model.headers.as_ref().expect("bearer header on model");
928        assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer tok-123"));
929        // ANTHROPIC_AUTH_TOKEN is a *global* credential (not endpoint-specific
930        // like a models.json gateway key): the default claude-sonnet-5 is picked
931        // (it carries the env Bearer) — NOT a gateway model.
932        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
933    }
934
935    #[test]
936    fn api_key_flag_beats_env_and_stored() {
937        let _env = TestEnv::new();
938        std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
939        config::upsert_credential(
940            DEFAULT_PROVIDER_ID,
941            Credential::ApiKey { key: Some("stored-key".into()), env: None },
942        )
943        .unwrap();
944        // `--api-key flag-key` wins; resolve succeeds + takes the x-api-key path
945        // (no Bearer header on the model).
946        let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
947        assert!(
948            r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
949            "--api-key should take the x-api-key path, not Bearer"
950        );
951    }
952
953    #[test]
954    fn base_url_override_applies_to_model() {
955        let _env = TestEnv::new();
956        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
957        let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
958        assert_eq!(r.model.base_url, "https://gw.example.com");
959    }
960
961    #[test]
962    fn base_url_env_is_fallback_for_flag() {
963        let _env = TestEnv::new();
964        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
965        std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
966        let r = resolve(None, None, None, None, None).unwrap();
967        assert_eq!(r.model.base_url, "https://env-gw.example.com");
968    }
969
970    #[test]
971    fn models_json_adds_custom_model() {
972        let _env = TestEnv::new();
973        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
974        std::fs::write(
975            config::models_path().unwrap(),
976            r#"{
977  "providers": {
978    "gateway": {
979      "baseUrl": "https://gw.example.com",
980      "authHeader": true,
981      "apiKey": "gw-secret",
982      "models": [
983        { "id": "custom-claude", "name": "Custom" }
984      ]
985    }
986  }
987}"#,
988        )
989        .unwrap();
990        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
991        assert_eq!(r.model.id, "custom-claude");
992        assert_eq!(r.model.base_url, "https://gw.example.com");
993        // The model is routed through the single AnthropicProvider (provider
994        // stamped "anthropic" by config::provider_to_models).
995        assert_eq!(r.model.provider, DEFAULT_PROVIDER_ID);
996        // Provider-level authHeader folded in.
997        let headers = r.model.headers.as_ref().expect("headers merged");
998        assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
999    }
1000
1001    /// A models.json gateway with `authHeader:true` + `apiKey` is itself an auth
1002    /// source — it satisfies the `resolve` auth gate WITHOUT any env var, stored
1003    /// cred, or `--api-key`. This is the "models.json file alone sets up a
1004    /// third-party endpoint" path. The Bearer folds onto the gateway model only
1005    /// (built-in claude-* stays Bearer-less), and — with no `--model` — the
1006    /// default selector picks that gateway model (the only authed one).
1007    #[test]
1008    fn models_json_auth_header_satisfies_auth_without_env() {
1009        let _env = TestEnv::new();
1010        // No ANTHROPIC_* env, no auth.json — only the models.json gateway.
1011        std::fs::write(
1012            config::models_path().unwrap(),
1013            r#"{
1014  "providers": {
1015    "gateway": {
1016      "baseUrl": "https://gw.example.com",
1017      "api": "anthropic-messages",
1018      "authHeader": true,
1019      "apiKey": "gw-secret",
1020      "models": [
1021        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1022      ]
1023    }
1024  }
1025}"#,
1026        )
1027        .unwrap();
1028        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1029        assert_eq!(r.model.id, "custom-claude");
1030        assert_eq!(r.model.base_url, "https://gw.example.com");
1031        let headers = r.model.headers.as_ref().expect("bearer folded onto model");
1032        assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
1033    }
1034
1035    /// The `--api-key` flag wins over a models.json `authHeader:true` gateway
1036    /// key (the flag is the highest-priority x-api-key source; the gateway
1037    /// Bearer is only consulted when no key path is taken).
1038    /// A `models.json`-only gateway config (no `--model`, no env, no auth.json)
1039    /// should pick the gateway model by default — mirroring the TS
1040    /// `findInitialModel` step-4 fallback `availableModels[0]` over the
1041    /// auth-filtered snapshot. The built-in Anthropic models carry no auth in a
1042    /// gateway-only setup, so the gateway model is the first (and only)
1043    /// authenticated model. This is the `rpi -p hi` (no `--model`) case.
1044    #[test]
1045    fn default_prefers_gateway_when_only_gateway_configured() {
1046        // TestEnv already holds the shared env_lock for its whole lifetime —
1047        // don't take it again here (would self-deadlock and poison the mutex).
1048        let _env = TestEnv::new();
1049        std::fs::write(
1050            config::models_path().unwrap(),
1051            r#"{
1052  "providers": {
1053    "gateway": {
1054      "baseUrl": "https://gw.example.com",
1055      "api": "anthropic-messages",
1056      "authHeader": true,
1057      "apiKey": "gw-secret",
1058      "models": [
1059        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1060      ]
1061    }
1062  }
1063}"#,
1064        )
1065        .unwrap();
1066        // No --model (None): the default selector must pick the gateway model,
1067        // NOT the built-in claude-sonnet-5 (which would carry a foreign Bearer
1068        // to api.anthropic.com → 401, the bug this fixes).
1069        let r = resolve(None, None, None, None, None).unwrap();
1070        assert_eq!(r.model.id, "custom-claude");
1071        assert_eq!(r.model.base_url, "https://gw.example.com");
1072        // Gateway model carries the folded Bearer.
1073        let headers = r.model.headers.as_ref().expect("bearer on gateway model");
1074        assert_eq!(
1075            headers.get("authorization").map(|s| s.as_str()),
1076            Some("Bearer gw-secret")
1077        );
1078    }
1079
1080    #[test]
1081    fn api_key_flag_beats_models_json_bearer() {
1082        let _env = TestEnv::new();
1083        std::fs::write(
1084            config::models_path().unwrap(),
1085            r#"{
1086  "providers": {
1087    "gateway": {
1088      "baseUrl": "https://gw.example.com",
1089      "authHeader": true,
1090      "apiKey": "gw-secret",
1091      "models": [ { "id": "custom-claude" } ]
1092    }
1093  }
1094}"#,
1095        )
1096        .unwrap();
1097        let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
1098        // --api-key path: no Bearer folded on (the gateway bearer is skipped).
1099        assert!(
1100            r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
1101            "--api-key should win over the models.json gateway bearer"
1102        );
1103    }
1104
1105    /// A models.json gateway with a **bare** `apiKey` (no `authHeader`) is the
1106    /// `composeApiKeyAuth` arm — it satisfies the `resolve` auth gate WITHOUT
1107    /// any env var, stored cred, or `--api-key`, routing the resolved key as
1108    /// `x-api-key` onto THAT provider's models only. The fold is
1109    /// endpoint-specific: the built-in claude-* catalog (base_url
1110    /// api.anthropic.com) carries no `x-api-key`, so a gateway key is never sent
1111    /// to the wrong endpoint. This is the user's reported case — a copied pi
1112    /// models.json using bare `apiKey` (the default pi shape).
1113    #[test]
1114    fn models_json_bare_apikey_satisfies_auth_without_env() {
1115        let _env = TestEnv::new();
1116        // No ANTHROPIC_* env, no auth.json — only the bare-apiKey models.json gateway.
1117        std::fs::write(
1118            config::models_path().unwrap(),
1119            r#"{
1120  "providers": {
1121    "gateway": {
1122      "baseUrl": "https://gw.example.com",
1123      "api": "anthropic-messages",
1124      "apiKey": "gw-secret",
1125      "models": [
1126        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1127      ]
1128    }
1129  }
1130}"#,
1131        )
1132        .unwrap();
1133        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1134        assert_eq!(r.model.id, "custom-claude");
1135        assert_eq!(r.model.base_url, "https://gw.example.com");
1136        // x-api-key folded onto the gateway model — header-owned auth.
1137        let headers = r.model.headers.as_ref().expect("x-api-key folded onto model");
1138        assert_eq!(headers.get("x-api-key").map(|s| s.as_str()), Some("gw-secret"));
1139        // No Bearer synthesized (bare apiKey ≠ authHeader path).
1140        assert!(
1141            headers.get("authorization").is_none(),
1142            "bare apiKey must NOT synthesize a Bearer (that is the authHeader path)"
1143        );
1144    }
1145
1146    /// The bare-`apiKey` x-api-key fold is endpoint-specific: with no `--model`,
1147    /// the default selector must pick the gateway model (the only authed one),
1148    // NOT the built-in claude-sonnet-5 — which would carry a gateway x-api-key to
1149    // api.anthropic.com → 401, the same misrouting the Bearer fold guards
1150    // against. This is the `rpi -p hi` (no `--model`) case for a bare-apiKey
1151    /// gateway.
1152    #[test]
1153    fn default_prefers_gateway_when_only_bare_apikey_configured() {
1154        let _env = TestEnv::new();
1155        std::fs::write(
1156            config::models_path().unwrap(),
1157            r#"{
1158  "providers": {
1159    "gateway": {
1160      "baseUrl": "https://gw.example.com",
1161      "api": "anthropic-messages",
1162      "apiKey": "gw-secret",
1163      "models": [
1164        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1165      ]
1166    }
1167  }
1168}"#,
1169        )
1170        .unwrap();
1171        // No --model (None): the default selector must pick the gateway model.
1172        let r = resolve(None, None, None, None, None).unwrap();
1173        assert_eq!(r.model.id, "custom-claude");
1174        assert_eq!(r.model.base_url, "https://gw.example.com");
1175        // Gateway model carries the folded x-api-key.
1176        let headers = r.model.headers.as_ref().expect("x-api-key on gateway model");
1177        assert_eq!(headers.get("x-api-key").map(|s| s.as_str()), Some("gw-secret"));
1178    }
1179
1180    /// A bare `apiKey` that references an unset env var resolves to `None` and
1181    /// is skipped (mirrors pi `resolveConfigValue` semantics) — the auth gate
1182    /// falls through to the env/`rpi auth login` sources rather than partially
1183    /// authenticating with an empty key.
1184    #[test]
1185    fn models_json_bare_apikey_env_template_resolves() {
1186        let _env = TestEnv::new();
1187        // Prime the env var the apiKey references.
1188        std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
1189        std::fs::write(
1190            config::models_path().unwrap(),
1191            r#"{
1192  "providers": {
1193    "gateway": {
1194      "baseUrl": "https://gw.example.com",
1195      "api": "anthropic-messages",
1196      "apiKey": "$RPI_TEST_GATEWAY_KEY",
1197      "models": [
1198        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1199      ]
1200    }
1201  }
1202}"#,
1203        )
1204        .unwrap();
1205        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1206        let headers = r.model.headers.as_ref().expect("x-api-key folded");
1207        assert_eq!(
1208            headers.get("x-api-key").map(|s| s.as_str()),
1209            Some("env-resolved-secret")
1210        );
1211        std::env::remove_var("RPI_TEST_GATEWAY_KEY");
1212    }
1213
1214    /// `authHeader: true` takes precedence over a bare `apiKey` on the SAME or a
1215    /// later provider: the Bearer step (3a) runs before the bare-apiKey step
1216    /// A models.json with BOTH auth shapes — `authHeader:true` and bare
1217    /// `apiKey` — routes each provider's credential onto ITS OWN models
1218    /// (per-provider fold, mirroring upstream `composeApiKeyAuth`): the
1219    /// authHeader provider's key becomes `Authorization: Bearer` on its model,
1220    /// the bare-apiKey provider's key becomes `x-api-key` on its model. A
1221    /// copied pi models.json mixing both shapes works end-to-end — no model
1222    /// ends up unauthenticated because another provider "won" the gate.
1223    #[test]
1224    fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
1225        let _env = TestEnv::new();
1226        std::fs::write(
1227            config::models_path().unwrap(),
1228            r#"{
1229  "providers": {
1230    "bearer-gw": {
1231      "baseUrl": "https://bearer.example.com",
1232      "api": "anthropic-messages",
1233      "authHeader": true,
1234      "apiKey": "bearer-secret",
1235      "models": [ { "id": "bearer-model" } ]
1236    },
1237    "xkey-gw": {
1238      "baseUrl": "https://xkey.example.com",
1239      "api": "anthropic-messages",
1240      "apiKey": "xkey-secret",
1241      "models": [ { "id": "xkey-model" } ]
1242    }
1243  }
1244}"#,
1245        )
1246        .unwrap();
1247        // Both providers satisfy the auth gate together (no env / stored cred
1248        // needed); the default selector picks the first authed model.
1249        let r = resolve(None, None, None, None, None).unwrap();
1250        assert_eq!(r.model.id, "bearer-model");
1251
1252        // bearer-gw's key folds as Bearer onto bearer-model only.
1253        let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
1254        let h = r.model.headers.as_ref().expect("bearer folded");
1255        assert_eq!(h.get("authorization").map(|s| s.as_str()), Some("Bearer bearer-secret"));
1256        assert!(h.get("x-api-key").is_none(), "authHeader path must not synthesize x-api-key");
1257
1258        // xkey-gw's bare apiKey folds as x-api-key onto xkey-model only (its
1259        // own provider's key — per-provider, NOT the bearer-gw secret).
1260        let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
1261        let h2 = r2.model.headers.as_ref().expect("x-api-key folded");
1262        assert_eq!(h2.get("x-api-key").map(|s| s.as_str()), Some("xkey-secret"));
1263        assert!(h2.get("authorization").is_none(), "xkey-gw has no authHeader");
1264
1265        // Both gateway models are authed ⇒ BOTH appear in the `/model`
1266        // selector catalog (the multi-gateway case the old single-key fold
1267        // made impossible — it 401'd the 2nd gateway).
1268        let catalog = available_catalog(&r);
1269        let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
1270        assert_eq!(ids, vec!["bearer-model", "xkey-model"]);
1271    }
1272
1273    /// A copied pi settings.json whose `defaultProvider` names a **models.json
1274    /// gateway** (not "anthropic") must still honor the saved `defaultModel` —
1275    /// pi's `findInitialModel` step-3 applies `defaultModelPerProvider`
1276    /// regardless of provider id. Without this, enabling a second gateway
1277    /// flips the no-`--model` default to the FIRST authed model in catalog
1278    /// order (BTreeMap sorts provider ids), not the user's saved choice.
1279    #[test]
1280    fn settings_default_model_honored_for_models_json_provider() {
1281        let _env = TestEnv::new();
1282        std::fs::write(
1283            config::models_path().unwrap(),
1284            r#"{
1285  "providers": {
1286    "beta-gw": {
1287      "baseUrl": "https://beta.example.com",
1288      "api": "anthropic-messages",
1289      "apiKey": "beta-secret",
1290      "models": [ { "id": "beta-model" } ]
1291    },
1292    "alpha-gw": {
1293      "baseUrl": "https://alpha.example.com",
1294      "api": "anthropic-messages",
1295      "apiKey": "alpha-secret",
1296      "models": [ { "id": "alpha-model" } ]
1297    }
1298  }
1299}"#,
1300        )
1301        .unwrap();
1302        // Saved default points at the BETA gateway's model — even though
1303        // "alpha-gw" sorts first and would win first-authed without the
1304        // settings arm.
1305        std::fs::write(
1306            config::settings_path().unwrap(),
1307            r#"{"defaultProvider":"beta-gw","defaultModel":"beta-model"}"#,
1308        )
1309        .unwrap();
1310        let r = resolve(None, None, None, None, None).unwrap();
1311        assert_eq!(r.model.id, "beta-model");
1312        // An unknown provider id falls through to first-authed (alpha-gw).
1313        std::fs::write(
1314            config::settings_path().unwrap(),
1315            r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
1316        )
1317        .unwrap();
1318        let r = resolve(None, None, None, None, None).unwrap();
1319        assert_eq!(r.model.id, "alpha-model");
1320    }
1321
1322    /// The `/model` selector catalog (`available_catalog`) is auth-filtered —
1323    /// it must NOT offer built-in claude-* models that carry no auth headers in
1324    /// a gateway-only setup (selecting one would fail at request time with
1325    /// "No API key for provider: anthropic"). Mirrors pi's
1326    /// `getAvailableSnapshot` filter (`available = all.filter(m =>
1327    /// configuredProviders.has(m.provider))`): only the gateway model is
1328    /// loadable, so only it appears in the selector / Ctrl+M cycle.
1329    #[test]
1330    fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
1331        let _env = TestEnv::new();
1332        std::fs::write(
1333            config::models_path().unwrap(),
1334            r#"{
1335  "providers": {
1336    "gateway": {
1337      "baseUrl": "https://gw.example.com",
1338      "api": "anthropic-messages",
1339      "apiKey": "gw-secret",
1340      "models": [
1341        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1342      ]
1343    }
1344  }
1345}"#,
1346        )
1347        .unwrap();
1348        let r = resolve(None, None, None, None, None).unwrap();
1349        // Auth is header-carried (provider_key = None ⇒ has_provider_key false)
1350        assert!(!r.has_provider_key);
1351        let catalog = available_catalog(&r);
1352        // Exactly one loadable model: the gateway one. The 7 built-in Anthropic
1353        // models are filtered out.
1354        let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
1355        assert_eq!(ids, vec!["custom-claude"], "selector must only list authed models");
1356        // Sanity: the provider still serves the full catalog (the filter is
1357        // selector-side only — resolve/pick_default_model unchanged).
1358        assert!(r.provider.models().len() > catalog.len());
1359    }
1360
1361    /// On the x-api-key path (`--api-key`/auth.json/`ANTHROPIC_API_KEY`), the
1362    /// provider's default key attaches to EVERY model out-of-band — so the
1363    /// catalog filter keeps the full list (all models are loadable).
1364    #[test]
1365    fn available_catalog_keeps_all_models_on_provider_key_path() {
1366        let _env = TestEnv::new();
1367        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1368        let r = resolve(None, None, None, None, None).unwrap();
1369        assert!(r.has_provider_key);
1370        let catalog = available_catalog(&r);
1371        assert_eq!(catalog.len(), r.provider.models().len());
1372        assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
1373    }
1374}