1use 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
30pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
33
34const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] =
37 &[("anthropic", DEFAULT_MODEL_ID), ("openai", "gpt-5.5")];
38
39pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
43
44#[derive(Clone)]
47pub struct ResolvedModel {
48 pub provider: Arc<dyn Provider>,
50 pub model: Model,
52 pub thinking_level: ThinkingLevel,
55 pub has_provider_key: bool,
61 pub theme: Option<String>,
65 pub providers: Vec<Arc<dyn Provider>>,
69 pub compat_warnings: Vec<String>,
72 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
90pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
92
93pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
99
100pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
104
105pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
108
109pub 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#[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
147fn provider_http_client(
150 models: &[Model],
151 default_base_url: &str,
152 settings: &RuntimeSettings,
153 values: &dyn ValueSource,
154) -> Result<reqwest::Client, ResolveError> {
155 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 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 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 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 let cli_api_key = cli_api_key
275 .filter(|key| !key.is_empty())
276 .map(str::to_string);
277
278 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 let mut catalog = build_catalog(&models_cfg, &factories.manifests, values);
287
288 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 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 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 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 let thinking_level = cli_thinking
449 .or(pattern_thinking)
450 .unwrap_or(DEFAULT_THINKING_LEVEL);
451 (model, thinking_level)
452 }
453 None => {
454 let settings_thinking = settings
460 .default_thinking_level
461 .as_deref()
462 .and_then(parse_thinking_level);
463
464 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 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 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 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 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
722type 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
762pub 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 .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
781pub 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
794pub 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}
803pub(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 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
903fn 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 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 ProviderKey(String),
935 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#[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 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
1105fn 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 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
1178fn 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
1194fn 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
1261fn 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 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
1337fn model_belongs_to_default_provider(
1342 model: &Model,
1343 requested: &str,
1344 _cfg: &config::ModelsConfig,
1345) -> bool {
1346 model.provider == requested
1347}
1348
1349fn 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
1376fn model_has_header_auth(m: &Model) -> bool {
1380 crate::credentials::has_header_auth(m.headers.as_ref())
1381}
1382
1383fn 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 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 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 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;