1use std::collections::BTreeMap;
69use std::sync::Arc;
70
71use rpi_ai::providers::anthropic::models::anthropic_models;
72use rpi_ai::providers::anthropic::AnthropicProvider;
73use rpi_ai::providers::openai_completions::OpenAiCompletionsProvider;
74use rpi_ai::providers::openai_responses::openai_responses_models;
75use rpi_ai::providers::openai_responses::OpenAiResponsesProvider;
76use rpi_ai::{
77 AssistantMessageEventStream, Context, Model, Provider, SimpleStreamOptions, ThinkingLevel,
78};
79
80mod binding;
81mod factories;
82use binding::{ExecutionBinding, RequestAuth};
83use factories::RuntimeFactories;
84
85use crate::args::parse_thinking_level;
86#[cfg(test)]
87use crate::config::Credential;
88use crate::config::{self, DEFAULT_PROVIDER_ID};
89use crate::settings;
90
91pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
94
95const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] =
98 &[("anthropic", DEFAULT_MODEL_ID), ("openai", "gpt-5.5")];
99
100pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
104
105#[derive(Clone)]
108pub struct ResolvedModel {
109 pub provider: Arc<dyn Provider>,
111 pub model: Model,
113 pub thinking_level: ThinkingLevel,
116 pub has_provider_key: bool,
122 pub theme: Option<String>,
126 pub providers: Vec<Arc<dyn Provider>>,
130 execution: Arc<ExecutionBinding>,
132}
133
134impl std::fmt::Debug for ResolvedModel {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("ResolvedModel")
137 .field("provider", &self.provider.id())
138 .field("model", &self.model.id)
139 .field("thinking_level", &self.thinking_level)
140 .field("has_provider_key", &self.has_provider_key)
141 .field("theme", &self.theme)
142 .finish()
143 }
144}
145
146pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
148
149pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
155
156pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
160
161pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
164
165pub const NO_API_KEY_HINT: &str =
168 "models.json apiKey, OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login`";
169
170#[derive(Debug, thiserror::Error)]
174pub enum ResolveError {
175 #[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, openai-responses, a models.json provider id, or an installed provider package")]
176 UnknownProvider(String),
177 #[error(
178 "Provider \"{requested}\" is ambiguous: {matches}. Use the exact provider id to select one."
179 )]
180 AmbiguousProvider { requested: String, matches: String },
181 #[error("No model matches \"{pattern}\". Available: {available}")]
182 NoMatch { pattern: String, available: String },
183 #[error(
184 "Model \"{pattern}\" is ambiguous across providers: {matches}. {auth_hint} Use --provider or provider/model."
185 )]
186 AmbiguousModel {
187 pattern: String,
188 matches: String,
189 auth_hint: &'static str,
190 },
191 #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
192 InvalidThinkingLevel(String, String),
193 #[error("No API key. Set one of: {hint}")]
194 NoApiKey { hint: &'static str },
195 #[error("Could not read config: {0}")]
196 Config(#[from] config::ConfigError),
197}
198
199fn provider_http_client(
212 models: &[Model],
213 default_base_url: &str,
214 settings: &settings::Settings,
215) -> reqwest::Client {
216 let base_url = models
217 .iter()
218 .map(|m| m.base_url.as_str())
219 .find(|u| !u.is_empty())
220 .unwrap_or(default_base_url);
221 rpi_ai::http::build_client(base_url, settings.http_idle_timeout_ms(), None)
222 .unwrap_or_else(|_| reqwest::Client::new())
223}
224
225pub fn resolve(
226 cli_provider: Option<&str>,
227 cli_model: Option<&str>,
228 cli_thinking: Option<ThinkingLevel>,
229 cli_api_key: Option<&str>,
230 cli_base_url: Option<&str>,
231) -> Result<ResolvedModel, ResolveError> {
232 let settings = settings::load_settings().unwrap_or_default();
233 resolve_with_settings(
234 cli_provider,
235 cli_model,
236 cli_thinking,
237 cli_api_key,
238 cli_base_url,
239 settings,
240 )
241}
242
243pub fn resolve_for_cwd(
245 cli_provider: Option<&str>,
246 cli_model: Option<&str>,
247 cli_thinking: Option<ThinkingLevel>,
248 cli_api_key: Option<&str>,
249 cli_base_url: Option<&str>,
250 cwd: &std::path::Path,
251 project_trusted: bool,
252) -> Result<ResolvedModel, ResolveError> {
253 let settings = settings::load_effective_model_settings(cwd, project_trusted)?;
254 resolve_with_settings(
255 cli_provider,
256 cli_model,
257 cli_thinking,
258 cli_api_key,
259 cli_base_url,
260 settings,
261 )
262}
263
264fn resolve_with_settings(
265 cli_provider: Option<&str>,
266 cli_model: Option<&str>,
267 cli_thinking: Option<ThinkingLevel>,
268 cli_api_key: Option<&str>,
269 cli_base_url: Option<&str>,
270 settings: settings::Settings,
271) -> Result<ResolvedModel, ResolveError> {
272 resolve_with_factories(
273 cli_provider,
274 cli_model,
275 cli_thinking,
276 cli_api_key,
277 cli_base_url,
278 settings,
279 &RuntimeFactories::default(),
280 )
281}
282
283pub fn resolve_with_extensions(
284 args: &crate::args::Args,
285 cwd: &std::path::Path,
286 trusted: bool,
287 session: &rpi_extensions::ExtensionSession,
288) -> Result<ResolvedModel, ResolveError> {
289 let settings = settings::load_effective_model_settings(cwd, trusted)?;
290 resolve_with_factories(
291 args.provider.as_deref(),
292 args.model.as_deref(),
293 args.thinking,
294 args.api_key.as_deref(),
295 args.base_url.as_deref(),
296 settings,
297 &RuntimeFactories::from_session(session),
298 )
299}
300
301fn resolve_with_factories(
302 cli_provider: Option<&str>,
303 cli_model: Option<&str>,
304 cli_thinking: Option<ThinkingLevel>,
305 cli_api_key: Option<&str>,
306 cli_base_url: Option<&str>,
307 settings: settings::Settings,
308 factories: &RuntimeFactories,
309) -> Result<ResolvedModel, ResolveError> {
310 let mut models_cfg = config::load_models_config()?;
314 apply_manifest_defaults(&mut models_cfg, &factories.manifests);
315 let canonical_cli_provider = cli_provider
316 .map(|requested| canonicalize_cli_provider(requested, &models_cfg))
317 .transpose()?;
318 let cli_provider = canonical_cli_provider.as_deref();
319 let auth_store = config::read_auth()?;
320 let anthropic_credentials =
321 resolve_anthropic_credentials(&models_cfg, &auth_store, &factories.manifests);
322 let openai_credentials =
323 resolve_openai_credentials(&models_cfg, &auth_store, &factories.manifests);
324 let cli_api_key = cli_api_key
327 .filter(|key| !key.is_empty())
328 .map(str::to_string);
329
330 let cli_base_url_override = cli_base_url.map(str::to_string);
332 let anthropic_base_url_override = std::env::var(ANTHROPIC_BASE_URL_ENV)
333 .ok()
334 .filter(|value| !value.is_empty());
335
336 let mut catalog = build_catalog(&models_cfg, &factories.manifests);
339
340 for model in &mut catalog {
344 if matches!(model.api, rpi_ai::Api::AnthropicMessages)
345 && model.provider == DEFAULT_PROVIDER_ID
346 {
347 if let Some(base) = &anthropic_base_url_override {
348 model.base_url = base.clone();
349 }
350 }
351 }
352
353 let mut full_catalog = catalog.clone();
356
357 if let Some(requested) = cli_provider {
358 catalog.retain(|model| provider_matches(model, requested, &models_cfg));
359 }
360
361 let available = catalog
362 .iter()
363 .map(|m| m.id.clone())
364 .collect::<Vec<_>>()
365 .join(", ");
366 if catalog.is_empty() {
367 return Err(ResolveError::NoMatch {
368 pattern: cli_provider.unwrap_or("default").to_string(),
369 available,
370 });
371 }
372
373 let (mut model, thinking_level) = match cli_model {
382 Some(raw) => {
383 let parsed_pattern = split_model_pattern(raw, cli_provider, &catalog, &models_cfg)?;
384 if let Some(provider) = parsed_pattern.provider.as_deref() {
385 if !provider_is_known(provider, &models_cfg) {
386 return Err(ResolveError::UnknownProvider(provider.to_string()));
387 }
388 }
389 let mut pattern_thinking = parsed_pattern.thinking;
390 let mut selected = find_cli_model(
391 &parsed_pattern.model_id,
392 parsed_pattern.provider.as_deref(),
393 &catalog,
394 &models_cfg,
395 &anthropic_credentials,
396 &openai_credentials,
397 )?;
398
399 if selected.is_none() && !parsed_pattern.inferred_provider {
403 selected = find_cli_model(
404 &parsed_pattern.raw_model_id,
405 cli_provider,
406 &catalog,
407 &models_cfg,
408 &anthropic_credentials,
409 &openai_credentials,
410 )?;
411 if selected.is_some() {
412 pattern_thinking = None;
413 }
414 }
415 if parsed_pattern.inferred_provider {
416 if let Some(inferred) = selected.as_ref() {
417 if !model_is_authed_for_resolution(
418 inferred,
419 &anthropic_credentials,
420 &openai_credentials,
421 false,
422 ) {
423 let authenticated_raw_matches = |candidate: &str| {
424 catalog
425 .iter()
426 .filter(|model| {
427 model.id.eq_ignore_ascii_case(candidate)
428 && (model.provider != inferred.provider
429 || model.id != inferred.id)
430 && model_is_authed_for_resolution(
431 model,
432 &anthropic_credentials,
433 &openai_credentials,
434 false,
435 )
436 })
437 .collect::<Vec<_>>()
438 };
439 let mut raw_matches =
440 authenticated_raw_matches(&parsed_pattern.raw_model_id);
441 let mut matched_complete_raw_id = true;
442 if raw_matches.is_empty() && parsed_pattern.thinking.is_some() {
443 if let Some((raw_without_thinking, _)) =
444 parsed_pattern.raw_model_id.rsplit_once(':')
445 {
446 raw_matches = authenticated_raw_matches(raw_without_thinking);
447 matched_complete_raw_id = false;
448 }
449 }
450 if let [raw_match] = raw_matches.as_slice() {
451 selected = Some((*raw_match).clone());
452 if matched_complete_raw_id {
453 pattern_thinking = None;
454 }
455 }
456 }
457 } else {
458 selected = find_cli_model(
459 &parsed_pattern.raw_model_id,
460 None,
461 &catalog,
462 &models_cfg,
463 &anthropic_credentials,
464 &openai_credentials,
465 )?;
466 if selected.is_some() {
467 pattern_thinking = None;
468 } else if parsed_pattern.thinking.is_some() {
469 if let Some((raw_without_thinking, _)) =
470 parsed_pattern.raw_model_id.rsplit_once(':')
471 {
472 selected = find_cli_model(
473 raw_without_thinking,
474 None,
475 &catalog,
476 &models_cfg,
477 &anthropic_credentials,
478 &openai_credentials,
479 )?;
480 }
481 }
482 }
483 }
484
485 let model = match selected {
486 Some(m) => m,
487 None => {
488 return Err(ResolveError::NoMatch {
489 pattern: parsed_pattern.model_id,
490 available,
491 });
492 }
493 };
494 let thinking_level = cli_thinking
497 .or(pattern_thinking)
498 .unwrap_or(DEFAULT_THINKING_LEVEL);
499 (model, thinking_level)
500 }
501 None => {
502 let settings_thinking = settings
508 .default_thinking_level
509 .as_deref()
510 .and_then(parse_thinking_level);
511
512 let saved_provider = settings
522 .default_provider
523 .as_deref()
524 .filter(|provider| provider_is_known(provider, &models_cfg));
525 let saved = settings.default_model.as_deref().and_then(|id| {
526 if settings.default_provider.is_some() && saved_provider.is_none() {
527 return None;
528 }
529 find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
530 model_is_authed_for_resolution(
531 m,
532 &anthropic_credentials,
533 &openai_credentials,
534 cli_api_key.is_some(),
535 )
536 })
537 });
538 if let Some(model) = saved {
539 let thinking_level = cli_thinking
540 .or(settings_thinking)
541 .unwrap_or(DEFAULT_THINKING_LEVEL);
542 (model, thinking_level)
543 } else {
544 let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
546 let model = pick_default_model(
547 &catalog,
548 &models_cfg,
549 &anthropic_credentials,
550 &openai_credentials,
551 cli_api_key.is_some(),
552 );
553 (model, thinking_level)
554 }
555 }
556 };
557
558 let selected_api = model.api.clone();
560 let selected_provider = model.provider.clone();
561 if let Some(base) = &cli_base_url_override {
562 model.base_url = base.clone();
563 for candidate in &mut full_catalog {
564 if candidate.provider == selected_provider && candidate.api == selected_api {
565 candidate.base_url = base.clone();
566 }
567 }
568 }
569 let selected_anthropic_credential = if matches!(selected_api, rpi_ai::Api::AnthropicMessages) {
570 let auth_header = models_cfg
571 .providers
572 .get(&selected_provider)
573 .and_then(|provider| provider.auth_header)
574 .unwrap_or(false);
575 credential_for_selected_anthropic_provider(
576 cli_api_key.as_deref(),
577 &anthropic_credentials,
578 &selected_provider,
579 auth_header,
580 )
581 } else {
582 None
583 };
584 let selected_openai_key = if selected_api != rpi_ai::Api::AnthropicMessages {
585 cli_api_key
586 .clone()
587 .or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
588 } else {
589 None
590 };
591 let selected_is_authed = openai_credentials.contains_key(&selected_provider)
592 || model_has_header_auth(&model)
593 || match selected_api {
594 rpi_ai::Api::AnthropicMessages => selected_anthropic_credential.is_some(),
595 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
596 selected_openai_key.is_some()
597 }
598 _ => false,
599 };
600 if !selected_is_authed {
601 return Err(ResolveError::NoApiKey {
602 hint: NO_API_KEY_HINT,
603 });
604 }
605
606 let (provider, execution, has_provider_key) = build_runtime(
607 &selected_provider,
608 &selected_api,
609 &full_catalog,
610 selected_anthropic_credential.as_ref(),
611 selected_openai_key.as_deref(),
612 &settings,
613 factories,
614 );
615
616 let mut providers: Vec<Arc<dyn Provider>> = vec![provider.clone()];
620 {
621 let mut seen: Vec<(String, rpi_ai::Api)> =
622 vec![(selected_provider.clone(), selected_api.clone())];
623 for candidate in &full_catalog {
624 let key = (candidate.provider.clone(), candidate.api.clone());
625 if seen.contains(&key) {
626 continue;
627 }
628 seen.push(key.clone());
629 let (anth, openai_key) = match &candidate.api {
630 rpi_ai::Api::AnthropicMessages => {
631 let auth_header = models_cfg
632 .providers
633 .get(&candidate.provider)
634 .and_then(|p| p.auth_header)
635 .unwrap_or(false);
636 (
637 credential_for_selected_anthropic_provider(
638 None,
639 &anthropic_credentials,
640 &candidate.provider,
641 auth_header,
642 ),
643 None,
644 )
645 }
646 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => (
647 None,
648 openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
649 ),
650 _ => (
651 None,
652 openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
653 ),
654 };
655 let authed = openai_credentials.contains_key(&candidate.provider)
656 || model_has_header_auth(candidate)
657 || match candidate.api {
658 rpi_ai::Api::AnthropicMessages => anth.is_some(),
659 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
660 openai_key.is_some()
661 }
662 _ => false,
663 };
664 if !authed {
665 continue;
666 }
667 let (runtime, _, _) = build_runtime(
668 &candidate.provider,
669 &candidate.api,
670 &full_catalog,
671 anth.as_ref(),
672 openai_key.as_deref(),
673 &settings,
674 factories,
675 );
676 providers.push(runtime);
677 }
678 }
679
680 let providers = factories::combine_provider_runtimes(providers);
681 let provider = providers
682 .iter()
683 .find(|provider| provider.id() == selected_provider)
684 .expect("selected provider is registered")
685 .clone();
686
687 model = execution
690 .models()
691 .iter()
692 .find(|candidate| {
693 candidate.api == model.api && candidate.id.eq_ignore_ascii_case(&model.id)
694 })
695 .expect("selected model is bound")
696 .clone();
697 let resolved = ResolvedModel {
698 provider,
699 model,
700 thinking_level,
701 has_provider_key,
702 theme: settings.theme.clone(),
703 providers,
704 execution,
705 };
706 debug_assert!(resolved
707 .execution
708 .models()
709 .iter()
710 .all(|model| model.headers.is_none()));
711 Ok(resolved)
712}
713
714fn build_runtime(
718 provider_id: &str,
719 api: &rpi_ai::Api,
720 catalog: &[Model],
721 anthropic_credential: Option<&AnthropicCredential>,
722 openai_key: Option<&str>,
723 settings: &settings::Settings,
724 factories: &RuntimeFactories,
725) -> (Arc<dyn Provider>, Arc<ExecutionBinding>, bool) {
726 let anonymous = factories
727 .manifests
728 .iter()
729 .any(|manifest| manifest.id == provider_id && !manifest.requires_auth);
730 let provider_has_auth = anthropic_credential.is_some() || openai_key.is_some() || anonymous;
731 let models = catalog
732 .iter()
733 .filter(|model| model.api == *api && model.provider == provider_id)
734 .filter(|model| provider_has_auth || model_has_header_auth(model))
735 .cloned()
736 .collect();
737 let auth = match anthropic_credential {
738 Some(AnthropicCredential::ProviderKey(key)) => RequestAuth::ApiKey(key.clone()),
739 Some(AnthropicCredential::Headers(headers)) => RequestAuth::Headers(headers.clone()),
740 None => openai_key.map_or(RequestAuth::None, |key| RequestAuth::ApiKey(key.into())),
741 };
742 let has_provider_key = matches!(auth, RequestAuth::ApiKey(_)) || anonymous;
743 let (provider, binding) = factories
744 .configure(provider_id, api, models, auth, settings)
745 .expect("supported protocol or registered provider factory");
746 (provider, binding, has_provider_key)
747}
748
749pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
752 let selected_api = &resolved.model.api;
753 let selected_provider = &resolved.model.provider;
754 let mut seen = std::collections::HashSet::new();
755 resolved
756 .provider
757 .models()
758 .iter()
759 .filter(|m| m.api == *selected_api && m.provider == *selected_provider)
763 .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
764 .cloned()
765 .collect()
766}
767
768pub fn authenticated_catalog(resolved: &ResolvedModel) -> Vec<Model> {
771 let mut seen = std::collections::HashSet::new();
772 resolved
773 .providers
774 .iter()
775 .flat_map(|provider| provider.models())
776 .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
777 .cloned()
778 .collect()
779}
780
781pub fn catalog_all() -> Result<Vec<Model>, config::ConfigError> {
785 catalog_with_manifests(&[])
786}
787
788pub fn manifests_from_session(
791 session: &rpi_extensions::ExtensionSession,
792) -> Vec<rpi_extensions::ProviderManifest> {
793 session.snapshot().map_or_else(Vec::new, |snapshot| {
794 snapshot
795 .providers()
796 .iter()
797 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
798 .collect()
799 })
800}
801
802pub fn catalog_with_manifests(
805 manifests: &[rpi_extensions::ProviderManifest],
806) -> Result<Vec<Model>, config::ConfigError> {
807 Ok(execution_catalog_with_manifests(manifests)?
808 .iter()
809 .map(binding::descriptor)
810 .collect())
811}
812
813pub(crate) fn execution_catalog_with_manifests(
815 manifests: &[rpi_extensions::ProviderManifest],
816) -> Result<Vec<Model>, config::ConfigError> {
817 let mut cfg = config::load_models_config()?;
818 apply_manifest_defaults(&mut cfg, manifests);
819 let mut catalog = build_catalog(&cfg, manifests);
820 catalog.sort_by(|a, b| {
821 a.provider
822 .to_ascii_lowercase()
823 .cmp(&b.provider.to_ascii_lowercase())
824 .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
825 });
826 Ok(catalog)
827}
828
829fn apply_manifest_defaults(
830 models_cfg: &mut config::ModelsConfig,
831 manifests: &[rpi_extensions::ProviderManifest],
832) {
833 for manifest in manifests {
834 if !models_cfg.providers.contains_key(&manifest.id) {
837 let first = manifest.models.first();
838 let config = serde_json::from_value(serde_json::json!({
839 "api": first.map(|model| model.api.as_str()),
840 "baseUrl": first.map(|model| model.base_url.as_str()), "models":[]
841 }))
842 .expect("provider config defaults");
843 models_cfg.providers.insert(manifest.id.clone(), config);
844 }
845 if let (Some(default), Some(config)) = (
846 manifest.models.first(),
847 models_cfg.providers.get_mut(&manifest.id),
848 ) {
849 config
850 .api
851 .get_or_insert_with(|| default.api.as_str().to_string());
852 config
853 .base_url
854 .get_or_insert_with(|| default.base_url.clone());
855 }
856 }
857}
858
859fn build_catalog(
860 cfg: &config::ModelsConfig,
861 manifests: &[rpi_extensions::ProviderManifest],
862) -> Vec<Model> {
863 let mut catalog = anthropic_models();
864 catalog.extend(openai_responses_models());
865 for manifest in manifests {
866 let config = cfg.providers.get(&manifest.id);
867 for mut model in manifest.models.clone() {
868 if let Some(base) = config.and_then(|config| config.base_url.as_ref()) {
869 model.base_url = base.clone();
870 }
871 if let Some(headers) = config.and_then(|config| config.headers.as_ref()) {
872 merge_auth_headers(&mut model, &config::resolve_headers(headers, None));
873 }
874 catalog.push(model);
875 }
876 }
877 merge_user_catalog(&mut catalog, cfg);
878 catalog
879}
880
881fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
886 for (provider_id, provider_cfg) in &cfg.providers {
887 let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
888 continue;
890 };
891 for m in models {
892 if let Some(existing) = catalog.iter_mut().find(|candidate| {
893 candidate.api == m.api
894 && candidate.provider == m.provider
895 && candidate.id.eq_ignore_ascii_case(&m.id)
896 }) {
897 *existing = m;
898 } else {
899 catalog.push(m);
900 }
901 }
902 }
903}
904
905#[derive(Clone, Debug, PartialEq, Eq)]
906enum AnthropicCredential {
907 ProviderKey(String),
909 Headers(BTreeMap<String, String>),
911}
912
913type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
914enum OpenAiCredential {
915 Key(String),
916 Anonymous,
917}
918type OpenAiCredentials = BTreeMap<String, OpenAiCredential>;
919
920fn resolve_anthropic_credentials(
924 cfg: &config::ModelsConfig,
925 auth_store: &config::AuthStore,
926 manifests: &[rpi_extensions::ProviderManifest],
927) -> AnthropicCredentials {
928 let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
929 for (provider_id, provider_cfg) in &cfg.providers {
930 if config::provider_is_anthropic_compatible(provider_cfg)
931 && !provider_ids.iter().any(|known| known == provider_id)
932 {
933 provider_ids.push(provider_id.clone());
934 }
935 }
936
937 let mut credentials = BTreeMap::new();
938 for provider_id in provider_ids {
939 let resolved = crate::credentials::resolve(
940 &provider_id,
941 cfg.providers.get(&provider_id),
942 auth_store,
943 manifests.iter().find(|manifest| manifest.id == provider_id),
944 );
945 let credential = resolved
946 .key
947 .map(|key| anthropic_credential_from_key(key, resolved.bearer));
948 if let Some(credential) = credential {
949 credentials.insert(provider_id, credential);
950 }
951 }
952 credentials
953}
954
955fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
956 if auth_header {
957 AnthropicCredential::Headers(BTreeMap::from([(
958 "authorization".to_string(),
959 format!("Bearer {key}"),
960 )]))
961 } else {
962 AnthropicCredential::ProviderKey(key)
963 }
964}
965
966fn anthropic_credential_for<'a>(
967 credentials: &'a AnthropicCredentials,
968 provider_id: &str,
969) -> Option<&'a AnthropicCredential> {
970 credentials.get(provider_id)
971}
972
973fn credential_for_selected_anthropic_provider(
974 cli_api_key: Option<&str>,
975 credentials: &AnthropicCredentials,
976 provider_id: &str,
977 auth_header: bool,
978) -> Option<AnthropicCredential> {
979 cli_api_key
980 .filter(|key| !key.is_empty())
981 .map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
982 .or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
983}
984
985fn resolve_openai_credentials(
986 cfg: &config::ModelsConfig,
987 auth_store: &config::AuthStore,
988 manifests: &[rpi_extensions::ProviderManifest],
989) -> OpenAiCredentials {
990 let mut provider_ids = vec!["openai".to_string()];
991 for (provider_id, provider_cfg) in &cfg.providers {
992 if (config::provider_is_openai_completions(provider_cfg)
993 || config::provider_is_openai_responses(provider_cfg))
994 && !provider_ids.contains(provider_id)
995 {
996 provider_ids.push(provider_id.clone());
997 }
998 }
999 for manifest in manifests {
1000 if !provider_ids.contains(&manifest.id) {
1001 provider_ids.push(manifest.id.clone());
1002 }
1003 }
1004 let mut credentials = BTreeMap::new();
1005 for provider_id in provider_ids {
1006 let provider_cfg = cfg.providers.get(&provider_id);
1007 let manifest = manifests.iter().find(|manifest| manifest.id == provider_id);
1008 let resolved =
1009 crate::credentials::resolve(&provider_id, provider_cfg, auth_store, manifest);
1010 if provider_cfg.is_some_and(config::provider_is_anthropic_compatible) {
1013 if matches!(
1014 resolved.source,
1015 crate::credentials::CredentialSource::Anonymous
1016 ) {
1017 credentials.insert(provider_id, OpenAiCredential::Anonymous);
1018 }
1019 } else if let Some(key) = resolved.key {
1020 credentials.insert(provider_id, OpenAiCredential::Key(key));
1021 } else if matches!(
1022 resolved.source,
1023 crate::credentials::CredentialSource::Anonymous
1024 ) {
1025 credentials.insert(provider_id, OpenAiCredential::Anonymous);
1026 }
1027 }
1028 credentials
1029}
1030
1031fn openai_credential_for<'a>(
1032 credentials: &'a OpenAiCredentials,
1033 provider_id: &str,
1034) -> Option<&'a String> {
1035 match credentials.get(provider_id) {
1036 Some(OpenAiCredential::Key(key)) => Some(key),
1037 _ => None,
1038 }
1039}
1040
1041fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
1042 let headers = model.headers.get_or_insert_with(BTreeMap::new);
1043 for (name, value) in auth_headers {
1044 headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
1045 headers.insert(name.clone(), value.clone());
1046 }
1047}
1048
1049struct SplitModelPattern {
1050 provider: Option<String>,
1051 model_id: String,
1052 raw_model_id: String,
1053 thinking: Option<ThinkingLevel>,
1054 inferred_provider: bool,
1055}
1056
1057fn split_model_pattern(
1066 value: &str,
1067 cli_provider: Option<&str>,
1068 catalog: &[Model],
1069 cfg: &config::ModelsConfig,
1070) -> Result<SplitModelPattern, ResolveError> {
1071 let mut provider = cli_provider.map(str::to_string);
1072 let mut model_id = value.to_string();
1073 let mut inferred_provider = false;
1074
1075 if let Some((prefix, remainder)) = value.split_once('/') {
1076 if !prefix.is_empty() && !remainder.is_empty() {
1077 let matches_explicit = cli_provider
1078 .and_then(|requested| {
1079 canonicalize_cli_provider(prefix, cfg)
1080 .ok()
1081 .map(|canonical| canonical == requested)
1082 })
1083 .unwrap_or(false);
1084 if matches_explicit {
1085 model_id = remainder.to_string();
1086 } else if cli_provider.is_none() {
1087 match canonicalize_cli_provider(prefix, cfg) {
1088 Ok(canonical) => {
1089 provider = Some(canonical);
1090 model_id = remainder.to_string();
1091 inferred_provider = true;
1092 }
1093 Err(ResolveError::UnknownProvider(_)) => {}
1094 Err(error) => return Err(error),
1095 }
1096 }
1097 }
1098 }
1099
1100 let has_full_exact_match = catalog.iter().any(|model| {
1103 model.id.eq_ignore_ascii_case(&model_id)
1104 && provider
1105 .as_deref()
1106 .map_or(true, |requested| provider_matches(model, requested, cfg))
1107 });
1108 let thinking = if has_full_exact_match {
1109 None
1110 } else if let Some((head, suffix)) = model_id.rsplit_once(':') {
1111 if let Some(level) = parse_thinking_level(suffix) {
1112 model_id = head.to_string();
1113 Some(level)
1114 } else {
1115 None
1116 }
1117 } else {
1118 None
1119 };
1120
1121 Ok(SplitModelPattern {
1122 provider,
1123 model_id,
1124 raw_model_id: value.to_string(),
1125 thinking,
1126 inferred_provider,
1127 })
1128}
1129
1130fn find_model(
1132 pattern: &str,
1133 provider: Option<&str>,
1134 catalog: &[Model],
1135 cfg: &config::ModelsConfig,
1136) -> Option<Model> {
1137 catalog
1138 .iter()
1139 .find(|model| {
1140 model.id.eq_ignore_ascii_case(pattern)
1141 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1142 })
1143 .cloned()
1144}
1145
1146fn find_cli_model(
1151 pattern: &str,
1152 provider: Option<&str>,
1153 catalog: &[Model],
1154 cfg: &config::ModelsConfig,
1155 anthropic_credentials: &AnthropicCredentials,
1156 openai_credentials: &OpenAiCredentials,
1157) -> Result<Option<Model>, ResolveError> {
1158 let exact_matches: Vec<&Model> = catalog
1159 .iter()
1160 .filter(|model| {
1161 model.id.eq_ignore_ascii_case(pattern)
1162 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1163 })
1164 .collect();
1165
1166 match exact_matches.as_slice() {
1167 [] => Ok(None),
1168 [model] => Ok(Some((*model).clone())),
1169 _ => {
1170 let authenticated: Vec<&Model> = exact_matches
1171 .iter()
1172 .copied()
1173 .filter(|model| {
1174 model_is_authed_for_resolution(
1175 model,
1176 anthropic_credentials,
1177 openai_credentials,
1178 false,
1179 )
1180 })
1181 .collect();
1182 if let [model] = authenticated.as_slice() {
1183 return Ok(Some((*model).clone()));
1184 }
1185
1186 let mut matches = exact_matches
1187 .iter()
1188 .map(|model| format!("{}/{}", model.provider, model.id))
1189 .collect::<Vec<_>>();
1190 matches.sort();
1191 let auth_hint = if authenticated.is_empty() {
1192 "No matching provider is authenticated."
1193 } else {
1194 "More than one matching provider is authenticated."
1195 };
1196 Err(ResolveError::AmbiguousModel {
1197 pattern: pattern.to_string(),
1198 matches: matches.join(", "),
1199 auth_hint,
1200 })
1201 }
1202 }
1203}
1204
1205fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
1206 cfg.providers.contains_key(requested)
1207 || requested.eq_ignore_ascii_case("anthropic")
1208 || requested.eq_ignore_ascii_case("openai")
1209 || requested.eq_ignore_ascii_case("openai-completions")
1210 || requested.eq_ignore_ascii_case("openai-responses")
1211}
1212
1213fn canonicalize_cli_provider(
1218 requested: &str,
1219 cfg: &config::ModelsConfig,
1220) -> Result<String, ResolveError> {
1221 if cfg.providers.contains_key(requested) {
1222 return Ok(requested.to_string());
1223 }
1224
1225 const BUILTIN_PROVIDER_IDS: &[&str] = &[
1226 "anthropic",
1227 "openai",
1228 "openai-completions",
1229 "openai-responses",
1230 ];
1231 if BUILTIN_PROVIDER_IDS.contains(&requested) {
1232 return Ok(requested.to_string());
1233 }
1234
1235 let mut matches = cfg
1236 .providers
1237 .keys()
1238 .filter(|provider| provider.eq_ignore_ascii_case(requested))
1239 .cloned()
1240 .collect::<Vec<_>>();
1241 matches.extend(
1242 BUILTIN_PROVIDER_IDS
1243 .iter()
1244 .filter(|provider| provider.eq_ignore_ascii_case(requested))
1245 .map(|provider| (*provider).to_string()),
1246 );
1247 matches.sort();
1248 matches.dedup();
1249
1250 match matches.as_slice() {
1251 [provider] => return Ok(provider.clone()),
1252 [] => {}
1253 _ => {
1254 return Err(ResolveError::AmbiguousProvider {
1255 requested: requested.to_string(),
1256 matches: matches.join(", "),
1257 });
1258 }
1259 }
1260
1261 Err(ResolveError::UnknownProvider(requested.to_string()))
1262}
1263
1264fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
1265 if model.provider == requested {
1269 return true;
1270 }
1271 if cfg.providers.contains_key(requested) {
1272 return false;
1273 }
1274 if requested.eq_ignore_ascii_case("anthropic") {
1275 return model.provider == DEFAULT_PROVIDER_ID;
1276 }
1277 if requested.eq_ignore_ascii_case("openai") {
1278 return model.provider == "openai";
1279 }
1280 if requested.eq_ignore_ascii_case("openai-completions") {
1281 return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
1282 }
1283 if requested.eq_ignore_ascii_case("openai-responses") {
1284 return matches!(model.api, rpi_ai::Api::OpenaiResponses);
1285 }
1286 false
1287}
1288
1289fn model_belongs_to_default_provider(
1294 model: &Model,
1295 requested: &str,
1296 _cfg: &config::ModelsConfig,
1297) -> bool {
1298 model.provider == requested
1299}
1300
1301fn model_is_authed_for_resolution(
1309 model: &Model,
1310 anthropic_credentials: &AnthropicCredentials,
1311 openai_credentials: &OpenAiCredentials,
1312 has_cli_key: bool,
1313) -> bool {
1314 openai_credentials.contains_key(&model.provider)
1315 || model_has_header_auth(model)
1316 || has_cli_key
1317 || match model.api {
1318 rpi_ai::Api::AnthropicMessages => {
1319 anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
1320 }
1321 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
1322 openai_credential_for(openai_credentials, &model.provider).is_some()
1323 }
1324 _ => false,
1325 }
1326}
1327
1328fn model_has_header_auth(m: &Model) -> bool {
1332 crate::credentials::has_header_auth(m.headers.as_ref())
1333}
1334
1335fn pick_default_model(
1345 catalog: &[Model],
1346 models_cfg: &config::ModelsConfig,
1347 anthropic_credentials: &AnthropicCredentials,
1348 openai_credentials: &OpenAiCredentials,
1349 has_cli_key: bool,
1350) -> Model {
1351 for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
1353 if let Some(model) = catalog.iter().find(|model| {
1354 model.id.eq_ignore_ascii_case(model_id)
1355 && model_belongs_to_default_provider(model, provider, models_cfg)
1356 && model_is_authed_for_resolution(
1357 model,
1358 anthropic_credentials,
1359 openai_credentials,
1360 has_cli_key,
1361 )
1362 }) {
1363 return model.clone();
1364 }
1365 }
1366
1367 if let Some(m) = catalog.iter().find(|m| {
1371 model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_key)
1372 }) {
1373 return m.clone();
1374 }
1375 catalog
1380 .iter()
1381 .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
1382 .or_else(|| catalog.first())
1383 .expect("catalog is never empty (built-in anthropic_models)")
1384 .clone()
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390 use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
1391 use crate::config::test_support::env_lock;
1392
1393 struct TestEnv {
1398 _guard: std::sync::MutexGuard<'static, ()>,
1399 prev_key: Option<std::ffi::OsString>,
1400 prev_tok: Option<std::ffi::OsString>,
1401 prev_base: Option<std::ffi::OsString>,
1402 prev_openai_key: Option<std::ffi::OsString>,
1403 prev_dir: Option<std::ffi::OsString>,
1404 _tmp: tempfile::TempDir,
1405 }
1406 impl TestEnv {
1407 fn new() -> Self {
1408 let guard = env_lock().lock().unwrap();
1409 let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
1410 let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
1411 let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
1412 let prev_openai_key = std::env::var_os(OPENAI_API_KEY_ENV);
1413 let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
1414 std::env::remove_var(ANTHROPIC_API_KEY_ENV);
1415 std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
1416 std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
1417 std::env::remove_var(OPENAI_API_KEY_ENV);
1418 let tmp = tempfile::TempDir::new().unwrap();
1419 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
1420 Self {
1421 _guard: guard,
1422 prev_key,
1423 prev_tok,
1424 prev_base,
1425 prev_openai_key,
1426 prev_dir,
1427 _tmp: tmp,
1428 }
1429 }
1430 }
1431 impl Drop for TestEnv {
1432 fn drop(&mut self) {
1433 restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
1434 restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
1435 restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
1436 restore(OPENAI_API_KEY_ENV, self.prev_openai_key.take());
1437 restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
1438 }
1439 }
1440 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
1441 match prev {
1442 Some(v) => std::env::set_var(name, v),
1443 None => std::env::remove_var(name),
1444 }
1445 }
1446
1447 fn resolve_with_key(
1453 provider: Option<&str>,
1454 model: Option<&str>,
1455 thinking: Option<ThinkingLevel>,
1456 ) -> Result<ResolvedModel, ResolveError> {
1457 let _env = TestEnv::new();
1458 std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
1459 resolve(provider, model, thinking, None, None)
1460 }
1461
1462 #[test]
1463 fn default_model_matches_native_anthropic_default() {
1464 let r = resolve_with_key(None, None, None).unwrap();
1465 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1466 assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
1467 assert_eq!(r.provider.id(), "anthropic");
1468 }
1469
1470 #[test]
1471 fn cli_endpoint_override_does_not_redirect_other_authenticated_providers() {
1472 let _env = TestEnv::new();
1473 std::fs::write(config::models_path().unwrap(), r#"{"providers":{
1474 "first":{"api":"openai-completions","apiKey":"first-key","baseUrl":"https://first.example/v1","models":[{"id":"one"}]},
1475 "second":{"api":"openai-completions","apiKey":"second-key","baseUrl":"https://second.example/v1","models":[{"id":"two"}]}
1476 }}"#).unwrap();
1477 let resolved = resolve(
1478 Some("first"),
1479 Some("one"),
1480 None,
1481 None,
1482 Some("http://localhost:8080/v1"),
1483 )
1484 .unwrap();
1485 assert_eq!(resolved.model.base_url, "http://localhost:8080/v1");
1486 let catalog = authenticated_catalog(&resolved);
1487 let second = catalog
1488 .iter()
1489 .find(|model| model.provider == "second")
1490 .unwrap();
1491 assert_eq!(second.base_url, "https://second.example/v1");
1492 assert!(
1493 second.headers.is_none(),
1494 "catalogs never expose credentials"
1495 );
1496 }
1497
1498 #[test]
1499 fn header_only_auth_does_not_expose_unauthenticated_sibling_models() {
1500 let _env = TestEnv::new();
1501 std::fs::write(
1502 config::models_path().unwrap(),
1503 r#"{"providers":{"gateway":{
1504 "api":"openai-completions","models":[
1505 {"id":"authenticated","headers":{"Authorization":"Bearer key"}},
1506 {"id":"missing"},{"id":"empty","headers":{"Authorization":""}}
1507 ]
1508 }}}"#,
1509 )
1510 .unwrap();
1511 let resolved = resolve(Some("gateway"), Some("authenticated"), None, None, None).unwrap();
1512 let ids: Vec<_> = authenticated_catalog(&resolved)
1513 .into_iter()
1514 .map(|model| model.id)
1515 .collect();
1516 assert_eq!(ids, ["authenticated"]);
1517 assert!(resolve(Some("gateway"), Some("empty"), None, None, None).is_err());
1518 }
1519
1520 #[test]
1521 fn every_published_catalog_omits_header_auth_but_requests_recover_it() {
1522 let _env = TestEnv::new();
1523 std::fs::write(config::models_path().unwrap(), r#"{"providers":{"gateway":{
1524 "api":"openai-completions","headers":{"Authorization":"Bearer catalog-secret","X-Private-Header":"private-value"},
1525 "models":[{"id":"model"}]
1526 }}}"#).unwrap();
1527 let resolved = resolve(Some("gateway"), Some("model"), None, None, None).unwrap();
1528 for catalog in [
1529 catalog_all().unwrap(),
1530 available_catalog(&resolved),
1531 authenticated_catalog(&resolved),
1532 ] {
1533 assert!(catalog.iter().all(|model| model.headers.is_none()));
1534 let json = serde_json::to_string(&catalog).unwrap();
1535 assert!(!json.contains("catalog-secret") && !json.contains("private-value"));
1536 }
1537 assert!(resolved.model.headers.is_none());
1538 let request = resolved.execution.request_model(&resolved.model);
1539 assert_eq!(
1540 request.headers.as_ref().unwrap()["Authorization"],
1541 "Bearer catalog-secret"
1542 );
1543 assert_eq!(
1544 request.headers.as_ref().unwrap()["X-Private-Header"],
1545 "private-value"
1546 );
1547 }
1548
1549 #[test]
1550 fn stored_oauth_uses_bearer_and_expired_tokens_do_not_authenticate_requests() {
1551 let _env = TestEnv::new();
1552 config::upsert_oauth_token("anthropic", "access-token".into(), "refresh".into(), 0)
1553 .unwrap();
1554 let resolved = resolve(Some("anthropic"), None, None, None, None).unwrap();
1555 assert_eq!(
1556 resolved
1557 .execution
1558 .request_model(&resolved.model)
1559 .headers
1560 .as_ref()
1561 .unwrap()["authorization"],
1562 "Bearer access-token"
1563 );
1564 config::upsert_oauth_token("anthropic", "expired-token".into(), "refresh".into(), 1)
1565 .unwrap();
1566 assert!(resolve(Some("anthropic"), None, None, None, None).is_err());
1567 }
1568
1569 #[test]
1570 fn settings_default_model_wins_when_authed() {
1571 let _env = TestEnv::new();
1577 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1578 let path = config::settings_path().unwrap();
1579 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1580 std::fs::write(
1581 &path,
1582 r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
1583 )
1584 .unwrap();
1585 let r = resolve(None, None, None, None, None).unwrap();
1586 assert_eq!(r.model.id, "claude-haiku-4-5");
1587 assert_eq!(r.thinking_level, ThinkingLevel::High);
1588 std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
1590 let r = resolve(None, None, None, None, None).unwrap();
1591 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1592 }
1593
1594 #[test]
1595 fn explicit_id_match() {
1596 let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
1597 assert_eq!(r.model.id, "claude-haiku-4-5");
1598 }
1599
1600 #[test]
1601 fn case_insensitive_id() {
1602 let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
1603 assert_eq!(r.model.id, "claude-opus-5");
1604 }
1605
1606 #[test]
1607 fn provider_prefix_stripped() {
1608 let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
1609 assert_eq!(r.model.id, "claude-sonnet-5");
1610 }
1611
1612 #[test]
1613 fn custom_provider_prefix_stripped() {
1614 let _env = TestEnv::new();
1617 std::env::set_var(ANTHROPIC_API_KEY_ENV, "official-key");
1618 std::fs::write(
1619 config::models_path().unwrap(),
1620 r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "apiKey": "gateway-key", "models": [{"id":"custom-claude"}] } } }"#,
1621 )
1622 .unwrap();
1623 let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
1624 assert_eq!(r.model.id, "custom-claude");
1625 }
1626
1627 #[test]
1628 fn unknown_slash_prefix_remains_part_of_the_raw_model_id() {
1629 let _env = TestEnv::new();
1630 std::fs::write(
1631 config::models_path().unwrap(),
1632 r#"{
1633 "providers": {
1634 "gateway": {
1635 "api": "openai-completions",
1636 "apiKey": "gateway-key",
1637 "models": [{"id":"meta-llama/llama-3.3"}]
1638 }
1639 }
1640}"#,
1641 )
1642 .unwrap();
1643
1644 let resolved = resolve(None, Some("meta-llama/llama-3.3:high"), None, None, None).unwrap();
1645 assert_eq!(resolved.model.provider, "gateway");
1646 assert_eq!(resolved.model.id, "meta-llama/llama-3.3");
1647 assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1648 }
1649
1650 #[test]
1651 fn complete_model_id_wins_before_parsing_a_thinking_suffix() {
1652 let _env = TestEnv::new();
1653 std::fs::write(
1654 config::models_path().unwrap(),
1655 r#"{
1656 "providers": {
1657 "gateway": {
1658 "api": "openai-completions",
1659 "apiKey": "gateway-key",
1660 "models": [
1661 {"id":"vendor/model"},
1662 {"id":"vendor/model:high"}
1663 ]
1664 }
1665 }
1666}"#,
1667 )
1668 .unwrap();
1669
1670 let resolved = resolve(None, Some("gateway/vendor/model:high"), None, None, None).unwrap();
1671 assert_eq!(resolved.model.provider, "gateway");
1672 assert_eq!(resolved.model.id, "vendor/model:high");
1673 assert_eq!(resolved.thinking_level, DEFAULT_THINKING_LEVEL);
1674 }
1675
1676 #[test]
1677 fn authenticated_inferred_provider_beats_a_matching_raw_model_id() {
1678 let _env = TestEnv::new();
1679 std::fs::write(
1680 config::models_path().unwrap(),
1681 r#"{
1682 "providers": {
1683 "alpha": {
1684 "api": "openai-completions",
1685 "baseUrl": "https://alpha.example.com",
1686 "apiKey": "alpha-key",
1687 "models": [{"id":"target"}]
1688 },
1689 "gateway": {
1690 "api": "openai-completions",
1691 "baseUrl": "https://gateway.example.com",
1692 "apiKey": "gateway-key",
1693 "models": [{"id":"alpha/target"}]
1694 }
1695 }
1696}"#,
1697 )
1698 .unwrap();
1699
1700 let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1701 assert_eq!(resolved.model.provider, "alpha");
1702 assert_eq!(resolved.model.id, "target");
1703 assert_eq!(resolved.model.base_url, "https://alpha.example.com");
1704 }
1705
1706 #[test]
1707 fn authenticated_raw_model_beats_an_unauthenticated_inferred_provider() {
1708 let _env = TestEnv::new();
1709 std::fs::write(
1710 config::models_path().unwrap(),
1711 r#"{
1712 "providers": {
1713 "alpha": {
1714 "api": "openai-completions",
1715 "baseUrl": "https://alpha.example.com",
1716 "models": [{"id":"target"}]
1717 },
1718 "gateway": {
1719 "api": "openai-completions",
1720 "baseUrl": "https://gateway.example.com",
1721 "apiKey": "gateway-key",
1722 "models": [{"id":"alpha/target"}]
1723 }
1724 }
1725}"#,
1726 )
1727 .unwrap();
1728
1729 let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1730 assert_eq!(resolved.model.provider, "gateway");
1731 assert_eq!(resolved.model.id, "alpha/target");
1732 assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1733 }
1734
1735 #[test]
1736 fn thinking_suffix_raw_model_beats_an_unauthenticated_inferred_provider() {
1737 let _env = TestEnv::new();
1738 std::fs::write(
1739 config::models_path().unwrap(),
1740 r#"{
1741 "providers": {
1742 "alpha": {
1743 "api": "openai-completions",
1744 "baseUrl": "https://alpha.example.com",
1745 "models": [{"id":"target"}]
1746 },
1747 "gateway": {
1748 "api": "openai-completions",
1749 "baseUrl": "https://gateway.example.com",
1750 "apiKey": "gateway-key",
1751 "models": [{"id":"alpha/target"}]
1752 }
1753 }
1754}"#,
1755 )
1756 .unwrap();
1757
1758 let resolved = resolve(None, Some("alpha/target:high"), None, None, None).unwrap();
1759 assert_eq!(resolved.model.provider, "gateway");
1760 assert_eq!(resolved.model.id, "alpha/target");
1761 assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1762 assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1763 }
1764
1765 #[test]
1766 fn thinking_suffix_in_model() {
1767 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
1768 assert_eq!(r.model.id, "claude-sonnet-5");
1769 assert_eq!(r.thinking_level, ThinkingLevel::High);
1770 }
1771
1772 #[test]
1773 fn thinking_flag_overrides_suffix() {
1774 let r =
1776 resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
1777 assert_eq!(r.thinking_level, ThinkingLevel::Low);
1778 }
1779
1780 #[test]
1781 fn explicit_provider_anthropic_ok() {
1782 let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
1783 assert_eq!(r.model.id, "claude-sonnet-5");
1784
1785 let r = resolve_with_key(Some("ANTHROPIC"), Some("claude-sonnet-5"), None).unwrap();
1786 assert_eq!(r.provider.id(), DEFAULT_PROVIDER_ID);
1787 }
1788
1789 #[test]
1790 fn unknown_provider_rejected() {
1791 let err = resolve_with_key(Some("unsupported-provider"), None, None).unwrap_err();
1792 assert!(matches!(err, ResolveError::UnknownProvider(_)));
1793 }
1794
1795 #[test]
1796 fn no_match_lists_available() {
1797 let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
1798 match err {
1799 ResolveError::NoMatch { pattern, available } => {
1800 assert_eq!(pattern, "claude-does-not-exist");
1801 assert!(available.contains("claude-sonnet-5"));
1802 }
1803 other => panic!("expected NoMatch, got {other:?}"),
1804 }
1805 }
1806
1807 #[test]
1808 fn colon_not_a_thinking_level_kept_in_id() {
1809 let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
1812 assert!(matches!(err, ResolveError::NoMatch { .. }));
1813 }
1814
1815 #[test]
1816 fn parse_thinking_level_roundtrip() {
1817 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
1818 assert_eq!(parse_thinking_level("bogus"), None);
1819 for lvl in VALID_THINKING_LEVELS {
1821 assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
1822 }
1823 }
1824
1825 #[test]
1826 fn no_api_key_errors_with_hint() {
1827 let _env = TestEnv::new();
1828 let err = resolve(None, None, None, None, None).unwrap_err();
1829 match err {
1830 ResolveError::NoApiKey { hint } => {
1831 assert!(hint.contains("ANTHROPIC_API_KEY"));
1832 assert!(hint.contains("auth login"));
1833 }
1834 other => panic!("expected NoApiKey, got {other:?}"),
1835 }
1836 }
1837
1838 #[test]
1839 fn stored_credential_satisfies_auth() {
1840 let _env = TestEnv::new();
1841 config::upsert_credential(
1842 DEFAULT_PROVIDER_ID,
1843 Credential::ApiKey {
1844 key: Some("stored-key".into()),
1845 env: None,
1846 },
1847 )
1848 .unwrap();
1849 let r = resolve(None, None, None, None, None).unwrap();
1850 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1851 assert!(
1854 r.execution
1855 .request_model(&r.model)
1856 .headers
1857 .as_ref()
1858 .and_then(|h| h.get("authorization"))
1859 .is_none(),
1860 "x-api-key path should not synthesize a Bearer header"
1861 );
1862 }
1863
1864 #[test]
1865 fn gateway_models_key_wins_over_unrelated_anthropic_auth() {
1866 let _env = TestEnv::new();
1867 config::upsert_credential(
1868 DEFAULT_PROVIDER_ID,
1869 Credential::ApiKey {
1870 key: Some("official-key".into()),
1871 env: None,
1872 },
1873 )
1874 .unwrap();
1875 std::fs::write(
1876 config::models_path().unwrap(),
1877 r#"{
1878 "providers": {
1879 "gateway": {
1880 "api": "anthropic-messages",
1881 "baseUrl": "https://gateway.example.com",
1882 "apiKey": "gateway-key",
1883 "models": [{"id":"gateway-model"}]
1884 }
1885 }
1886}"#,
1887 )
1888 .unwrap();
1889
1890 let resolved = resolve(None, Some("gateway/gateway-model"), None, None, None).unwrap();
1891 assert_eq!(resolved.provider.id(), "gateway");
1892 assert_eq!(resolved.model.provider, "gateway");
1893 assert!(resolved.has_provider_key);
1894
1895 let cfg = config::load_models_config().unwrap();
1896 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap(), &[]);
1897 assert_eq!(
1898 anthropic_credential_for(&credentials, DEFAULT_PROVIDER_ID),
1899 Some(&AnthropicCredential::ProviderKey("official-key".into()))
1900 );
1901 assert_eq!(
1902 anthropic_credential_for(&credentials, "gateway"),
1903 Some(&AnthropicCredential::ProviderKey("gateway-key".into()))
1904 );
1905 }
1906
1907 #[test]
1908 fn gateway_auth_json_credential_is_usable() {
1909 let _env = TestEnv::new();
1910 config::upsert_credential(
1911 "gateway",
1912 Credential::ApiKey {
1913 key: Some("stored-gateway-key".into()),
1914 env: None,
1915 },
1916 )
1917 .unwrap();
1918 std::fs::write(
1919 config::models_path().unwrap(),
1920 r#"{
1921 "providers": {
1922 "gateway": {
1923 "api": "anthropic-messages",
1924 "baseUrl": "https://gateway.example.com",
1925 "models": [{"id":"gateway-model"}]
1926 }
1927 }
1928}"#,
1929 )
1930 .unwrap();
1931
1932 let resolved = resolve(Some("gateway"), None, None, None, None).unwrap();
1933 assert_eq!(resolved.model.id, "gateway-model");
1934 assert_eq!(resolved.provider.id(), "gateway");
1935 assert!(resolved.has_provider_key);
1936 }
1937
1938 #[test]
1939 fn anthropic_auth_does_not_authenticate_an_unkeyed_gateway() {
1940 let _env = TestEnv::new();
1941 config::upsert_credential(
1942 DEFAULT_PROVIDER_ID,
1943 Credential::ApiKey {
1944 key: Some("official-key".into()),
1945 env: None,
1946 },
1947 )
1948 .unwrap();
1949 std::fs::write(
1950 config::models_path().unwrap(),
1951 r#"{
1952 "providers": {
1953 "gateway": {
1954 "api": "anthropic-messages",
1955 "baseUrl": "https://gateway.example.com",
1956 "models": [{"id":"gateway-model"}]
1957 }
1958 }
1959}"#,
1960 )
1961 .unwrap();
1962
1963 let error = resolve(
1964 Some("gateway"),
1965 Some("gateway/gateway-model"),
1966 None,
1967 None,
1968 None,
1969 )
1970 .unwrap_err();
1971 assert!(matches!(error, ResolveError::NoApiKey { .. }));
1972
1973 let default = resolve(None, None, None, None, None).unwrap();
1974 assert_eq!(default.provider.id(), DEFAULT_PROVIDER_ID);
1975 assert_eq!(default.model.id, DEFAULT_MODEL_ID);
1976 }
1977
1978 #[test]
1979 fn two_anthropic_gateways_keep_credentials_isolated_by_id() {
1980 let _env = TestEnv::new();
1981 std::fs::write(
1982 config::models_path().unwrap(),
1983 r#"{
1984 "providers": {
1985 "alpha": {
1986 "api": "anthropic-messages",
1987 "baseUrl": "https://shared.example.com",
1988 "apiKey": "alpha-key",
1989 "models": [{"id":"shared-model"}]
1990 },
1991 "beta": {
1992 "api": "anthropic-messages",
1993 "baseUrl": "https://shared.example.com",
1994 "apiKey": "beta-key",
1995 "models": [{"id":"shared-model"}]
1996 }
1997 }
1998}"#,
1999 )
2000 .unwrap();
2001
2002 let cfg = config::load_models_config().unwrap();
2003 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap(), &[]);
2004 assert_eq!(
2005 anthropic_credential_for(&credentials, "alpha"),
2006 Some(&AnthropicCredential::ProviderKey("alpha-key".into()))
2007 );
2008 assert_eq!(
2009 anthropic_credential_for(&credentials, "beta"),
2010 Some(&AnthropicCredential::ProviderKey("beta-key".into()))
2011 );
2012 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2013 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2014 assert_eq!(alpha.provider.id(), "alpha");
2015 assert_eq!(beta.provider.id(), "beta");
2016 assert!(alpha.has_provider_key && beta.has_provider_key);
2017 }
2018
2019 #[test]
2020 fn case_distinct_provider_ids_keep_endpoints_and_credentials_isolated() {
2021 let _env = TestEnv::new();
2022 std::fs::write(
2023 config::models_path().unwrap(),
2024 r#"{
2025 "providers": {
2026 "alpha": {
2027 "api": "anthropic-messages",
2028 "baseUrl": "https://lower.example.com",
2029 "authHeader": true,
2030 "models": [{"id":"shared-model"}]
2031 },
2032 "ALPHA": {
2033 "api": "anthropic-messages",
2034 "baseUrl": "https://upper.example.com",
2035 "authHeader": true,
2036 "models": [{"id":"shared-model"}]
2037 }
2038 }
2039}"#,
2040 )
2041 .unwrap();
2042 config::upsert_credential(
2043 "alpha",
2044 Credential::ApiKey {
2045 key: Some("lower-key".into()),
2046 env: None,
2047 },
2048 )
2049 .unwrap();
2050 config::upsert_credential(
2051 "ALPHA",
2052 Credential::ApiKey {
2053 key: Some("upper-key".into()),
2054 env: None,
2055 },
2056 )
2057 .unwrap();
2058
2059 let lower = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2060 assert_eq!(lower.provider.id(), "alpha");
2061 assert_eq!(lower.model.base_url, "https://lower.example.com");
2062 assert_eq!(
2063 lower
2064 .execution
2065 .request_model(&lower.model)
2066 .headers
2067 .as_ref()
2068 .and_then(|headers| headers.get("authorization"))
2069 .map(String::as_str),
2070 Some("Bearer lower-key")
2071 );
2072
2073 let upper = resolve(None, Some("ALPHA/shared-model"), None, None, None).unwrap();
2074 assert_eq!(upper.provider.id(), "ALPHA");
2075 assert_eq!(upper.model.base_url, "https://upper.example.com");
2076 assert_eq!(
2077 upper
2078 .execution
2079 .request_model(&upper.model)
2080 .headers
2081 .as_ref()
2082 .and_then(|headers| headers.get("authorization"))
2083 .map(String::as_str),
2084 Some("Bearer upper-key")
2085 );
2086
2087 let error = resolve(Some("Alpha"), None, None, None, None).unwrap_err();
2088 assert!(matches!(
2089 error,
2090 ResolveError::AmbiguousProvider {
2091 requested,
2092 matches
2093 } if requested == "Alpha" && matches == "ALPHA, alpha"
2094 ));
2095
2096 let prefix_error = resolve(None, Some("Alpha/shared-model"), None, None, None).unwrap_err();
2097 assert!(matches!(
2098 prefix_error,
2099 ResolveError::AmbiguousProvider {
2100 requested,
2101 matches
2102 } if requested == "Alpha" && matches == "ALPHA, alpha"
2103 ));
2104 }
2105
2106 #[test]
2107 fn cli_provider_case_insensitively_selects_one_canonical_custom_id() {
2108 let _env = TestEnv::new();
2109 std::fs::write(
2110 config::models_path().unwrap(),
2111 r#"{
2112 "providers": {
2113 "routeryo-copy": {
2114 "api": "openai-completions",
2115 "baseUrl": "https://routeryo-copy.example.com",
2116 "apiKey": "copy-key",
2117 "models": [{"id":"copy-model"}]
2118 }
2119 }
2120}"#,
2121 )
2122 .unwrap();
2123
2124 let resolved =
2125 resolve(Some("ROUTERYO-COPY"), Some("copy-model"), None, None, None).unwrap();
2126 assert_eq!(resolved.model.provider, "routeryo-copy");
2127 assert_eq!(resolved.provider.id(), "routeryo-copy");
2128 assert_eq!(resolved.model.base_url, "https://routeryo-copy.example.com");
2129
2130 let prefixed = resolve(None, Some("ROUTERYO-COPY/copy-model"), None, None, None).unwrap();
2131 assert_eq!(prefixed.model.provider, "routeryo-copy");
2132 assert_eq!(prefixed.model.id, "copy-model");
2133 }
2134
2135 #[test]
2136 fn cli_provider_canonicalization_keeps_builtin_and_custom_case_ids_distinct() {
2137 let _env = TestEnv::new();
2138 std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
2139 std::fs::write(
2140 config::models_path().unwrap(),
2141 r#"{
2142 "providers": {
2143 "OpenAI": {
2144 "api": "openai-completions",
2145 "baseUrl": "https://custom-openai.example.com",
2146 "apiKey": "custom-openai-key",
2147 "models": [{"id":"custom-model"}]
2148 }
2149 }
2150}"#,
2151 )
2152 .unwrap();
2153
2154 let custom = resolve(Some("OpenAI"), Some("custom-model"), None, None, None).unwrap();
2155 assert_eq!(custom.model.provider, "OpenAI");
2156 assert_eq!(custom.model.base_url, "https://custom-openai.example.com");
2157
2158 let builtin = resolve(Some("openai"), Some("gpt-6-astra"), None, None, None).unwrap();
2159 assert_eq!(builtin.model.provider, "openai");
2160
2161 let error = resolve(Some("OPENAI"), None, None, None, None).unwrap_err();
2162 assert!(matches!(
2163 error,
2164 ResolveError::AmbiguousProvider {
2165 requested,
2166 matches
2167 } if requested == "OPENAI" && matches == "OpenAI, openai"
2168 ));
2169 }
2170
2171 #[test]
2172 fn cli_api_key_overrides_only_the_selected_anthropic_provider() {
2173 let _env = TestEnv::new();
2174 std::fs::write(
2175 config::models_path().unwrap(),
2176 r#"{
2177 "providers": {
2178 "gateway": {
2179 "api": "anthropic-messages",
2180 "baseUrl": "https://gateway.example.com",
2181 "authHeader": true,
2182 "apiKey": "configured-key",
2183 "models": [{"id":"gateway-model"}]
2184 }
2185 }
2186}"#,
2187 )
2188 .unwrap();
2189
2190 let cfg = config::load_models_config().unwrap();
2191 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap(), &[]);
2192 assert_eq!(
2193 credential_for_selected_anthropic_provider(
2194 Some("cli-key"),
2195 &credentials,
2196 "gateway",
2197 true,
2198 ),
2199 Some(AnthropicCredential::Headers(BTreeMap::from([(
2200 "authorization".into(),
2201 "Bearer cli-key".into(),
2202 )])))
2203 );
2204 assert_eq!(
2205 anthropic_credential_for(&credentials, "gateway"),
2206 Some(&AnthropicCredential::Headers(BTreeMap::from([(
2207 "authorization".into(),
2208 "Bearer configured-key".into(),
2209 )])))
2210 );
2211
2212 let resolved = resolve(
2213 Some("gateway"),
2214 Some("gateway/gateway-model"),
2215 None,
2216 Some("cli-key"),
2217 None,
2218 )
2219 .unwrap();
2220 assert!(!resolved.has_provider_key);
2221 assert_eq!(
2222 resolved
2223 .execution
2224 .request_model(&resolved.model)
2225 .headers
2226 .as_ref()
2227 .and_then(|headers| headers.get("authorization"))
2228 .map(String::as_str),
2229 Some("Bearer cli-key")
2230 );
2231 }
2232
2233 #[test]
2234 fn auth_token_routes_via_bearer_header() {
2235 let _env = TestEnv::new();
2236 std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
2237 let r = resolve(None, None, None, None, None).unwrap();
2238 let headers = r
2240 .execution
2241 .request_model(&r.model)
2242 .headers
2243 .expect("bearer header on model");
2244 assert_eq!(
2245 headers.get("authorization").map(|s| s.as_str()),
2246 Some("Bearer tok-123")
2247 );
2248 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
2252 }
2253
2254 #[test]
2255 fn api_key_flag_beats_env_and_stored() {
2256 let _env = TestEnv::new();
2257 std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
2258 config::upsert_credential(
2259 DEFAULT_PROVIDER_ID,
2260 Credential::ApiKey {
2261 key: Some("stored-key".into()),
2262 env: None,
2263 },
2264 )
2265 .unwrap();
2266 let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
2269 assert!(
2270 r.execution
2271 .request_model(&r.model)
2272 .headers
2273 .as_ref()
2274 .and_then(|h| h.get("authorization"))
2275 .is_none(),
2276 "--api-key should take the x-api-key path, not Bearer"
2277 );
2278 }
2279
2280 #[test]
2281 fn base_url_override_applies_to_model() {
2282 let _env = TestEnv::new();
2283 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2284 let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
2285 assert_eq!(r.model.base_url, "https://gw.example.com");
2286 }
2287
2288 #[test]
2289 fn base_url_env_is_fallback_for_flag() {
2290 let _env = TestEnv::new();
2291 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2292 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
2293 let r = resolve(None, None, None, None, None).unwrap();
2294 assert_eq!(r.model.base_url, "https://env-gw.example.com");
2295 }
2296
2297 #[test]
2298 fn anthropic_base_url_env_does_not_redirect_a_custom_provider() {
2299 let _env = TestEnv::new();
2300 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://ambient-proxy.example.com");
2301 std::fs::write(
2302 config::models_path().unwrap(),
2303 r#"{
2304 "providers": {
2305 "gateway": {
2306 "api": "anthropic-messages",
2307 "baseUrl": "https://gateway.example.com",
2308 "apiKey": "gateway-secret",
2309 "models": [{"id":"gateway-model"}]
2310 }
2311 }
2312}"#,
2313 )
2314 .unwrap();
2315
2316 let r = resolve(
2317 Some("gateway"),
2318 Some("gateway/gateway-model"),
2319 None,
2320 None,
2321 None,
2322 )
2323 .unwrap();
2324 assert_eq!(r.model.base_url, "https://gateway.example.com");
2325 assert!(r.has_provider_key);
2326 }
2327
2328 #[test]
2329 fn models_json_adds_custom_model() {
2330 let _env = TestEnv::new();
2331 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2332 std::fs::write(
2333 config::models_path().unwrap(),
2334 r#"{
2335 "providers": {
2336 "gateway": {
2337 "baseUrl": "https://gw.example.com",
2338 "authHeader": true,
2339 "apiKey": "gw-secret",
2340 "models": [
2341 { "id": "custom-claude", "name": "Custom" }
2342 ]
2343 }
2344 }
2345}"#,
2346 )
2347 .unwrap();
2348 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2349 assert_eq!(r.model.id, "custom-claude");
2350 assert_eq!(r.model.base_url, "https://gw.example.com");
2351 assert_eq!(r.model.provider, "gateway");
2352 assert_eq!(r.provider.id(), "gateway");
2353 let headers = r
2355 .execution
2356 .request_model(&r.model)
2357 .headers
2358 .expect("headers merged");
2359 assert_eq!(
2360 headers.get("authorization").map(|s| s.as_str()),
2361 Some("Bearer gw-secret")
2362 );
2363 }
2364
2365 #[test]
2366 fn resolved_providers_include_all_authenticated_runtimes() {
2367 let _env = TestEnv::new();
2368 std::fs::write(
2369 config::models_path().unwrap(),
2370 r#"{
2371 "providers": {
2372 "gateway-a": {
2373 "baseUrl": "https://a.example.com",
2374 "api": "openai-completions",
2375 "apiKey": "key-a",
2376 "models": [ { "id": "model-a" } ]
2377 },
2378 "gateway-b": {
2379 "baseUrl": "https://b.example.com",
2380 "api": "openai-completions",
2381 "apiKey": "key-b",
2382 "models": [ { "id": "model-b" } ]
2383 }
2384 }
2385}"#,
2386 )
2387 .unwrap();
2388
2389 let resolved = resolve(None, None, None, None, None).unwrap();
2390 let ids: Vec<&str> = resolved.providers.iter().map(|p| p.id()).collect();
2391 assert_eq!(resolved.provider.id(), "gateway-a");
2394 assert_eq!(ids[0], "gateway-a");
2395 assert!(
2396 ids.contains(&"gateway-b"),
2397 "gateway-b runtime missing: {ids:?}"
2398 );
2399 }
2400
2401 #[test]
2402 fn authenticated_catalog_spans_all_configured_providers() {
2403 let _env = TestEnv::new();
2404 std::fs::write(
2405 config::models_path().unwrap(),
2406 r#"{
2407 "providers": {
2408 "gateway-a": {
2409 "baseUrl": "https://a.example.com",
2410 "api": "openai-completions",
2411 "apiKey": "key-a",
2412 "models": [ { "id": "model-a" } ]
2413 },
2414 "gateway-b": {
2415 "baseUrl": "https://b.example.com",
2416 "api": "openai-completions",
2417 "apiKey": "key-b",
2418 "models": [ { "id": "model-b" } ]
2419 }
2420 }
2421}"#,
2422 )
2423 .unwrap();
2424
2425 let resolved = resolve(None, None, None, None, None).unwrap();
2426 let catalog = authenticated_catalog(&resolved);
2427 let providers: std::collections::BTreeSet<&str> =
2428 catalog.iter().map(|m| m.provider.as_str()).collect();
2429 assert!(
2430 providers.contains("gateway-a"),
2431 "gateway-a models missing: {providers:?}"
2432 );
2433 assert!(
2434 providers.contains("gateway-b"),
2435 "gateway-b models missing: {providers:?}"
2436 );
2437 assert!(!providers.contains("anthropic"));
2439 assert!(!providers.contains("openai"));
2440 assert!(catalog.iter().all(|m| m.headers.is_none()));
2442 }
2443
2444 #[test]
2445 fn openai_completions_models_json_is_a_complete_provider_config() {
2446 let _env = TestEnv::new();
2447 std::fs::write(
2448 config::models_path().unwrap(),
2449 r#"{
2450 "providers": {
2451 "routeryo": {
2452 "baseUrl": "https://api.routeryo.com",
2453 "api": "openai-completions",
2454 "apiKey": "router-secret",
2455 "models": [
2456 {
2457 "id": "gpt-5.6-sol",
2458 "name": "GPT 5.6",
2459 "reasoning": true,
2460 "contextWindow": 200000,
2461 "maxTokens": 32768
2462 }
2463 ]
2464 }
2465 }
2466}"#,
2467 )
2468 .unwrap();
2469
2470 let resolved = resolve(None, None, None, None, None).unwrap();
2471 assert_eq!(resolved.model.id, "gpt-5.6-sol");
2472 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2473 assert_eq!(resolved.model.provider, "routeryo");
2474 assert_eq!(resolved.provider.id(), "routeryo");
2475 assert!(resolved.has_provider_key);
2476 assert_eq!(
2477 resolved
2478 .execution
2479 .request_model(&resolved.model)
2480 .headers
2481 .as_ref()
2482 .and_then(|headers| headers.get("authorization"))
2483 .map(String::as_str),
2484 Some("Bearer router-secret")
2485 );
2486
2487 let explicit = resolve(
2488 Some("routeryo"),
2489 Some("routeryo/gpt-5.6-sol"),
2490 None,
2491 None,
2492 None,
2493 )
2494 .unwrap();
2495 assert_eq!(explicit.provider.id(), "routeryo");
2496 assert_eq!(explicit.model.id, "gpt-5.6-sol");
2497 }
2498
2499 #[test]
2500 fn openai_completions_provider_alias_excludes_responses_models() {
2501 let _env = TestEnv::new();
2502 std::fs::write(
2503 config::models_path().unwrap(),
2504 r#"{
2505 "providers": {
2506 "chat-gateway": {
2507 "api": "openai-completions",
2508 "baseUrl": "https://chat.example.com/v1",
2509 "apiKey": "chat-key",
2510 "models": [{"id":"chat-model"}]
2511 },
2512 "responses-gateway": {
2513 "api": "openai-responses",
2514 "baseUrl": "https://responses.example.com/v1",
2515 "apiKey": "responses-key",
2516 "models": [{"id":"responses-model"}]
2517 }
2518 }
2519}"#,
2520 )
2521 .unwrap();
2522
2523 let resolved = resolve(Some("openai-completions"), None, None, None, None).unwrap();
2524 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2525 assert_eq!(resolved.model.provider, "chat-gateway");
2526 assert_eq!(resolved.model.id, "chat-model");
2527 }
2528
2529 #[test]
2530 fn openai_model_prefix_disambiguates_providers_with_the_same_model_id() {
2531 let _env = TestEnv::new();
2532 std::fs::write(
2533 config::models_path().unwrap(),
2534 r#"{
2535 "providers": {
2536 "alpha": {
2537 "api": "openai-completions",
2538 "baseUrl": "https://alpha.example.com",
2539 "apiKey": "alpha-secret",
2540 "models": [{"id":"shared-model"}]
2541 },
2542 "beta": {
2543 "api": "openai-completions",
2544 "baseUrl": "https://beta.example.com",
2545 "apiKey": "beta-secret",
2546 "models": [{"id":"shared-model"}]
2547 }
2548 }
2549}"#,
2550 )
2551 .unwrap();
2552
2553 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2554 assert_eq!(alpha.provider.id(), "alpha");
2555 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2556 assert_eq!(
2557 alpha
2558 .execution
2559 .request_model(&alpha.model)
2560 .headers
2561 .as_ref()
2562 .and_then(|headers| headers.get("authorization"))
2563 .map(String::as_str),
2564 Some("Bearer alpha-secret")
2565 );
2566
2567 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2568 assert_eq!(beta.provider.id(), "beta");
2569 assert_eq!(beta.model.base_url, "https://beta.example.com");
2570 assert_eq!(
2571 beta.execution
2572 .request_model(&beta.model)
2573 .headers
2574 .as_ref()
2575 .and_then(|headers| headers.get("authorization"))
2576 .map(String::as_str),
2577 Some("Bearer beta-secret")
2578 );
2579 }
2580
2581 #[test]
2582 fn bare_duplicate_model_id_prefers_the_only_authenticated_provider() {
2583 let _env = TestEnv::new();
2584 std::fs::write(
2585 config::models_path().unwrap(),
2586 r#"{
2587 "providers": {
2588 "alpha": {
2589 "api": "openai-completions",
2590 "baseUrl": "https://alpha.example.com",
2591 "models": [{"id":"shared-model"}]
2592 },
2593 "beta": {
2594 "api": "openai-completions",
2595 "baseUrl": "https://beta.example.com",
2596 "apiKey": "beta-secret",
2597 "models": [{"id":"shared-model"}]
2598 }
2599 }
2600}"#,
2601 )
2602 .unwrap();
2603
2604 let resolved = resolve(None, Some("shared-model"), None, None, None).unwrap();
2605 assert_eq!(resolved.model.provider, "beta");
2606 assert_eq!(resolved.model.base_url, "https://beta.example.com");
2607 }
2608
2609 #[test]
2610 fn bare_duplicate_model_id_rejects_multiple_authenticated_providers() {
2611 let _env = TestEnv::new();
2612 std::fs::write(
2613 config::models_path().unwrap(),
2614 r#"{
2615 "providers": {
2616 "alpha": {
2617 "api": "openai-completions",
2618 "apiKey": "alpha-secret",
2619 "models": [{"id":"shared-model"}]
2620 },
2621 "beta": {
2622 "api": "openai-completions",
2623 "apiKey": "beta-secret",
2624 "models": [{"id":"shared-model"}]
2625 }
2626 }
2627}"#,
2628 )
2629 .unwrap();
2630
2631 let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2632 match error {
2633 ResolveError::AmbiguousModel {
2634 pattern,
2635 matches,
2636 auth_hint,
2637 } => {
2638 assert_eq!(pattern, "shared-model");
2639 assert_eq!(matches, "alpha/shared-model, beta/shared-model");
2640 assert_eq!(
2641 auth_hint,
2642 "More than one matching provider is authenticated."
2643 );
2644 }
2645 other => panic!("expected AmbiguousModel, got {other:?}"),
2646 }
2647 }
2648
2649 #[test]
2650 fn bare_duplicate_model_id_rejects_when_no_provider_is_authenticated() {
2651 let _env = TestEnv::new();
2652 std::fs::write(
2653 config::models_path().unwrap(),
2654 r#"{
2655 "providers": {
2656 "alpha": {
2657 "api": "openai-completions",
2658 "models": [{"id":"shared-model"}]
2659 },
2660 "beta": {
2661 "api": "openai-completions",
2662 "models": [{"id":"shared-model"}]
2663 }
2664 }
2665}"#,
2666 )
2667 .unwrap();
2668
2669 let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2670 assert!(matches!(
2671 error,
2672 ResolveError::AmbiguousModel {
2673 auth_hint: "No matching provider is authenticated.",
2674 ..
2675 }
2676 ));
2677 }
2678
2679 #[test]
2680 fn openai_auth_json_overrides_config_for_both_protocols() {
2681 let _env = TestEnv::new();
2682 config::upsert_credential(
2683 "chat-gateway",
2684 Credential::ApiKey {
2685 key: Some("stored-chat-key".into()),
2686 env: None,
2687 },
2688 )
2689 .unwrap();
2690 config::upsert_credential(
2691 "responses-gateway",
2692 Credential::ApiKey {
2693 key: Some("stored-responses-key".into()),
2694 env: None,
2695 },
2696 )
2697 .unwrap();
2698 std::fs::write(
2699 config::models_path().unwrap(),
2700 r#"{
2701 "providers": {
2702 "chat-gateway": {
2703 "api": "openai-completions",
2704 "baseUrl": "https://chat.example.com/v1",
2705 "apiKey": "configured-chat-key",
2706 "models": [{"id":"chat-model"}]
2707 },
2708 "responses-gateway": {
2709 "api": "openai-responses",
2710 "baseUrl": "https://responses.example.com/v1",
2711 "apiKey": "configured-responses-key",
2712 "models": [{"id":"responses-model"}]
2713 }
2714 }
2715}"#,
2716 )
2717 .unwrap();
2718
2719 let chat = resolve(
2720 Some("chat-gateway"),
2721 Some("chat-gateway/chat-model"),
2722 None,
2723 None,
2724 None,
2725 )
2726 .unwrap();
2727 assert!(chat.has_provider_key);
2728 assert_eq!(
2729 chat.execution
2730 .request_model(&chat.model)
2731 .headers
2732 .as_ref()
2733 .and_then(|headers| headers.get("authorization"))
2734 .map(String::as_str),
2735 Some("Bearer stored-chat-key")
2736 );
2737
2738 let responses = resolve(
2739 Some("responses-gateway"),
2740 Some("responses-gateway/responses-model"),
2741 None,
2742 None,
2743 None,
2744 )
2745 .unwrap();
2746 assert!(responses.has_provider_key);
2747 assert_eq!(
2748 responses
2749 .execution
2750 .request_model(&responses.model)
2751 .headers
2752 .as_ref()
2753 .and_then(|headers| headers.get("authorization"))
2754 .map(String::as_str),
2755 Some("Bearer stored-responses-key")
2756 );
2757 }
2758
2759 #[test]
2760 fn openai_env_does_not_authenticate_custom_providers() {
2761 let _env = TestEnv::new();
2762 std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
2763 std::fs::write(
2764 config::models_path().unwrap(),
2765 r#"{
2766 "providers": {
2767 "chat-gateway": {
2768 "api": "openai-completions",
2769 "baseUrl": "https://chat.example.com/v1",
2770 "models": [{"id":"chat-model"}]
2771 },
2772 "responses-gateway": {
2773 "api": "openai-responses",
2774 "baseUrl": "https://responses.example.com/v1",
2775 "models": [{"id":"responses-model"}]
2776 },
2777 "openai-completions": {
2778 "api": "openai-completions",
2779 "baseUrl": "https://alias-chat.example.com/v1",
2780 "models": [{"id":"alias-chat-model"}]
2781 },
2782 "openai-responses": {
2783 "api": "openai-responses",
2784 "baseUrl": "https://alias-responses.example.com/v1",
2785 "models": [{"id":"alias-responses-model"}]
2786 },
2787 "OpenAI": {
2788 "api": "openai-responses",
2789 "baseUrl": "https://case-distinct.example.com/v1",
2790 "models": [{"id":"case-distinct-model"}]
2791 }
2792 }
2793}"#,
2794 )
2795 .unwrap();
2796
2797 for (provider, model) in [
2798 ("chat-gateway", "chat-gateway/chat-model"),
2799 ("responses-gateway", "responses-gateway/responses-model"),
2800 ("openai-completions", "openai-completions/alias-chat-model"),
2801 ("openai-responses", "openai-responses/alias-responses-model"),
2802 ("OpenAI", "OpenAI/case-distinct-model"),
2803 ] {
2804 let error = resolve(Some(provider), Some(model), None, None, None).unwrap_err();
2805 assert!(matches!(error, ResolveError::NoApiKey { .. }));
2806 }
2807
2808 let official = resolve(None, None, None, None, None).unwrap();
2809 assert_eq!(official.provider.id(), "openai");
2810 assert_eq!(official.model.id, "gpt-6-astra");
2811 assert!(official.has_provider_key);
2812 }
2813
2814 #[test]
2815 fn cli_api_key_overrides_selected_openai_provider() {
2816 let _env = TestEnv::new();
2817 std::fs::write(
2818 config::models_path().unwrap(),
2819 r#"{
2820 "providers": {
2821 "chat-gateway": {
2822 "api": "openai-completions",
2823 "baseUrl": "https://chat.example.com/v1",
2824 "apiKey": "configured-chat-key",
2825 "models": [{"id":"chat-model"}]
2826 },
2827 "responses-gateway": {
2828 "api": "openai-responses",
2829 "baseUrl": "https://responses.example.com/v1",
2830 "apiKey": "configured-responses-key",
2831 "models": [{"id":"responses-model"}]
2832 }
2833 }
2834}"#,
2835 )
2836 .unwrap();
2837
2838 for (provider, model) in [
2839 ("chat-gateway", "chat-gateway/chat-model"),
2840 ("responses-gateway", "responses-gateway/responses-model"),
2841 ] {
2842 let resolved =
2843 resolve(Some(provider), Some(model), None, Some("cli-key"), None).unwrap();
2844 assert_eq!(resolved.provider.id(), provider);
2845 assert!(resolved.has_provider_key);
2846 assert_eq!(
2847 resolved
2848 .execution
2849 .request_model(&resolved.model)
2850 .headers
2851 .as_ref()
2852 .and_then(|headers| headers.get("authorization"))
2853 .map(String::as_str),
2854 Some("Bearer cli-key")
2855 );
2856 }
2857 }
2858
2859 #[test]
2860 fn anthropic_model_prefix_disambiguates_providers_with_the_same_model_id() {
2861 let _env = TestEnv::new();
2862 std::fs::write(
2863 config::models_path().unwrap(),
2864 r#"{
2865 "providers": {
2866 "alpha": {
2867 "api": "anthropic-messages",
2868 "baseUrl": "https://alpha.example.com",
2869 "apiKey": "alpha-secret",
2870 "models": [{"id":"shared-model"}]
2871 },
2872 "beta": {
2873 "api": "anthropic-messages",
2874 "baseUrl": "https://beta.example.com",
2875 "authHeader": true,
2876 "apiKey": "beta-secret",
2877 "models": [{"id":"shared-model"}]
2878 }
2879 }
2880}"#,
2881 )
2882 .unwrap();
2883
2884 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2885 assert_eq!(alpha.provider.id(), "alpha");
2886 assert_eq!(alpha.model.provider, "alpha");
2887 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2888 assert!(alpha.has_provider_key);
2889 assert!(alpha
2890 .execution
2891 .request_model(&alpha.model)
2892 .headers
2893 .as_ref()
2894 .map_or(true, |headers| {
2895 headers
2896 .keys()
2897 .all(|name| !name.eq_ignore_ascii_case("x-api-key"))
2898 }));
2899
2900 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2901 assert_eq!(beta.provider.id(), "beta");
2902 assert_eq!(beta.model.provider, "beta");
2903 assert_eq!(beta.model.base_url, "https://beta.example.com");
2904 assert_eq!(
2905 beta.execution
2906 .request_model(&beta.model)
2907 .headers
2908 .as_ref()
2909 .and_then(|headers| headers.get("authorization"))
2910 .map(String::as_str),
2911 Some("Bearer beta-secret")
2912 );
2913 }
2914
2915 #[test]
2916 fn trusted_project_defaults_override_global_defaults_for_resolution() {
2917 let _env = TestEnv::new();
2918 std::fs::write(
2919 config::models_path().unwrap(),
2920 r#"{
2921 "providers": {
2922 "global": {
2923 "api": "anthropic-messages",
2924 "apiKey": "global-secret",
2925 "models": [{"id":"global-model"}]
2926 },
2927 "project": {
2928 "api": "anthropic-messages",
2929 "apiKey": "project-secret",
2930 "models": [{"id":"project-model"}]
2931 }
2932 }
2933}"#,
2934 )
2935 .unwrap();
2936 std::fs::write(
2937 config::settings_path().unwrap(),
2938 r#"{"defaultProvider":"global","defaultModel":"global-model"}"#,
2939 )
2940 .unwrap();
2941 let project = tempfile::tempdir().unwrap();
2942 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
2943 std::fs::write(
2944 project.path().join(".rpi/settings.json"),
2945 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
2946 )
2947 .unwrap();
2948
2949 let trusted = resolve_for_cwd(None, None, None, None, None, project.path(), true).unwrap();
2950 assert_eq!(trusted.provider.id(), "project");
2951 assert_eq!(trusted.model.id, "project-model");
2952
2953 let untrusted =
2954 resolve_for_cwd(None, None, None, None, None, project.path(), false).unwrap();
2955 assert_eq!(untrusted.provider.id(), "global");
2956 assert_eq!(untrusted.model.id, "global-model");
2957 }
2958
2959 #[test]
2960 fn unknown_model_prefix_without_a_raw_match_returns_no_match() {
2961 let _env = TestEnv::new();
2962 std::fs::write(
2963 config::models_path().unwrap(),
2964 r#"{
2965 "providers": {
2966 "routeryo": {
2967 "api": "openai-completions",
2968 "apiKey": "secret",
2969 "models": [{"id":"gpt-test"}]
2970 }
2971 }
2972}"#,
2973 )
2974 .unwrap();
2975
2976 let error = resolve(None, Some("misspelled/gpt-test"), None, None, None).unwrap_err();
2977 assert!(matches!(
2978 error,
2979 ResolveError::NoMatch { pattern, .. } if pattern == "misspelled/gpt-test"
2980 ));
2981 }
2982
2983 #[test]
2990 fn models_json_auth_header_satisfies_auth_without_env() {
2991 let _env = TestEnv::new();
2992 std::fs::write(
2994 config::models_path().unwrap(),
2995 r#"{
2996 "providers": {
2997 "gateway": {
2998 "baseUrl": "https://gw.example.com",
2999 "api": "anthropic-messages",
3000 "authHeader": true,
3001 "apiKey": "gw-secret",
3002 "models": [
3003 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3004 ]
3005 }
3006 }
3007}"#,
3008 )
3009 .unwrap();
3010 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
3011 assert_eq!(r.model.id, "custom-claude");
3012 assert_eq!(r.model.base_url, "https://gw.example.com");
3013 let headers = r
3014 .execution
3015 .request_model(&r.model)
3016 .headers
3017 .expect("bearer folded onto model");
3018 assert_eq!(
3019 headers.get("authorization").map(|s| s.as_str()),
3020 Some("Bearer gw-secret")
3021 );
3022 }
3023
3024 #[test]
3033 fn default_prefers_gateway_when_only_gateway_configured() {
3034 let _env = TestEnv::new();
3037 std::fs::write(
3038 config::models_path().unwrap(),
3039 r#"{
3040 "providers": {
3041 "gateway": {
3042 "baseUrl": "https://gw.example.com",
3043 "api": "anthropic-messages",
3044 "authHeader": true,
3045 "apiKey": "gw-secret",
3046 "models": [
3047 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3048 ]
3049 }
3050 }
3051}"#,
3052 )
3053 .unwrap();
3054 let r = resolve(None, None, None, None, None).unwrap();
3058 assert_eq!(r.model.id, "custom-claude");
3059 assert_eq!(r.model.base_url, "https://gw.example.com");
3060 let headers = r
3062 .execution
3063 .request_model(&r.model)
3064 .headers
3065 .expect("bearer on gateway model");
3066 assert_eq!(
3067 headers.get("authorization").map(|s| s.as_str()),
3068 Some("Bearer gw-secret")
3069 );
3070 }
3071
3072 #[test]
3073 fn api_key_flag_honors_models_json_auth_header() {
3074 let _env = TestEnv::new();
3075 std::fs::write(
3076 config::models_path().unwrap(),
3077 r#"{
3078 "providers": {
3079 "gateway": {
3080 "baseUrl": "https://gw.example.com",
3081 "authHeader": true,
3082 "apiKey": "gw-secret",
3083 "models": [ { "id": "custom-claude" } ]
3084 }
3085 }
3086}"#,
3087 )
3088 .unwrap();
3089 let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
3090 assert_eq!(
3091 r.execution
3092 .request_model(&r.model)
3093 .headers
3094 .as_ref()
3095 .and_then(|h| h.get("authorization"))
3096 .map(String::as_str),
3097 Some("Bearer flag-key")
3098 );
3099 assert!(!r.has_provider_key);
3100 }
3101
3102 #[test]
3109 fn models_json_bare_apikey_satisfies_auth_without_env() {
3110 let _env = TestEnv::new();
3111 std::fs::write(
3113 config::models_path().unwrap(),
3114 r#"{
3115 "providers": {
3116 "gateway": {
3117 "baseUrl": "https://gw.example.com",
3118 "api": "anthropic-messages",
3119 "apiKey": "gw-secret",
3120 "models": [
3121 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3122 ]
3123 }
3124 }
3125}"#,
3126 )
3127 .unwrap();
3128 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
3129 assert_eq!(r.model.id, "custom-claude");
3130 assert_eq!(r.model.base_url, "https://gw.example.com");
3131 assert!(r.has_provider_key);
3132 assert!(r
3133 .execution
3134 .request_model(&r.model)
3135 .headers
3136 .as_ref()
3137 .map_or(true, |headers| {
3138 headers.keys().all(|name| {
3139 !name.eq_ignore_ascii_case("x-api-key")
3140 && !name.eq_ignore_ascii_case("authorization")
3141 })
3142 }));
3143 }
3144
3145 #[test]
3152 fn default_prefers_gateway_when_only_bare_apikey_configured() {
3153 let _env = TestEnv::new();
3154 std::fs::write(
3155 config::models_path().unwrap(),
3156 r#"{
3157 "providers": {
3158 "gateway": {
3159 "baseUrl": "https://gw.example.com",
3160 "api": "anthropic-messages",
3161 "apiKey": "gw-secret",
3162 "models": [
3163 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3164 ]
3165 }
3166 }
3167}"#,
3168 )
3169 .unwrap();
3170 let r = resolve(None, None, None, None, None).unwrap();
3172 assert_eq!(r.model.id, "custom-claude");
3173 assert_eq!(r.model.base_url, "https://gw.example.com");
3174 assert!(r.has_provider_key);
3175 }
3176
3177 #[test]
3182 fn models_json_bare_apikey_env_template_resolves() {
3183 let _env = TestEnv::new();
3184 std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
3186 std::fs::write(
3187 config::models_path().unwrap(),
3188 r#"{
3189 "providers": {
3190 "gateway": {
3191 "baseUrl": "https://gw.example.com",
3192 "api": "anthropic-messages",
3193 "apiKey": "$RPI_TEST_GATEWAY_KEY",
3194 "models": [
3195 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3196 ]
3197 }
3198 }
3199}"#,
3200 )
3201 .unwrap();
3202 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
3203 assert!(r.has_provider_key);
3204 let cfg = config::load_models_config().unwrap();
3205 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap(), &[]);
3206 assert_eq!(
3207 anthropic_credential_for(&credentials, "gateway"),
3208 Some(&AnthropicCredential::ProviderKey(
3209 "env-resolved-secret".into()
3210 ))
3211 );
3212 std::env::remove_var("RPI_TEST_GATEWAY_KEY");
3213 }
3214
3215 #[test]
3225 fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
3226 let _env = TestEnv::new();
3227 std::fs::write(
3228 config::models_path().unwrap(),
3229 r#"{
3230 "providers": {
3231 "bearer-gw": {
3232 "baseUrl": "https://bearer.example.com",
3233 "api": "anthropic-messages",
3234 "authHeader": true,
3235 "apiKey": "bearer-secret",
3236 "models": [ { "id": "bearer-model" } ]
3237 },
3238 "xkey-gw": {
3239 "baseUrl": "https://xkey.example.com",
3240 "api": "anthropic-messages",
3241 "apiKey": "xkey-secret",
3242 "models": [ { "id": "xkey-model" } ]
3243 }
3244 }
3245}"#,
3246 )
3247 .unwrap();
3248 let r = resolve(None, None, None, None, None).unwrap();
3251 assert_eq!(r.model.id, "bearer-model");
3252
3253 let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
3255 let h = r
3256 .execution
3257 .request_model(&r.model)
3258 .headers
3259 .expect("bearer folded");
3260 assert_eq!(
3261 h.get("authorization").map(|s| s.as_str()),
3262 Some("Bearer bearer-secret")
3263 );
3264 assert!(
3265 h.get("x-api-key").is_none(),
3266 "authHeader path must not synthesize x-api-key"
3267 );
3268
3269 let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
3271 assert!(r2.has_provider_key);
3272 assert!(r2
3273 .execution
3274 .request_model(&r2.model)
3275 .headers
3276 .as_ref()
3277 .map_or(true, |headers| {
3278 headers.keys().all(|name| {
3279 !name.eq_ignore_ascii_case("x-api-key")
3280 && !name.eq_ignore_ascii_case("authorization")
3281 })
3282 }));
3283
3284 let catalog = available_catalog(&r);
3287 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
3288 assert_eq!(ids, vec!["bearer-model"]);
3289 let other_catalog = available_catalog(&r2);
3290 let other_ids: Vec<&str> = other_catalog.iter().map(|m| m.id.as_str()).collect();
3291 assert_eq!(other_ids, vec!["xkey-model"]);
3292 }
3293
3294 #[test]
3301 fn settings_default_model_honored_for_models_json_provider() {
3302 let _env = TestEnv::new();
3303 std::fs::write(
3304 config::models_path().unwrap(),
3305 r#"{
3306 "providers": {
3307 "beta-gw": {
3308 "baseUrl": "https://beta.example.com",
3309 "api": "anthropic-messages",
3310 "apiKey": "beta-secret",
3311 "models": [ { "id": "beta-model" } ]
3312 },
3313 "alpha-gw": {
3314 "baseUrl": "https://alpha.example.com",
3315 "api": "anthropic-messages",
3316 "apiKey": "alpha-secret",
3317 "models": [ { "id": "alpha-model" } ]
3318 }
3319 }
3320}"#,
3321 )
3322 .unwrap();
3323 std::fs::write(
3327 config::settings_path().unwrap(),
3328 r#"{"defaultProvider":"alpha-gw","defaultModel":"alpha-model"}"#,
3329 )
3330 .unwrap();
3331 let r = resolve(None, None, None, None, None).unwrap();
3332 assert_eq!(r.model.id, "alpha-model");
3333 std::fs::write(
3335 config::settings_path().unwrap(),
3336 r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
3337 )
3338 .unwrap();
3339 let r = resolve(None, None, None, None, None).unwrap();
3340 assert_eq!(r.model.id, "beta-model");
3341 }
3342
3343 #[test]
3344 fn models_json_fallback_preserves_provider_and_model_declaration_order() {
3345 let _env = TestEnv::new();
3346 std::fs::write(
3347 config::models_path().unwrap(),
3348 r#"{
3349 "providers": {
3350 "routeryo-copy": {
3351 "api": "openai-completions",
3352 "baseUrl": "https://router.example.com/v1",
3353 "apiKey": "router-key",
3354 "models": [
3355 { "id": "gpt-5.6-sol" },
3356 { "id": "gpt-5.6-terra" }
3357 ]
3358 },
3359 "alpha-gw": {
3360 "api": "openai-completions",
3361 "baseUrl": "https://alpha.example.com/v1",
3362 "apiKey": "alpha-key",
3363 "models": [ { "id": "alpha-model" } ]
3364 }
3365 }
3366}"#,
3367 )
3368 .unwrap();
3369
3370 let resolved = resolve(None, None, None, None, None).unwrap();
3371 assert_eq!(resolved.model.provider, "routeryo-copy");
3372 assert_eq!(resolved.model.id, "gpt-5.6-sol");
3373 }
3374
3375 #[test]
3376 fn external_provider_uses_its_own_catalog_order_without_a_builtin_default() {
3377 let _env = TestEnv::new();
3378 std::fs::write(
3379 config::models_path().unwrap(),
3380 r#"{
3381 "providers": {
3382 "deepseek": {
3383 "api": "openai-completions",
3384 "baseUrl": "https://api.deepseek.com",
3385 "apiKey": "deepseek-key",
3386 "models": [
3387 { "id": "deepseek-chat" },
3388 { "id": "deepseek-v4-pro" }
3389 ]
3390 }
3391 }
3392}"#,
3393 )
3394 .unwrap();
3395
3396 let resolved = resolve(None, None, None, None, None).unwrap();
3397 assert_eq!(resolved.model.provider, "deepseek");
3398 assert_eq!(resolved.model.id, "deepseek-chat");
3399 }
3400
3401 #[test]
3409 fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
3410 let _env = TestEnv::new();
3411 std::fs::write(
3412 config::models_path().unwrap(),
3413 r#"{
3414 "providers": {
3415 "gateway": {
3416 "baseUrl": "https://gw.example.com",
3417 "api": "anthropic-messages",
3418 "apiKey": "gw-secret",
3419 "models": [
3420 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3421 ]
3422 }
3423 }
3424}"#,
3425 )
3426 .unwrap();
3427 let r = resolve(None, None, None, None, None).unwrap();
3428 assert!(r.has_provider_key);
3430 let catalog = available_catalog(&r);
3431 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
3434 assert_eq!(
3435 ids,
3436 vec!["custom-claude"],
3437 "selector must only list authed models"
3438 );
3439 assert_eq!(r.provider.models().len(), catalog.len());
3442 }
3443
3444 #[test]
3448 fn available_catalog_keeps_all_models_on_provider_key_path() {
3449 let _env = TestEnv::new();
3450 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
3451 let r = resolve(None, None, None, None, None).unwrap();
3452 assert!(r.has_provider_key);
3453 let catalog = available_catalog(&r);
3454 assert_eq!(catalog.len(), r.provider.models().len());
3455 assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
3456 }
3457}