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