Skip to main content

rpi_cli/
provider.rs

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