Skip to main content

rpi_cli/
provider.rs

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