Skip to main content

rpi_cli/
provider.rs

1//! Provider + model resolution. Mirrors the provider/model selection and
2//! request-auth portions of upstream's model runtime.
3//!
4//! The built-in lane uses Anthropic's protocol, while `models.json` may add
5//! named Anthropic-compatible, OpenAI Completions, and OpenAI Responses
6//! providers. OAuth/Copilot remains deferred. This module resolves one provider
7//! identity and its isolated model catalog for each run.
8//!
9//! # Auth resolution and isolation
10//!
11//! Credentials are resolved independently for every provider id:
12//!
13//! 1. `--api-key` overrides only the finally selected provider.
14//! 2. `auth.json[provider-id]` wins over that provider's configured key.
15//! 3. `models.json.providers[provider-id].apiKey` (plus `authHeader`) applies
16//!    only to models owned by that provider.
17//! 4. Provider-specific environment variables are fallbacks. The global
18//!    `ANTHROPIC_*` and `OPENAI_API_KEY` variables belong only to the built-in
19//!    `anthropic` and `openai` identities respectively.
20//!
21//! A credential never makes another provider's model available and is never
22//! passed to another provider's runtime, even when endpoints or model ids are
23//! identical.
24//!
25//! # Endpoint + catalog
26//!
27//! - `--base-url` overrides the selected `model.base_url` at resolve time;
28//!   `ANTHROPIC_BASE_URL` is the fallback for the built-in `anthropic` identity
29//!   only (the request URL is built from it per-request in rpi-ai).
30//! - `~/.rpi/models.json` (if present) merges/overrides the built-in catalog:
31//!   each `anthropic-messages` provider contributes its models, with
32//!   provider-level `base_url`/`headers`/`authHeader` folded in. The models.json
33//!   provider id (e.g. `gateway`) remains the routing and credential identity,
34//!   so providers may safely share a protocol, endpoint, or model id.
35//!
36//! # Model pattern precedence (mirrors `resolveCliModel`)
37//!
38//! 1. `--model` may carry `provider/id[:thinking]`. The prefix is interpreted
39//!    as a provider only when it names a known built-in or `models.json`
40//!    provider. Otherwise the slash remains part of the raw model id (for
41//!    example `meta-llama/llama-*`). If both interpretations exist, the known
42//!    provider wins while authenticated; an unauthenticated inferred provider
43//!    yields to one uniquely authenticated raw-id match, as in upstream.
44//! 2. Otherwise treat `--model` as `id[:thinking]`: if a trailing `:level` is a
45//!    valid thinking level, strip it and apply it (overriding `--thinking`);
46//!    else the whole string is the id.
47//! 3. `--provider` must name a built-in provider or a configured models.json
48//!    provider and restricts selection to that identity.
49//! 4. The model id is matched **exactly, case-insensitively** against the
50//!    catalog. The TS resolver additionally does fuzzy/partial matching; v1
51//!    keeps it exact to avoid surprising model picks (partial match is a common
52//!    source of "got the wrong model" bugs — documented as a divergence in
53//!    the initial port notes (retired)).
54//! 5. No `--model` ⇒ [`pick_default_model`]:
55//!    (a) scan upstream's `defaultModelPerProvider` entries in their declared
56//!    order and take the first authenticated match; otherwise (b) take the
57//!    **first authenticated model** in the catalog — mirroring the TS
58//!    `findInitialModel` fallback over `availableModels`. This lets a
59//!    `models.json`-only gateway config "just work": the built-in Anthropic
60//!    models carry no auth, so the gateway model (the only authenticated one)
61//!    is picked. The all-builtin/no-custom-code default (`ANTHROPIC_API_KEY`
62//!    path) selects `claude-opus-4-8`. Last resort falls back to
63//!    [`DEFAULT_MODEL_ID`] (or the catalog head) — unreachable in practice
64//!    because the auth gate refuses an unauthed catalog earlier.
65//!
66//! [`AnthropicProvider`]: rpi_ai::providers::anthropic::AnthropicProvider
67
68use std::collections::BTreeMap;
69use std::sync::Arc;
70
71use rpi_ai::providers::anthropic::models::anthropic_models;
72use rpi_ai::providers::anthropic::AnthropicProvider;
73use rpi_ai::providers::openai_completions::OpenAiCompletionsProvider;
74use rpi_ai::providers::openai_responses::openai_responses_models;
75use rpi_ai::providers::openai_responses::OpenAiResponsesProvider;
76use rpi_ai::{
77    AssistantMessageEventStream, Context, Model, Provider, SimpleStreamOptions, ThinkingLevel,
78};
79
80use crate::args::parse_thinking_level;
81use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
82use crate::settings;
83
84/// The default Anthropic model when `--model` is absent. Kept in sync with the
85/// current upstream `defaultModelPerProvider.anthropic` entry.
86pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
87
88/// upstream checks these provider defaults in declaration order before it
89/// falls back to `availableModels[0]`. Configured providers using one of rpi's
90/// supported wire protocols participate too, even when they are not built in.
91const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] = &[
92    ("amazon-bedrock", "us.anthropic.claude-opus-4-6-v1"),
93    ("ant-ling", "Ring-2.6-1T"),
94    ("anthropic", DEFAULT_MODEL_ID),
95    ("openai", "gpt-5.5"),
96    ("azure-openai-responses", "gpt-5.4"),
97    ("openai-codex", "gpt-5.5"),
98    ("radius", "auto"),
99    ("nvidia", "nvidia/nemotron-3-super-120b-a12b"),
100    ("deepseek", "deepseek-v4-pro"),
101    ("google", "gemini-3.1-pro-preview"),
102    ("google-vertex", "gemini-3.1-pro-preview"),
103    ("github-copilot", "gpt-5.4"),
104    ("openrouter", "moonshotai/kimi-k2.6"),
105    ("vercel-ai-gateway", "zai/glm-5.1"),
106    ("xai", "grok-4.6"),
107    ("groq", "openai/gpt-oss-120b"),
108    ("cerebras", "gpt-oss-120b"),
109    ("zai", "glm-5.3"),
110    ("zai-coding-cn", "glm-5.3"),
111    ("mistral", "devstral-medium-latest"),
112    ("minimax", "MiniMax-M2.7"),
113    ("minimax-cn", "MiniMax-M2.7"),
114    ("moonshotai", "kimi-k2.6"),
115    ("moonshotai-cn", "kimi-k2.6"),
116    ("huggingface", "moonshotai/Kimi-K2.6"),
117    ("fireworks", "accounts/fireworks/models/kimi-k2p6"),
118    ("together", "moonshotai/Kimi-K2.6"),
119    ("baseten", "zai-org/GLM-5.2"),
120    ("opencode", "kimi-k2.6"),
121    ("opencode-go", "kimi-k2.6"),
122    ("kimi-coding", "kimi-for-coding"),
123    ("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"),
124    (
125        "cloudflare-ai-gateway",
126        "workers-ai/@cf/moonshotai/kimi-k2.6",
127    ),
128    ("qwen-token-plan", "qwen3.7-max"),
129    ("qwen-token-plan-cn", "qwen3.7-max"),
130    ("qwen-token-plan-individual", "qwen3.8-max"),
131    ("xiaomi", "mimo-v2.5-pro"),
132    ("xiaomi-token-plan-cn", "mimo-v2.5-pro"),
133    ("xiaomi-token-plan-ams", "mimo-v2.5-pro"),
134    ("xiaomi-token-plan-sgp", "mimo-v2.5-pro"),
135];
136
137/// The default thinking level when neither `--thinking` nor a `:level` suffix
138/// is present. Mirrors the TS `DEFAULT_THINKING_LEVEL` (`"medium"`, clamped to
139/// model capabilities by the harness's provider build_params).
140pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
141
142/// The resolved run configuration: the provider handle, the chosen model, and
143/// the effective thinking level (after `--thinking` / `:level` / model-clamp).
144#[derive(Clone)]
145pub struct ResolvedModel {
146    /// The selected provider. Cheap to clone through the trait-object `Arc`.
147    pub provider: Arc<dyn Provider>,
148    /// The chosen model from the catalog.
149    pub model: Model,
150    /// Effective thinking level (the requested level, before model-clamp — the
151    /// harness/provider clamps to the model's supported set).
152    pub thinking_level: ThinkingLevel,
153    /// Whether the selected runtime carries a provider-default key. When
154    /// false, authentication is owned by the selected model's headers.
155    ///
156    /// Kept so [`available_catalog`] can reproduce the auth-filtered snapshot
157    /// (pi `getAvailableSnapshot`: `available = all.filter(m =>
158    /// configuredProviders.has(m.provider))`) and surface only models that
159    /// won't fail at request time with "No API key for provider".
160    pub has_provider_key: bool,
161    /// Saved theme name from `~/.rpi/agent/settings.json`, if any. Best-effort:
162    /// the TUI applies it at startup when it matches a known preset
163    /// (dark/light/monochrome); otherwise ignored.
164    pub theme: Option<String>,
165    /// Every authenticated provider runtime, with the selected provider first.
166    /// The harness resolves a provider lazily by `model.provider`, so this lets
167    /// `/model` hot-switch across providers without a restart.
168    pub providers: Vec<Arc<dyn Provider>>,
169}
170
171impl std::fmt::Debug for ResolvedModel {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.debug_struct("ResolvedModel")
174            .field("provider", &self.provider.id())
175            .field("model", &self.model.id)
176            .field("thinking_level", &self.thinking_level)
177            .field("has_provider_key", &self.has_provider_key)
178            .field("theme", &self.theme)
179            .finish()
180    }
181}
182
183/// Bind the Anthropic wire implementation to the provider id declared in
184/// models.json. upstream keeps providers isolated by id even when they share
185/// the same protocol and model ids; the wrapper preserves that routing identity
186/// without duplicating the protocol implementation.
187struct NamedAnthropicProvider {
188    id: String,
189    inner: AnthropicProvider,
190}
191
192#[async_trait::async_trait]
193impl Provider for NamedAnthropicProvider {
194    fn id(&self) -> &str {
195        &self.id
196    }
197
198    fn models(&self) -> &[Model] {
199        self.inner.models()
200    }
201
202    async fn stream_simple(
203        &self,
204        model: &Model,
205        ctx: &Context,
206        opts: &SimpleStreamOptions,
207    ) -> AssistantMessageEventStream {
208        self.inner.stream_simple(model, ctx, opts).await
209    }
210}
211
212/// The env var consulted for the API key. Mirrors TS `ANTHROPIC_API_KEY`.
213pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
214
215/// The env var consulted for a bearer token (routed as
216/// `Authorization: Bearer`). Mirrors TS `ANTHROPIC_AUTH_TOKEN` — used by
217/// third-party Anthropic-compatible gateways (one-api/new-api/claude-code-router
218/// and private reverse proxies) that authenticate via `Authorization` rather
219/// than `x-api-key`.
220pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
221
222/// The env var that overrides the Anthropic endpoint base URL. Mirrors TS
223/// `ANTHROPIC_BASE_URL` — point this at a gateway/proxy that speaks the
224/// `/v1/messages` protocol.
225pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
226
227/// Standard OpenAI API-key environment variable used by the
228/// `openai-completions` provider.
229pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
230
231/// Hint text surfaced when no credential source is available. Lists every
232/// accepted source so the user can pick the one that fits their setup.
233pub const NO_API_KEY_HINT: &str =
234    "models.json apiKey, OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login`";
235
236/// A resolution error. The TS resolver returns `{ error, warning }`; v1 folds
237/// both into a single enum since the CLI treats them the same (print + non-zero
238/// exit) except `NoApiKey`, which prints guidance then exits.
239#[derive(Debug, thiserror::Error)]
240pub enum ResolveError {
241    #[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, openai-responses, or a models.json provider id")]
242    UnknownProvider(String),
243    #[error(
244        "Provider \"{requested}\" is ambiguous: {matches}. Use the exact provider id to select one."
245    )]
246    AmbiguousProvider { requested: String, matches: String },
247    #[error("No model matches \"{pattern}\". Available: {available}")]
248    NoMatch { pattern: String, available: String },
249    #[error(
250        "Model \"{pattern}\" is ambiguous across providers: {matches}. {auth_hint} Use --provider or provider/model."
251    )]
252    AmbiguousModel {
253        pattern: String,
254        matches: String,
255        auth_hint: &'static str,
256    },
257    #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
258    InvalidThinkingLevel(String, String),
259    #[error("No API key. Set one of: {hint}")]
260    NoApiKey { hint: &'static str },
261    #[error("Could not read config: {0}")]
262    Config(#[from] config::ConfigError),
263}
264
265/// Resolve the provider + model + thinking level from the CLI flags + env +
266/// `~/.rpi/` config.
267///
268/// `cli_provider` is the `--provider` value (optional). `cli_model` is the
269/// `--model` value (optional; may be `provider/id[:thinking]` or `id[:thinking]`).
270/// `cli_thinking` is the `--thinking` value (optional). `cli_api_key` is the
271/// `--api-key` value (optional; highest-priority `x-api-key` source).
272/// `cli_base_url` is the `--base-url` value (optional; overrides
273/// `ANTHROPIC_BASE_URL` + each model's `base_url`).
274/// Build the provider HTTP client honoring system proxy settings and the
275/// `httpIdleTimeout` setting. Uses the model's own `base_url` when present (so
276/// `NO_PROXY` matches the real host), else `default_base_url`.
277fn provider_http_client(
278    models: &[Model],
279    default_base_url: &str,
280    settings: &settings::Settings,
281) -> reqwest::Client {
282    let base_url = models
283        .iter()
284        .map(|m| m.base_url.as_str())
285        .find(|u| !u.is_empty())
286        .unwrap_or(default_base_url);
287    rpi_ai::http::build_client(base_url, settings.http_idle_timeout_ms(), None)
288        .unwrap_or_else(|_| reqwest::Client::new())
289}
290
291pub fn resolve(
292    cli_provider: Option<&str>,
293    cli_model: Option<&str>,
294    cli_thinking: Option<ThinkingLevel>,
295    cli_api_key: Option<&str>,
296    cli_base_url: Option<&str>,
297) -> Result<ResolvedModel, ResolveError> {
298    let settings = settings::load_settings().unwrap_or_default();
299    resolve_with_settings(
300        cli_provider,
301        cli_model,
302        cli_thinking,
303        cli_api_key,
304        cli_base_url,
305        settings,
306    )
307}
308
309/// Resolve using upstream's global -> trusted-project settings precedence.
310pub fn resolve_for_cwd(
311    cli_provider: Option<&str>,
312    cli_model: Option<&str>,
313    cli_thinking: Option<ThinkingLevel>,
314    cli_api_key: Option<&str>,
315    cli_base_url: Option<&str>,
316    cwd: &std::path::Path,
317    project_trusted: bool,
318) -> Result<ResolvedModel, ResolveError> {
319    let settings = settings::load_effective_model_settings(cwd, project_trusted)?;
320    resolve_with_settings(
321        cli_provider,
322        cli_model,
323        cli_thinking,
324        cli_api_key,
325        cli_base_url,
326        settings,
327    )
328}
329
330fn resolve_with_settings(
331    cli_provider: Option<&str>,
332    cli_model: Option<&str>,
333    cli_thinking: Option<ThinkingLevel>,
334    cli_api_key: Option<&str>,
335    cli_base_url: Option<&str>,
336    settings: settings::Settings,
337) -> Result<ResolvedModel, ResolveError> {
338    // Load models/auth once, then resolve Anthropic-wire credentials per
339    // provider id. A key belonging to `anthropic` must never make an unrelated
340    // gateway available or become that gateway's provider default.
341    let models_cfg = config::load_models_config()?;
342    let canonical_cli_provider = cli_provider
343        .map(|requested| canonicalize_cli_provider(requested, &models_cfg))
344        .transpose()?;
345    let cli_provider = canonical_cli_provider.as_deref();
346    let auth_store = config::read_auth()?;
347    let anthropic_credentials = resolve_anthropic_credentials(&models_cfg, &auth_store);
348    let openai_credentials = resolve_openai_credentials(&models_cfg, &auth_store);
349    // The CLI override is intentionally not inserted into either provider map:
350    // it is applied only after the final model/provider identity is selected.
351    let cli_api_key = cli_api_key
352        .filter(|key| !key.is_empty())
353        .map(str::to_string);
354
355    // ---- Endpoint override (--base-url → ANTHROPIC_BASE_URL) ----
356    let cli_base_url_override = cli_base_url.map(str::to_string);
357    let anthropic_base_url_override = std::env::var(ANTHROPIC_BASE_URL_ENV)
358        .ok()
359        .filter(|value| !value.is_empty());
360
361    // ---- Catalog: built-in + ~/.rpi/models.json (merged, reusing the
362    // already-loaded config) ----
363    let mut catalog = anthropic_models();
364    catalog.extend(openai_responses_models());
365    merge_user_catalog(&mut catalog, &models_cfg);
366
367    // Apply an explicit CLI endpoint override to the selected catalog lane.
368    // The ambient Anthropic override belongs only to the built-in `anthropic`
369    // identity; applying it to custom Anthropic-wire providers would redirect
370    // their provider-specific credentials to an unrelated endpoint.
371    for model in &mut catalog {
372        if let Some(base) = &cli_base_url_override {
373            model.base_url = base.clone();
374        } else if matches!(model.api, rpi_ai::Api::AnthropicMessages)
375            && model.provider == DEFAULT_PROVIDER_ID
376        {
377            if let Some(base) = &anthropic_base_url_override {
378                model.base_url = base.clone();
379            }
380        }
381    }
382
383    // Snapshot the full (un-retained) catalog so auxiliary provider runtimes
384    // for `/model` hot-switching can be built after the selected one.
385    let full_catalog = catalog.clone();
386
387    if let Some(requested) = cli_provider {
388        catalog.retain(|model| provider_matches(model, requested, &models_cfg));
389    }
390
391    let available = catalog
392        .iter()
393        .map(|m| m.id.clone())
394        .collect::<Vec<_>>()
395        .join(", ");
396    if catalog.is_empty() {
397        return Err(ResolveError::NoMatch {
398            pattern: cli_provider.unwrap_or("default").to_string(),
399            available,
400        });
401    }
402
403    // ---- Model selection ----
404    // With `--model`: parse the pattern (`provider/id[:thinking]`), match it
405    // exactly against the catalog (TS fuzzy/partial match is a deliberate v1
406    // omission — see module docs §5). Without `--model`: pi `findInitialModel`
407    // precedence — (3) the saved default from settings (when present + authed),
408    // then (4) `pick_default_model` (built-in default if authed, else first
409    // authed). The saved default mirrors `findInitialModel` step 3 and lets a
410    // copied pi `settings.json`'s `defaultModel` come alive on launch.
411    let (mut model, thinking_level) = match cli_model {
412        Some(raw) => {
413            let parsed_pattern = split_model_pattern(raw, cli_provider, &catalog, &models_cfg)?;
414            if let Some(provider) = parsed_pattern.provider.as_deref() {
415                if !provider_is_known(provider, &models_cfg) {
416                    return Err(ResolveError::UnknownProvider(provider.to_string()));
417                }
418            }
419            let mut pattern_thinking = parsed_pattern.thinking;
420            let mut selected = find_cli_model(
421                &parsed_pattern.model_id,
422                parsed_pattern.provider.as_deref(),
423                &catalog,
424                &models_cfg,
425                &anthropic_credentials,
426                &openai_credentials,
427            )?;
428
429            if parsed_pattern.inferred_provider {
430                if let Some(inferred) = selected.as_ref() {
431                    if !model_is_authed_for_resolution(
432                        inferred,
433                        &anthropic_credentials,
434                        &openai_credentials,
435                        false,
436                    ) {
437                        let authenticated_raw_matches = |candidate: &str| {
438                            catalog
439                                .iter()
440                                .filter(|model| {
441                                    model.id.eq_ignore_ascii_case(candidate)
442                                        && (model.provider != inferred.provider
443                                            || model.id != inferred.id)
444                                        && model_is_authed_for_resolution(
445                                            model,
446                                            &anthropic_credentials,
447                                            &openai_credentials,
448                                            false,
449                                        )
450                                })
451                                .collect::<Vec<_>>()
452                        };
453                        let mut raw_matches =
454                            authenticated_raw_matches(&parsed_pattern.raw_model_id);
455                        let mut matched_complete_raw_id = true;
456                        if raw_matches.is_empty() && parsed_pattern.thinking.is_some() {
457                            if let Some((raw_without_thinking, _)) =
458                                parsed_pattern.raw_model_id.rsplit_once(':')
459                            {
460                                raw_matches = authenticated_raw_matches(raw_without_thinking);
461                                matched_complete_raw_id = false;
462                            }
463                        }
464                        if let [raw_match] = raw_matches.as_slice() {
465                            selected = Some((*raw_match).clone());
466                            if matched_complete_raw_id {
467                                pattern_thinking = None;
468                            }
469                        }
470                    }
471                } else {
472                    selected = find_cli_model(
473                        &parsed_pattern.raw_model_id,
474                        None,
475                        &catalog,
476                        &models_cfg,
477                        &anthropic_credentials,
478                        &openai_credentials,
479                    )?;
480                    if selected.is_some() {
481                        pattern_thinking = None;
482                    } else if parsed_pattern.thinking.is_some() {
483                        if let Some((raw_without_thinking, _)) =
484                            parsed_pattern.raw_model_id.rsplit_once(':')
485                        {
486                            selected = find_cli_model(
487                                raw_without_thinking,
488                                None,
489                                &catalog,
490                                &models_cfg,
491                                &anthropic_credentials,
492                                &openai_credentials,
493                            )?;
494                        }
495                    }
496                }
497            }
498
499            let model = match selected {
500                Some(m) => m,
501                None => {
502                    return Err(ResolveError::NoMatch {
503                        pattern: parsed_pattern.model_id,
504                        available,
505                    });
506                }
507            };
508            // `--thinking` wins over a parsed `:level` suffix; an exact model
509            // id containing that suffix does not implicitly set thinking.
510            let thinking_level = cli_thinking
511                .or(pattern_thinking)
512                .unwrap_or(DEFAULT_THINKING_LEVEL);
513            (model, thinking_level)
514        }
515        None => {
516            // `--thinking` > settings `defaultThinkingLevel` > built-in default.
517            // The settings level is honored only when its model is also the
518            // saved default (matches pi, which applies `defaultThinkingLevel`
519            // inside the step-3 branch). For the fallback default, keep
520            // `DEFAULT_THINKING_LEVEL`.
521            let settings_thinking = settings
522                .default_thinking_level
523                .as_deref()
524                .and_then(parse_thinking_level);
525
526            // (3) Saved default from settings, when the provider is anthropic
527            // (or absent — v1 is anthropic-only) OR names a configured
528            // models.json gateway (config-namespacing: the saved
529            // `defaultProvider` id matches a `~/.rpi/models.json` provider
530            // key), and the saved model is authed. Without the gateway arm a
531            // copied pi settings.json (`defaultProvider:
532            // "cc-switch-deep-seek-copy-2"`) is ignored and the default falls
533            // to first-authed — which, once a second gateway is enabled, may
534            // NOT be the user's saved choice (BTreeMap provider order).
535            let saved_provider = settings
536                .default_provider
537                .as_deref()
538                .filter(|provider| provider_is_known(provider, &models_cfg));
539            let saved = settings.default_model.as_deref().and_then(|id| {
540                if settings.default_provider.is_some() && saved_provider.is_none() {
541                    return None;
542                }
543                find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
544                    model_is_authed_for_resolution(
545                        m,
546                        &anthropic_credentials,
547                        &openai_credentials,
548                        cli_api_key.is_some(),
549                    )
550                })
551            });
552            if let Some(model) = saved {
553                let thinking_level = cli_thinking
554                    .or(settings_thinking)
555                    .unwrap_or(DEFAULT_THINKING_LEVEL);
556                (model, thinking_level)
557            } else {
558                // (4) Fallback: built-in default if authed, else first authed.
559                let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
560                let model = pick_default_model(
561                    &catalog,
562                    &models_cfg,
563                    &anthropic_credentials,
564                    &openai_credentials,
565                    cli_api_key.is_some(),
566                );
567                (model, thinking_level)
568            }
569        }
570    };
571
572    // ---- Provider build ----
573    let selected_api = model.api.clone();
574    let selected_provider = model.provider.clone();
575    let selected_anthropic_credential = if matches!(selected_api, rpi_ai::Api::AnthropicMessages) {
576        let auth_header = models_cfg
577            .providers
578            .get(&selected_provider)
579            .and_then(|provider| provider.auth_header)
580            .unwrap_or(false);
581        credential_for_selected_anthropic_provider(
582            cli_api_key.as_deref(),
583            &anthropic_credentials,
584            &selected_provider,
585            auth_header,
586        )
587    } else {
588        None
589    };
590    let selected_openai_key = if matches!(
591        selected_api,
592        rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses
593    ) {
594        cli_api_key
595            .clone()
596            .or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
597    } else {
598        None
599    };
600    let selected_is_authed = model_has_header_auth(&model)
601        || match selected_api {
602            rpi_ai::Api::AnthropicMessages => selected_anthropic_credential.is_some(),
603            rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
604                selected_openai_key.is_some()
605            }
606            _ => false,
607        };
608    if !selected_is_authed {
609        return Err(ResolveError::NoApiKey {
610            hint: NO_API_KEY_HINT,
611        });
612    }
613
614    if let Some(AnthropicCredential::Headers(headers)) = &selected_anthropic_credential {
615        merge_auth_headers(&mut model, headers);
616    }
617    if let Some(key) = &selected_openai_key {
618        merge_auth_headers(
619            &mut model,
620            &BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]),
621        );
622    }
623    let (provider, has_provider_key) = build_runtime(
624        &selected_provider,
625        &selected_api,
626        &full_catalog,
627        selected_anthropic_credential.as_ref(),
628        selected_openai_key.as_deref(),
629        &settings,
630    );
631
632    // Build runtimes for every OTHER authenticated provider so the harness can
633    // hot-switch providers by `model.provider` (its lazy resolve matches by
634    // id). The selected provider is already built above.
635    let mut providers: Vec<Arc<dyn Provider>> = vec![provider.clone()];
636    {
637        let mut seen: Vec<(String, rpi_ai::Api)> =
638            vec![(selected_provider.clone(), selected_api.clone())];
639        for candidate in &full_catalog {
640            let key = (candidate.provider.clone(), candidate.api.clone());
641            if seen.contains(&key) {
642                continue;
643            }
644            seen.push(key.clone());
645            let (anth, openai_key) = match &candidate.api {
646                rpi_ai::Api::AnthropicMessages => {
647                    let auth_header = models_cfg
648                        .providers
649                        .get(&candidate.provider)
650                        .and_then(|p| p.auth_header)
651                        .unwrap_or(false);
652                    (
653                        credential_for_selected_anthropic_provider(
654                            None,
655                            &anthropic_credentials,
656                            &candidate.provider,
657                            auth_header,
658                        ),
659                        None,
660                    )
661                }
662                rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => (
663                    None,
664                    openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
665                ),
666                _ => (None, None),
667            };
668            let authed = model_has_header_auth(candidate)
669                || match candidate.api {
670                    rpi_ai::Api::AnthropicMessages => anth.is_some(),
671                    rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
672                        openai_key.is_some()
673                    }
674                    _ => false,
675                };
676            if !authed {
677                continue;
678            }
679            let (runtime, _) = build_runtime(
680                &candidate.provider,
681                &candidate.api,
682                &full_catalog,
683                anth.as_ref(),
684                openai_key.as_deref(),
685                &settings,
686            );
687            providers.push(runtime);
688        }
689    }
690
691    Ok(ResolvedModel {
692        provider,
693        model,
694        thinking_level,
695        has_provider_key,
696        theme: settings.theme.clone(),
697        providers,
698    })
699}
700
701/// Build one provider runtime from a pre-computed credential. Shared by
702/// [`resolve_with_settings`] (for the selected provider) and the
703/// all-provider runtime list built for `/model` hot-switching.
704fn build_runtime(
705    provider_id: &str,
706    api: &rpi_ai::Api,
707    catalog: &[Model],
708    anthropic_credential: Option<&AnthropicCredential>,
709    openai_key: Option<&str>,
710    settings: &settings::Settings,
711) -> (Arc<dyn Provider>, bool) {
712    let mut provider_models: Vec<Model> = catalog
713        .iter()
714        .filter(|candidate| candidate.api == *api && candidate.provider == provider_id)
715        .cloned()
716        .collect();
717    if let Some(AnthropicCredential::Headers(headers)) = anthropic_credential {
718        for candidate in &mut provider_models {
719            merge_auth_headers(candidate, headers);
720        }
721    }
722    if let Some(key) = openai_key {
723        let headers = BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]);
724        for candidate in &mut provider_models {
725            merge_auth_headers(candidate, &headers);
726        }
727    }
728    match api {
729        rpi_ai::Api::AnthropicMessages => {
730            let provider_key = anthropic_credential
731                .and_then(AnthropicCredential::provider_key)
732                .map(str::to_string);
733            let has_key = provider_key.is_some();
734            let http =
735                provider_http_client(&provider_models, "https://api.anthropic.com", settings);
736            let inner = if provider_id == DEFAULT_PROVIDER_ID
737                && !matches!(anthropic_credential, Some(AnthropicCredential::Headers(_)))
738            {
739                AnthropicProvider::with_models(provider_key, http, provider_models)
740            } else {
741                AnthropicProvider::with_models_without_env_api_key(
742                    provider_key,
743                    http,
744                    provider_models,
745                )
746            };
747            (
748                Arc::new(NamedAnthropicProvider {
749                    id: provider_id.to_string(),
750                    inner,
751                }),
752                has_key,
753            )
754        }
755        rpi_ai::Api::OpenaiCompletions => {
756            let has_key = openai_key.is_some();
757            let http =
758                provider_http_client(&provider_models, "https://api.openai.com/v1", settings);
759            let inner = if provider_id == "openai" {
760                OpenAiCompletionsProvider::with_models(
761                    provider_id.to_string(),
762                    openai_key.map(str::to_string),
763                    http,
764                    provider_models,
765                )
766            } else {
767                OpenAiCompletionsProvider::with_models_without_env_api_key(
768                    provider_id.to_string(),
769                    openai_key.map(str::to_string),
770                    http,
771                    provider_models,
772                )
773            };
774            (Arc::new(inner), has_key)
775        }
776        rpi_ai::Api::OpenaiResponses => {
777            let has_key = openai_key.is_some();
778            let http =
779                provider_http_client(&provider_models, "https://api.openai.com/v1", settings);
780            let inner = if provider_id == "openai" {
781                OpenAiResponsesProvider::with_models(
782                    provider_id.to_string(),
783                    openai_key.map(str::to_string),
784                    http,
785                    provider_models,
786                )
787            } else {
788                OpenAiResponsesProvider::with_models_without_env_api_key(
789                    provider_id.to_string(),
790                    openai_key.map(str::to_string),
791                    http,
792                    provider_models,
793                )
794            };
795            (Arc::new(inner), has_key)
796        }
797        _ => unreachable!("unsupported APIs are filtered while loading models.json"),
798    }
799}
800
801/// The catalog the TUI's `/model` selector displays (read-only). Re-derives the
802/// **auth-filtered** snapshot the provider was built from so the selector shows
803/// exactly the models that can actually run (mirrors pi `getAvailableSnapshot`:
804/// `available = all.filter(m => configuredProviders.has(m.provider))` — v1's
805/// single-provider equivalent of "configured" is [`model_is_authed`]).
806///
807/// The runtime provider already owns one provider-id-specific catalog. The
808/// additional auth filter keeps header-only and provider-key paths consistent
809/// if a future runtime exposes a broader snapshot.
810///
811/// On any config read error it falls back to the built-in Anthropic catalog —
812/// the selector is non-critical and must never block the TUI from starting.
813pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
814    let selected_api = &resolved.model.api;
815    let selected_provider = &resolved.model.provider;
816    let mut seen = std::collections::HashSet::new();
817    resolved
818        .provider
819        .models()
820        .iter()
821        // A provider snapshot is expected to be homogeneous, but extension
822        // and gateway providers can expose a broader catalog. The selector
823        // must only offer models the current lane can actually route to.
824        .filter(|m| m.api == *selected_api && m.provider == *selected_provider)
825        .filter(|m| model_is_authed(m, resolved.has_provider_key))
826        .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
827        .cloned()
828        .collect()
829}
830
831/// Return every authenticated model across ALL configured providers (built-in
832/// + models.json), not just the currently selected one. Derived from the
833/// already-built provider runtimes so each model keeps its merged auth headers
834/// and base-url override (a `catalog_all()`-based derivation would drop both).
835pub fn authenticated_catalog(resolved: &ResolvedModel) -> Vec<Model> {
836    let mut seen = std::collections::HashSet::new();
837    resolved
838        .providers
839        .iter()
840        .flat_map(|provider| provider.models())
841        .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
842        .cloned()
843        .collect()
844}
845
846/// Return the merged built-in + `models.json` catalog without requiring a
847/// credential or selecting a runnable model. This is used by CLI commands
848/// such as `--list-models`, which must remain useful before authentication.
849pub fn catalog_all() -> Result<Vec<Model>, config::ConfigError> {
850    let cfg = config::load_models_config()?;
851    let mut catalog = anthropic_models();
852    catalog.extend(openai_responses_models());
853    merge_user_catalog(&mut catalog, &cfg);
854    catalog.sort_by(|a, b| {
855        a.provider
856            .to_ascii_lowercase()
857            .cmp(&b.provider.to_ascii_lowercase())
858            .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
859    });
860    Ok(catalog)
861}
862
863/// Merge `~/.rpi/models.json` providers into the built-in catalog. Models from
864/// the same runtime provider and API replace entries with the same id; models
865/// with the same id under different OpenAI-compatible providers remain
866/// distinct so `provider/id` can select the intended endpoint.
867fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
868    for (provider_id, provider_cfg) in &cfg.providers {
869        let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
870            // Unsupported protocol.
871            continue;
872        };
873        for m in models {
874            if let Some(existing) = catalog.iter_mut().find(|candidate| {
875                candidate.api == m.api
876                    && candidate.provider == m.provider
877                    && candidate.id.eq_ignore_ascii_case(&m.id)
878            }) {
879                *existing = m;
880            } else {
881                catalog.push(m);
882            }
883        }
884    }
885}
886
887#[derive(Clone, Debug, PartialEq, Eq)]
888enum AnthropicCredential {
889    /// Passed as the selected provider's default `x-api-key`.
890    ProviderKey(String),
891    /// Folded only onto models owned by the selected provider.
892    Headers(BTreeMap<String, String>),
893}
894
895impl AnthropicCredential {
896    fn provider_key(&self) -> Option<&str> {
897        match self {
898            Self::ProviderKey(key) => Some(key),
899            Self::Headers(_) => None,
900        }
901    }
902}
903
904type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
905type OpenAiCredentials = BTreeMap<String, String>;
906
907/// Resolve one independent Anthropic-wire credential for each provider id.
908/// Stored credentials win over `models.json`; only the built-in `anthropic`
909/// identity may fall back to the `ANTHROPIC_*` environment variables.
910fn resolve_anthropic_credentials(
911    cfg: &config::ModelsConfig,
912    auth_store: &config::AuthStore,
913) -> AnthropicCredentials {
914    let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
915    for (provider_id, provider_cfg) in &cfg.providers {
916        if config::provider_is_anthropic_compatible(provider_cfg)
917            && !provider_ids.iter().any(|known| known == provider_id)
918        {
919            provider_ids.push(provider_id.clone());
920        }
921    }
922
923    let mut credentials = BTreeMap::new();
924    for provider_id in provider_ids {
925        let provider_cfg = cfg.providers.get(&provider_id);
926        let auth_header = provider_cfg
927            .and_then(|provider| provider.auth_header)
928            .unwrap_or(false);
929        let stored_key = stored_api_key(auth_store, &provider_id);
930        let configured_key = provider_cfg.and_then(|provider| {
931            provider
932                .api_key
933                .as_deref()
934                .filter(|raw| !raw.is_empty())
935                .and_then(|raw| config::resolve_config_value(raw, None))
936                .filter(|key| !key.is_empty())
937        });
938
939        let credential = stored_key
940            .or(configured_key)
941            .map(|key| anthropic_credential_from_key(key, auth_header))
942            .or_else(|| {
943                if provider_id != DEFAULT_PROVIDER_ID {
944                    return None;
945                }
946                std::env::var(ANTHROPIC_AUTH_TOKEN_ENV)
947                    .ok()
948                    .filter(|token| !token.is_empty())
949                    .map(|token| {
950                        AnthropicCredential::Headers(BTreeMap::from([(
951                            "authorization".to_string(),
952                            format!("Bearer {token}"),
953                        )]))
954                    })
955                    .or_else(|| {
956                        std::env::var(ANTHROPIC_API_KEY_ENV)
957                            .ok()
958                            .filter(|key| !key.is_empty())
959                            .map(|key| anthropic_credential_from_key(key, auth_header))
960                    })
961            });
962        if let Some(credential) = credential {
963            credentials.insert(provider_id, credential);
964        }
965    }
966    credentials
967}
968
969fn stored_api_key(auth_store: &config::AuthStore, provider_id: &str) -> Option<String> {
970    auth_store
971        .get(provider_id)
972        .and_then(|credential| match credential {
973            Credential::ApiKey {
974                key: Some(key),
975                env,
976            } => config::resolve_config_value(key, env.as_ref()).filter(|key| !key.is_empty()),
977            _ => None,
978        })
979}
980
981fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
982    if auth_header {
983        AnthropicCredential::Headers(BTreeMap::from([(
984            "authorization".to_string(),
985            format!("Bearer {key}"),
986        )]))
987    } else {
988        AnthropicCredential::ProviderKey(key)
989    }
990}
991
992fn anthropic_credential_for<'a>(
993    credentials: &'a AnthropicCredentials,
994    provider_id: &str,
995) -> Option<&'a AnthropicCredential> {
996    credentials.get(provider_id)
997}
998
999fn credential_for_selected_anthropic_provider(
1000    cli_api_key: Option<&str>,
1001    credentials: &AnthropicCredentials,
1002    provider_id: &str,
1003    auth_header: bool,
1004) -> Option<AnthropicCredential> {
1005    cli_api_key
1006        .filter(|key| !key.is_empty())
1007        .map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
1008        .or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
1009}
1010
1011fn resolve_openai_credentials(
1012    cfg: &config::ModelsConfig,
1013    auth_store: &config::AuthStore,
1014) -> OpenAiCredentials {
1015    let mut provider_ids = vec!["openai".to_string()];
1016    for (provider_id, provider_cfg) in &cfg.providers {
1017        if (config::provider_is_openai_completions(provider_cfg)
1018            || config::provider_is_openai_responses(provider_cfg))
1019            && !provider_ids.iter().any(|known| known == provider_id)
1020        {
1021            provider_ids.push(provider_id.clone());
1022        }
1023    }
1024
1025    let mut credentials = BTreeMap::new();
1026    for provider_id in provider_ids {
1027        let provider_cfg = cfg.providers.get(&provider_id);
1028        let key = stored_api_key(auth_store, &provider_id)
1029            .or_else(|| configured_openai_api_key(&provider_id, provider_cfg));
1030        if let Some(key) = key {
1031            credentials.insert(provider_id, key);
1032        }
1033    }
1034    credentials
1035}
1036
1037fn configured_openai_api_key(
1038    provider_id: &str,
1039    provider_cfg: Option<&config::ProviderConfig>,
1040) -> Option<String> {
1041    if let Some(provider_cfg) = provider_cfg {
1042        return config::openai_provider_api_key(provider_id, provider_cfg);
1043    }
1044
1045    (provider_id == "openai")
1046        .then(|| std::env::var(OPENAI_API_KEY_ENV).ok())
1047        .flatten()
1048        .filter(|value| !value.is_empty())
1049}
1050
1051fn openai_credential_for<'a>(
1052    credentials: &'a OpenAiCredentials,
1053    provider_id: &str,
1054) -> Option<&'a String> {
1055    credentials.get(provider_id)
1056}
1057
1058fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
1059    let headers = model.headers.get_or_insert_with(BTreeMap::new);
1060    for (name, value) in auth_headers {
1061        headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
1062        headers.insert(name.clone(), value.clone());
1063    }
1064}
1065
1066struct SplitModelPattern {
1067    provider: Option<String>,
1068    model_id: String,
1069    raw_model_id: String,
1070    thinking: Option<ThinkingLevel>,
1071    inferred_provider: bool,
1072}
1073
1074/// Split a `--model` value into its provider, id, and thinking components.
1075///
1076/// A slash prefix is provider syntax only when it names a known provider (or
1077/// matches an explicit `--provider`). Unknown prefixes stay in the raw model
1078/// id, which is required for OpenRouter-style ids such as
1079/// `meta-llama/llama-3.3`.
1080///
1081/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
1082fn split_model_pattern(
1083    value: &str,
1084    cli_provider: Option<&str>,
1085    catalog: &[Model],
1086    cfg: &config::ModelsConfig,
1087) -> Result<SplitModelPattern, ResolveError> {
1088    let mut provider = cli_provider.map(str::to_string);
1089    let mut model_id = value.to_string();
1090    let mut inferred_provider = false;
1091
1092    if let Some((prefix, remainder)) = value.split_once('/') {
1093        if !prefix.is_empty() && !remainder.is_empty() {
1094            let matches_explicit = cli_provider
1095                .and_then(|requested| {
1096                    canonicalize_cli_provider(prefix, cfg)
1097                        .ok()
1098                        .map(|canonical| canonical == requested)
1099                })
1100                .unwrap_or(false);
1101            if matches_explicit {
1102                model_id = remainder.to_string();
1103            } else if cli_provider.is_none() {
1104                match canonicalize_cli_provider(prefix, cfg) {
1105                    Ok(canonical) => {
1106                        provider = Some(canonical);
1107                        model_id = remainder.to_string();
1108                        inferred_provider = true;
1109                    }
1110                    Err(ResolveError::UnknownProvider(_)) => {}
1111                    Err(error) => return Err(error),
1112                }
1113            }
1114        }
1115    }
1116
1117    // upstream attempts the complete id first. Only peel a valid thinking
1118    // suffix when that complete id does not exist in the selected scope.
1119    let has_full_exact_match = catalog.iter().any(|model| {
1120        model.id.eq_ignore_ascii_case(&model_id)
1121            && provider
1122                .as_deref()
1123                .map_or(true, |requested| provider_matches(model, requested, cfg))
1124    });
1125    let thinking = if has_full_exact_match {
1126        None
1127    } else if let Some((head, suffix)) = model_id.rsplit_once(':') {
1128        if let Some(level) = parse_thinking_level(suffix) {
1129            model_id = head.to_string();
1130            Some(level)
1131        } else {
1132            None
1133        }
1134    } else {
1135        None
1136    };
1137
1138    Ok(SplitModelPattern {
1139        provider,
1140        model_id,
1141        raw_model_id: value.to_string(),
1142        thinking,
1143        inferred_provider,
1144    })
1145}
1146
1147/// Case-insensitive exact id match, optionally scoped to a provider.
1148fn find_model(
1149    pattern: &str,
1150    provider: Option<&str>,
1151    catalog: &[Model],
1152    cfg: &config::ModelsConfig,
1153) -> Option<Model> {
1154    catalog
1155        .iter()
1156        .find(|model| {
1157            model.id.eq_ignore_ascii_case(pattern)
1158                && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1159        })
1160        .cloned()
1161}
1162
1163/// Resolve an exact CLI model reference without silently choosing the first
1164/// provider when a bare model id is duplicated. This mirrors upstream's
1165/// `resolveCliModel`: a sole configured-auth match wins; zero or multiple
1166/// configured-auth matches require an explicit provider.
1167fn find_cli_model(
1168    pattern: &str,
1169    provider: Option<&str>,
1170    catalog: &[Model],
1171    cfg: &config::ModelsConfig,
1172    anthropic_credentials: &AnthropicCredentials,
1173    openai_credentials: &OpenAiCredentials,
1174) -> Result<Option<Model>, ResolveError> {
1175    let exact_matches: Vec<&Model> = catalog
1176        .iter()
1177        .filter(|model| {
1178            model.id.eq_ignore_ascii_case(pattern)
1179                && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1180        })
1181        .collect();
1182
1183    match exact_matches.as_slice() {
1184        [] => Ok(None),
1185        [model] => Ok(Some((*model).clone())),
1186        _ => {
1187            let authenticated: Vec<&Model> = exact_matches
1188                .iter()
1189                .copied()
1190                .filter(|model| {
1191                    model_is_authed_for_resolution(
1192                        model,
1193                        anthropic_credentials,
1194                        openai_credentials,
1195                        false,
1196                    )
1197                })
1198                .collect();
1199            if let [model] = authenticated.as_slice() {
1200                return Ok(Some((*model).clone()));
1201            }
1202
1203            let mut matches = exact_matches
1204                .iter()
1205                .map(|model| format!("{}/{}", model.provider, model.id))
1206                .collect::<Vec<_>>();
1207            matches.sort();
1208            let auth_hint = if authenticated.is_empty() {
1209                "No matching provider is authenticated."
1210            } else {
1211                "More than one matching provider is authenticated."
1212            };
1213            Err(ResolveError::AmbiguousModel {
1214                pattern: pattern.to_string(),
1215                matches: matches.join(", "),
1216                auth_hint,
1217            })
1218        }
1219    }
1220}
1221
1222fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
1223    cfg.providers.contains_key(requested)
1224        || requested.eq_ignore_ascii_case("anthropic")
1225        || requested.eq_ignore_ascii_case("openai")
1226        || requested.eq_ignore_ascii_case("openai-completions")
1227        || requested.eq_ignore_ascii_case("openai-responses")
1228}
1229
1230/// Canonicalize user-facing `--provider` input without weakening provider
1231/// identity. Exact configured ids win. A case-insensitive custom match is
1232/// accepted only when unique; otherwise the user must provide exact casing so
1233/// credentials and endpoints can never cross between distinct ids.
1234fn canonicalize_cli_provider(
1235    requested: &str,
1236    cfg: &config::ModelsConfig,
1237) -> Result<String, ResolveError> {
1238    if cfg.providers.contains_key(requested) {
1239        return Ok(requested.to_string());
1240    }
1241
1242    const BUILTIN_PROVIDER_IDS: &[&str] = &[
1243        "anthropic",
1244        "openai",
1245        "openai-completions",
1246        "openai-responses",
1247    ];
1248    if BUILTIN_PROVIDER_IDS.contains(&requested) {
1249        return Ok(requested.to_string());
1250    }
1251
1252    let mut matches = cfg
1253        .providers
1254        .keys()
1255        .filter(|provider| provider.eq_ignore_ascii_case(requested))
1256        .cloned()
1257        .collect::<Vec<_>>();
1258    matches.extend(
1259        BUILTIN_PROVIDER_IDS
1260            .iter()
1261            .filter(|provider| provider.eq_ignore_ascii_case(requested))
1262            .map(|provider| (*provider).to_string()),
1263    );
1264    matches.sort();
1265    matches.dedup();
1266
1267    match matches.as_slice() {
1268        [provider] => return Ok(provider.clone()),
1269        [] => {}
1270        _ => {
1271            return Err(ResolveError::AmbiguousProvider {
1272                requested: requested.to_string(),
1273                matches: matches.join(", "),
1274            });
1275        }
1276    }
1277
1278    Err(ResolveError::UnknownProvider(requested.to_string()))
1279}
1280
1281fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
1282    // Configured provider ids are exact identities in upstream. An exact
1283    // config match takes precedence over the case-insensitive built-in/protocol
1284    // aliases below, so a custom `Anthropic` remains distinct from `anthropic`.
1285    if model.provider == requested {
1286        return true;
1287    }
1288    if cfg.providers.contains_key(requested) {
1289        return false;
1290    }
1291    if requested.eq_ignore_ascii_case("anthropic") {
1292        return model.provider == DEFAULT_PROVIDER_ID;
1293    }
1294    if requested.eq_ignore_ascii_case("openai") {
1295        return model.provider == "openai";
1296    }
1297    if requested.eq_ignore_ascii_case("openai-completions") {
1298        return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
1299    }
1300    if requested.eq_ignore_ascii_case("openai-responses") {
1301        return matches!(model.api, rpi_ai::Api::OpenaiResponses);
1302    }
1303    false
1304}
1305
1306/// Provider identity matching for upstream's default-model table. Unlike the
1307/// CLI matcher, this intentionally does not treat `openai` as a protocol alias:
1308/// a default belonging to OpenAI must not select the same model id from an
1309/// unrelated OpenAI-compatible gateway.
1310fn model_belongs_to_default_provider(
1311    model: &Model,
1312    requested: &str,
1313    _cfg: &config::ModelsConfig,
1314) -> bool {
1315    model.provider == requested
1316}
1317
1318/// Whether a catalog model is "configured-auth" — i.e. the request built for it
1319/// would pass `assertRequestAuth` and not return "No API key". Mirrors the TS
1320/// `hasConfiguredAuth(providerId)` filter that `getAvailableSnapshot()` applies
1321/// (`available = all.filter(m => configuredProviders.has(m.provider))`).
1322///
1323/// The selected provider snapshot is identity-homogeneous. A model is runnable
1324/// when its own headers carry auth or that selected provider has a default key.
1325fn model_is_authed(m: &Model, has_provider_key: bool) -> bool {
1326    model_has_header_auth(m) || has_provider_key
1327}
1328
1329fn model_is_authed_for_resolution(
1330    model: &Model,
1331    anthropic_credentials: &AnthropicCredentials,
1332    openai_credentials: &OpenAiCredentials,
1333    has_cli_key: bool,
1334) -> bool {
1335    model_has_header_auth(model)
1336        || has_cli_key
1337        || match model.api {
1338            rpi_ai::Api::AnthropicMessages => {
1339                anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
1340            }
1341            rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
1342                openai_credential_for(openai_credentials, &model.provider).is_some()
1343            }
1344            _ => false,
1345        }
1346}
1347
1348/// Same three-name check as rpi-ai's `has_header_auth`, but called from the
1349/// CLI layer (rpi-ai's `has_header_auth` is private to the provider module, so
1350/// we mirror it here over the model's `headers` map).
1351fn model_has_header_auth(m: &Model) -> bool {
1352    let Some(h) = &m.headers else { return false };
1353    const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
1354    h.keys()
1355        .any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
1356}
1357
1358/// Choose the default model when `--model` is absent. Mirrors upstream
1359/// `findInitialModel` [`packages/coding-agent/src/core/model-resolver.ts`]:
1360/// known-provider defaults are checked in native declaration order, followed by
1361/// the first authenticated model in the catalog (`availableModels[0]`). This
1362/// also keeps a models.json-only gateway from accidentally selecting an
1363/// unauthenticated built-in model.
1364///
1365/// Credential maps are keyed by provider id, so only a credential belonging to
1366/// the candidate model can make it participate in default selection.
1367fn pick_default_model(
1368    catalog: &[Model],
1369    models_cfg: &config::ModelsConfig,
1370    anthropic_credentials: &AnthropicCredentials,
1371    openai_credentials: &OpenAiCredentials,
1372    has_cli_key: bool,
1373) -> Model {
1374    // 1. upstream's known-provider defaults, in its declared priority order.
1375    for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
1376        if let Some(model) = catalog.iter().find(|model| {
1377            model.id.eq_ignore_ascii_case(model_id)
1378                && model_belongs_to_default_provider(model, provider, models_cfg)
1379                && model_is_authed_for_resolution(
1380                    model,
1381                    anthropic_credentials,
1382                    openai_credentials,
1383                    has_cli_key,
1384                )
1385        }) {
1386            return model.clone();
1387        }
1388    }
1389
1390    // 2. First authed model (TS `availableModels[0]`). Provider and model array
1391    //    declaration order is preserved while loading models.json.
1392    //    this is the gateway model (Bearer folded onto it, base_url = gateway).
1393    if let Some(m) = catalog.iter().find(|m| {
1394        model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_key)
1395    }) {
1396        return m.clone();
1397    }
1398    // 3. Last resort: the built-in Anthropic default, authed or not. The auth gate above
1399    //    already errored when no source resolved, so reaching here means *some*
1400    //    auth exists but none folded/attached to a model we can see — keep the
1401    //    historical default to avoid a NoMatch surprise.
1402    catalog
1403        .iter()
1404        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
1405        .or_else(|| catalog.first())
1406        .expect("catalog is never empty (built-in anthropic_models)")
1407        .clone()
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412    use super::*;
1413    use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
1414    use crate::config::test_support::env_lock;
1415
1416    /// Scope a test to a throwaway config dir + clear the `ANTHROPIC_*` env
1417    /// vars, restoring both on drop. Holds the shared env lock for its whole
1418    /// lifetime so parallel env-mutating tests across config/provider/auth all
1419    /// serialize on one mutex.
1420    struct TestEnv {
1421        _guard: std::sync::MutexGuard<'static, ()>,
1422        prev_key: Option<std::ffi::OsString>,
1423        prev_tok: Option<std::ffi::OsString>,
1424        prev_base: Option<std::ffi::OsString>,
1425        prev_openai_key: Option<std::ffi::OsString>,
1426        prev_dir: Option<std::ffi::OsString>,
1427        _tmp: tempfile::TempDir,
1428    }
1429    impl TestEnv {
1430        fn new() -> Self {
1431            let guard = env_lock().lock().unwrap();
1432            let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
1433            let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
1434            let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
1435            let prev_openai_key = std::env::var_os(OPENAI_API_KEY_ENV);
1436            let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
1437            std::env::remove_var(ANTHROPIC_API_KEY_ENV);
1438            std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
1439            std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
1440            std::env::remove_var(OPENAI_API_KEY_ENV);
1441            let tmp = tempfile::TempDir::new().unwrap();
1442            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
1443            Self {
1444                _guard: guard,
1445                prev_key,
1446                prev_tok,
1447                prev_base,
1448                prev_openai_key,
1449                prev_dir,
1450                _tmp: tmp,
1451            }
1452        }
1453    }
1454    impl Drop for TestEnv {
1455        fn drop(&mut self) {
1456            restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
1457            restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
1458            restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
1459            restore(OPENAI_API_KEY_ENV, self.prev_openai_key.take());
1460            restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
1461        }
1462    }
1463    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
1464        match prev {
1465            Some(v) => std::env::set_var(name, v),
1466            None => std::env::remove_var(name),
1467        }
1468    }
1469
1470    // These tests hit the network-free resolution path only (provider/model
1471    // selection). They set a throwaway credential so `resolve` clears the
1472    // `NoApiKey` gate, then assert the model + thinking choice — never making
1473    // a real request.
1474
1475    fn resolve_with_key(
1476        provider: Option<&str>,
1477        model: Option<&str>,
1478        thinking: Option<ThinkingLevel>,
1479    ) -> Result<ResolvedModel, ResolveError> {
1480        let _env = TestEnv::new();
1481        std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
1482        resolve(provider, model, thinking, None, None)
1483    }
1484
1485    #[test]
1486    fn default_model_matches_native_anthropic_default() {
1487        let r = resolve_with_key(None, None, None).unwrap();
1488        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1489        assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
1490        assert_eq!(r.provider.id(), "anthropic");
1491    }
1492
1493    #[test]
1494    fn settings_default_model_wins_when_authed() {
1495        // A copied pi `settings.json` carrying `defaultModel` (step 3 of pi's
1496        // `findInitialModel`) overrides the built-in Anthropic default
1497        // when that model is in the catalog and authed. Mirrors the on-disk-
1498        // Drop an agent dir at `~/.rpi/agent/` and the saved
1499        // default comes alive on launch (no `--model` needed).
1500        let _env = TestEnv::new();
1501        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1502        let path = config::settings_path().unwrap();
1503        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1504        std::fs::write(
1505            &path,
1506            r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
1507        )
1508        .unwrap();
1509        let r = resolve(None, None, None, None, None).unwrap();
1510        assert_eq!(r.model.id, "claude-haiku-4-5");
1511        assert_eq!(r.thinking_level, ThinkingLevel::High);
1512        // An unauthed saved default (unknown id) falls through to the built-in.
1513        std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
1514        let r = resolve(None, None, None, None, None).unwrap();
1515        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1516    }
1517
1518    #[test]
1519    fn explicit_id_match() {
1520        let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
1521        assert_eq!(r.model.id, "claude-haiku-4-5");
1522    }
1523
1524    #[test]
1525    fn case_insensitive_id() {
1526        let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
1527        assert_eq!(r.model.id, "claude-opus-5");
1528    }
1529
1530    #[test]
1531    fn provider_prefix_stripped() {
1532        let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
1533        assert_eq!(r.model.id, "claude-sonnet-5");
1534    }
1535
1536    #[test]
1537    fn custom_provider_prefix_stripped() {
1538        // `gateway/custom-claude` resolves to the catalog id `custom-claude`
1539        // after the `foo/` prefix is stripped.
1540        let _env = TestEnv::new();
1541        std::env::set_var(ANTHROPIC_API_KEY_ENV, "official-key");
1542        std::fs::write(
1543            config::models_path().unwrap(),
1544            r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "apiKey": "gateway-key", "models": [{"id":"custom-claude"}] } } }"#,
1545        )
1546        .unwrap();
1547        let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
1548        assert_eq!(r.model.id, "custom-claude");
1549    }
1550
1551    #[test]
1552    fn unknown_slash_prefix_remains_part_of_the_raw_model_id() {
1553        let _env = TestEnv::new();
1554        std::fs::write(
1555            config::models_path().unwrap(),
1556            r#"{
1557  "providers": {
1558    "gateway": {
1559      "api": "openai-completions",
1560      "apiKey": "gateway-key",
1561      "models": [{"id":"meta-llama/llama-3.3"}]
1562    }
1563  }
1564}"#,
1565        )
1566        .unwrap();
1567
1568        let resolved = resolve(None, Some("meta-llama/llama-3.3:high"), None, None, None).unwrap();
1569        assert_eq!(resolved.model.provider, "gateway");
1570        assert_eq!(resolved.model.id, "meta-llama/llama-3.3");
1571        assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1572    }
1573
1574    #[test]
1575    fn complete_model_id_wins_before_parsing_a_thinking_suffix() {
1576        let _env = TestEnv::new();
1577        std::fs::write(
1578            config::models_path().unwrap(),
1579            r#"{
1580  "providers": {
1581    "gateway": {
1582      "api": "openai-completions",
1583      "apiKey": "gateway-key",
1584      "models": [
1585        {"id":"vendor/model"},
1586        {"id":"vendor/model:high"}
1587      ]
1588    }
1589  }
1590}"#,
1591        )
1592        .unwrap();
1593
1594        let resolved = resolve(None, Some("gateway/vendor/model:high"), None, None, None).unwrap();
1595        assert_eq!(resolved.model.provider, "gateway");
1596        assert_eq!(resolved.model.id, "vendor/model:high");
1597        assert_eq!(resolved.thinking_level, DEFAULT_THINKING_LEVEL);
1598    }
1599
1600    #[test]
1601    fn authenticated_inferred_provider_beats_a_matching_raw_model_id() {
1602        let _env = TestEnv::new();
1603        std::fs::write(
1604            config::models_path().unwrap(),
1605            r#"{
1606  "providers": {
1607    "alpha": {
1608      "api": "openai-completions",
1609      "baseUrl": "https://alpha.example.com",
1610      "apiKey": "alpha-key",
1611      "models": [{"id":"target"}]
1612    },
1613    "gateway": {
1614      "api": "openai-completions",
1615      "baseUrl": "https://gateway.example.com",
1616      "apiKey": "gateway-key",
1617      "models": [{"id":"alpha/target"}]
1618    }
1619  }
1620}"#,
1621        )
1622        .unwrap();
1623
1624        let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1625        assert_eq!(resolved.model.provider, "alpha");
1626        assert_eq!(resolved.model.id, "target");
1627        assert_eq!(resolved.model.base_url, "https://alpha.example.com");
1628    }
1629
1630    #[test]
1631    fn authenticated_raw_model_beats_an_unauthenticated_inferred_provider() {
1632        let _env = TestEnv::new();
1633        std::fs::write(
1634            config::models_path().unwrap(),
1635            r#"{
1636  "providers": {
1637    "alpha": {
1638      "api": "openai-completions",
1639      "baseUrl": "https://alpha.example.com",
1640      "models": [{"id":"target"}]
1641    },
1642    "gateway": {
1643      "api": "openai-completions",
1644      "baseUrl": "https://gateway.example.com",
1645      "apiKey": "gateway-key",
1646      "models": [{"id":"alpha/target"}]
1647    }
1648  }
1649}"#,
1650        )
1651        .unwrap();
1652
1653        let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1654        assert_eq!(resolved.model.provider, "gateway");
1655        assert_eq!(resolved.model.id, "alpha/target");
1656        assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1657    }
1658
1659    #[test]
1660    fn thinking_suffix_raw_model_beats_an_unauthenticated_inferred_provider() {
1661        let _env = TestEnv::new();
1662        std::fs::write(
1663            config::models_path().unwrap(),
1664            r#"{
1665  "providers": {
1666    "alpha": {
1667      "api": "openai-completions",
1668      "baseUrl": "https://alpha.example.com",
1669      "models": [{"id":"target"}]
1670    },
1671    "gateway": {
1672      "api": "openai-completions",
1673      "baseUrl": "https://gateway.example.com",
1674      "apiKey": "gateway-key",
1675      "models": [{"id":"alpha/target"}]
1676    }
1677  }
1678}"#,
1679        )
1680        .unwrap();
1681
1682        let resolved = resolve(None, Some("alpha/target:high"), None, None, None).unwrap();
1683        assert_eq!(resolved.model.provider, "gateway");
1684        assert_eq!(resolved.model.id, "alpha/target");
1685        assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1686        assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1687    }
1688
1689    #[test]
1690    fn thinking_suffix_in_model() {
1691        let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
1692        assert_eq!(r.model.id, "claude-sonnet-5");
1693        assert_eq!(r.thinking_level, ThinkingLevel::High);
1694    }
1695
1696    #[test]
1697    fn thinking_flag_overrides_suffix() {
1698        // `--thinking low` wins over a `:high` suffix.
1699        let r =
1700            resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
1701        assert_eq!(r.thinking_level, ThinkingLevel::Low);
1702    }
1703
1704    #[test]
1705    fn explicit_provider_anthropic_ok() {
1706        let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
1707        assert_eq!(r.model.id, "claude-sonnet-5");
1708
1709        let r = resolve_with_key(Some("ANTHROPIC"), Some("claude-sonnet-5"), None).unwrap();
1710        assert_eq!(r.provider.id(), DEFAULT_PROVIDER_ID);
1711    }
1712
1713    #[test]
1714    fn unknown_provider_rejected() {
1715        let err = resolve_with_key(Some("unsupported-provider"), None, None).unwrap_err();
1716        assert!(matches!(err, ResolveError::UnknownProvider(_)));
1717    }
1718
1719    #[test]
1720    fn no_match_lists_available() {
1721        let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
1722        match err {
1723            ResolveError::NoMatch { pattern, available } => {
1724                assert_eq!(pattern, "claude-does-not-exist");
1725                assert!(available.contains("claude-sonnet-5"));
1726            }
1727            other => panic!("expected NoMatch, got {other:?}"),
1728        }
1729    }
1730
1731    #[test]
1732    fn colon_not_a_thinking_level_kept_in_id() {
1733        // A trailing `:foo` that isn't a thinking level stays part of the id
1734        // pattern → no match (no model id contains `:foo`).
1735        let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
1736        assert!(matches!(err, ResolveError::NoMatch { .. }));
1737    }
1738
1739    #[test]
1740    fn parse_thinking_level_roundtrip() {
1741        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
1742        assert_eq!(parse_thinking_level("bogus"), None);
1743        // Sanity: the valid set matches what help advertises.
1744        for lvl in VALID_THINKING_LEVELS {
1745            assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
1746        }
1747    }
1748
1749    #[test]
1750    fn no_api_key_errors_with_hint() {
1751        let _env = TestEnv::new();
1752        let err = resolve(None, None, None, None, None).unwrap_err();
1753        match err {
1754            ResolveError::NoApiKey { hint } => {
1755                assert!(hint.contains("ANTHROPIC_API_KEY"));
1756                assert!(hint.contains("auth login"));
1757            }
1758            other => panic!("expected NoApiKey, got {other:?}"),
1759        }
1760    }
1761
1762    #[test]
1763    fn stored_credential_satisfies_auth() {
1764        let _env = TestEnv::new();
1765        config::upsert_credential(
1766            DEFAULT_PROVIDER_ID,
1767            Credential::ApiKey {
1768                key: Some("stored-key".into()),
1769                env: None,
1770            },
1771        )
1772        .unwrap();
1773        let r = resolve(None, None, None, None, None).unwrap();
1774        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1775        // x-api-key path: no Bearer header folded onto the model (auth rides on
1776        // the provider's default key, surfaced to the provider at build time).
1777        assert!(
1778            r.model
1779                .headers
1780                .as_ref()
1781                .and_then(|h| h.get("authorization"))
1782                .is_none(),
1783            "x-api-key path should not synthesize a Bearer header"
1784        );
1785    }
1786
1787    #[test]
1788    fn gateway_models_key_wins_over_unrelated_anthropic_auth() {
1789        let _env = TestEnv::new();
1790        config::upsert_credential(
1791            DEFAULT_PROVIDER_ID,
1792            Credential::ApiKey {
1793                key: Some("official-key".into()),
1794                env: None,
1795            },
1796        )
1797        .unwrap();
1798        std::fs::write(
1799            config::models_path().unwrap(),
1800            r#"{
1801  "providers": {
1802    "gateway": {
1803      "api": "anthropic-messages",
1804      "baseUrl": "https://gateway.example.com",
1805      "apiKey": "gateway-key",
1806      "models": [{"id":"gateway-model"}]
1807    }
1808  }
1809}"#,
1810        )
1811        .unwrap();
1812
1813        let resolved = resolve(None, Some("gateway/gateway-model"), None, None, None).unwrap();
1814        assert_eq!(resolved.provider.id(), "gateway");
1815        assert_eq!(resolved.model.provider, "gateway");
1816        assert!(resolved.has_provider_key);
1817
1818        let cfg = config::load_models_config().unwrap();
1819        let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
1820        assert_eq!(
1821            anthropic_credential_for(&credentials, DEFAULT_PROVIDER_ID),
1822            Some(&AnthropicCredential::ProviderKey("official-key".into()))
1823        );
1824        assert_eq!(
1825            anthropic_credential_for(&credentials, "gateway"),
1826            Some(&AnthropicCredential::ProviderKey("gateway-key".into()))
1827        );
1828    }
1829
1830    #[test]
1831    fn gateway_auth_json_credential_is_usable() {
1832        let _env = TestEnv::new();
1833        config::upsert_credential(
1834            "gateway",
1835            Credential::ApiKey {
1836                key: Some("stored-gateway-key".into()),
1837                env: None,
1838            },
1839        )
1840        .unwrap();
1841        std::fs::write(
1842            config::models_path().unwrap(),
1843            r#"{
1844  "providers": {
1845    "gateway": {
1846      "api": "anthropic-messages",
1847      "baseUrl": "https://gateway.example.com",
1848      "models": [{"id":"gateway-model"}]
1849    }
1850  }
1851}"#,
1852        )
1853        .unwrap();
1854
1855        let resolved = resolve(Some("gateway"), None, None, None, None).unwrap();
1856        assert_eq!(resolved.model.id, "gateway-model");
1857        assert_eq!(resolved.provider.id(), "gateway");
1858        assert!(resolved.has_provider_key);
1859    }
1860
1861    #[test]
1862    fn anthropic_auth_does_not_authenticate_an_unkeyed_gateway() {
1863        let _env = TestEnv::new();
1864        config::upsert_credential(
1865            DEFAULT_PROVIDER_ID,
1866            Credential::ApiKey {
1867                key: Some("official-key".into()),
1868                env: None,
1869            },
1870        )
1871        .unwrap();
1872        std::fs::write(
1873            config::models_path().unwrap(),
1874            r#"{
1875  "providers": {
1876    "gateway": {
1877      "api": "anthropic-messages",
1878      "baseUrl": "https://gateway.example.com",
1879      "models": [{"id":"gateway-model"}]
1880    }
1881  }
1882}"#,
1883        )
1884        .unwrap();
1885
1886        let error = resolve(
1887            Some("gateway"),
1888            Some("gateway/gateway-model"),
1889            None,
1890            None,
1891            None,
1892        )
1893        .unwrap_err();
1894        assert!(matches!(error, ResolveError::NoApiKey { .. }));
1895
1896        let default = resolve(None, None, None, None, None).unwrap();
1897        assert_eq!(default.provider.id(), DEFAULT_PROVIDER_ID);
1898        assert_eq!(default.model.id, DEFAULT_MODEL_ID);
1899    }
1900
1901    #[test]
1902    fn two_anthropic_gateways_keep_credentials_isolated_by_id() {
1903        let _env = TestEnv::new();
1904        std::fs::write(
1905            config::models_path().unwrap(),
1906            r#"{
1907  "providers": {
1908    "alpha": {
1909      "api": "anthropic-messages",
1910      "baseUrl": "https://shared.example.com",
1911      "apiKey": "alpha-key",
1912      "models": [{"id":"shared-model"}]
1913    },
1914    "beta": {
1915      "api": "anthropic-messages",
1916      "baseUrl": "https://shared.example.com",
1917      "apiKey": "beta-key",
1918      "models": [{"id":"shared-model"}]
1919    }
1920  }
1921}"#,
1922        )
1923        .unwrap();
1924
1925        let cfg = config::load_models_config().unwrap();
1926        let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
1927        assert_eq!(
1928            anthropic_credential_for(&credentials, "alpha"),
1929            Some(&AnthropicCredential::ProviderKey("alpha-key".into()))
1930        );
1931        assert_eq!(
1932            anthropic_credential_for(&credentials, "beta"),
1933            Some(&AnthropicCredential::ProviderKey("beta-key".into()))
1934        );
1935        let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1936        let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
1937        assert_eq!(alpha.provider.id(), "alpha");
1938        assert_eq!(beta.provider.id(), "beta");
1939        assert!(alpha.has_provider_key && beta.has_provider_key);
1940    }
1941
1942    #[test]
1943    fn case_distinct_provider_ids_keep_endpoints_and_credentials_isolated() {
1944        let _env = TestEnv::new();
1945        std::fs::write(
1946            config::models_path().unwrap(),
1947            r#"{
1948  "providers": {
1949    "alpha": {
1950      "api": "anthropic-messages",
1951      "baseUrl": "https://lower.example.com",
1952      "authHeader": true,
1953      "models": [{"id":"shared-model"}]
1954    },
1955    "ALPHA": {
1956      "api": "anthropic-messages",
1957      "baseUrl": "https://upper.example.com",
1958      "authHeader": true,
1959      "models": [{"id":"shared-model"}]
1960    }
1961  }
1962}"#,
1963        )
1964        .unwrap();
1965        config::upsert_credential(
1966            "alpha",
1967            Credential::ApiKey {
1968                key: Some("lower-key".into()),
1969                env: None,
1970            },
1971        )
1972        .unwrap();
1973        config::upsert_credential(
1974            "ALPHA",
1975            Credential::ApiKey {
1976                key: Some("upper-key".into()),
1977                env: None,
1978            },
1979        )
1980        .unwrap();
1981
1982        let lower = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1983        assert_eq!(lower.provider.id(), "alpha");
1984        assert_eq!(lower.model.base_url, "https://lower.example.com");
1985        assert_eq!(
1986            lower
1987                .model
1988                .headers
1989                .as_ref()
1990                .and_then(|headers| headers.get("authorization"))
1991                .map(String::as_str),
1992            Some("Bearer lower-key")
1993        );
1994
1995        let upper = resolve(None, Some("ALPHA/shared-model"), None, None, None).unwrap();
1996        assert_eq!(upper.provider.id(), "ALPHA");
1997        assert_eq!(upper.model.base_url, "https://upper.example.com");
1998        assert_eq!(
1999            upper
2000                .model
2001                .headers
2002                .as_ref()
2003                .and_then(|headers| headers.get("authorization"))
2004                .map(String::as_str),
2005            Some("Bearer upper-key")
2006        );
2007
2008        let error = resolve(Some("Alpha"), None, None, None, None).unwrap_err();
2009        assert!(matches!(
2010            error,
2011            ResolveError::AmbiguousProvider {
2012                requested,
2013                matches
2014            } if requested == "Alpha" && matches == "ALPHA, alpha"
2015        ));
2016
2017        let prefix_error = resolve(None, Some("Alpha/shared-model"), None, None, None).unwrap_err();
2018        assert!(matches!(
2019            prefix_error,
2020            ResolveError::AmbiguousProvider {
2021                requested,
2022                matches
2023            } if requested == "Alpha" && matches == "ALPHA, alpha"
2024        ));
2025    }
2026
2027    #[test]
2028    fn cli_provider_case_insensitively_selects_one_canonical_custom_id() {
2029        let _env = TestEnv::new();
2030        std::fs::write(
2031            config::models_path().unwrap(),
2032            r#"{
2033  "providers": {
2034    "routeryo-copy": {
2035      "api": "openai-completions",
2036      "baseUrl": "https://routeryo-copy.example.com",
2037      "apiKey": "copy-key",
2038      "models": [{"id":"copy-model"}]
2039    }
2040  }
2041}"#,
2042        )
2043        .unwrap();
2044
2045        let resolved =
2046            resolve(Some("ROUTERYO-COPY"), Some("copy-model"), None, None, None).unwrap();
2047        assert_eq!(resolved.model.provider, "routeryo-copy");
2048        assert_eq!(resolved.provider.id(), "routeryo-copy");
2049        assert_eq!(resolved.model.base_url, "https://routeryo-copy.example.com");
2050
2051        let prefixed = resolve(None, Some("ROUTERYO-COPY/copy-model"), None, None, None).unwrap();
2052        assert_eq!(prefixed.model.provider, "routeryo-copy");
2053        assert_eq!(prefixed.model.id, "copy-model");
2054    }
2055
2056    #[test]
2057    fn cli_provider_canonicalization_keeps_builtin_and_custom_case_ids_distinct() {
2058        let _env = TestEnv::new();
2059        std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
2060        std::fs::write(
2061            config::models_path().unwrap(),
2062            r#"{
2063  "providers": {
2064    "OpenAI": {
2065      "api": "openai-completions",
2066      "baseUrl": "https://custom-openai.example.com",
2067      "apiKey": "custom-openai-key",
2068      "models": [{"id":"custom-model"}]
2069    }
2070  }
2071}"#,
2072        )
2073        .unwrap();
2074
2075        let custom = resolve(Some("OpenAI"), Some("custom-model"), None, None, None).unwrap();
2076        assert_eq!(custom.model.provider, "OpenAI");
2077        assert_eq!(custom.model.base_url, "https://custom-openai.example.com");
2078
2079        let builtin = resolve(Some("openai"), Some("gpt-6-astra"), None, None, None).unwrap();
2080        assert_eq!(builtin.model.provider, "openai");
2081
2082        let error = resolve(Some("OPENAI"), None, None, None, None).unwrap_err();
2083        assert!(matches!(
2084            error,
2085            ResolveError::AmbiguousProvider {
2086                requested,
2087                matches
2088            } if requested == "OPENAI" && matches == "OpenAI, openai"
2089        ));
2090    }
2091
2092    #[test]
2093    fn cli_api_key_overrides_only_the_selected_anthropic_provider() {
2094        let _env = TestEnv::new();
2095        std::fs::write(
2096            config::models_path().unwrap(),
2097            r#"{
2098  "providers": {
2099    "gateway": {
2100      "api": "anthropic-messages",
2101      "baseUrl": "https://gateway.example.com",
2102      "authHeader": true,
2103      "apiKey": "configured-key",
2104      "models": [{"id":"gateway-model"}]
2105    }
2106  }
2107}"#,
2108        )
2109        .unwrap();
2110
2111        let cfg = config::load_models_config().unwrap();
2112        let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
2113        assert_eq!(
2114            credential_for_selected_anthropic_provider(
2115                Some("cli-key"),
2116                &credentials,
2117                "gateway",
2118                true,
2119            ),
2120            Some(AnthropicCredential::Headers(BTreeMap::from([(
2121                "authorization".into(),
2122                "Bearer cli-key".into(),
2123            )])))
2124        );
2125        assert_eq!(
2126            anthropic_credential_for(&credentials, "gateway"),
2127            Some(&AnthropicCredential::Headers(BTreeMap::from([(
2128                "authorization".into(),
2129                "Bearer configured-key".into(),
2130            )])))
2131        );
2132
2133        let resolved = resolve(
2134            Some("gateway"),
2135            Some("gateway/gateway-model"),
2136            None,
2137            Some("cli-key"),
2138            None,
2139        )
2140        .unwrap();
2141        assert!(!resolved.has_provider_key);
2142        assert_eq!(
2143            resolved
2144                .model
2145                .headers
2146                .as_ref()
2147                .and_then(|headers| headers.get("authorization"))
2148                .map(String::as_str),
2149            Some("Bearer cli-key")
2150        );
2151    }
2152
2153    #[test]
2154    fn auth_token_routes_via_bearer_header() {
2155        let _env = TestEnv::new();
2156        std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
2157        let r = resolve(None, None, None, None, None).unwrap();
2158        // No provider key carries auth — it lives on the model header.
2159        let headers = r.model.headers.as_ref().expect("bearer header on model");
2160        assert_eq!(
2161            headers.get("authorization").map(|s| s.as_str()),
2162            Some("Bearer tok-123")
2163        );
2164        // ANTHROPIC_AUTH_TOKEN is a *global* credential (not endpoint-specific
2165        // like a models.json gateway key): the default claude-sonnet-5 is picked
2166        // (it carries the env Bearer) — NOT a gateway model.
2167        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
2168    }
2169
2170    #[test]
2171    fn api_key_flag_beats_env_and_stored() {
2172        let _env = TestEnv::new();
2173        std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
2174        config::upsert_credential(
2175            DEFAULT_PROVIDER_ID,
2176            Credential::ApiKey {
2177                key: Some("stored-key".into()),
2178                env: None,
2179            },
2180        )
2181        .unwrap();
2182        // `--api-key flag-key` wins; resolve succeeds + takes the x-api-key path
2183        // (no Bearer header on the model).
2184        let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
2185        assert!(
2186            r.model
2187                .headers
2188                .as_ref()
2189                .and_then(|h| h.get("authorization"))
2190                .is_none(),
2191            "--api-key should take the x-api-key path, not Bearer"
2192        );
2193    }
2194
2195    #[test]
2196    fn base_url_override_applies_to_model() {
2197        let _env = TestEnv::new();
2198        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2199        let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
2200        assert_eq!(r.model.base_url, "https://gw.example.com");
2201    }
2202
2203    #[test]
2204    fn base_url_env_is_fallback_for_flag() {
2205        let _env = TestEnv::new();
2206        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2207        std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
2208        let r = resolve(None, None, None, None, None).unwrap();
2209        assert_eq!(r.model.base_url, "https://env-gw.example.com");
2210    }
2211
2212    #[test]
2213    fn anthropic_base_url_env_does_not_redirect_a_custom_provider() {
2214        let _env = TestEnv::new();
2215        std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://ambient-proxy.example.com");
2216        std::fs::write(
2217            config::models_path().unwrap(),
2218            r#"{
2219  "providers": {
2220    "gateway": {
2221      "api": "anthropic-messages",
2222      "baseUrl": "https://gateway.example.com",
2223      "apiKey": "gateway-secret",
2224      "models": [{"id":"gateway-model"}]
2225    }
2226  }
2227}"#,
2228        )
2229        .unwrap();
2230
2231        let r = resolve(
2232            Some("gateway"),
2233            Some("gateway/gateway-model"),
2234            None,
2235            None,
2236            None,
2237        )
2238        .unwrap();
2239        assert_eq!(r.model.base_url, "https://gateway.example.com");
2240        assert!(r.has_provider_key);
2241    }
2242
2243    #[test]
2244    fn models_json_adds_custom_model() {
2245        let _env = TestEnv::new();
2246        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2247        std::fs::write(
2248            config::models_path().unwrap(),
2249            r#"{
2250  "providers": {
2251    "gateway": {
2252      "baseUrl": "https://gw.example.com",
2253      "authHeader": true,
2254      "apiKey": "gw-secret",
2255      "models": [
2256        { "id": "custom-claude", "name": "Custom" }
2257      ]
2258    }
2259  }
2260}"#,
2261        )
2262        .unwrap();
2263        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2264        assert_eq!(r.model.id, "custom-claude");
2265        assert_eq!(r.model.base_url, "https://gw.example.com");
2266        assert_eq!(r.model.provider, "gateway");
2267        assert_eq!(r.provider.id(), "gateway");
2268        // Provider-level authHeader folded in.
2269        let headers = r.model.headers.as_ref().expect("headers merged");
2270        assert_eq!(
2271            headers.get("authorization").map(|s| s.as_str()),
2272            Some("Bearer gw-secret")
2273        );
2274    }
2275
2276    #[test]
2277    fn resolved_providers_include_all_authenticated_runtimes() {
2278        let _env = TestEnv::new();
2279        std::fs::write(
2280            config::models_path().unwrap(),
2281            r#"{
2282  "providers": {
2283    "gateway-a": {
2284      "baseUrl": "https://a.example.com",
2285      "api": "openai-completions",
2286      "apiKey": "key-a",
2287      "models": [ { "id": "model-a" } ]
2288    },
2289    "gateway-b": {
2290      "baseUrl": "https://b.example.com",
2291      "api": "openai-completions",
2292      "apiKey": "key-b",
2293      "models": [ { "id": "model-b" } ]
2294    }
2295  }
2296}"#,
2297        )
2298        .unwrap();
2299
2300        let resolved = resolve(None, None, None, None, None).unwrap();
2301        let ids: Vec<&str> = resolved.providers.iter().map(|p| p.id()).collect();
2302        // Both runtimes are registered so the harness can hot-switch by
2303        // `model.provider`; the selected one comes first.
2304        assert_eq!(resolved.provider.id(), "gateway-a");
2305        assert_eq!(ids[0], "gateway-a");
2306        assert!(
2307            ids.contains(&"gateway-b"),
2308            "gateway-b runtime missing: {ids:?}"
2309        );
2310    }
2311
2312    #[test]
2313    fn authenticated_catalog_spans_all_configured_providers() {
2314        let _env = TestEnv::new();
2315        std::fs::write(
2316            config::models_path().unwrap(),
2317            r#"{
2318  "providers": {
2319    "gateway-a": {
2320      "baseUrl": "https://a.example.com",
2321      "api": "openai-completions",
2322      "apiKey": "key-a",
2323      "models": [ { "id": "model-a" } ]
2324    },
2325    "gateway-b": {
2326      "baseUrl": "https://b.example.com",
2327      "api": "openai-completions",
2328      "apiKey": "key-b",
2329      "models": [ { "id": "model-b" } ]
2330    }
2331  }
2332}"#,
2333        )
2334        .unwrap();
2335
2336        let resolved = resolve(None, None, None, None, None).unwrap();
2337        let catalog = authenticated_catalog(&resolved);
2338        let providers: std::collections::BTreeSet<&str> =
2339            catalog.iter().map(|m| m.provider.as_str()).collect();
2340        assert!(
2341            providers.contains("gateway-a"),
2342            "gateway-a models missing: {providers:?}"
2343        );
2344        assert!(
2345            providers.contains("gateway-b"),
2346            "gateway-b models missing: {providers:?}"
2347        );
2348        // Unauthenticated built-ins are excluded (no ANTHROPIC/OPENAI key here).
2349        assert!(!providers.contains("anthropic"));
2350        assert!(!providers.contains("openai"));
2351        // Auth headers merged at build time survive into the selector catalog.
2352        assert!(catalog.iter().all(|m| m.headers.is_some()));
2353    }
2354
2355    #[test]
2356    fn openai_completions_models_json_is_a_complete_provider_config() {
2357        let _env = TestEnv::new();
2358        std::fs::write(
2359            config::models_path().unwrap(),
2360            r#"{
2361  "providers": {
2362    "routeryo": {
2363      "baseUrl": "https://api.routeryo.com",
2364      "api": "openai-completions",
2365      "apiKey": "router-secret",
2366      "models": [
2367        {
2368          "id": "gpt-5.6-sol",
2369          "name": "GPT 5.6",
2370          "reasoning": true,
2371          "contextWindow": 200000,
2372          "maxTokens": 32768
2373        }
2374      ]
2375    }
2376  }
2377}"#,
2378        )
2379        .unwrap();
2380
2381        let resolved = resolve(None, None, None, None, None).unwrap();
2382        assert_eq!(resolved.model.id, "gpt-5.6-sol");
2383        assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2384        assert_eq!(resolved.model.provider, "routeryo");
2385        assert_eq!(resolved.provider.id(), "routeryo");
2386        assert!(resolved.has_provider_key);
2387        assert_eq!(
2388            resolved
2389                .model
2390                .headers
2391                .as_ref()
2392                .and_then(|headers| headers.get("authorization"))
2393                .map(String::as_str),
2394            Some("Bearer router-secret")
2395        );
2396
2397        let explicit = resolve(
2398            Some("routeryo"),
2399            Some("routeryo/gpt-5.6-sol"),
2400            None,
2401            None,
2402            None,
2403        )
2404        .unwrap();
2405        assert_eq!(explicit.provider.id(), "routeryo");
2406        assert_eq!(explicit.model.id, "gpt-5.6-sol");
2407    }
2408
2409    #[test]
2410    fn openai_completions_provider_alias_excludes_responses_models() {
2411        let _env = TestEnv::new();
2412        std::fs::write(
2413            config::models_path().unwrap(),
2414            r#"{
2415  "providers": {
2416    "chat-gateway": {
2417      "api": "openai-completions",
2418      "baseUrl": "https://chat.example.com/v1",
2419      "apiKey": "chat-key",
2420      "models": [{"id":"chat-model"}]
2421    },
2422    "responses-gateway": {
2423      "api": "openai-responses",
2424      "baseUrl": "https://responses.example.com/v1",
2425      "apiKey": "responses-key",
2426      "models": [{"id":"responses-model"}]
2427    }
2428  }
2429}"#,
2430        )
2431        .unwrap();
2432
2433        let resolved = resolve(Some("openai-completions"), None, None, None, None).unwrap();
2434        assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2435        assert_eq!(resolved.model.provider, "chat-gateway");
2436        assert_eq!(resolved.model.id, "chat-model");
2437    }
2438
2439    #[test]
2440    fn openai_model_prefix_disambiguates_providers_with_the_same_model_id() {
2441        let _env = TestEnv::new();
2442        std::fs::write(
2443            config::models_path().unwrap(),
2444            r#"{
2445  "providers": {
2446    "alpha": {
2447      "api": "openai-completions",
2448      "baseUrl": "https://alpha.example.com",
2449      "apiKey": "alpha-secret",
2450      "models": [{"id":"shared-model"}]
2451    },
2452    "beta": {
2453      "api": "openai-completions",
2454      "baseUrl": "https://beta.example.com",
2455      "apiKey": "beta-secret",
2456      "models": [{"id":"shared-model"}]
2457    }
2458  }
2459}"#,
2460        )
2461        .unwrap();
2462
2463        let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2464        assert_eq!(alpha.provider.id(), "alpha");
2465        assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2466        assert_eq!(
2467            alpha
2468                .model
2469                .headers
2470                .as_ref()
2471                .and_then(|headers| headers.get("authorization"))
2472                .map(String::as_str),
2473            Some("Bearer alpha-secret")
2474        );
2475
2476        let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2477        assert_eq!(beta.provider.id(), "beta");
2478        assert_eq!(beta.model.base_url, "https://beta.example.com");
2479        assert_eq!(
2480            beta.model
2481                .headers
2482                .as_ref()
2483                .and_then(|headers| headers.get("authorization"))
2484                .map(String::as_str),
2485            Some("Bearer beta-secret")
2486        );
2487    }
2488
2489    #[test]
2490    fn bare_duplicate_model_id_prefers_the_only_authenticated_provider() {
2491        let _env = TestEnv::new();
2492        std::fs::write(
2493            config::models_path().unwrap(),
2494            r#"{
2495  "providers": {
2496    "alpha": {
2497      "api": "openai-completions",
2498      "baseUrl": "https://alpha.example.com",
2499      "models": [{"id":"shared-model"}]
2500    },
2501    "beta": {
2502      "api": "openai-completions",
2503      "baseUrl": "https://beta.example.com",
2504      "apiKey": "beta-secret",
2505      "models": [{"id":"shared-model"}]
2506    }
2507  }
2508}"#,
2509        )
2510        .unwrap();
2511
2512        let resolved = resolve(None, Some("shared-model"), None, None, None).unwrap();
2513        assert_eq!(resolved.model.provider, "beta");
2514        assert_eq!(resolved.model.base_url, "https://beta.example.com");
2515    }
2516
2517    #[test]
2518    fn bare_duplicate_model_id_rejects_multiple_authenticated_providers() {
2519        let _env = TestEnv::new();
2520        std::fs::write(
2521            config::models_path().unwrap(),
2522            r#"{
2523  "providers": {
2524    "alpha": {
2525      "api": "openai-completions",
2526      "apiKey": "alpha-secret",
2527      "models": [{"id":"shared-model"}]
2528    },
2529    "beta": {
2530      "api": "openai-completions",
2531      "apiKey": "beta-secret",
2532      "models": [{"id":"shared-model"}]
2533    }
2534  }
2535}"#,
2536        )
2537        .unwrap();
2538
2539        let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2540        match error {
2541            ResolveError::AmbiguousModel {
2542                pattern,
2543                matches,
2544                auth_hint,
2545            } => {
2546                assert_eq!(pattern, "shared-model");
2547                assert_eq!(matches, "alpha/shared-model, beta/shared-model");
2548                assert_eq!(
2549                    auth_hint,
2550                    "More than one matching provider is authenticated."
2551                );
2552            }
2553            other => panic!("expected AmbiguousModel, got {other:?}"),
2554        }
2555    }
2556
2557    #[test]
2558    fn bare_duplicate_model_id_rejects_when_no_provider_is_authenticated() {
2559        let _env = TestEnv::new();
2560        std::fs::write(
2561            config::models_path().unwrap(),
2562            r#"{
2563  "providers": {
2564    "alpha": {
2565      "api": "openai-completions",
2566      "models": [{"id":"shared-model"}]
2567    },
2568    "beta": {
2569      "api": "openai-completions",
2570      "models": [{"id":"shared-model"}]
2571    }
2572  }
2573}"#,
2574        )
2575        .unwrap();
2576
2577        let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2578        assert!(matches!(
2579            error,
2580            ResolveError::AmbiguousModel {
2581                auth_hint: "No matching provider is authenticated.",
2582                ..
2583            }
2584        ));
2585    }
2586
2587    #[test]
2588    fn openai_auth_json_overrides_config_for_both_protocols() {
2589        let _env = TestEnv::new();
2590        config::upsert_credential(
2591            "chat-gateway",
2592            Credential::ApiKey {
2593                key: Some("stored-chat-key".into()),
2594                env: None,
2595            },
2596        )
2597        .unwrap();
2598        config::upsert_credential(
2599            "responses-gateway",
2600            Credential::ApiKey {
2601                key: Some("stored-responses-key".into()),
2602                env: None,
2603            },
2604        )
2605        .unwrap();
2606        std::fs::write(
2607            config::models_path().unwrap(),
2608            r#"{
2609  "providers": {
2610    "chat-gateway": {
2611      "api": "openai-completions",
2612      "baseUrl": "https://chat.example.com/v1",
2613      "apiKey": "configured-chat-key",
2614      "models": [{"id":"chat-model"}]
2615    },
2616    "responses-gateway": {
2617      "api": "openai-responses",
2618      "baseUrl": "https://responses.example.com/v1",
2619      "apiKey": "configured-responses-key",
2620      "models": [{"id":"responses-model"}]
2621    }
2622  }
2623}"#,
2624        )
2625        .unwrap();
2626
2627        let chat = resolve(
2628            Some("chat-gateway"),
2629            Some("chat-gateway/chat-model"),
2630            None,
2631            None,
2632            None,
2633        )
2634        .unwrap();
2635        assert!(chat.has_provider_key);
2636        assert_eq!(
2637            chat.model
2638                .headers
2639                .as_ref()
2640                .and_then(|headers| headers.get("authorization"))
2641                .map(String::as_str),
2642            Some("Bearer stored-chat-key")
2643        );
2644
2645        let responses = resolve(
2646            Some("responses-gateway"),
2647            Some("responses-gateway/responses-model"),
2648            None,
2649            None,
2650            None,
2651        )
2652        .unwrap();
2653        assert!(responses.has_provider_key);
2654        assert_eq!(
2655            responses
2656                .model
2657                .headers
2658                .as_ref()
2659                .and_then(|headers| headers.get("authorization"))
2660                .map(String::as_str),
2661            Some("Bearer stored-responses-key")
2662        );
2663    }
2664
2665    #[test]
2666    fn openai_env_does_not_authenticate_custom_providers() {
2667        let _env = TestEnv::new();
2668        std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
2669        std::fs::write(
2670            config::models_path().unwrap(),
2671            r#"{
2672  "providers": {
2673    "chat-gateway": {
2674      "api": "openai-completions",
2675      "baseUrl": "https://chat.example.com/v1",
2676      "models": [{"id":"chat-model"}]
2677    },
2678    "responses-gateway": {
2679      "api": "openai-responses",
2680      "baseUrl": "https://responses.example.com/v1",
2681      "models": [{"id":"responses-model"}]
2682    },
2683    "openai-completions": {
2684      "api": "openai-completions",
2685      "baseUrl": "https://alias-chat.example.com/v1",
2686      "models": [{"id":"alias-chat-model"}]
2687    },
2688    "openai-responses": {
2689      "api": "openai-responses",
2690      "baseUrl": "https://alias-responses.example.com/v1",
2691      "models": [{"id":"alias-responses-model"}]
2692    },
2693    "OpenAI": {
2694      "api": "openai-responses",
2695      "baseUrl": "https://case-distinct.example.com/v1",
2696      "models": [{"id":"case-distinct-model"}]
2697    }
2698  }
2699}"#,
2700        )
2701        .unwrap();
2702
2703        for (provider, model) in [
2704            ("chat-gateway", "chat-gateway/chat-model"),
2705            ("responses-gateway", "responses-gateway/responses-model"),
2706            ("openai-completions", "openai-completions/alias-chat-model"),
2707            ("openai-responses", "openai-responses/alias-responses-model"),
2708            ("OpenAI", "OpenAI/case-distinct-model"),
2709        ] {
2710            let error = resolve(Some(provider), Some(model), None, None, None).unwrap_err();
2711            assert!(matches!(error, ResolveError::NoApiKey { .. }));
2712        }
2713
2714        let official = resolve(None, None, None, None, None).unwrap();
2715        assert_eq!(official.provider.id(), "openai");
2716        assert_eq!(official.model.id, "gpt-6-astra");
2717        assert!(official.has_provider_key);
2718    }
2719
2720    #[test]
2721    fn cli_api_key_overrides_selected_openai_provider() {
2722        let _env = TestEnv::new();
2723        std::fs::write(
2724            config::models_path().unwrap(),
2725            r#"{
2726  "providers": {
2727    "chat-gateway": {
2728      "api": "openai-completions",
2729      "baseUrl": "https://chat.example.com/v1",
2730      "apiKey": "configured-chat-key",
2731      "models": [{"id":"chat-model"}]
2732    },
2733    "responses-gateway": {
2734      "api": "openai-responses",
2735      "baseUrl": "https://responses.example.com/v1",
2736      "apiKey": "configured-responses-key",
2737      "models": [{"id":"responses-model"}]
2738    }
2739  }
2740}"#,
2741        )
2742        .unwrap();
2743
2744        for (provider, model) in [
2745            ("chat-gateway", "chat-gateway/chat-model"),
2746            ("responses-gateway", "responses-gateway/responses-model"),
2747        ] {
2748            let resolved =
2749                resolve(Some(provider), Some(model), None, Some("cli-key"), None).unwrap();
2750            assert_eq!(resolved.provider.id(), provider);
2751            assert!(resolved.has_provider_key);
2752            assert_eq!(
2753                resolved
2754                    .model
2755                    .headers
2756                    .as_ref()
2757                    .and_then(|headers| headers.get("authorization"))
2758                    .map(String::as_str),
2759                Some("Bearer cli-key")
2760            );
2761        }
2762    }
2763
2764    #[test]
2765    fn anthropic_model_prefix_disambiguates_providers_with_the_same_model_id() {
2766        let _env = TestEnv::new();
2767        std::fs::write(
2768            config::models_path().unwrap(),
2769            r#"{
2770  "providers": {
2771    "alpha": {
2772      "api": "anthropic-messages",
2773      "baseUrl": "https://alpha.example.com",
2774      "apiKey": "alpha-secret",
2775      "models": [{"id":"shared-model"}]
2776    },
2777    "beta": {
2778      "api": "anthropic-messages",
2779      "baseUrl": "https://beta.example.com",
2780      "authHeader": true,
2781      "apiKey": "beta-secret",
2782      "models": [{"id":"shared-model"}]
2783    }
2784  }
2785}"#,
2786        )
2787        .unwrap();
2788
2789        let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2790        assert_eq!(alpha.provider.id(), "alpha");
2791        assert_eq!(alpha.model.provider, "alpha");
2792        assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2793        assert!(alpha.has_provider_key);
2794        assert!(alpha.model.headers.as_ref().map_or(true, |headers| {
2795            headers
2796                .keys()
2797                .all(|name| !name.eq_ignore_ascii_case("x-api-key"))
2798        }));
2799
2800        let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2801        assert_eq!(beta.provider.id(), "beta");
2802        assert_eq!(beta.model.provider, "beta");
2803        assert_eq!(beta.model.base_url, "https://beta.example.com");
2804        assert_eq!(
2805            beta.model
2806                .headers
2807                .as_ref()
2808                .and_then(|headers| headers.get("authorization"))
2809                .map(String::as_str),
2810            Some("Bearer beta-secret")
2811        );
2812    }
2813
2814    #[test]
2815    fn trusted_project_defaults_override_global_defaults_for_resolution() {
2816        let _env = TestEnv::new();
2817        std::fs::write(
2818            config::models_path().unwrap(),
2819            r#"{
2820  "providers": {
2821    "global": {
2822      "api": "anthropic-messages",
2823      "apiKey": "global-secret",
2824      "models": [{"id":"global-model"}]
2825    },
2826    "project": {
2827      "api": "anthropic-messages",
2828      "apiKey": "project-secret",
2829      "models": [{"id":"project-model"}]
2830    }
2831  }
2832}"#,
2833        )
2834        .unwrap();
2835        std::fs::write(
2836            config::settings_path().unwrap(),
2837            r#"{"defaultProvider":"global","defaultModel":"global-model"}"#,
2838        )
2839        .unwrap();
2840        let project = tempfile::tempdir().unwrap();
2841        std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
2842        std::fs::write(
2843            project.path().join(".rpi/settings.json"),
2844            r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
2845        )
2846        .unwrap();
2847
2848        let trusted = resolve_for_cwd(None, None, None, None, None, project.path(), true).unwrap();
2849        assert_eq!(trusted.provider.id(), "project");
2850        assert_eq!(trusted.model.id, "project-model");
2851
2852        let untrusted =
2853            resolve_for_cwd(None, None, None, None, None, project.path(), false).unwrap();
2854        assert_eq!(untrusted.provider.id(), "global");
2855        assert_eq!(untrusted.model.id, "global-model");
2856    }
2857
2858    #[test]
2859    fn unknown_model_prefix_without_a_raw_match_returns_no_match() {
2860        let _env = TestEnv::new();
2861        std::fs::write(
2862            config::models_path().unwrap(),
2863            r#"{
2864  "providers": {
2865    "routeryo": {
2866      "api": "openai-completions",
2867      "apiKey": "secret",
2868      "models": [{"id":"gpt-test"}]
2869    }
2870  }
2871}"#,
2872        )
2873        .unwrap();
2874
2875        let error = resolve(None, Some("misspelled/gpt-test"), None, None, None).unwrap_err();
2876        assert!(matches!(
2877            error,
2878            ResolveError::NoMatch { pattern, .. } if pattern == "misspelled/gpt-test"
2879        ));
2880    }
2881
2882    /// A models.json gateway with `authHeader:true` + `apiKey` is itself an auth
2883    /// source — it satisfies the `resolve` auth gate WITHOUT any env var, stored
2884    /// cred, or `--api-key`. This is the "models.json file alone sets up a
2885    /// third-party endpoint" path. The Bearer folds onto the gateway model only
2886    /// (built-in claude-* stays Bearer-less), and — with no `--model` — the
2887    /// default selector picks that gateway model (the only authed one).
2888    #[test]
2889    fn models_json_auth_header_satisfies_auth_without_env() {
2890        let _env = TestEnv::new();
2891        // No ANTHROPIC_* env, no auth.json — only the models.json gateway.
2892        std::fs::write(
2893            config::models_path().unwrap(),
2894            r#"{
2895  "providers": {
2896    "gateway": {
2897      "baseUrl": "https://gw.example.com",
2898      "api": "anthropic-messages",
2899      "authHeader": true,
2900      "apiKey": "gw-secret",
2901      "models": [
2902        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2903      ]
2904    }
2905  }
2906}"#,
2907        )
2908        .unwrap();
2909        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2910        assert_eq!(r.model.id, "custom-claude");
2911        assert_eq!(r.model.base_url, "https://gw.example.com");
2912        let headers = r.model.headers.as_ref().expect("bearer folded onto model");
2913        assert_eq!(
2914            headers.get("authorization").map(|s| s.as_str()),
2915            Some("Bearer gw-secret")
2916        );
2917    }
2918
2919    /// The `--api-key` flag wins over a models.json `authHeader:true` gateway
2920    /// key while preserving the provider's Bearer transport contract.
2921    /// A `models.json`-only gateway config (no `--model`, no env, no auth.json)
2922    /// should pick the gateway model by default — mirroring the TS
2923    /// `findInitialModel` step-4 fallback `availableModels[0]` over the
2924    /// auth-filtered snapshot. The built-in Anthropic models carry no auth in a
2925    /// gateway-only setup, so the gateway model is the first (and only)
2926    /// authenticated model. This is the `rpi -p hi` (no `--model`) case.
2927    #[test]
2928    fn default_prefers_gateway_when_only_gateway_configured() {
2929        // TestEnv already holds the shared env_lock for its whole lifetime —
2930        // don't take it again here (would self-deadlock and poison the mutex).
2931        let _env = TestEnv::new();
2932        std::fs::write(
2933            config::models_path().unwrap(),
2934            r#"{
2935  "providers": {
2936    "gateway": {
2937      "baseUrl": "https://gw.example.com",
2938      "api": "anthropic-messages",
2939      "authHeader": true,
2940      "apiKey": "gw-secret",
2941      "models": [
2942        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2943      ]
2944    }
2945  }
2946}"#,
2947        )
2948        .unwrap();
2949        // No --model (None): the default selector must pick the gateway model,
2950        // NOT the built-in claude-sonnet-5 (which would carry a foreign Bearer
2951        // to api.anthropic.com → 401, the bug this fixes).
2952        let r = resolve(None, None, None, None, None).unwrap();
2953        assert_eq!(r.model.id, "custom-claude");
2954        assert_eq!(r.model.base_url, "https://gw.example.com");
2955        // Gateway model carries the folded Bearer.
2956        let headers = r.model.headers.as_ref().expect("bearer on gateway model");
2957        assert_eq!(
2958            headers.get("authorization").map(|s| s.as_str()),
2959            Some("Bearer gw-secret")
2960        );
2961    }
2962
2963    #[test]
2964    fn api_key_flag_honors_models_json_auth_header() {
2965        let _env = TestEnv::new();
2966        std::fs::write(
2967            config::models_path().unwrap(),
2968            r#"{
2969  "providers": {
2970    "gateway": {
2971      "baseUrl": "https://gw.example.com",
2972      "authHeader": true,
2973      "apiKey": "gw-secret",
2974      "models": [ { "id": "custom-claude" } ]
2975    }
2976  }
2977}"#,
2978        )
2979        .unwrap();
2980        let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
2981        assert_eq!(
2982            r.model
2983                .headers
2984                .as_ref()
2985                .and_then(|h| h.get("authorization"))
2986                .map(String::as_str),
2987            Some("Bearer flag-key")
2988        );
2989        assert!(!r.has_provider_key);
2990    }
2991
2992    /// A models.json gateway with a **bare** `apiKey` (no `authHeader`) is the
2993    /// `composeApiKeyAuth` arm — it satisfies the `resolve` auth gate WITHOUT
2994    /// any env var, stored cred, or `--api-key`, routing the resolved key as
2995    /// the selected provider's default `x-api-key`. It is not copied into model
2996    /// headers, so a different provider can never inherit it. This is the
2997    /// default copied-pi `models.json` shape.
2998    #[test]
2999    fn models_json_bare_apikey_satisfies_auth_without_env() {
3000        let _env = TestEnv::new();
3001        // No ANTHROPIC_* env, no auth.json — only the bare-apiKey models.json gateway.
3002        std::fs::write(
3003            config::models_path().unwrap(),
3004            r#"{
3005  "providers": {
3006    "gateway": {
3007      "baseUrl": "https://gw.example.com",
3008      "api": "anthropic-messages",
3009      "apiKey": "gw-secret",
3010      "models": [
3011        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3012      ]
3013    }
3014  }
3015}"#,
3016        )
3017        .unwrap();
3018        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
3019        assert_eq!(r.model.id, "custom-claude");
3020        assert_eq!(r.model.base_url, "https://gw.example.com");
3021        assert!(r.has_provider_key);
3022        assert!(r.model.headers.as_ref().map_or(true, |headers| {
3023            headers.keys().all(|name| {
3024                !name.eq_ignore_ascii_case("x-api-key")
3025                    && !name.eq_ignore_ascii_case("authorization")
3026            })
3027        }));
3028    }
3029
3030    /// The bare-`apiKey` x-api-key fold is endpoint-specific: with no `--model`,
3031    /// the default selector must pick the gateway model (the only authed one),
3032    // NOT the built-in claude-sonnet-5 — which would carry a gateway x-api-key to
3033    // api.anthropic.com → 401, the same misrouting the Bearer fold guards
3034    // against. This is the `rpi -p hi` (no `--model`) case for a bare-apiKey
3035    /// gateway.
3036    #[test]
3037    fn default_prefers_gateway_when_only_bare_apikey_configured() {
3038        let _env = TestEnv::new();
3039        std::fs::write(
3040            config::models_path().unwrap(),
3041            r#"{
3042  "providers": {
3043    "gateway": {
3044      "baseUrl": "https://gw.example.com",
3045      "api": "anthropic-messages",
3046      "apiKey": "gw-secret",
3047      "models": [
3048        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3049      ]
3050    }
3051  }
3052}"#,
3053        )
3054        .unwrap();
3055        // No --model (None): the default selector must pick the gateway model.
3056        let r = resolve(None, None, None, None, None).unwrap();
3057        assert_eq!(r.model.id, "custom-claude");
3058        assert_eq!(r.model.base_url, "https://gw.example.com");
3059        assert!(r.has_provider_key);
3060    }
3061
3062    /// A bare `apiKey` that references an unset env var resolves to `None` and
3063    /// is skipped (mirrors pi `resolveConfigValue` semantics) — the auth gate
3064    /// falls through to the env/`rpi auth login` sources rather than partially
3065    /// authenticating with an empty key.
3066    #[test]
3067    fn models_json_bare_apikey_env_template_resolves() {
3068        let _env = TestEnv::new();
3069        // Prime the env var the apiKey references.
3070        std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
3071        std::fs::write(
3072            config::models_path().unwrap(),
3073            r#"{
3074  "providers": {
3075    "gateway": {
3076      "baseUrl": "https://gw.example.com",
3077      "api": "anthropic-messages",
3078      "apiKey": "$RPI_TEST_GATEWAY_KEY",
3079      "models": [
3080        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3081      ]
3082    }
3083  }
3084}"#,
3085        )
3086        .unwrap();
3087        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
3088        assert!(r.has_provider_key);
3089        let cfg = config::load_models_config().unwrap();
3090        let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
3091        assert_eq!(
3092            anthropic_credential_for(&credentials, "gateway"),
3093            Some(&AnthropicCredential::ProviderKey(
3094                "env-resolved-secret".into()
3095            ))
3096        );
3097        std::env::remove_var("RPI_TEST_GATEWAY_KEY");
3098    }
3099
3100    /// `authHeader: true` takes precedence over a bare `apiKey` on the SAME or a
3101    /// later provider: the Bearer step (3a) runs before the bare-apiKey step
3102    /// A models.json with BOTH auth shapes — `authHeader:true` and bare
3103    /// `apiKey` — routes each provider's credential onto ITS OWN models
3104    /// (per-provider fold, mirroring upstream `composeApiKeyAuth`): the
3105    /// authHeader provider's key becomes `Authorization: Bearer` on its model,
3106    /// the bare-apiKey provider's key becomes `x-api-key` on its model. A
3107    /// copied pi models.json mixing both shapes works end-to-end — no model
3108    /// ends up unauthenticated because another provider "won" the gate.
3109    #[test]
3110    fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
3111        let _env = TestEnv::new();
3112        std::fs::write(
3113            config::models_path().unwrap(),
3114            r#"{
3115  "providers": {
3116    "bearer-gw": {
3117      "baseUrl": "https://bearer.example.com",
3118      "api": "anthropic-messages",
3119      "authHeader": true,
3120      "apiKey": "bearer-secret",
3121      "models": [ { "id": "bearer-model" } ]
3122    },
3123    "xkey-gw": {
3124      "baseUrl": "https://xkey.example.com",
3125      "api": "anthropic-messages",
3126      "apiKey": "xkey-secret",
3127      "models": [ { "id": "xkey-model" } ]
3128    }
3129  }
3130}"#,
3131        )
3132        .unwrap();
3133        // Both providers satisfy the auth gate together (no env / stored cred
3134        // needed); the default selector picks the first authed model.
3135        let r = resolve(None, None, None, None, None).unwrap();
3136        assert_eq!(r.model.id, "bearer-model");
3137
3138        // bearer-gw's key folds as Bearer onto bearer-model only.
3139        let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
3140        let h = r.model.headers.as_ref().expect("bearer folded");
3141        assert_eq!(
3142            h.get("authorization").map(|s| s.as_str()),
3143            Some("Bearer bearer-secret")
3144        );
3145        assert!(
3146            h.get("x-api-key").is_none(),
3147            "authHeader path must not synthesize x-api-key"
3148        );
3149
3150        // xkey-gw's bare apiKey becomes only that provider's default key.
3151        let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
3152        assert!(r2.has_provider_key);
3153        assert!(r2.model.headers.as_ref().map_or(true, |headers| {
3154            headers.keys().all(|name| {
3155                !name.eq_ignore_ascii_case("x-api-key")
3156                    && !name.eq_ignore_ascii_case("authorization")
3157            })
3158        }));
3159
3160        // The active provider exposes only its own catalog. Selecting the
3161        // other provider explicitly creates a separately keyed provider.
3162        let catalog = available_catalog(&r);
3163        let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
3164        assert_eq!(ids, vec!["bearer-model"]);
3165        let other_catalog = available_catalog(&r2);
3166        let other_ids: Vec<&str> = other_catalog.iter().map(|m| m.id.as_str()).collect();
3167        assert_eq!(other_ids, vec!["xkey-model"]);
3168    }
3169
3170    /// A copied pi settings.json whose `defaultProvider` names a **models.json
3171    /// gateway** (not "anthropic") must still honor the saved `defaultModel` —
3172    /// pi's `findInitialModel` step-3 applies `defaultModelPerProvider`
3173    /// regardless of provider id. Without this, enabling a second gateway
3174    /// flips the no-`--model` default to the FIRST authed model in catalog
3175    /// order, not the user's saved choice.
3176    #[test]
3177    fn settings_default_model_honored_for_models_json_provider() {
3178        let _env = TestEnv::new();
3179        std::fs::write(
3180            config::models_path().unwrap(),
3181            r#"{
3182  "providers": {
3183    "beta-gw": {
3184      "baseUrl": "https://beta.example.com",
3185      "api": "anthropic-messages",
3186      "apiKey": "beta-secret",
3187      "models": [ { "id": "beta-model" } ]
3188    },
3189    "alpha-gw": {
3190      "baseUrl": "https://alpha.example.com",
3191      "api": "anthropic-messages",
3192      "apiKey": "alpha-secret",
3193      "models": [ { "id": "alpha-model" } ]
3194    }
3195  }
3196}"#,
3197        )
3198        .unwrap();
3199        // Saved default points at the ALPHA gateway's model even though
3200        // "beta-gw" is declared first and would win first-authed without the
3201        // settings arm, matching upstream's Object.entries order.
3202        std::fs::write(
3203            config::settings_path().unwrap(),
3204            r#"{"defaultProvider":"alpha-gw","defaultModel":"alpha-model"}"#,
3205        )
3206        .unwrap();
3207        let r = resolve(None, None, None, None, None).unwrap();
3208        assert_eq!(r.model.id, "alpha-model");
3209        // An unknown provider id falls through to first-authed (beta-gw).
3210        std::fs::write(
3211            config::settings_path().unwrap(),
3212            r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
3213        )
3214        .unwrap();
3215        let r = resolve(None, None, None, None, None).unwrap();
3216        assert_eq!(r.model.id, "beta-model");
3217    }
3218
3219    #[test]
3220    fn models_json_fallback_preserves_provider_and_model_declaration_order() {
3221        let _env = TestEnv::new();
3222        std::fs::write(
3223            config::models_path().unwrap(),
3224            r#"{
3225  "providers": {
3226    "routeryo-copy": {
3227      "api": "openai-completions",
3228      "baseUrl": "https://router.example.com/v1",
3229      "apiKey": "router-key",
3230      "models": [
3231        { "id": "gpt-5.6-sol" },
3232        { "id": "gpt-5.6-terra" }
3233      ]
3234    },
3235    "alpha-gw": {
3236      "api": "openai-completions",
3237      "baseUrl": "https://alpha.example.com/v1",
3238      "apiKey": "alpha-key",
3239      "models": [ { "id": "alpha-model" } ]
3240    }
3241  }
3242}"#,
3243        )
3244        .unwrap();
3245
3246        let resolved = resolve(None, None, None, None, None).unwrap();
3247        assert_eq!(resolved.model.provider, "routeryo-copy");
3248        assert_eq!(resolved.model.id, "gpt-5.6-sol");
3249    }
3250
3251    #[test]
3252    fn native_known_provider_default_beats_first_model_in_array() {
3253        let _env = TestEnv::new();
3254        std::fs::write(
3255            config::models_path().unwrap(),
3256            r#"{
3257  "providers": {
3258    "deepseek": {
3259      "api": "openai-completions",
3260      "baseUrl": "https://api.deepseek.com",
3261      "apiKey": "deepseek-key",
3262      "models": [
3263        { "id": "deepseek-chat" },
3264        { "id": "deepseek-v4-pro" }
3265      ]
3266    }
3267  }
3268}"#,
3269        )
3270        .unwrap();
3271
3272        let resolved = resolve(None, None, None, None, None).unwrap();
3273        assert_eq!(resolved.model.provider, "deepseek");
3274        assert_eq!(resolved.model.id, "deepseek-v4-pro");
3275    }
3276
3277    /// The `/model` selector catalog (`available_catalog`) is auth-filtered —
3278    /// it must NOT offer built-in claude-* models that carry no auth headers in
3279    /// a gateway-only setup (selecting one would fail at request time with
3280    /// "No API key for provider: anthropic"). Mirrors pi's
3281    /// `getAvailableSnapshot` filter (`available = all.filter(m =>
3282    /// configuredProviders.has(m.provider))`): only the gateway model is
3283    /// loadable, so only it appears in the selector / Ctrl+P cycle.
3284    #[test]
3285    fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
3286        let _env = TestEnv::new();
3287        std::fs::write(
3288            config::models_path().unwrap(),
3289            r#"{
3290  "providers": {
3291    "gateway": {
3292      "baseUrl": "https://gw.example.com",
3293      "api": "anthropic-messages",
3294      "apiKey": "gw-secret",
3295      "models": [
3296        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3297      ]
3298    }
3299  }
3300}"#,
3301        )
3302        .unwrap();
3303        let r = resolve(None, None, None, None, None).unwrap();
3304        // A bare models.json key is carried by this provider, not model headers.
3305        assert!(r.has_provider_key);
3306        let catalog = available_catalog(&r);
3307        // Exactly one loadable model: the gateway one. The 7 built-in Anthropic
3308        // models are filtered out.
3309        let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
3310        assert_eq!(
3311            ids,
3312            vec!["custom-claude"],
3313            "selector must only list authed models"
3314        );
3315        // The runtime provider and selector share the same provider-isolated
3316        // catalog, so models from another identity cannot be routed through it.
3317        assert_eq!(r.provider.models().len(), catalog.len());
3318    }
3319
3320    /// On the x-api-key path (`--api-key`/auth.json/`ANTHROPIC_API_KEY`), the
3321    /// provider's default key attaches to EVERY model out-of-band — so the
3322    /// catalog filter keeps the full list (all models are loadable).
3323    #[test]
3324    fn available_catalog_keeps_all_models_on_provider_key_path() {
3325        let _env = TestEnv::new();
3326        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
3327        let r = resolve(None, None, None, None, None).unwrap();
3328        assert!(r.has_provider_key);
3329        let catalog = available_catalog(&r);
3330        assert_eq!(catalog.len(), r.provider.models().len());
3331        assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
3332    }
3333}