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 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 let cli_api_key = cli_api_key
264 .filter(|key| !key.is_empty())
265 .map(str::to_string);
266
267 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 let mut catalog = build_catalog(&models_cfg, &factories.manifests, values);
276
277 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 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 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 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 let thinking_level = cli_thinking
438 .or(pattern_thinking)
439 .unwrap_or(DEFAULT_THINKING_LEVEL);
440 (model, thinking_level)
441 }
442 None => {
443 let settings_thinking = settings
449 .default_thinking_level
450 .as_deref()
451 .and_then(parse_thinking_level);
452
453 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 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 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 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 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
708type 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
745pub 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 .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
764pub 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
777pub 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}
786pub(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 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
886fn 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 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 ProviderKey(String),
918 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
929fn 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 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
1069fn 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 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
1142fn 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
1158fn 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
1225fn 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 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
1301fn model_belongs_to_default_provider(
1306 model: &Model,
1307 requested: &str,
1308 _cfg: &config::ModelsConfig,
1309) -> bool {
1310 model.provider == requested
1311}
1312
1313fn 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
1340fn model_has_header_auth(m: &Model) -> bool {
1344 crate::credentials::has_header_auth(m.headers.as_ref())
1345}
1346
1347fn 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 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 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 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;