Skip to main content

rpi_cli/
provider.rs

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