Skip to main content

rpi_runtime/
provider.rs

1//! Model catalog merging, provider-scoped selection and private execution binding.
2//!
3//! Hosts supply all configuration and environment access through RuntimeConfig
4//! and ValueSource. Credential precedence, exact model matching, saved defaults
5//! and provider/API routing are shared by embedded apps and the CLI adapter.
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use rpi_ai::providers::anthropic::models::anthropic_models;
11use rpi_ai::providers::anthropic::AnthropicProvider;
12use rpi_ai::providers::openai_completions::OpenAiCompletionsProvider;
13use rpi_ai::providers::openai_responses::openai_responses_models;
14use rpi_ai::providers::openai_responses::OpenAiResponsesProvider;
15use rpi_ai::{
16    AssistantMessageEventStream, Context, Model, Provider, SimpleStreamOptions, ThinkingLevel,
17};
18
19mod binding;
20mod factories;
21use binding::{ExecutionBinding, RequestAuth};
22pub use factories::RuntimeFactories;
23
24#[cfg(test)]
25use crate::config::Credential;
26use crate::config::{self, DEFAULT_PROVIDER_ID};
27use crate::thinking::parse_thinking_level;
28use crate::{RuntimeSettings, ValueSource};
29
30/// The default Anthropic model when `--model` is absent. Kept in sync with the
31/// current upstream `defaultModelPerProvider.anthropic` entry.
32pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
33
34/// Core provider defaults, checked before the first authenticated catalog entry.
35/// Other providers own their default catalog ordering through their package.
36const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] =
37    &[("anthropic", DEFAULT_MODEL_ID), ("openai", "gpt-5.5")];
38
39/// The default thinking level when neither `--thinking` nor a `:level` suffix
40/// is present. Mirrors the TS `DEFAULT_THINKING_LEVEL` (`"medium"`, clamped to
41/// model capabilities by the harness's provider build_params).
42pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
43
44/// The resolved run configuration: the provider handle, the chosen model, and
45/// the effective thinking level (after `--thinking` / `:level` / model-clamp).
46#[derive(Clone)]
47pub struct ResolvedModel {
48    /// The selected provider. Cheap to clone through the trait-object `Arc`.
49    pub provider: Arc<dyn Provider>,
50    /// The chosen model from the catalog.
51    pub model: Model,
52    /// Effective thinking level (the requested level, before model-clamp — the
53    /// harness/provider clamps to the model's supported set).
54    pub thinking_level: ThinkingLevel,
55    /// Whether the selected runtime is configured with a provider-default key
56    /// or an explicit anonymous policy. Otherwise auth belongs to model headers.
57    ///
58    /// Retained for diagnostics and API compatibility. Published catalogs are
59    /// already filtered when their execution bindings are constructed.
60    pub has_provider_key: bool,
61    /// Saved theme name from `~/.rpi/agent/settings.json`, if any. Best-effort:
62    /// the TUI applies it at startup when it matches a known preset
63    /// (dark/light/monochrome); otherwise ignored.
64    pub theme: Option<String>,
65    /// Every authenticated provider runtime, with the selected provider first.
66    /// The harness resolves a provider lazily by `model.provider`, so this lets
67    /// `/model` hot-switch across providers without a restart.
68    pub providers: Vec<Arc<dyn Provider>>,
69    /// Compatibility diagnostics for the caller to display through its UI.
70    /// Resolution and catalog queries never write directly to the terminal.
71    pub compat_warnings: Vec<String>,
72    /// Private state shared with the selected configured provider.
73    execution: Arc<ExecutionBinding>,
74    pub(crate) snapshot_catalog: Vec<Model>,
75    pub(crate) snapshot_auth: BTreeMap<String, crate::credentials::AuthState>,
76}
77
78impl std::fmt::Debug for ResolvedModel {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("ResolvedModel")
81            .field("provider", &self.provider.id())
82            .field("model", &self.model.id)
83            .field("thinking_level", &self.thinking_level)
84            .field("has_provider_key", &self.has_provider_key)
85            .field("theme", &self.theme)
86            .finish()
87    }
88}
89
90/// The env var consulted for the API key. Mirrors TS `ANTHROPIC_API_KEY`.
91pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
92
93/// The env var consulted for a bearer token (routed as
94/// `Authorization: Bearer`). Mirrors TS `ANTHROPIC_AUTH_TOKEN` — used by
95/// third-party Anthropic-compatible gateways (one-api/new-api/claude-code-router
96/// and private reverse proxies) that authenticate via `Authorization` rather
97/// than `x-api-key`.
98pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
99
100/// The env var that overrides the Anthropic endpoint base URL. Mirrors TS
101/// `ANTHROPIC_BASE_URL` — point this at a gateway/proxy that speaks the
102/// `/v1/messages` protocol.
103pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
104
105/// Standard OpenAI API-key environment variable used by the
106/// `openai-completions` provider.
107pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
108
109/// Hint text surfaced when no credential source is available. Lists every
110/// accepted source so the user can pick the one that fits their setup.
111pub const NO_API_KEY_HINT: &str =
112    "models.json apiKey, OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login`";
113
114/// A resolution error. The TS resolver returns `{ error, warning }`; v1 folds
115/// both into a single enum since the CLI treats them the same (print + non-zero
116/// exit) except `NoApiKey`, which prints guidance then exits.
117#[derive(Debug, thiserror::Error)]
118pub enum ResolveError {
119    #[error("Invalid provider HTTP/proxy configuration")]
120    HttpConfiguration,
121    #[error("No implementation registered for provider {provider:?} and API {api}")]
122    UnsupportedApi { provider: String, api: String },
123    #[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, openai-responses, a models.json provider id, or an installed provider package")]
124    UnknownProvider(String),
125    #[error(
126        "Provider \"{requested}\" is ambiguous: {matches}. Use the exact provider id to select one."
127    )]
128    AmbiguousProvider { requested: String, matches: String },
129    #[error("No model matches \"{pattern}\". Available: {available}")]
130    NoMatch { pattern: String, available: String },
131    #[error(
132        "Model \"{pattern}\" is ambiguous across providers: {matches}. {auth_hint} Use --provider or provider/model."
133    )]
134    AmbiguousModel {
135        pattern: String,
136        matches: String,
137        auth_hint: &'static str,
138    },
139    #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
140    InvalidThinkingLevel(String, String),
141    #[error("No API key. Set one of: {hint}")]
142    NoApiKey { hint: &'static str },
143    #[error("Could not read config: {0}")]
144    Config(#[from] config::ConfigError),
145}
146
147/// Validate configured endpoints and build a client whose proxy routing uses
148/// each request URL, including NO_PROXY and scheme-specific proxy settings.
149fn provider_http_client(
150    models: &[Model],
151    default_base_url: &str,
152    settings: &RuntimeSettings,
153    values: &dyn ValueSource,
154) -> Result<reqwest::Client, ResolveError> {
155    // Explicit entries prevent proxy lookup from falling back to process env.
156    // Disable reqwest's own implicit system proxy discovery as well.
157    let env: std::collections::HashMap<String, String> = [
158        "http_proxy",
159        "https_proxy",
160        "all_proxy",
161        "no_proxy",
162        "HTTP_PROXY",
163        "HTTPS_PROXY",
164        "ALL_PROXY",
165        "NO_PROXY",
166    ]
167    .into_iter()
168    .map(|name| {
169        (
170            name.into(),
171            values
172                .env(name)
173                .or_else(|| values.env(&name.to_ascii_uppercase()))
174                .unwrap_or_default(),
175        )
176    })
177    .collect();
178    let builder = reqwest::Client::builder().no_proxy();
179    // A redirect can leave the configured NO_PROXY hosts. Validate both proxy
180    // routes independently of those hosts, unless every URL is explicitly
181    // bypassed. Callback errors must not silently become direct connections.
182    let bypass_all = env["no_proxy"]
183        .split([',', ' ', '\t', '\n'])
184        .any(|host| host == "*");
185    if !bypass_all {
186        let mut policy = env.clone();
187        policy.insert("no_proxy".into(), String::new());
188        for target in [
189            "http://proxy-validation.invalid",
190            "https://proxy-validation.invalid",
191        ] {
192            rpi_ai::http::resolve_http_proxy_url_for_target(target, Some(&policy))
193                .map_err(|_| ResolveError::HttpConfiguration)?;
194        }
195    }
196    // Validate endpoints before creating a usable provider. Error text must
197    // never include proxy URLs, which can contain usernames and passwords.
198    let targets: Vec<_> = if models.is_empty() {
199        vec![default_base_url]
200    } else {
201        models
202            .iter()
203            .map(|model| {
204                if model.base_url.is_empty() {
205                    default_base_url
206                } else {
207                    model.base_url.as_str()
208                }
209            })
210            .collect()
211    };
212    for target in targets {
213        rpi_ai::http::resolve_http_proxy_url_for_target(target, Some(&env))
214            .map_err(|_| ResolveError::HttpConfiguration)?;
215    }
216    let mut builder = builder.proxy(reqwest::Proxy::custom(move |url| {
217        rpi_ai::http::resolve_http_proxy_url_for_target(url.as_str(), Some(&env))
218            .ok()
219            .flatten()
220    }));
221    let timeout = settings
222        .http_idle_timeout_ms
223        .unwrap_or(rpi_ai::http::DEFAULT_HTTP_IDLE_TIMEOUT_MS);
224    builder = builder.pool_idle_timeout(if timeout > 0 {
225        Some(std::time::Duration::from_millis(timeout as u64))
226    } else {
227        None
228    });
229    builder.build().map_err(|_| ResolveError::HttpConfiguration)
230}
231
232pub(crate) fn resolve_with_factories(
233    request: &crate::ModelRequest<'_>,
234    inputs: &crate::RuntimeConfig,
235    factories: &RuntimeFactories,
236    values: &dyn ValueSource,
237) -> Result<ResolvedModel, ResolveError> {
238    let resolution_values = crate::values::ResolutionValues::new(values);
239    let values: &dyn ValueSource = &resolution_values;
240    let cli_provider = request.provider;
241    let cli_model = request.model;
242    let cli_thinking = request.thinking;
243    let cli_api_key = request.api_key;
244    let cli_base_url = request.base_url;
245    let settings = &inputs.settings;
246    let mut models_cfg = inputs.models.clone();
247    let auth_store = &inputs.credentials;
248    // Load models/auth once, then resolve Anthropic-wire credentials per
249    // provider id. A key belonging to `anthropic` must never make an unrelated
250    // gateway available or become that gateway's provider default.
251    apply_manifest_defaults(&mut models_cfg, &factories.manifests);
252    let compat_warnings = config::model_compat_warnings(&models_cfg);
253    let canonical_cli_provider = cli_provider
254        .map(|requested| canonicalize_cli_provider(requested, &models_cfg))
255        .transpose()?;
256    let cli_provider = canonical_cli_provider.as_deref();
257    let refreshable = |provider: &str| factories.refreshes_oauth(provider);
258    let anthropic_credentials = resolve_anthropic_credentials_with(
259        &models_cfg,
260        auth_store,
261        &factories.manifests,
262        values,
263        &refreshable,
264    );
265    let openai_credentials = resolve_openai_credentials_with(
266        &models_cfg,
267        auth_store,
268        &factories.manifests,
269        values,
270        &refreshable,
271    );
272    // The CLI override is intentionally not inserted into either provider map:
273    // it is applied only after the final model/provider identity is selected.
274    let cli_api_key = cli_api_key
275        .filter(|key| !key.is_empty())
276        .map(str::to_string);
277
278    // ---- Endpoint override (--base-url → ANTHROPIC_BASE_URL) ----
279    let cli_base_url_override = cli_base_url.map(str::to_string);
280    let anthropic_base_url_override = values
281        .env(ANTHROPIC_BASE_URL_ENV)
282        .filter(|value| !value.is_empty());
283
284    // ---- Catalog: built-in + ~/.rpi/models.json (merged, reusing the
285    // already-loaded config) ----
286    let mut catalog = build_catalog(&models_cfg, &factories.manifests, values);
287
288    // The ambient Anthropic override belongs only to the built-in identity.
289    // Apply the CLI override after selection so other providers keep their
290    // own endpoints and credentials when switched to via `/model`.
291    for model in &mut catalog {
292        if matches!(model.api, rpi_ai::Api::AnthropicMessages)
293            && model.provider == DEFAULT_PROVIDER_ID
294        {
295            if let Some(base) = &anthropic_base_url_override {
296                model.base_url = base.clone();
297            }
298        }
299    }
300
301    // Snapshot the full (un-retained) catalog so auxiliary provider runtimes
302    // for `/model` hot-switching can be built after the selected one.
303    let mut full_catalog = catalog.clone();
304
305    if let Some(api) = &request.api {
306        catalog.retain(|model| &model.api == api);
307    }
308
309    if let Some(requested) = cli_provider {
310        catalog.retain(|model| provider_matches(model, requested, &models_cfg));
311    }
312
313    let available = catalog
314        .iter()
315        .map(|m| m.id.clone())
316        .collect::<Vec<_>>()
317        .join(", ");
318    if catalog.is_empty() {
319        return Err(ResolveError::NoMatch {
320            pattern: cli_provider.unwrap_or("default").to_string(),
321            available,
322        });
323    }
324
325    // ---- Model selection ----
326    // With `--model`: parse the pattern (`provider/id[:thinking]`), match it
327    // exactly against the catalog (TS fuzzy/partial match is a deliberate v1
328    // omission — see module docs §5). Without `--model`: pi `findInitialModel`
329    // precedence — (3) the saved default from settings (when present + authed),
330    // then (4) `pick_default_model` (built-in default if authed, else first
331    // authed). The saved default mirrors `findInitialModel` step 3 and lets a
332    // copied pi `settings.json`'s `defaultModel` come alive on launch.
333    let (mut model, thinking_level) = match cli_model {
334        Some(raw) => {
335            let parsed_pattern = split_model_pattern(raw, cli_provider, &catalog, &models_cfg)?;
336            if let Some(provider) = parsed_pattern.provider.as_deref() {
337                if !provider_is_known(provider, &models_cfg) {
338                    return Err(ResolveError::UnknownProvider(provider.to_string()));
339                }
340            }
341            let mut pattern_thinking = parsed_pattern.thinking;
342            let mut selected = find_cli_model(
343                &parsed_pattern.model_id,
344                parsed_pattern.provider.as_deref(),
345                &catalog,
346                &models_cfg,
347                &anthropic_credentials,
348                &openai_credentials,
349            )?;
350
351            // A vendor may include its own name in the actual wire model id
352            // (OpenRouter's `openrouter/auto`). If the stripped id is absent,
353            // retry the full id even when --provider was explicit.
354            if selected.is_none() && !parsed_pattern.inferred_provider {
355                selected = find_cli_model(
356                    &parsed_pattern.raw_model_id,
357                    cli_provider,
358                    &catalog,
359                    &models_cfg,
360                    &anthropic_credentials,
361                    &openai_credentials,
362                )?;
363                if selected.is_some() {
364                    pattern_thinking = None;
365                }
366            }
367            if parsed_pattern.inferred_provider {
368                if let Some(inferred) = selected.as_ref() {
369                    if !model_is_authed_for_resolution(
370                        inferred,
371                        &anthropic_credentials,
372                        &openai_credentials,
373                        false,
374                    ) {
375                        let authenticated_raw_matches = |candidate: &str| {
376                            catalog
377                                .iter()
378                                .filter(|model| {
379                                    model.id.eq_ignore_ascii_case(candidate)
380                                        && (model.provider != inferred.provider
381                                            || model.id != inferred.id)
382                                        && model_is_authed_for_resolution(
383                                            model,
384                                            &anthropic_credentials,
385                                            &openai_credentials,
386                                            false,
387                                        )
388                                })
389                                .collect::<Vec<_>>()
390                        };
391                        let mut raw_matches =
392                            authenticated_raw_matches(&parsed_pattern.raw_model_id);
393                        let mut matched_complete_raw_id = true;
394                        if raw_matches.is_empty() && parsed_pattern.thinking.is_some() {
395                            if let Some((raw_without_thinking, _)) =
396                                parsed_pattern.raw_model_id.rsplit_once(':')
397                            {
398                                raw_matches = authenticated_raw_matches(raw_without_thinking);
399                                matched_complete_raw_id = false;
400                            }
401                        }
402                        if let [raw_match] = raw_matches.as_slice() {
403                            selected = Some((*raw_match).clone());
404                            if matched_complete_raw_id {
405                                pattern_thinking = None;
406                            }
407                        }
408                    }
409                } else {
410                    selected = find_cli_model(
411                        &parsed_pattern.raw_model_id,
412                        None,
413                        &catalog,
414                        &models_cfg,
415                        &anthropic_credentials,
416                        &openai_credentials,
417                    )?;
418                    if selected.is_some() {
419                        pattern_thinking = None;
420                    } else if parsed_pattern.thinking.is_some() {
421                        if let Some((raw_without_thinking, _)) =
422                            parsed_pattern.raw_model_id.rsplit_once(':')
423                        {
424                            selected = find_cli_model(
425                                raw_without_thinking,
426                                None,
427                                &catalog,
428                                &models_cfg,
429                                &anthropic_credentials,
430                                &openai_credentials,
431                            )?;
432                        }
433                    }
434                }
435            }
436
437            let model = match selected {
438                Some(m) => m,
439                None => {
440                    return Err(ResolveError::NoMatch {
441                        pattern: parsed_pattern.model_id,
442                        available,
443                    });
444                }
445            };
446            // `--thinking` wins over a parsed `:level` suffix; an exact model
447            // id containing that suffix does not implicitly set thinking.
448            let thinking_level = cli_thinking
449                .or(pattern_thinking)
450                .unwrap_or(DEFAULT_THINKING_LEVEL);
451            (model, thinking_level)
452        }
453        None => {
454            // `--thinking` > settings `defaultThinkingLevel` > built-in default.
455            // The settings level is honored only when its model is also the
456            // saved default (matches pi, which applies `defaultThinkingLevel`
457            // inside the step-3 branch). For the fallback default, keep
458            // `DEFAULT_THINKING_LEVEL`.
459            let settings_thinking = settings
460                .default_thinking_level
461                .as_deref()
462                .and_then(parse_thinking_level);
463
464            // (3) Saved default from settings, when the provider is anthropic
465            // (or absent — v1 is anthropic-only) OR names a configured
466            // models.json gateway (config-namespacing: the saved
467            // `defaultProvider` id matches a `~/.rpi/models.json` provider
468            // key), and the saved model is authed. Without the gateway arm a
469            // copied pi settings.json (`defaultProvider:
470            // "cc-switch-deep-seek-copy-2"`) is ignored and the default falls
471            // to first-authed — which, once a second gateway is enabled, may
472            // NOT be the user's saved choice (BTreeMap provider order).
473            let saved_provider = settings
474                .default_provider
475                .as_deref()
476                .filter(|provider| provider_is_known(provider, &models_cfg));
477            let saved = settings.default_model.as_deref().and_then(|id| {
478                if settings.default_provider.is_some() && saved_provider.is_none() {
479                    return None;
480                }
481                find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
482                    model_is_authed_for_resolution(
483                        m,
484                        &anthropic_credentials,
485                        &openai_credentials,
486                        cli_api_key.is_some(),
487                    )
488                })
489            });
490            if let Some(model) = saved {
491                let thinking_level = cli_thinking
492                    .or(settings_thinking)
493                    .unwrap_or(DEFAULT_THINKING_LEVEL);
494                (model, thinking_level)
495            } else {
496                // (4) Fallback: built-in default if authed, else first authed.
497                let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
498                let model = pick_default_model(
499                    &catalog,
500                    &models_cfg,
501                    &anthropic_credentials,
502                    &openai_credentials,
503                    cli_api_key.is_some(),
504                );
505                (model, thinking_level)
506            }
507        }
508    };
509
510    // ---- Provider build ----
511    let selected_api = model.api.clone();
512    let selected_provider = model.provider.clone();
513    if let Some(base) = &cli_base_url_override {
514        model.base_url = base.clone();
515        for candidate in &mut full_catalog {
516            if candidate.provider == selected_provider && candidate.api == selected_api {
517                candidate.base_url = base.clone();
518            }
519        }
520    }
521    let selected_anthropic_credential = if matches!(selected_api, rpi_ai::Api::AnthropicMessages) {
522        let auth_header = models_cfg
523            .providers
524            .get(&selected_provider)
525            .and_then(|provider| provider.auth_header)
526            .unwrap_or(false);
527        credential_for_selected_anthropic_provider(
528            cli_api_key.as_deref(),
529            &anthropic_credentials,
530            &selected_provider,
531            auth_header,
532        )
533    } else {
534        None
535    };
536    let selected_openai_key = if selected_api != rpi_ai::Api::AnthropicMessages {
537        cli_api_key
538            .clone()
539            .or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
540    } else {
541        None
542    };
543    let selected_is_authed = openai_credentials.contains_key(&selected_provider)
544        || model_has_header_auth(&model)
545        || match selected_api {
546            rpi_ai::Api::AnthropicMessages => selected_anthropic_credential.is_some(),
547            rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
548                selected_openai_key.is_some()
549            }
550            _ => false,
551        };
552    if !selected_is_authed {
553        return Err(ResolveError::NoApiKey {
554            hint: NO_API_KEY_HINT,
555        });
556    }
557
558    let (provider, execution, has_provider_key) = build_runtime(
559        &selected_provider,
560        &selected_api,
561        &full_catalog,
562        (
563            selected_anthropic_credential.as_ref(),
564            selected_openai_key.as_deref(),
565        ),
566        cli_api_key.is_none().then_some(auth_store),
567        settings,
568        values,
569        factories,
570    )?;
571
572    // Build runtimes for every OTHER authenticated provider so the harness can
573    // hot-switch providers by `model.provider` (its lazy resolve matches by
574    // id). The selected provider is already built above.
575    let mut providers: Vec<Arc<dyn Provider>> = vec![provider.clone()];
576    {
577        let mut seen: Vec<(String, rpi_ai::Api)> =
578            vec![(selected_provider.clone(), selected_api.clone())];
579        for candidate in &full_catalog {
580            let key = (candidate.provider.clone(), candidate.api.clone());
581            if seen.contains(&key) {
582                continue;
583            }
584            seen.push(key.clone());
585            let (anth, openai_key) = match &candidate.api {
586                rpi_ai::Api::AnthropicMessages => {
587                    let auth_header = models_cfg
588                        .providers
589                        .get(&candidate.provider)
590                        .and_then(|p| p.auth_header)
591                        .unwrap_or(false);
592                    (
593                        credential_for_selected_anthropic_provider(
594                            None,
595                            &anthropic_credentials,
596                            &candidate.provider,
597                            auth_header,
598                        ),
599                        None,
600                    )
601                }
602                rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => (
603                    None,
604                    openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
605                ),
606                _ => (
607                    None,
608                    openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
609                ),
610            };
611            let authed = openai_credentials.contains_key(&candidate.provider)
612                || model_has_header_auth(candidate)
613                || match candidate.api {
614                    rpi_ai::Api::AnthropicMessages => anth.is_some(),
615                    rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
616                        openai_key.is_some()
617                    }
618                    _ => false,
619                };
620            if !authed {
621                continue;
622            }
623            let (runtime, _, _) = build_runtime(
624                &candidate.provider,
625                &candidate.api,
626                &full_catalog,
627                (anth.as_ref(), openai_key.as_deref()),
628                Some(auth_store),
629                settings,
630                values,
631                factories,
632            )?;
633            providers.push(runtime);
634        }
635    }
636
637    let providers = factories::combine_provider_runtimes(providers);
638    let provider = providers
639        .iter()
640        .find(|provider| provider.id() == selected_provider)
641        .expect("selected provider is registered")
642        .clone();
643
644    // Only credential-free descriptors leave the resolver. The private binding
645    // owns the authoritative endpoint and all request headers.
646    model = execution
647        .descriptors()
648        .iter()
649        .find(|candidate| {
650            candidate.api == model.api && candidate.id.eq_ignore_ascii_case(&model.id)
651        })
652        .expect("selected model is bound")
653        .clone();
654    let ids: std::collections::BTreeSet<_> = full_catalog
655        .iter()
656        .map(|model| model.provider.clone())
657        .chain(
658            factories
659                .manifests
660                .iter()
661                .map(|manifest| manifest.id.clone()),
662        )
663        .collect();
664    let mut snapshot_auth: BTreeMap<_, _> = ids
665        .into_iter()
666        .map(|id| {
667            let state = crate::credentials::state(
668                &id,
669                models_cfg.providers.get(&id),
670                auth_store,
671                factories
672                    .manifests
673                    .iter()
674                    .find(|manifest| manifest.id == id),
675                values,
676                refreshable(&id),
677                || {
678                    full_catalog
679                        .iter()
680                        .any(|model| model.provider == id && model_has_header_auth(model))
681                },
682            );
683            (id, state)
684        })
685        .collect();
686    if cli_api_key.is_some() {
687        snapshot_auth.insert(
688            model.provider.clone(),
689            crate::credentials::AuthState {
690                status: crate::credentials::AuthStatus::Ready,
691                source: crate::credentials::CredentialSource::Explicit,
692            },
693        );
694    }
695    let mut snapshot_catalog: Vec<_> = full_catalog.iter().map(binding::descriptor).collect();
696    snapshot_catalog.sort_by(|a, b| {
697        a.provider
698            .to_ascii_lowercase()
699            .cmp(&b.provider.to_ascii_lowercase())
700            .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
701    });
702    let resolved = ResolvedModel {
703        provider,
704        model,
705        thinking_level,
706        has_provider_key,
707        theme: settings.theme.clone(),
708        providers,
709        compat_warnings,
710        execution,
711        snapshot_catalog,
712        snapshot_auth,
713    };
714    debug_assert!(resolved
715        .execution
716        .descriptors()
717        .iter()
718        .all(|model| model.headers.is_none()));
719    Ok(resolved)
720}
721
722/// Build one provider runtime from a pre-computed credential. Shared by
723/// [`resolve_with_factories`] (for the selected provider) and the
724/// all-provider runtime list built for `/model` hot-switching.
725type BoundRuntime = (Arc<dyn Provider>, Arc<ExecutionBinding>, bool);
726
727#[allow(clippy::too_many_arguments)]
728fn build_runtime(
729    provider_id: &str,
730    api: &rpi_ai::Api,
731    catalog: &[Model],
732    credentials: (Option<&AnthropicCredential>, Option<&str>),
733    auth_store: Option<&config::AuthStore>,
734    settings: &RuntimeSettings,
735    values: &dyn ValueSource,
736    factories: &RuntimeFactories,
737) -> Result<BoundRuntime, ResolveError> {
738    let (anthropic_credential, openai_key) = credentials;
739    let anonymous = factories
740        .manifests
741        .iter()
742        .any(|manifest| manifest.id == provider_id && !manifest.requires_auth);
743    let provider_has_auth = anthropic_credential.is_some() || openai_key.is_some() || anonymous;
744    let models = catalog
745        .iter()
746        .filter(|model| model.api == *api && model.provider == provider_id)
747        .filter(|model| provider_has_auth || model_has_header_auth(model))
748        .cloned()
749        .collect();
750    let auth = match anthropic_credential {
751        Some(AnthropicCredential::ProviderKey(key)) => RequestAuth::ApiKey(key.clone()),
752        Some(AnthropicCredential::Headers(headers)) => RequestAuth::Headers(headers.clone()),
753        None => openai_key.map_or(RequestAuth::None, |key| RequestAuth::ApiKey(key.into())),
754    };
755    let has_provider_key = matches!(auth, RequestAuth::ApiKey(_)) || anonymous;
756    let oauth = auth_store.and_then(|store| factories.oauth_lease(provider_id, store, &auth));
757    let (provider, binding) =
758        factories.configure_with_oauth(provider_id, api, models, auth, oauth, settings, values)?;
759    Ok((provider, binding, has_provider_key))
760}
761
762/// Credential-free descriptors for the selected provider. Authentication was
763/// already checked before the common factory created its execution binding.
764pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
765    let selected_api = &resolved.model.api;
766    let selected_provider = &resolved.model.provider;
767    let mut seen = std::collections::HashSet::new();
768    resolved
769        .provider
770        .models()
771        .iter()
772        // A provider snapshot is expected to be homogeneous, but extension
773        // and gateway providers can expose a broader catalog. The selector
774        // must only offer models the current lane can actually route to.
775        .filter(|m| m.api == *selected_api && m.provider == *selected_provider)
776        .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
777        .cloned()
778        .collect()
779}
780
781/// Credential-free descriptors for every configured runtime, including packages.
782/// Bound credentials and request headers stay inside each execution binding.
783pub fn authenticated_catalog(resolved: &ResolvedModel) -> Vec<Model> {
784    let mut seen = std::collections::HashSet::new();
785    resolved
786        .providers
787        .iter()
788        .flat_map(|provider| provider.models())
789        .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
790        .cloned()
791        .collect()
792}
793
794/// Credential-free catalog and compatibility diagnostics without selecting a model.
795pub fn catalog_with_diagnostics(
796    mut cfg: config::ModelsConfig,
797    manifests: &[crate::ProviderDeclaration],
798    values: &dyn ValueSource,
799) -> (Vec<Model>, Vec<String>) {
800    let (models, warnings) = execution_catalog_with_diagnostics(&mut cfg, manifests, values);
801    (models.iter().map(binding::descriptor).collect(), warnings)
802}
803/// Private configuration view for the host's credential status adapter.
804pub(crate) fn execution_catalog_with_diagnostics(
805    cfg: &mut config::ModelsConfig,
806    manifests: &[crate::ProviderDeclaration],
807    values: &dyn ValueSource,
808) -> (Vec<Model>, Vec<String>) {
809    apply_manifest_defaults(cfg, manifests);
810    let warnings = config::model_compat_warnings(cfg);
811    let mut catalog = build_catalog(cfg, manifests, values);
812    catalog.sort_by(|a, b| {
813        a.provider
814            .to_ascii_lowercase()
815            .cmp(&b.provider.to_ascii_lowercase())
816            .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
817    });
818    (catalog, warnings)
819}
820
821fn apply_manifest_defaults(
822    models_cfg: &mut config::ModelsConfig,
823    manifests: &[crate::ProviderDeclaration],
824) {
825    for manifest in manifests {
826        // Give package ids their own configuration namespace. User configuration
827        // overrides package defaults; no credentials are stored by declarations.
828        if !models_cfg.providers.contains_key(&manifest.id) {
829            let first = manifest.models.first();
830            let config = serde_json::from_value(serde_json::json!({
831                "api": first.map(|model| model.api.as_str()),
832                "baseUrl": first.map(|model| model.base_url.as_str()), "models":[]
833            }))
834            .expect("provider config defaults");
835            models_cfg.providers.insert(manifest.id.clone(), config);
836        }
837        if let (Some(default), Some(config)) = (
838            manifest.models.first(),
839            models_cfg.providers.get_mut(&manifest.id),
840        ) {
841            config
842                .api
843                .get_or_insert_with(|| default.api.as_str().to_string());
844            config
845                .base_url
846                .get_or_insert_with(|| default.base_url.clone());
847        }
848    }
849}
850
851pub(crate) fn builtin_catalog() -> Vec<Model> {
852    let mut catalog = anthropic_models();
853    catalog.extend(openai_responses_models());
854    catalog
855}
856
857pub(crate) fn provider_has_auth_headers(
858    provider: &str,
859    cfg: &config::ModelsConfig,
860    manifests: &[crate::ProviderDeclaration],
861    values: &dyn ValueSource,
862) -> bool {
863    let mut selected = config::ModelsConfig::default();
864    if let Some(config) = cfg.providers.get(provider) {
865        selected.providers.insert(provider.into(), config.clone());
866    }
867    let manifests: Vec<_> = manifests
868        .iter()
869        .filter(|manifest| manifest.id == provider)
870        .cloned()
871        .collect();
872    apply_manifest_defaults(&mut selected, &manifests);
873    build_catalog(&selected, &manifests, values)
874        .iter()
875        .any(|model| model.provider == provider && model_has_header_auth(model))
876}
877
878fn build_catalog(
879    cfg: &config::ModelsConfig,
880    manifests: &[crate::ProviderDeclaration],
881    values: &dyn ValueSource,
882) -> Vec<Model> {
883    let mut catalog = builtin_catalog();
884    for manifest in manifests {
885        let config = cfg.providers.get(&manifest.id);
886        for mut model in manifest.models.clone() {
887            if let Some(base) = config.and_then(|config| config.base_url.as_ref()) {
888                model.base_url = base.clone();
889            }
890            if let Some(headers) = config.and_then(|config| config.headers.as_ref()) {
891                merge_auth_headers(
892                    &mut model,
893                    &crate::values::resolve_headers(headers, None, values),
894                );
895            }
896            catalog.push(model);
897        }
898    }
899    merge_user_catalog(&mut catalog, cfg, values);
900    catalog
901}
902
903/// Merge `~/.rpi/models.json` providers into the built-in catalog. Models from
904/// the same runtime provider and API replace entries with the same id; models
905/// with the same id under different OpenAI-compatible providers remain
906/// distinct so `provider/id` can select the intended endpoint.
907fn merge_user_catalog(
908    catalog: &mut Vec<Model>,
909    cfg: &config::ModelsConfig,
910    values: &dyn ValueSource,
911) {
912    for (provider_id, provider_cfg) in &cfg.providers {
913        let Some(models) = config::provider_to_models(provider_id, provider_cfg, values) else {
914            // Unsupported protocol.
915            continue;
916        };
917        for m in models {
918            if let Some(existing) = catalog.iter_mut().find(|candidate| {
919                candidate.api == m.api
920                    && candidate.provider == m.provider
921                    && candidate.id.eq_ignore_ascii_case(&m.id)
922            }) {
923                *existing = m;
924            } else {
925                catalog.push(m);
926            }
927        }
928    }
929}
930
931#[derive(Clone, Debug, PartialEq, Eq)]
932enum AnthropicCredential {
933    /// Passed as the selected provider's default `x-api-key`.
934    ProviderKey(String),
935    /// Folded only onto models owned by the selected provider.
936    Headers(BTreeMap<String, String>),
937}
938
939type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
940enum OpenAiCredential {
941    Key(String),
942    Anonymous,
943}
944type OpenAiCredentials = BTreeMap<String, OpenAiCredential>;
945
946/// Resolve one independent Anthropic-wire credential for each provider id.
947/// Stored credentials win over `models.json`; only the built-in `anthropic`
948/// identity may fall back to the `ANTHROPIC_*` environment variables.
949#[cfg(test)]
950fn resolve_anthropic_credentials(
951    cfg: &config::ModelsConfig,
952    auth_store: &config::AuthStore,
953    manifests: &[crate::ProviderDeclaration],
954    values: &dyn ValueSource,
955) -> AnthropicCredentials {
956    resolve_anthropic_credentials_with(cfg, auth_store, manifests, values, &|_| false)
957}
958
959fn resolve_anthropic_credentials_with(
960    cfg: &config::ModelsConfig,
961    auth_store: &config::AuthStore,
962    manifests: &[crate::ProviderDeclaration],
963    values: &dyn ValueSource,
964    refreshable: &dyn Fn(&str) -> bool,
965) -> AnthropicCredentials {
966    let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
967    for (provider_id, provider_cfg) in &cfg.providers {
968        if config::provider_is_anthropic_compatible(provider_cfg)
969            && !provider_ids.iter().any(|known| known == provider_id)
970        {
971            provider_ids.push(provider_id.clone());
972        }
973    }
974
975    let mut credentials = BTreeMap::new();
976    for provider_id in provider_ids {
977        let resolved = crate::credentials::resolve_refreshable(
978            &provider_id,
979            cfg.providers.get(&provider_id),
980            auth_store,
981            manifests.iter().find(|manifest| manifest.id == provider_id),
982            values,
983            refreshable(&provider_id),
984        );
985        let credential = resolved
986            .key
987            .map(|key| anthropic_credential_from_key(key, resolved.bearer));
988        if let Some(credential) = credential {
989            credentials.insert(provider_id, credential);
990        }
991    }
992    credentials
993}
994
995fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
996    if auth_header {
997        AnthropicCredential::Headers(BTreeMap::from([(
998            "authorization".to_string(),
999            format!("Bearer {key}"),
1000        )]))
1001    } else {
1002        AnthropicCredential::ProviderKey(key)
1003    }
1004}
1005
1006fn anthropic_credential_for<'a>(
1007    credentials: &'a AnthropicCredentials,
1008    provider_id: &str,
1009) -> Option<&'a AnthropicCredential> {
1010    credentials.get(provider_id)
1011}
1012
1013fn credential_for_selected_anthropic_provider(
1014    cli_api_key: Option<&str>,
1015    credentials: &AnthropicCredentials,
1016    provider_id: &str,
1017    auth_header: bool,
1018) -> Option<AnthropicCredential> {
1019    cli_api_key
1020        .filter(|key| !key.is_empty())
1021        .map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
1022        .or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
1023}
1024
1025fn resolve_openai_credentials_with(
1026    cfg: &config::ModelsConfig,
1027    auth_store: &config::AuthStore,
1028    manifests: &[crate::ProviderDeclaration],
1029    values: &dyn ValueSource,
1030    refreshable: &dyn Fn(&str) -> bool,
1031) -> OpenAiCredentials {
1032    let mut provider_ids = vec!["openai".to_string()];
1033    for (provider_id, provider_cfg) in &cfg.providers {
1034        if (config::provider_is_openai_completions(provider_cfg)
1035            || config::provider_is_openai_responses(provider_cfg))
1036            && !provider_ids.contains(provider_id)
1037        {
1038            provider_ids.push(provider_id.clone());
1039        }
1040    }
1041    for manifest in manifests {
1042        if !provider_ids.contains(&manifest.id) {
1043            provider_ids.push(manifest.id.clone());
1044        }
1045    }
1046    let mut credentials = BTreeMap::new();
1047    for provider_id in provider_ids {
1048        let provider_cfg = cfg.providers.get(&provider_id);
1049        let manifest = manifests.iter().find(|manifest| manifest.id == provider_id);
1050        let resolved = crate::credentials::resolve_refreshable(
1051            &provider_id,
1052            provider_cfg,
1053            auth_store,
1054            manifest,
1055            values,
1056            refreshable(&provider_id),
1057        );
1058        // Anthropic keys are represented by their own map, preserving bearer vs
1059        // x-api-key semantics. Anonymous declarations apply to every protocol.
1060        if provider_cfg.is_some_and(config::provider_is_anthropic_compatible) {
1061            if matches!(
1062                resolved.source,
1063                crate::credentials::CredentialSource::Anonymous
1064            ) {
1065                credentials.insert(provider_id, OpenAiCredential::Anonymous);
1066            }
1067        } else if let Some(key) = resolved.key {
1068            credentials.insert(provider_id, OpenAiCredential::Key(key));
1069        } else if matches!(
1070            resolved.source,
1071            crate::credentials::CredentialSource::Anonymous
1072        ) {
1073            credentials.insert(provider_id, OpenAiCredential::Anonymous);
1074        }
1075    }
1076    credentials
1077}
1078
1079fn openai_credential_for<'a>(
1080    credentials: &'a OpenAiCredentials,
1081    provider_id: &str,
1082) -> Option<&'a String> {
1083    match credentials.get(provider_id) {
1084        Some(OpenAiCredential::Key(key)) => Some(key),
1085        _ => None,
1086    }
1087}
1088
1089fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
1090    let headers = model.headers.get_or_insert_with(BTreeMap::new);
1091    for (name, value) in auth_headers {
1092        headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
1093        headers.insert(name.clone(), value.clone());
1094    }
1095}
1096
1097struct SplitModelPattern {
1098    provider: Option<String>,
1099    model_id: String,
1100    raw_model_id: String,
1101    thinking: Option<ThinkingLevel>,
1102    inferred_provider: bool,
1103}
1104
1105/// Split a `--model` value into its provider, id, and thinking components.
1106///
1107/// A slash prefix is provider syntax only when it names a known provider (or
1108/// matches an explicit `--provider`). Unknown prefixes stay in the raw model
1109/// id, which is required for OpenRouter-style ids such as
1110/// `meta-llama/llama-3.3`.
1111///
1112/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
1113fn split_model_pattern(
1114    value: &str,
1115    cli_provider: Option<&str>,
1116    catalog: &[Model],
1117    cfg: &config::ModelsConfig,
1118) -> Result<SplitModelPattern, ResolveError> {
1119    let mut provider = cli_provider.map(str::to_string);
1120    let mut model_id = value.to_string();
1121    let mut inferred_provider = false;
1122
1123    if let Some((prefix, remainder)) = value.split_once('/') {
1124        if !prefix.is_empty() && !remainder.is_empty() {
1125            let matches_explicit = cli_provider
1126                .and_then(|requested| {
1127                    canonicalize_cli_provider(prefix, cfg)
1128                        .ok()
1129                        .map(|canonical| canonical == requested)
1130                })
1131                .unwrap_or(false);
1132            if matches_explicit {
1133                model_id = remainder.to_string();
1134            } else if cli_provider.is_none() {
1135                match canonicalize_cli_provider(prefix, cfg) {
1136                    Ok(canonical) => {
1137                        provider = Some(canonical);
1138                        model_id = remainder.to_string();
1139                        inferred_provider = true;
1140                    }
1141                    Err(ResolveError::UnknownProvider(_)) => {}
1142                    Err(error) => return Err(error),
1143                }
1144            }
1145        }
1146    }
1147
1148    // upstream attempts the complete id first. Only peel a valid thinking
1149    // suffix when that complete id does not exist in the selected scope.
1150    let has_full_exact_match = catalog.iter().any(|model| {
1151        model.id.eq_ignore_ascii_case(&model_id)
1152            && provider
1153                .as_deref()
1154                .map_or(true, |requested| provider_matches(model, requested, cfg))
1155    });
1156    let thinking = if has_full_exact_match {
1157        None
1158    } else if let Some((head, suffix)) = model_id.rsplit_once(':') {
1159        if let Some(level) = parse_thinking_level(suffix) {
1160            model_id = head.to_string();
1161            Some(level)
1162        } else {
1163            None
1164        }
1165    } else {
1166        None
1167    };
1168
1169    Ok(SplitModelPattern {
1170        provider,
1171        model_id,
1172        raw_model_id: value.to_string(),
1173        thinking,
1174        inferred_provider,
1175    })
1176}
1177
1178/// Case-insensitive exact id match, optionally scoped to a provider.
1179fn find_model(
1180    pattern: &str,
1181    provider: Option<&str>,
1182    catalog: &[Model],
1183    cfg: &config::ModelsConfig,
1184) -> Option<Model> {
1185    catalog
1186        .iter()
1187        .find(|model| {
1188            model.id.eq_ignore_ascii_case(pattern)
1189                && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1190        })
1191        .cloned()
1192}
1193
1194/// Resolve an exact CLI model reference without silently choosing the first
1195/// provider when a bare model id is duplicated. This mirrors upstream's
1196/// `resolveCliModel`: a sole configured-auth match wins; zero or multiple
1197/// configured-auth matches require an explicit provider.
1198fn find_cli_model(
1199    pattern: &str,
1200    provider: Option<&str>,
1201    catalog: &[Model],
1202    cfg: &config::ModelsConfig,
1203    anthropic_credentials: &AnthropicCredentials,
1204    openai_credentials: &OpenAiCredentials,
1205) -> Result<Option<Model>, ResolveError> {
1206    let exact_matches: Vec<&Model> = catalog
1207        .iter()
1208        .filter(|model| {
1209            model.id.eq_ignore_ascii_case(pattern)
1210                && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1211        })
1212        .collect();
1213
1214    match exact_matches.as_slice() {
1215        [] => Ok(None),
1216        [model] => Ok(Some((*model).clone())),
1217        _ => {
1218            let authenticated: Vec<&Model> = exact_matches
1219                .iter()
1220                .copied()
1221                .filter(|model| {
1222                    model_is_authed_for_resolution(
1223                        model,
1224                        anthropic_credentials,
1225                        openai_credentials,
1226                        false,
1227                    )
1228                })
1229                .collect();
1230            if let [model] = authenticated.as_slice() {
1231                return Ok(Some((*model).clone()));
1232            }
1233
1234            let mut matches = exact_matches
1235                .iter()
1236                .map(|model| format!("{}/{}", model.provider, model.id))
1237                .collect::<Vec<_>>();
1238            matches.sort();
1239            let auth_hint = if authenticated.is_empty() {
1240                "No matching provider is authenticated."
1241            } else {
1242                "More than one matching provider is authenticated."
1243            };
1244            Err(ResolveError::AmbiguousModel {
1245                pattern: pattern.to_string(),
1246                matches: matches.join(", "),
1247                auth_hint,
1248            })
1249        }
1250    }
1251}
1252
1253fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
1254    cfg.providers.contains_key(requested)
1255        || requested.eq_ignore_ascii_case("anthropic")
1256        || requested.eq_ignore_ascii_case("openai")
1257        || requested.eq_ignore_ascii_case("openai-completions")
1258        || requested.eq_ignore_ascii_case("openai-responses")
1259}
1260
1261/// Canonicalize user-facing `--provider` input without weakening provider
1262/// identity. Exact configured ids win. A case-insensitive custom match is
1263/// accepted only when unique; otherwise the user must provide exact casing so
1264/// credentials and endpoints can never cross between distinct ids.
1265fn canonicalize_cli_provider(
1266    requested: &str,
1267    cfg: &config::ModelsConfig,
1268) -> Result<String, ResolveError> {
1269    if cfg.providers.contains_key(requested) {
1270        return Ok(requested.to_string());
1271    }
1272
1273    const BUILTIN_PROVIDER_IDS: &[&str] = &[
1274        "anthropic",
1275        "openai",
1276        "openai-completions",
1277        "openai-responses",
1278    ];
1279    if BUILTIN_PROVIDER_IDS.contains(&requested) {
1280        return Ok(requested.to_string());
1281    }
1282
1283    let mut matches = cfg
1284        .providers
1285        .keys()
1286        .filter(|provider| provider.eq_ignore_ascii_case(requested))
1287        .cloned()
1288        .collect::<Vec<_>>();
1289    matches.extend(
1290        BUILTIN_PROVIDER_IDS
1291            .iter()
1292            .filter(|provider| provider.eq_ignore_ascii_case(requested))
1293            .map(|provider| (*provider).to_string()),
1294    );
1295    matches.sort();
1296    matches.dedup();
1297
1298    match matches.as_slice() {
1299        [provider] => return Ok(provider.clone()),
1300        [] => {}
1301        _ => {
1302            return Err(ResolveError::AmbiguousProvider {
1303                requested: requested.to_string(),
1304                matches: matches.join(", "),
1305            });
1306        }
1307    }
1308
1309    Err(ResolveError::UnknownProvider(requested.to_string()))
1310}
1311
1312fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
1313    // Configured provider ids are exact identities in upstream. An exact
1314    // config match takes precedence over the case-insensitive built-in/protocol
1315    // aliases below, so a custom `Anthropic` remains distinct from `anthropic`.
1316    if model.provider == requested {
1317        return true;
1318    }
1319    if cfg.providers.contains_key(requested) {
1320        return false;
1321    }
1322    if requested.eq_ignore_ascii_case("anthropic") {
1323        return model.provider == DEFAULT_PROVIDER_ID;
1324    }
1325    if requested.eq_ignore_ascii_case("openai") {
1326        return model.provider == "openai";
1327    }
1328    if requested.eq_ignore_ascii_case("openai-completions") {
1329        return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
1330    }
1331    if requested.eq_ignore_ascii_case("openai-responses") {
1332        return matches!(model.api, rpi_ai::Api::OpenaiResponses);
1333    }
1334    false
1335}
1336
1337/// Provider identity matching for upstream's default-model table. Unlike the
1338/// CLI matcher, this intentionally does not treat `openai` as a protocol alias:
1339/// a default belonging to OpenAI must not select the same model id from an
1340/// unrelated OpenAI-compatible gateway.
1341fn model_belongs_to_default_provider(
1342    model: &Model,
1343    requested: &str,
1344    _cfg: &config::ModelsConfig,
1345) -> bool {
1346    model.provider == requested
1347}
1348
1349/// Whether a catalog model is "configured-auth" — i.e. the request built for it
1350/// would pass `assertRequestAuth` and not return "No API key". Mirrors the TS
1351/// `hasConfiguredAuth(providerId)` filter that `getAvailableSnapshot()` applies
1352/// (`available = all.filter(m => configuredProviders.has(m.provider))`).
1353///
1354/// The selected provider snapshot is identity-homogeneous. A model is runnable
1355/// when its own headers carry auth or that selected provider has a default key.
1356fn model_is_authed_for_resolution(
1357    model: &Model,
1358    anthropic_credentials: &AnthropicCredentials,
1359    openai_credentials: &OpenAiCredentials,
1360    has_cli_key: bool,
1361) -> bool {
1362    openai_credentials.contains_key(&model.provider)
1363        || model_has_header_auth(model)
1364        || has_cli_key
1365        || match model.api {
1366            rpi_ai::Api::AnthropicMessages => {
1367                anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
1368            }
1369            rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
1370                openai_credential_for(openai_credentials, &model.provider).is_some()
1371            }
1372            _ => false,
1373        }
1374}
1375
1376/// Same three-name check as rpi-ai's `has_header_auth`, but called from the
1377/// CLI layer (rpi-ai's `has_header_auth` is private to the provider module, so
1378/// we mirror it here over the model's `headers` map).
1379fn model_has_header_auth(m: &Model) -> bool {
1380    crate::credentials::has_header_auth(m.headers.as_ref())
1381}
1382
1383/// Choose the default model when `--model` is absent. Mirrors upstream
1384/// Upstream `findInitialModel` behavior:
1385/// known-provider defaults are checked in native declaration order, followed by
1386/// the first authenticated model in the catalog (`availableModels[0]`). This
1387/// also keeps a models.json-only gateway from accidentally selecting an
1388/// unauthenticated built-in model.
1389///
1390/// Credential maps are keyed by provider id, so only a credential belonging to
1391/// the candidate model can make it participate in default selection.
1392fn pick_default_model(
1393    catalog: &[Model],
1394    models_cfg: &config::ModelsConfig,
1395    anthropic_credentials: &AnthropicCredentials,
1396    openai_credentials: &OpenAiCredentials,
1397    has_cli_key: bool,
1398) -> Model {
1399    // 1. Core provider defaults, in their declared priority order.
1400    for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
1401        if let Some(model) = catalog.iter().find(|model| {
1402            model.id.eq_ignore_ascii_case(model_id)
1403                && model_belongs_to_default_provider(model, provider, models_cfg)
1404                && model_is_authed_for_resolution(
1405                    model,
1406                    anthropic_credentials,
1407                    openai_credentials,
1408                    has_cli_key,
1409                )
1410        }) {
1411            return model.clone();
1412        }
1413    }
1414
1415    // 2. First authed model (TS `availableModels[0]`). Provider and model array
1416    //    declaration order is preserved while loading models.json.
1417    //    this is the gateway model (Bearer folded onto it, base_url = gateway).
1418    if let Some(m) = catalog.iter().find(|m| {
1419        model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_key)
1420    }) {
1421        return m.clone();
1422    }
1423    // 3. Last resort: the built-in Anthropic default, authed or not. The auth gate above
1424    //    already errored when no source resolved, so reaching here means *some*
1425    //    auth exists but none folded/attached to a model we can see — keep the
1426    //    historical default to avoid a NoMatch surprise.
1427    catalog
1428        .iter()
1429        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
1430        .or_else(|| catalog.first())
1431        .expect("catalog is never empty (built-in anthropic_models)")
1432        .clone()
1433}
1434
1435#[cfg(test)]
1436mod tests;