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