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 anthropic_credentials =
258        resolve_anthropic_credentials(&models_cfg, auth_store, &factories.manifests, values);
259    let openai_credentials =
260        resolve_openai_credentials(&models_cfg, auth_store, &factories.manifests, values);
261    // The CLI override is intentionally not inserted into either provider map:
262    // it is applied only after the final model/provider identity is selected.
263    let cli_api_key = cli_api_key
264        .filter(|key| !key.is_empty())
265        .map(str::to_string);
266
267    // ---- Endpoint override (--base-url → ANTHROPIC_BASE_URL) ----
268    let cli_base_url_override = cli_base_url.map(str::to_string);
269    let anthropic_base_url_override = values
270        .env(ANTHROPIC_BASE_URL_ENV)
271        .filter(|value| !value.is_empty());
272
273    // ---- Catalog: built-in + ~/.rpi/models.json (merged, reusing the
274    // already-loaded config) ----
275    let mut catalog = build_catalog(&models_cfg, &factories.manifests, values);
276
277    // The ambient Anthropic override belongs only to the built-in identity.
278    // Apply the CLI override after selection so other providers keep their
279    // own endpoints and credentials when switched to via `/model`.
280    for model in &mut catalog {
281        if matches!(model.api, rpi_ai::Api::AnthropicMessages)
282            && model.provider == DEFAULT_PROVIDER_ID
283        {
284            if let Some(base) = &anthropic_base_url_override {
285                model.base_url = base.clone();
286            }
287        }
288    }
289
290    // Snapshot the full (un-retained) catalog so auxiliary provider runtimes
291    // for `/model` hot-switching can be built after the selected one.
292    let mut full_catalog = catalog.clone();
293
294    if let Some(api) = &request.api {
295        catalog.retain(|model| &model.api == api);
296    }
297
298    if let Some(requested) = cli_provider {
299        catalog.retain(|model| provider_matches(model, requested, &models_cfg));
300    }
301
302    let available = catalog
303        .iter()
304        .map(|m| m.id.clone())
305        .collect::<Vec<_>>()
306        .join(", ");
307    if catalog.is_empty() {
308        return Err(ResolveError::NoMatch {
309            pattern: cli_provider.unwrap_or("default").to_string(),
310            available,
311        });
312    }
313
314    // ---- Model selection ----
315    // With `--model`: parse the pattern (`provider/id[:thinking]`), match it
316    // exactly against the catalog (TS fuzzy/partial match is a deliberate v1
317    // omission — see module docs §5). Without `--model`: pi `findInitialModel`
318    // precedence — (3) the saved default from settings (when present + authed),
319    // then (4) `pick_default_model` (built-in default if authed, else first
320    // authed). The saved default mirrors `findInitialModel` step 3 and lets a
321    // copied pi `settings.json`'s `defaultModel` come alive on launch.
322    let (mut model, thinking_level) = match cli_model {
323        Some(raw) => {
324            let parsed_pattern = split_model_pattern(raw, cli_provider, &catalog, &models_cfg)?;
325            if let Some(provider) = parsed_pattern.provider.as_deref() {
326                if !provider_is_known(provider, &models_cfg) {
327                    return Err(ResolveError::UnknownProvider(provider.to_string()));
328                }
329            }
330            let mut pattern_thinking = parsed_pattern.thinking;
331            let mut selected = find_cli_model(
332                &parsed_pattern.model_id,
333                parsed_pattern.provider.as_deref(),
334                &catalog,
335                &models_cfg,
336                &anthropic_credentials,
337                &openai_credentials,
338            )?;
339
340            // A vendor may include its own name in the actual wire model id
341            // (OpenRouter's `openrouter/auto`). If the stripped id is absent,
342            // retry the full id even when --provider was explicit.
343            if selected.is_none() && !parsed_pattern.inferred_provider {
344                selected = find_cli_model(
345                    &parsed_pattern.raw_model_id,
346                    cli_provider,
347                    &catalog,
348                    &models_cfg,
349                    &anthropic_credentials,
350                    &openai_credentials,
351                )?;
352                if selected.is_some() {
353                    pattern_thinking = None;
354                }
355            }
356            if parsed_pattern.inferred_provider {
357                if let Some(inferred) = selected.as_ref() {
358                    if !model_is_authed_for_resolution(
359                        inferred,
360                        &anthropic_credentials,
361                        &openai_credentials,
362                        false,
363                    ) {
364                        let authenticated_raw_matches = |candidate: &str| {
365                            catalog
366                                .iter()
367                                .filter(|model| {
368                                    model.id.eq_ignore_ascii_case(candidate)
369                                        && (model.provider != inferred.provider
370                                            || model.id != inferred.id)
371                                        && model_is_authed_for_resolution(
372                                            model,
373                                            &anthropic_credentials,
374                                            &openai_credentials,
375                                            false,
376                                        )
377                                })
378                                .collect::<Vec<_>>()
379                        };
380                        let mut raw_matches =
381                            authenticated_raw_matches(&parsed_pattern.raw_model_id);
382                        let mut matched_complete_raw_id = true;
383                        if raw_matches.is_empty() && parsed_pattern.thinking.is_some() {
384                            if let Some((raw_without_thinking, _)) =
385                                parsed_pattern.raw_model_id.rsplit_once(':')
386                            {
387                                raw_matches = authenticated_raw_matches(raw_without_thinking);
388                                matched_complete_raw_id = false;
389                            }
390                        }
391                        if let [raw_match] = raw_matches.as_slice() {
392                            selected = Some((*raw_match).clone());
393                            if matched_complete_raw_id {
394                                pattern_thinking = None;
395                            }
396                        }
397                    }
398                } else {
399                    selected = find_cli_model(
400                        &parsed_pattern.raw_model_id,
401                        None,
402                        &catalog,
403                        &models_cfg,
404                        &anthropic_credentials,
405                        &openai_credentials,
406                    )?;
407                    if selected.is_some() {
408                        pattern_thinking = None;
409                    } else if parsed_pattern.thinking.is_some() {
410                        if let Some((raw_without_thinking, _)) =
411                            parsed_pattern.raw_model_id.rsplit_once(':')
412                        {
413                            selected = find_cli_model(
414                                raw_without_thinking,
415                                None,
416                                &catalog,
417                                &models_cfg,
418                                &anthropic_credentials,
419                                &openai_credentials,
420                            )?;
421                        }
422                    }
423                }
424            }
425
426            let model = match selected {
427                Some(m) => m,
428                None => {
429                    return Err(ResolveError::NoMatch {
430                        pattern: parsed_pattern.model_id,
431                        available,
432                    });
433                }
434            };
435            // `--thinking` wins over a parsed `:level` suffix; an exact model
436            // id containing that suffix does not implicitly set thinking.
437            let thinking_level = cli_thinking
438                .or(pattern_thinking)
439                .unwrap_or(DEFAULT_THINKING_LEVEL);
440            (model, thinking_level)
441        }
442        None => {
443            // `--thinking` > settings `defaultThinkingLevel` > built-in default.
444            // The settings level is honored only when its model is also the
445            // saved default (matches pi, which applies `defaultThinkingLevel`
446            // inside the step-3 branch). For the fallback default, keep
447            // `DEFAULT_THINKING_LEVEL`.
448            let settings_thinking = settings
449                .default_thinking_level
450                .as_deref()
451                .and_then(parse_thinking_level);
452
453            // (3) Saved default from settings, when the provider is anthropic
454            // (or absent — v1 is anthropic-only) OR names a configured
455            // models.json gateway (config-namespacing: the saved
456            // `defaultProvider` id matches a `~/.rpi/models.json` provider
457            // key), and the saved model is authed. Without the gateway arm a
458            // copied pi settings.json (`defaultProvider:
459            // "cc-switch-deep-seek-copy-2"`) is ignored and the default falls
460            // to first-authed — which, once a second gateway is enabled, may
461            // NOT be the user's saved choice (BTreeMap provider order).
462            let saved_provider = settings
463                .default_provider
464                .as_deref()
465                .filter(|provider| provider_is_known(provider, &models_cfg));
466            let saved = settings.default_model.as_deref().and_then(|id| {
467                if settings.default_provider.is_some() && saved_provider.is_none() {
468                    return None;
469                }
470                find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
471                    model_is_authed_for_resolution(
472                        m,
473                        &anthropic_credentials,
474                        &openai_credentials,
475                        cli_api_key.is_some(),
476                    )
477                })
478            });
479            if let Some(model) = saved {
480                let thinking_level = cli_thinking
481                    .or(settings_thinking)
482                    .unwrap_or(DEFAULT_THINKING_LEVEL);
483                (model, thinking_level)
484            } else {
485                // (4) Fallback: built-in default if authed, else first authed.
486                let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
487                let model = pick_default_model(
488                    &catalog,
489                    &models_cfg,
490                    &anthropic_credentials,
491                    &openai_credentials,
492                    cli_api_key.is_some(),
493                );
494                (model, thinking_level)
495            }
496        }
497    };
498
499    // ---- Provider build ----
500    let selected_api = model.api.clone();
501    let selected_provider = model.provider.clone();
502    if let Some(base) = &cli_base_url_override {
503        model.base_url = base.clone();
504        for candidate in &mut full_catalog {
505            if candidate.provider == selected_provider && candidate.api == selected_api {
506                candidate.base_url = base.clone();
507            }
508        }
509    }
510    let selected_anthropic_credential = if matches!(selected_api, rpi_ai::Api::AnthropicMessages) {
511        let auth_header = models_cfg
512            .providers
513            .get(&selected_provider)
514            .and_then(|provider| provider.auth_header)
515            .unwrap_or(false);
516        credential_for_selected_anthropic_provider(
517            cli_api_key.as_deref(),
518            &anthropic_credentials,
519            &selected_provider,
520            auth_header,
521        )
522    } else {
523        None
524    };
525    let selected_openai_key = if selected_api != rpi_ai::Api::AnthropicMessages {
526        cli_api_key
527            .clone()
528            .or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
529    } else {
530        None
531    };
532    let selected_is_authed = openai_credentials.contains_key(&selected_provider)
533        || model_has_header_auth(&model)
534        || match selected_api {
535            rpi_ai::Api::AnthropicMessages => selected_anthropic_credential.is_some(),
536            rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
537                selected_openai_key.is_some()
538            }
539            _ => false,
540        };
541    if !selected_is_authed {
542        return Err(ResolveError::NoApiKey {
543            hint: NO_API_KEY_HINT,
544        });
545    }
546
547    let (provider, execution, has_provider_key) = build_runtime(
548        &selected_provider,
549        &selected_api,
550        &full_catalog,
551        (
552            selected_anthropic_credential.as_ref(),
553            selected_openai_key.as_deref(),
554        ),
555        settings,
556        values,
557        factories,
558    )?;
559
560    // Build runtimes for every OTHER authenticated provider so the harness can
561    // hot-switch providers by `model.provider` (its lazy resolve matches by
562    // id). The selected provider is already built above.
563    let mut providers: Vec<Arc<dyn Provider>> = vec![provider.clone()];
564    {
565        let mut seen: Vec<(String, rpi_ai::Api)> =
566            vec![(selected_provider.clone(), selected_api.clone())];
567        for candidate in &full_catalog {
568            let key = (candidate.provider.clone(), candidate.api.clone());
569            if seen.contains(&key) {
570                continue;
571            }
572            seen.push(key.clone());
573            let (anth, openai_key) = match &candidate.api {
574                rpi_ai::Api::AnthropicMessages => {
575                    let auth_header = models_cfg
576                        .providers
577                        .get(&candidate.provider)
578                        .and_then(|p| p.auth_header)
579                        .unwrap_or(false);
580                    (
581                        credential_for_selected_anthropic_provider(
582                            None,
583                            &anthropic_credentials,
584                            &candidate.provider,
585                            auth_header,
586                        ),
587                        None,
588                    )
589                }
590                rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => (
591                    None,
592                    openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
593                ),
594                _ => (
595                    None,
596                    openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
597                ),
598            };
599            let authed = openai_credentials.contains_key(&candidate.provider)
600                || model_has_header_auth(candidate)
601                || match candidate.api {
602                    rpi_ai::Api::AnthropicMessages => anth.is_some(),
603                    rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
604                        openai_key.is_some()
605                    }
606                    _ => false,
607                };
608            if !authed {
609                continue;
610            }
611            let (runtime, _, _) = build_runtime(
612                &candidate.provider,
613                &candidate.api,
614                &full_catalog,
615                (anth.as_ref(), openai_key.as_deref()),
616                settings,
617                values,
618                factories,
619            )?;
620            providers.push(runtime);
621        }
622    }
623
624    let providers = factories::combine_provider_runtimes(providers);
625    let provider = providers
626        .iter()
627        .find(|provider| provider.id() == selected_provider)
628        .expect("selected provider is registered")
629        .clone();
630
631    // Only credential-free descriptors leave the resolver. The private binding
632    // owns the authoritative endpoint and all request headers.
633    model = execution
634        .descriptors()
635        .iter()
636        .find(|candidate| {
637            candidate.api == model.api && candidate.id.eq_ignore_ascii_case(&model.id)
638        })
639        .expect("selected model is bound")
640        .clone();
641    let ids: std::collections::BTreeSet<_> = full_catalog
642        .iter()
643        .map(|model| model.provider.clone())
644        .chain(
645            factories
646                .manifests
647                .iter()
648                .map(|manifest| manifest.id.clone()),
649        )
650        .collect();
651    let mut snapshot_auth: BTreeMap<_, _> = ids
652        .into_iter()
653        .map(|id| {
654            let state = crate::credentials::state(
655                &id,
656                models_cfg.providers.get(&id),
657                auth_store,
658                factories
659                    .manifests
660                    .iter()
661                    .find(|manifest| manifest.id == id),
662                values,
663                || {
664                    full_catalog
665                        .iter()
666                        .any(|model| model.provider == id && model_has_header_auth(model))
667                },
668            );
669            (id, state)
670        })
671        .collect();
672    if cli_api_key.is_some() {
673        snapshot_auth.insert(
674            model.provider.clone(),
675            crate::credentials::AuthState {
676                status: crate::credentials::AuthStatus::Ready,
677                source: crate::credentials::CredentialSource::Explicit,
678            },
679        );
680    }
681    let mut snapshot_catalog: Vec<_> = full_catalog.iter().map(binding::descriptor).collect();
682    snapshot_catalog.sort_by(|a, b| {
683        a.provider
684            .to_ascii_lowercase()
685            .cmp(&b.provider.to_ascii_lowercase())
686            .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
687    });
688    let resolved = ResolvedModel {
689        provider,
690        model,
691        thinking_level,
692        has_provider_key,
693        theme: settings.theme.clone(),
694        providers,
695        compat_warnings,
696        execution,
697        snapshot_catalog,
698        snapshot_auth,
699    };
700    debug_assert!(resolved
701        .execution
702        .descriptors()
703        .iter()
704        .all(|model| model.headers.is_none()));
705    Ok(resolved)
706}
707
708/// Build one provider runtime from a pre-computed credential. Shared by
709/// [`resolve_with_factories`] (for the selected provider) and the
710/// all-provider runtime list built for `/model` hot-switching.
711type BoundRuntime = (Arc<dyn Provider>, Arc<ExecutionBinding>, bool);
712
713fn build_runtime(
714    provider_id: &str,
715    api: &rpi_ai::Api,
716    catalog: &[Model],
717    credentials: (Option<&AnthropicCredential>, Option<&str>),
718    settings: &RuntimeSettings,
719    values: &dyn ValueSource,
720    factories: &RuntimeFactories,
721) -> Result<BoundRuntime, ResolveError> {
722    let (anthropic_credential, openai_key) = credentials;
723    let anonymous = factories
724        .manifests
725        .iter()
726        .any(|manifest| manifest.id == provider_id && !manifest.requires_auth);
727    let provider_has_auth = anthropic_credential.is_some() || openai_key.is_some() || anonymous;
728    let models = catalog
729        .iter()
730        .filter(|model| model.api == *api && model.provider == provider_id)
731        .filter(|model| provider_has_auth || model_has_header_auth(model))
732        .cloned()
733        .collect();
734    let auth = match anthropic_credential {
735        Some(AnthropicCredential::ProviderKey(key)) => RequestAuth::ApiKey(key.clone()),
736        Some(AnthropicCredential::Headers(headers)) => RequestAuth::Headers(headers.clone()),
737        None => openai_key.map_or(RequestAuth::None, |key| RequestAuth::ApiKey(key.into())),
738    };
739    let has_provider_key = matches!(auth, RequestAuth::ApiKey(_)) || anonymous;
740    let (provider, binding) =
741        factories.configure(provider_id, api, models, auth, settings, values)?;
742    Ok((provider, binding, has_provider_key))
743}
744
745/// Credential-free descriptors for the selected provider. Authentication was
746/// already checked before the common factory created its execution binding.
747pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
748    let selected_api = &resolved.model.api;
749    let selected_provider = &resolved.model.provider;
750    let mut seen = std::collections::HashSet::new();
751    resolved
752        .provider
753        .models()
754        .iter()
755        // A provider snapshot is expected to be homogeneous, but extension
756        // and gateway providers can expose a broader catalog. The selector
757        // must only offer models the current lane can actually route to.
758        .filter(|m| m.api == *selected_api && m.provider == *selected_provider)
759        .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
760        .cloned()
761        .collect()
762}
763
764/// Credential-free descriptors for every configured runtime, including packages.
765/// Bound credentials and request headers stay inside each execution binding.
766pub fn authenticated_catalog(resolved: &ResolvedModel) -> Vec<Model> {
767    let mut seen = std::collections::HashSet::new();
768    resolved
769        .providers
770        .iter()
771        .flat_map(|provider| provider.models())
772        .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
773        .cloned()
774        .collect()
775}
776
777/// Credential-free catalog and compatibility diagnostics without selecting a model.
778pub fn catalog_with_diagnostics(
779    mut cfg: config::ModelsConfig,
780    manifests: &[crate::ProviderDeclaration],
781    values: &dyn ValueSource,
782) -> (Vec<Model>, Vec<String>) {
783    let (models, warnings) = execution_catalog_with_diagnostics(&mut cfg, manifests, values);
784    (models.iter().map(binding::descriptor).collect(), warnings)
785}
786/// Private configuration view for the host's credential status adapter.
787pub(crate) fn execution_catalog_with_diagnostics(
788    cfg: &mut config::ModelsConfig,
789    manifests: &[crate::ProviderDeclaration],
790    values: &dyn ValueSource,
791) -> (Vec<Model>, Vec<String>) {
792    apply_manifest_defaults(cfg, manifests);
793    let warnings = config::model_compat_warnings(cfg);
794    let mut catalog = build_catalog(cfg, manifests, values);
795    catalog.sort_by(|a, b| {
796        a.provider
797            .to_ascii_lowercase()
798            .cmp(&b.provider.to_ascii_lowercase())
799            .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
800    });
801    (catalog, warnings)
802}
803
804fn apply_manifest_defaults(
805    models_cfg: &mut config::ModelsConfig,
806    manifests: &[crate::ProviderDeclaration],
807) {
808    for manifest in manifests {
809        // Give package ids their own configuration namespace. User configuration
810        // overrides package defaults; no credentials are stored by declarations.
811        if !models_cfg.providers.contains_key(&manifest.id) {
812            let first = manifest.models.first();
813            let config = serde_json::from_value(serde_json::json!({
814                "api": first.map(|model| model.api.as_str()),
815                "baseUrl": first.map(|model| model.base_url.as_str()), "models":[]
816            }))
817            .expect("provider config defaults");
818            models_cfg.providers.insert(manifest.id.clone(), config);
819        }
820        if let (Some(default), Some(config)) = (
821            manifest.models.first(),
822            models_cfg.providers.get_mut(&manifest.id),
823        ) {
824            config
825                .api
826                .get_or_insert_with(|| default.api.as_str().to_string());
827            config
828                .base_url
829                .get_or_insert_with(|| default.base_url.clone());
830        }
831    }
832}
833
834pub(crate) fn builtin_catalog() -> Vec<Model> {
835    let mut catalog = anthropic_models();
836    catalog.extend(openai_responses_models());
837    catalog
838}
839
840pub(crate) fn provider_has_auth_headers(
841    provider: &str,
842    cfg: &config::ModelsConfig,
843    manifests: &[crate::ProviderDeclaration],
844    values: &dyn ValueSource,
845) -> bool {
846    let mut selected = config::ModelsConfig::default();
847    if let Some(config) = cfg.providers.get(provider) {
848        selected.providers.insert(provider.into(), config.clone());
849    }
850    let manifests: Vec<_> = manifests
851        .iter()
852        .filter(|manifest| manifest.id == provider)
853        .cloned()
854        .collect();
855    apply_manifest_defaults(&mut selected, &manifests);
856    build_catalog(&selected, &manifests, values)
857        .iter()
858        .any(|model| model.provider == provider && model_has_header_auth(model))
859}
860
861fn build_catalog(
862    cfg: &config::ModelsConfig,
863    manifests: &[crate::ProviderDeclaration],
864    values: &dyn ValueSource,
865) -> Vec<Model> {
866    let mut catalog = builtin_catalog();
867    for manifest in manifests {
868        let config = cfg.providers.get(&manifest.id);
869        for mut model in manifest.models.clone() {
870            if let Some(base) = config.and_then(|config| config.base_url.as_ref()) {
871                model.base_url = base.clone();
872            }
873            if let Some(headers) = config.and_then(|config| config.headers.as_ref()) {
874                merge_auth_headers(
875                    &mut model,
876                    &crate::values::resolve_headers(headers, None, values),
877                );
878            }
879            catalog.push(model);
880        }
881    }
882    merge_user_catalog(&mut catalog, cfg, values);
883    catalog
884}
885
886/// Merge `~/.rpi/models.json` providers into the built-in catalog. Models from
887/// the same runtime provider and API replace entries with the same id; models
888/// with the same id under different OpenAI-compatible providers remain
889/// distinct so `provider/id` can select the intended endpoint.
890fn merge_user_catalog(
891    catalog: &mut Vec<Model>,
892    cfg: &config::ModelsConfig,
893    values: &dyn ValueSource,
894) {
895    for (provider_id, provider_cfg) in &cfg.providers {
896        let Some(models) = config::provider_to_models(provider_id, provider_cfg, values) else {
897            // Unsupported protocol.
898            continue;
899        };
900        for m in models {
901            if let Some(existing) = catalog.iter_mut().find(|candidate| {
902                candidate.api == m.api
903                    && candidate.provider == m.provider
904                    && candidate.id.eq_ignore_ascii_case(&m.id)
905            }) {
906                *existing = m;
907            } else {
908                catalog.push(m);
909            }
910        }
911    }
912}
913
914#[derive(Clone, Debug, PartialEq, Eq)]
915enum AnthropicCredential {
916    /// Passed as the selected provider's default `x-api-key`.
917    ProviderKey(String),
918    /// Folded only onto models owned by the selected provider.
919    Headers(BTreeMap<String, String>),
920}
921
922type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
923enum OpenAiCredential {
924    Key(String),
925    Anonymous,
926}
927type OpenAiCredentials = BTreeMap<String, OpenAiCredential>;
928
929/// Resolve one independent Anthropic-wire credential for each provider id.
930/// Stored credentials win over `models.json`; only the built-in `anthropic`
931/// identity may fall back to the `ANTHROPIC_*` environment variables.
932fn resolve_anthropic_credentials(
933    cfg: &config::ModelsConfig,
934    auth_store: &config::AuthStore,
935    manifests: &[crate::ProviderDeclaration],
936    values: &dyn ValueSource,
937) -> AnthropicCredentials {
938    let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
939    for (provider_id, provider_cfg) in &cfg.providers {
940        if config::provider_is_anthropic_compatible(provider_cfg)
941            && !provider_ids.iter().any(|known| known == provider_id)
942        {
943            provider_ids.push(provider_id.clone());
944        }
945    }
946
947    let mut credentials = BTreeMap::new();
948    for provider_id in provider_ids {
949        let resolved = crate::credentials::resolve(
950            &provider_id,
951            cfg.providers.get(&provider_id),
952            auth_store,
953            manifests.iter().find(|manifest| manifest.id == provider_id),
954            values,
955        );
956        let credential = resolved
957            .key
958            .map(|key| anthropic_credential_from_key(key, resolved.bearer));
959        if let Some(credential) = credential {
960            credentials.insert(provider_id, credential);
961        }
962    }
963    credentials
964}
965
966fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
967    if auth_header {
968        AnthropicCredential::Headers(BTreeMap::from([(
969            "authorization".to_string(),
970            format!("Bearer {key}"),
971        )]))
972    } else {
973        AnthropicCredential::ProviderKey(key)
974    }
975}
976
977fn anthropic_credential_for<'a>(
978    credentials: &'a AnthropicCredentials,
979    provider_id: &str,
980) -> Option<&'a AnthropicCredential> {
981    credentials.get(provider_id)
982}
983
984fn credential_for_selected_anthropic_provider(
985    cli_api_key: Option<&str>,
986    credentials: &AnthropicCredentials,
987    provider_id: &str,
988    auth_header: bool,
989) -> Option<AnthropicCredential> {
990    cli_api_key
991        .filter(|key| !key.is_empty())
992        .map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
993        .or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
994}
995
996fn resolve_openai_credentials(
997    cfg: &config::ModelsConfig,
998    auth_store: &config::AuthStore,
999    manifests: &[crate::ProviderDeclaration],
1000    values: &dyn ValueSource,
1001) -> OpenAiCredentials {
1002    let mut provider_ids = vec!["openai".to_string()];
1003    for (provider_id, provider_cfg) in &cfg.providers {
1004        if (config::provider_is_openai_completions(provider_cfg)
1005            || config::provider_is_openai_responses(provider_cfg))
1006            && !provider_ids.contains(provider_id)
1007        {
1008            provider_ids.push(provider_id.clone());
1009        }
1010    }
1011    for manifest in manifests {
1012        if !provider_ids.contains(&manifest.id) {
1013            provider_ids.push(manifest.id.clone());
1014        }
1015    }
1016    let mut credentials = BTreeMap::new();
1017    for provider_id in provider_ids {
1018        let provider_cfg = cfg.providers.get(&provider_id);
1019        let manifest = manifests.iter().find(|manifest| manifest.id == provider_id);
1020        let resolved =
1021            crate::credentials::resolve(&provider_id, provider_cfg, auth_store, manifest, values);
1022        // Anthropic keys are represented by their own map, preserving bearer vs
1023        // x-api-key semantics. Anonymous declarations apply to every protocol.
1024        if provider_cfg.is_some_and(config::provider_is_anthropic_compatible) {
1025            if matches!(
1026                resolved.source,
1027                crate::credentials::CredentialSource::Anonymous
1028            ) {
1029                credentials.insert(provider_id, OpenAiCredential::Anonymous);
1030            }
1031        } else if let Some(key) = resolved.key {
1032            credentials.insert(provider_id, OpenAiCredential::Key(key));
1033        } else if matches!(
1034            resolved.source,
1035            crate::credentials::CredentialSource::Anonymous
1036        ) {
1037            credentials.insert(provider_id, OpenAiCredential::Anonymous);
1038        }
1039    }
1040    credentials
1041}
1042
1043fn openai_credential_for<'a>(
1044    credentials: &'a OpenAiCredentials,
1045    provider_id: &str,
1046) -> Option<&'a String> {
1047    match credentials.get(provider_id) {
1048        Some(OpenAiCredential::Key(key)) => Some(key),
1049        _ => None,
1050    }
1051}
1052
1053fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
1054    let headers = model.headers.get_or_insert_with(BTreeMap::new);
1055    for (name, value) in auth_headers {
1056        headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
1057        headers.insert(name.clone(), value.clone());
1058    }
1059}
1060
1061struct SplitModelPattern {
1062    provider: Option<String>,
1063    model_id: String,
1064    raw_model_id: String,
1065    thinking: Option<ThinkingLevel>,
1066    inferred_provider: bool,
1067}
1068
1069/// Split a `--model` value into its provider, id, and thinking components.
1070///
1071/// A slash prefix is provider syntax only when it names a known provider (or
1072/// matches an explicit `--provider`). Unknown prefixes stay in the raw model
1073/// id, which is required for OpenRouter-style ids such as
1074/// `meta-llama/llama-3.3`.
1075///
1076/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
1077fn split_model_pattern(
1078    value: &str,
1079    cli_provider: Option<&str>,
1080    catalog: &[Model],
1081    cfg: &config::ModelsConfig,
1082) -> Result<SplitModelPattern, ResolveError> {
1083    let mut provider = cli_provider.map(str::to_string);
1084    let mut model_id = value.to_string();
1085    let mut inferred_provider = false;
1086
1087    if let Some((prefix, remainder)) = value.split_once('/') {
1088        if !prefix.is_empty() && !remainder.is_empty() {
1089            let matches_explicit = cli_provider
1090                .and_then(|requested| {
1091                    canonicalize_cli_provider(prefix, cfg)
1092                        .ok()
1093                        .map(|canonical| canonical == requested)
1094                })
1095                .unwrap_or(false);
1096            if matches_explicit {
1097                model_id = remainder.to_string();
1098            } else if cli_provider.is_none() {
1099                match canonicalize_cli_provider(prefix, cfg) {
1100                    Ok(canonical) => {
1101                        provider = Some(canonical);
1102                        model_id = remainder.to_string();
1103                        inferred_provider = true;
1104                    }
1105                    Err(ResolveError::UnknownProvider(_)) => {}
1106                    Err(error) => return Err(error),
1107                }
1108            }
1109        }
1110    }
1111
1112    // upstream attempts the complete id first. Only peel a valid thinking
1113    // suffix when that complete id does not exist in the selected scope.
1114    let has_full_exact_match = catalog.iter().any(|model| {
1115        model.id.eq_ignore_ascii_case(&model_id)
1116            && provider
1117                .as_deref()
1118                .map_or(true, |requested| provider_matches(model, requested, cfg))
1119    });
1120    let thinking = if has_full_exact_match {
1121        None
1122    } else if let Some((head, suffix)) = model_id.rsplit_once(':') {
1123        if let Some(level) = parse_thinking_level(suffix) {
1124            model_id = head.to_string();
1125            Some(level)
1126        } else {
1127            None
1128        }
1129    } else {
1130        None
1131    };
1132
1133    Ok(SplitModelPattern {
1134        provider,
1135        model_id,
1136        raw_model_id: value.to_string(),
1137        thinking,
1138        inferred_provider,
1139    })
1140}
1141
1142/// Case-insensitive exact id match, optionally scoped to a provider.
1143fn find_model(
1144    pattern: &str,
1145    provider: Option<&str>,
1146    catalog: &[Model],
1147    cfg: &config::ModelsConfig,
1148) -> Option<Model> {
1149    catalog
1150        .iter()
1151        .find(|model| {
1152            model.id.eq_ignore_ascii_case(pattern)
1153                && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1154        })
1155        .cloned()
1156}
1157
1158/// Resolve an exact CLI model reference without silently choosing the first
1159/// provider when a bare model id is duplicated. This mirrors upstream's
1160/// `resolveCliModel`: a sole configured-auth match wins; zero or multiple
1161/// configured-auth matches require an explicit provider.
1162fn find_cli_model(
1163    pattern: &str,
1164    provider: Option<&str>,
1165    catalog: &[Model],
1166    cfg: &config::ModelsConfig,
1167    anthropic_credentials: &AnthropicCredentials,
1168    openai_credentials: &OpenAiCredentials,
1169) -> Result<Option<Model>, ResolveError> {
1170    let exact_matches: Vec<&Model> = catalog
1171        .iter()
1172        .filter(|model| {
1173            model.id.eq_ignore_ascii_case(pattern)
1174                && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1175        })
1176        .collect();
1177
1178    match exact_matches.as_slice() {
1179        [] => Ok(None),
1180        [model] => Ok(Some((*model).clone())),
1181        _ => {
1182            let authenticated: Vec<&Model> = exact_matches
1183                .iter()
1184                .copied()
1185                .filter(|model| {
1186                    model_is_authed_for_resolution(
1187                        model,
1188                        anthropic_credentials,
1189                        openai_credentials,
1190                        false,
1191                    )
1192                })
1193                .collect();
1194            if let [model] = authenticated.as_slice() {
1195                return Ok(Some((*model).clone()));
1196            }
1197
1198            let mut matches = exact_matches
1199                .iter()
1200                .map(|model| format!("{}/{}", model.provider, model.id))
1201                .collect::<Vec<_>>();
1202            matches.sort();
1203            let auth_hint = if authenticated.is_empty() {
1204                "No matching provider is authenticated."
1205            } else {
1206                "More than one matching provider is authenticated."
1207            };
1208            Err(ResolveError::AmbiguousModel {
1209                pattern: pattern.to_string(),
1210                matches: matches.join(", "),
1211                auth_hint,
1212            })
1213        }
1214    }
1215}
1216
1217fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
1218    cfg.providers.contains_key(requested)
1219        || requested.eq_ignore_ascii_case("anthropic")
1220        || requested.eq_ignore_ascii_case("openai")
1221        || requested.eq_ignore_ascii_case("openai-completions")
1222        || requested.eq_ignore_ascii_case("openai-responses")
1223}
1224
1225/// Canonicalize user-facing `--provider` input without weakening provider
1226/// identity. Exact configured ids win. A case-insensitive custom match is
1227/// accepted only when unique; otherwise the user must provide exact casing so
1228/// credentials and endpoints can never cross between distinct ids.
1229fn canonicalize_cli_provider(
1230    requested: &str,
1231    cfg: &config::ModelsConfig,
1232) -> Result<String, ResolveError> {
1233    if cfg.providers.contains_key(requested) {
1234        return Ok(requested.to_string());
1235    }
1236
1237    const BUILTIN_PROVIDER_IDS: &[&str] = &[
1238        "anthropic",
1239        "openai",
1240        "openai-completions",
1241        "openai-responses",
1242    ];
1243    if BUILTIN_PROVIDER_IDS.contains(&requested) {
1244        return Ok(requested.to_string());
1245    }
1246
1247    let mut matches = cfg
1248        .providers
1249        .keys()
1250        .filter(|provider| provider.eq_ignore_ascii_case(requested))
1251        .cloned()
1252        .collect::<Vec<_>>();
1253    matches.extend(
1254        BUILTIN_PROVIDER_IDS
1255            .iter()
1256            .filter(|provider| provider.eq_ignore_ascii_case(requested))
1257            .map(|provider| (*provider).to_string()),
1258    );
1259    matches.sort();
1260    matches.dedup();
1261
1262    match matches.as_slice() {
1263        [provider] => return Ok(provider.clone()),
1264        [] => {}
1265        _ => {
1266            return Err(ResolveError::AmbiguousProvider {
1267                requested: requested.to_string(),
1268                matches: matches.join(", "),
1269            });
1270        }
1271    }
1272
1273    Err(ResolveError::UnknownProvider(requested.to_string()))
1274}
1275
1276fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
1277    // Configured provider ids are exact identities in upstream. An exact
1278    // config match takes precedence over the case-insensitive built-in/protocol
1279    // aliases below, so a custom `Anthropic` remains distinct from `anthropic`.
1280    if model.provider == requested {
1281        return true;
1282    }
1283    if cfg.providers.contains_key(requested) {
1284        return false;
1285    }
1286    if requested.eq_ignore_ascii_case("anthropic") {
1287        return model.provider == DEFAULT_PROVIDER_ID;
1288    }
1289    if requested.eq_ignore_ascii_case("openai") {
1290        return model.provider == "openai";
1291    }
1292    if requested.eq_ignore_ascii_case("openai-completions") {
1293        return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
1294    }
1295    if requested.eq_ignore_ascii_case("openai-responses") {
1296        return matches!(model.api, rpi_ai::Api::OpenaiResponses);
1297    }
1298    false
1299}
1300
1301/// Provider identity matching for upstream's default-model table. Unlike the
1302/// CLI matcher, this intentionally does not treat `openai` as a protocol alias:
1303/// a default belonging to OpenAI must not select the same model id from an
1304/// unrelated OpenAI-compatible gateway.
1305fn model_belongs_to_default_provider(
1306    model: &Model,
1307    requested: &str,
1308    _cfg: &config::ModelsConfig,
1309) -> bool {
1310    model.provider == requested
1311}
1312
1313/// Whether a catalog model is "configured-auth" — i.e. the request built for it
1314/// would pass `assertRequestAuth` and not return "No API key". Mirrors the TS
1315/// `hasConfiguredAuth(providerId)` filter that `getAvailableSnapshot()` applies
1316/// (`available = all.filter(m => configuredProviders.has(m.provider))`).
1317///
1318/// The selected provider snapshot is identity-homogeneous. A model is runnable
1319/// when its own headers carry auth or that selected provider has a default key.
1320fn model_is_authed_for_resolution(
1321    model: &Model,
1322    anthropic_credentials: &AnthropicCredentials,
1323    openai_credentials: &OpenAiCredentials,
1324    has_cli_key: bool,
1325) -> bool {
1326    openai_credentials.contains_key(&model.provider)
1327        || model_has_header_auth(model)
1328        || has_cli_key
1329        || match model.api {
1330            rpi_ai::Api::AnthropicMessages => {
1331                anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
1332            }
1333            rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
1334                openai_credential_for(openai_credentials, &model.provider).is_some()
1335            }
1336            _ => false,
1337        }
1338}
1339
1340/// Same three-name check as rpi-ai's `has_header_auth`, but called from the
1341/// CLI layer (rpi-ai's `has_header_auth` is private to the provider module, so
1342/// we mirror it here over the model's `headers` map).
1343fn model_has_header_auth(m: &Model) -> bool {
1344    crate::credentials::has_header_auth(m.headers.as_ref())
1345}
1346
1347/// Choose the default model when `--model` is absent. Mirrors upstream
1348/// Upstream `findInitialModel` behavior:
1349/// known-provider defaults are checked in native declaration order, followed by
1350/// the first authenticated model in the catalog (`availableModels[0]`). This
1351/// also keeps a models.json-only gateway from accidentally selecting an
1352/// unauthenticated built-in model.
1353///
1354/// Credential maps are keyed by provider id, so only a credential belonging to
1355/// the candidate model can make it participate in default selection.
1356fn pick_default_model(
1357    catalog: &[Model],
1358    models_cfg: &config::ModelsConfig,
1359    anthropic_credentials: &AnthropicCredentials,
1360    openai_credentials: &OpenAiCredentials,
1361    has_cli_key: bool,
1362) -> Model {
1363    // 1. Core provider defaults, in their declared priority order.
1364    for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
1365        if let Some(model) = catalog.iter().find(|model| {
1366            model.id.eq_ignore_ascii_case(model_id)
1367                && model_belongs_to_default_provider(model, provider, models_cfg)
1368                && model_is_authed_for_resolution(
1369                    model,
1370                    anthropic_credentials,
1371                    openai_credentials,
1372                    has_cli_key,
1373                )
1374        }) {
1375            return model.clone();
1376        }
1377    }
1378
1379    // 2. First authed model (TS `availableModels[0]`). Provider and model array
1380    //    declaration order is preserved while loading models.json.
1381    //    this is the gateway model (Bearer folded onto it, base_url = gateway).
1382    if let Some(m) = catalog.iter().find(|m| {
1383        model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_key)
1384    }) {
1385        return m.clone();
1386    }
1387    // 3. Last resort: the built-in Anthropic default, authed or not. The auth gate above
1388    //    already errored when no source resolved, so reaching here means *some*
1389    //    auth exists but none folded/attached to a model we can see — keep the
1390    //    historical default to avoid a NoMatch surprise.
1391    catalog
1392        .iter()
1393        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
1394        .or_else(|| catalog.first())
1395        .expect("catalog is never empty (built-in anthropic_models)")
1396        .clone()
1397}
1398
1399#[cfg(test)]
1400mod tests;