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