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