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