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