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
80use crate::args::parse_thinking_level;
81use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
82use crate::settings;
83
84pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
87
88const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] = &[
92 ("amazon-bedrock", "us.anthropic.claude-opus-4-6-v1"),
93 ("ant-ling", "Ring-2.6-1T"),
94 ("anthropic", DEFAULT_MODEL_ID),
95 ("openai", "gpt-5.5"),
96 ("azure-openai-responses", "gpt-5.4"),
97 ("openai-codex", "gpt-5.5"),
98 ("radius", "auto"),
99 ("nvidia", "nvidia/nemotron-3-super-120b-a12b"),
100 ("deepseek", "deepseek-v4-pro"),
101 ("google", "gemini-3.1-pro-preview"),
102 ("google-vertex", "gemini-3.1-pro-preview"),
103 ("github-copilot", "gpt-5.4"),
104 ("openrouter", "moonshotai/kimi-k2.6"),
105 ("vercel-ai-gateway", "zai/glm-5.1"),
106 ("xai", "grok-4.6"),
107 ("groq", "openai/gpt-oss-120b"),
108 ("cerebras", "gpt-oss-120b"),
109 ("zai", "glm-5.3"),
110 ("zai-coding-cn", "glm-5.3"),
111 ("mistral", "devstral-medium-latest"),
112 ("minimax", "MiniMax-M2.7"),
113 ("minimax-cn", "MiniMax-M2.7"),
114 ("moonshotai", "kimi-k2.6"),
115 ("moonshotai-cn", "kimi-k2.6"),
116 ("huggingface", "moonshotai/Kimi-K2.6"),
117 ("fireworks", "accounts/fireworks/models/kimi-k2p6"),
118 ("together", "moonshotai/Kimi-K2.6"),
119 ("baseten", "zai-org/GLM-5.2"),
120 ("opencode", "kimi-k2.6"),
121 ("opencode-go", "kimi-k2.6"),
122 ("kimi-coding", "kimi-for-coding"),
123 ("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"),
124 (
125 "cloudflare-ai-gateway",
126 "workers-ai/@cf/moonshotai/kimi-k2.6",
127 ),
128 ("qwen-token-plan", "qwen3.7-max"),
129 ("qwen-token-plan-cn", "qwen3.7-max"),
130 ("qwen-token-plan-individual", "qwen3.8-max"),
131 ("xiaomi", "mimo-v2.5-pro"),
132 ("xiaomi-token-plan-cn", "mimo-v2.5-pro"),
133 ("xiaomi-token-plan-ams", "mimo-v2.5-pro"),
134 ("xiaomi-token-plan-sgp", "mimo-v2.5-pro"),
135];
136
137pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
141
142#[derive(Clone)]
145pub struct ResolvedModel {
146 pub provider: Arc<dyn Provider>,
148 pub model: Model,
150 pub thinking_level: ThinkingLevel,
153 pub has_provider_key: bool,
161 pub theme: Option<String>,
165}
166
167impl std::fmt::Debug for ResolvedModel {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.debug_struct("ResolvedModel")
170 .field("provider", &self.provider.id())
171 .field("model", &self.model.id)
172 .field("thinking_level", &self.thinking_level)
173 .field("has_provider_key", &self.has_provider_key)
174 .field("theme", &self.theme)
175 .finish()
176 }
177}
178
179struct NamedAnthropicProvider {
184 id: String,
185 inner: AnthropicProvider,
186}
187
188#[async_trait::async_trait]
189impl Provider for NamedAnthropicProvider {
190 fn id(&self) -> &str {
191 &self.id
192 }
193
194 fn models(&self) -> &[Model] {
195 self.inner.models()
196 }
197
198 async fn stream_simple(
199 &self,
200 model: &Model,
201 ctx: &Context,
202 opts: &SimpleStreamOptions,
203 ) -> AssistantMessageEventStream {
204 self.inner.stream_simple(model, ctx, opts).await
205 }
206}
207
208pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
210
211pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
217
218pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
222
223pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
226
227pub const NO_API_KEY_HINT: &str =
230 "models.json apiKey, OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login`";
231
232#[derive(Debug, thiserror::Error)]
236pub enum ResolveError {
237 #[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, openai-responses, or a models.json provider id")]
238 UnknownProvider(String),
239 #[error(
240 "Provider \"{requested}\" is ambiguous: {matches}. Use the exact provider id to select one."
241 )]
242 AmbiguousProvider { requested: String, matches: String },
243 #[error("No model matches \"{pattern}\". Available: {available}")]
244 NoMatch { pattern: String, available: String },
245 #[error(
246 "Model \"{pattern}\" is ambiguous across providers: {matches}. {auth_hint} Use --provider or provider/model."
247 )]
248 AmbiguousModel {
249 pattern: String,
250 matches: String,
251 auth_hint: &'static str,
252 },
253 #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
254 InvalidThinkingLevel(String, String),
255 #[error("No API key. Set one of: {hint}")]
256 NoApiKey { hint: &'static str },
257 #[error("Could not read config: {0}")]
258 Config(#[from] config::ConfigError),
259}
260
261fn provider_http_client(
274 models: &[Model],
275 default_base_url: &str,
276 settings: &settings::Settings,
277) -> reqwest::Client {
278 let base_url = models
279 .iter()
280 .map(|m| m.base_url.as_str())
281 .find(|u| !u.is_empty())
282 .unwrap_or(default_base_url);
283 rpi_ai::http::build_client(base_url, settings.http_idle_timeout_ms(), None)
284 .unwrap_or_else(|_| reqwest::Client::new())
285}
286
287pub fn resolve(
288 cli_provider: Option<&str>,
289 cli_model: Option<&str>,
290 cli_thinking: Option<ThinkingLevel>,
291 cli_api_key: Option<&str>,
292 cli_base_url: Option<&str>,
293) -> Result<ResolvedModel, ResolveError> {
294 let settings = settings::load_settings().unwrap_or_default();
295 resolve_with_settings(
296 cli_provider,
297 cli_model,
298 cli_thinking,
299 cli_api_key,
300 cli_base_url,
301 settings,
302 )
303}
304
305pub fn resolve_for_cwd(
307 cli_provider: Option<&str>,
308 cli_model: Option<&str>,
309 cli_thinking: Option<ThinkingLevel>,
310 cli_api_key: Option<&str>,
311 cli_base_url: Option<&str>,
312 cwd: &std::path::Path,
313 project_trusted: bool,
314) -> Result<ResolvedModel, ResolveError> {
315 let settings = settings::load_effective_model_settings(cwd, project_trusted)?;
316 resolve_with_settings(
317 cli_provider,
318 cli_model,
319 cli_thinking,
320 cli_api_key,
321 cli_base_url,
322 settings,
323 )
324}
325
326fn resolve_with_settings(
327 cli_provider: Option<&str>,
328 cli_model: Option<&str>,
329 cli_thinking: Option<ThinkingLevel>,
330 cli_api_key: Option<&str>,
331 cli_base_url: Option<&str>,
332 settings: settings::Settings,
333) -> Result<ResolvedModel, ResolveError> {
334 let models_cfg = config::load_models_config()?;
338 let canonical_cli_provider = cli_provider
339 .map(|requested| canonicalize_cli_provider(requested, &models_cfg))
340 .transpose()?;
341 let cli_provider = canonical_cli_provider.as_deref();
342 let auth_store = config::read_auth()?;
343 let anthropic_credentials = resolve_anthropic_credentials(&models_cfg, &auth_store);
344 let openai_credentials = resolve_openai_credentials(&models_cfg, &auth_store);
345 let cli_api_key = cli_api_key
348 .filter(|key| !key.is_empty())
349 .map(str::to_string);
350
351 let cli_base_url_override = cli_base_url.map(str::to_string);
353 let anthropic_base_url_override = std::env::var(ANTHROPIC_BASE_URL_ENV)
354 .ok()
355 .filter(|value| !value.is_empty());
356
357 let mut catalog = anthropic_models();
360 catalog.extend(openai_responses_models());
361 merge_user_catalog(&mut catalog, &models_cfg);
362
363 for model in &mut catalog {
368 if let Some(base) = &cli_base_url_override {
369 model.base_url = base.clone();
370 } else if matches!(model.api, rpi_ai::Api::AnthropicMessages)
371 && model.provider == DEFAULT_PROVIDER_ID
372 {
373 if let Some(base) = &anthropic_base_url_override {
374 model.base_url = base.clone();
375 }
376 }
377 }
378
379 if let Some(requested) = cli_provider {
380 catalog.retain(|model| provider_matches(model, requested, &models_cfg));
381 }
382
383 let available = catalog
384 .iter()
385 .map(|m| m.id.clone())
386 .collect::<Vec<_>>()
387 .join(", ");
388 if catalog.is_empty() {
389 return Err(ResolveError::NoMatch {
390 pattern: cli_provider.unwrap_or("default").to_string(),
391 available,
392 });
393 }
394
395 let (mut model, thinking_level) = match cli_model {
404 Some(raw) => {
405 let parsed_pattern = split_model_pattern(raw, cli_provider, &catalog, &models_cfg)?;
406 if let Some(provider) = parsed_pattern.provider.as_deref() {
407 if !provider_is_known(provider, &models_cfg) {
408 return Err(ResolveError::UnknownProvider(provider.to_string()));
409 }
410 }
411 let mut pattern_thinking = parsed_pattern.thinking;
412 let mut selected = find_cli_model(
413 &parsed_pattern.model_id,
414 parsed_pattern.provider.as_deref(),
415 &catalog,
416 &models_cfg,
417 &anthropic_credentials,
418 &openai_credentials,
419 )?;
420
421 if parsed_pattern.inferred_provider {
422 if let Some(inferred) = selected.as_ref() {
423 if !model_is_authed_for_resolution(
424 inferred,
425 &anthropic_credentials,
426 &openai_credentials,
427 false,
428 ) {
429 let authenticated_raw_matches = |candidate: &str| {
430 catalog
431 .iter()
432 .filter(|model| {
433 model.id.eq_ignore_ascii_case(candidate)
434 && (model.provider != inferred.provider
435 || model.id != inferred.id)
436 && model_is_authed_for_resolution(
437 model,
438 &anthropic_credentials,
439 &openai_credentials,
440 false,
441 )
442 })
443 .collect::<Vec<_>>()
444 };
445 let mut raw_matches =
446 authenticated_raw_matches(&parsed_pattern.raw_model_id);
447 let mut matched_complete_raw_id = true;
448 if raw_matches.is_empty() && parsed_pattern.thinking.is_some() {
449 if let Some((raw_without_thinking, _)) =
450 parsed_pattern.raw_model_id.rsplit_once(':')
451 {
452 raw_matches = authenticated_raw_matches(raw_without_thinking);
453 matched_complete_raw_id = false;
454 }
455 }
456 if let [raw_match] = raw_matches.as_slice() {
457 selected = Some((*raw_match).clone());
458 if matched_complete_raw_id {
459 pattern_thinking = None;
460 }
461 }
462 }
463 } else {
464 selected = find_cli_model(
465 &parsed_pattern.raw_model_id,
466 None,
467 &catalog,
468 &models_cfg,
469 &anthropic_credentials,
470 &openai_credentials,
471 )?;
472 if selected.is_some() {
473 pattern_thinking = None;
474 } else if parsed_pattern.thinking.is_some() {
475 if let Some((raw_without_thinking, _)) =
476 parsed_pattern.raw_model_id.rsplit_once(':')
477 {
478 selected = find_cli_model(
479 raw_without_thinking,
480 None,
481 &catalog,
482 &models_cfg,
483 &anthropic_credentials,
484 &openai_credentials,
485 )?;
486 }
487 }
488 }
489 }
490
491 let model = match selected {
492 Some(m) => m,
493 None => {
494 return Err(ResolveError::NoMatch {
495 pattern: parsed_pattern.model_id,
496 available,
497 });
498 }
499 };
500 let thinking_level = cli_thinking
503 .or(pattern_thinking)
504 .unwrap_or(DEFAULT_THINKING_LEVEL);
505 (model, thinking_level)
506 }
507 None => {
508 let settings_thinking = settings
514 .default_thinking_level
515 .as_deref()
516 .and_then(parse_thinking_level);
517
518 let saved_provider = settings
528 .default_provider
529 .as_deref()
530 .filter(|provider| provider_is_known(provider, &models_cfg));
531 let saved = settings.default_model.as_deref().and_then(|id| {
532 if settings.default_provider.is_some() && saved_provider.is_none() {
533 return None;
534 }
535 find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
536 model_is_authed_for_resolution(
537 m,
538 &anthropic_credentials,
539 &openai_credentials,
540 cli_api_key.is_some(),
541 )
542 })
543 });
544 if let Some(model) = saved {
545 let thinking_level = cli_thinking
546 .or(settings_thinking)
547 .unwrap_or(DEFAULT_THINKING_LEVEL);
548 (model, thinking_level)
549 } else {
550 let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
552 let model = pick_default_model(
553 &catalog,
554 &models_cfg,
555 &anthropic_credentials,
556 &openai_credentials,
557 cli_api_key.is_some(),
558 );
559 (model, thinking_level)
560 }
561 }
562 };
563
564 let selected_api = model.api.clone();
566 let selected_provider = model.provider.clone();
567 let selected_anthropic_credential = if matches!(selected_api, rpi_ai::Api::AnthropicMessages) {
568 let auth_header = models_cfg
569 .providers
570 .get(&selected_provider)
571 .and_then(|provider| provider.auth_header)
572 .unwrap_or(false);
573 credential_for_selected_anthropic_provider(
574 cli_api_key.as_deref(),
575 &anthropic_credentials,
576 &selected_provider,
577 auth_header,
578 )
579 } else {
580 None
581 };
582 let selected_openai_key = if matches!(
583 selected_api,
584 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses
585 ) {
586 cli_api_key
587 .clone()
588 .or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
589 } else {
590 None
591 };
592 let selected_is_authed = model_has_header_auth(&model)
593 || match selected_api {
594 rpi_ai::Api::AnthropicMessages => selected_anthropic_credential.is_some(),
595 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
596 selected_openai_key.is_some()
597 }
598 _ => false,
599 };
600 if !selected_is_authed {
601 return Err(ResolveError::NoApiKey {
602 hint: NO_API_KEY_HINT,
603 });
604 }
605
606 if let Some(AnthropicCredential::Headers(headers)) = &selected_anthropic_credential {
607 merge_auth_headers(&mut model, headers);
608 }
609 if let Some(key) = &selected_openai_key {
610 merge_auth_headers(
611 &mut model,
612 &BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]),
613 );
614 }
615 let mut provider_models: Vec<Model> = catalog
616 .into_iter()
617 .filter(|candidate| {
618 candidate.api == selected_api && candidate.provider == selected_provider
619 })
620 .collect();
621 if let Some(AnthropicCredential::Headers(headers)) = &selected_anthropic_credential {
622 for candidate in &mut provider_models {
623 merge_auth_headers(candidate, headers);
624 }
625 }
626 if let Some(key) = &selected_openai_key {
627 let headers = BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]);
628 for candidate in &mut provider_models {
629 merge_auth_headers(candidate, &headers);
630 }
631 }
632 for candidate in &mut provider_models {
635 let attribution = crate::attribution::default_attribution_headers(candidate);
636 if attribution.is_empty() {
637 continue;
638 }
639 let mut headers = candidate.headers.clone().unwrap_or_default();
640 for (key, value) in attribution {
641 headers.entry(key).or_insert(value);
642 }
643 candidate.headers = Some(headers);
644 }
645 let (provider, has_provider_key): (Arc<dyn Provider>, bool) = match selected_api {
646 rpi_ai::Api::AnthropicMessages => {
647 let provider_key = selected_anthropic_credential
648 .as_ref()
649 .and_then(AnthropicCredential::provider_key)
650 .map(str::to_string);
651 let has_key = provider_key.is_some();
652 let http = provider_http_client(&provider_models, "https://api.anthropic.com", &settings);
653 let inner = if selected_provider == DEFAULT_PROVIDER_ID
654 && !matches!(
655 selected_anthropic_credential,
656 Some(AnthropicCredential::Headers(_))
657 ) {
658 AnthropicProvider::with_models(provider_key, http, provider_models)
659 } else {
660 AnthropicProvider::with_models_without_env_api_key(provider_key, http, provider_models)
661 };
662 (
663 Arc::new(NamedAnthropicProvider {
664 id: selected_provider,
665 inner,
666 }),
667 has_key,
668 )
669 }
670 rpi_ai::Api::OpenaiCompletions => {
671 let has_key = selected_openai_key.is_some();
672 let http = provider_http_client(&provider_models, "https://api.openai.com/v1", &settings);
673 let inner = if selected_provider == "openai" {
674 OpenAiCompletionsProvider::with_models(
675 selected_provider,
676 selected_openai_key,
677 http,
678 provider_models,
679 )
680 } else {
681 OpenAiCompletionsProvider::with_models_without_env_api_key(
682 selected_provider,
683 selected_openai_key,
684 http,
685 provider_models,
686 )
687 };
688 (Arc::new(inner), has_key)
689 }
690 rpi_ai::Api::OpenaiResponses => {
691 let has_key = selected_openai_key.is_some();
692 let http = provider_http_client(&provider_models, "https://api.openai.com/v1", &settings);
693 let inner = if selected_provider == "openai" {
694 OpenAiResponsesProvider::with_models(
695 selected_provider,
696 selected_openai_key,
697 http,
698 provider_models,
699 )
700 } else {
701 OpenAiResponsesProvider::with_models_without_env_api_key(
702 selected_provider,
703 selected_openai_key,
704 http,
705 provider_models,
706 )
707 };
708 (Arc::new(inner), has_key)
709 }
710 _ => unreachable!("unsupported APIs are filtered while loading models.json"),
711 };
712
713 Ok(ResolvedModel {
714 provider,
715 model,
716 thinking_level,
717 has_provider_key,
718 theme: settings.theme.clone(),
719 })
720}
721
722pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
735 let selected_api = &resolved.model.api;
736 let selected_provider = &resolved.model.provider;
737 let mut seen = std::collections::HashSet::new();
738 resolved
739 .provider
740 .models()
741 .iter()
742 .filter(|m| m.api == *selected_api && m.provider == *selected_provider)
746 .filter(|m| model_is_authed(m, resolved.has_provider_key))
747 .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
748 .cloned()
749 .collect()
750}
751
752pub fn catalog_all() -> Result<Vec<Model>, config::ConfigError> {
756 let cfg = config::load_models_config()?;
757 let mut catalog = anthropic_models();
758 catalog.extend(openai_responses_models());
759 merge_user_catalog(&mut catalog, &cfg);
760 catalog.sort_by(|a, b| {
761 a.provider
762 .to_ascii_lowercase()
763 .cmp(&b.provider.to_ascii_lowercase())
764 .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
765 });
766 Ok(catalog)
767}
768
769fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
774 for (provider_id, provider_cfg) in &cfg.providers {
775 let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
776 continue;
778 };
779 for m in models {
780 if let Some(existing) = catalog.iter_mut().find(|candidate| {
781 candidate.api == m.api
782 && candidate.provider == m.provider
783 && candidate.id.eq_ignore_ascii_case(&m.id)
784 }) {
785 *existing = m;
786 } else {
787 catalog.push(m);
788 }
789 }
790 }
791}
792
793#[derive(Clone, Debug, PartialEq, Eq)]
794enum AnthropicCredential {
795 ProviderKey(String),
797 Headers(BTreeMap<String, String>),
799}
800
801impl AnthropicCredential {
802 fn provider_key(&self) -> Option<&str> {
803 match self {
804 Self::ProviderKey(key) => Some(key),
805 Self::Headers(_) => None,
806 }
807 }
808}
809
810type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
811type OpenAiCredentials = BTreeMap<String, String>;
812
813fn resolve_anthropic_credentials(
817 cfg: &config::ModelsConfig,
818 auth_store: &config::AuthStore,
819) -> AnthropicCredentials {
820 let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
821 for (provider_id, provider_cfg) in &cfg.providers {
822 if config::provider_is_anthropic_compatible(provider_cfg)
823 && !provider_ids.iter().any(|known| known == provider_id)
824 {
825 provider_ids.push(provider_id.clone());
826 }
827 }
828
829 let mut credentials = BTreeMap::new();
830 for provider_id in provider_ids {
831 let provider_cfg = cfg.providers.get(&provider_id);
832 let auth_header = provider_cfg
833 .and_then(|provider| provider.auth_header)
834 .unwrap_or(false);
835 let stored_key = stored_api_key(auth_store, &provider_id);
836 let configured_key = provider_cfg.and_then(|provider| {
837 provider
838 .api_key
839 .as_deref()
840 .filter(|raw| !raw.is_empty())
841 .and_then(|raw| config::resolve_config_value(raw, None))
842 .filter(|key| !key.is_empty())
843 });
844
845 let credential = stored_key
846 .or(configured_key)
847 .map(|key| anthropic_credential_from_key(key, auth_header))
848 .or_else(|| {
849 if provider_id != DEFAULT_PROVIDER_ID {
850 return None;
851 }
852 std::env::var(ANTHROPIC_AUTH_TOKEN_ENV)
853 .ok()
854 .filter(|token| !token.is_empty())
855 .map(|token| {
856 AnthropicCredential::Headers(BTreeMap::from([(
857 "authorization".to_string(),
858 format!("Bearer {token}"),
859 )]))
860 })
861 .or_else(|| {
862 std::env::var(ANTHROPIC_API_KEY_ENV)
863 .ok()
864 .filter(|key| !key.is_empty())
865 .map(|key| anthropic_credential_from_key(key, auth_header))
866 })
867 });
868 if let Some(credential) = credential {
869 credentials.insert(provider_id, credential);
870 }
871 }
872 credentials
873}
874
875fn stored_api_key(auth_store: &config::AuthStore, provider_id: &str) -> Option<String> {
876 auth_store
877 .get(provider_id)
878 .and_then(|credential| match credential {
879 Credential::ApiKey {
880 key: Some(key),
881 env,
882 } => config::resolve_config_value(key, env.as_ref()).filter(|key| !key.is_empty()),
883 _ => None,
884 })
885}
886
887fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
888 if auth_header {
889 AnthropicCredential::Headers(BTreeMap::from([(
890 "authorization".to_string(),
891 format!("Bearer {key}"),
892 )]))
893 } else {
894 AnthropicCredential::ProviderKey(key)
895 }
896}
897
898fn anthropic_credential_for<'a>(
899 credentials: &'a AnthropicCredentials,
900 provider_id: &str,
901) -> Option<&'a AnthropicCredential> {
902 credentials.get(provider_id)
903}
904
905fn credential_for_selected_anthropic_provider(
906 cli_api_key: Option<&str>,
907 credentials: &AnthropicCredentials,
908 provider_id: &str,
909 auth_header: bool,
910) -> Option<AnthropicCredential> {
911 cli_api_key
912 .filter(|key| !key.is_empty())
913 .map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
914 .or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
915}
916
917fn resolve_openai_credentials(
918 cfg: &config::ModelsConfig,
919 auth_store: &config::AuthStore,
920) -> OpenAiCredentials {
921 let mut provider_ids = vec!["openai".to_string()];
922 for (provider_id, provider_cfg) in &cfg.providers {
923 if (config::provider_is_openai_completions(provider_cfg)
924 || config::provider_is_openai_responses(provider_cfg))
925 && !provider_ids.iter().any(|known| known == provider_id)
926 {
927 provider_ids.push(provider_id.clone());
928 }
929 }
930
931 let mut credentials = BTreeMap::new();
932 for provider_id in provider_ids {
933 let provider_cfg = cfg.providers.get(&provider_id);
934 let key = stored_api_key(auth_store, &provider_id)
935 .or_else(|| configured_openai_api_key(&provider_id, provider_cfg));
936 if let Some(key) = key {
937 credentials.insert(provider_id, key);
938 }
939 }
940 credentials
941}
942
943fn configured_openai_api_key(
944 provider_id: &str,
945 provider_cfg: Option<&config::ProviderConfig>,
946) -> Option<String> {
947 if let Some(provider_cfg) = provider_cfg {
948 return config::openai_provider_api_key(provider_id, provider_cfg);
949 }
950
951 (provider_id == "openai")
952 .then(|| std::env::var(OPENAI_API_KEY_ENV).ok())
953 .flatten()
954 .filter(|value| !value.is_empty())
955}
956
957fn openai_credential_for<'a>(
958 credentials: &'a OpenAiCredentials,
959 provider_id: &str,
960) -> Option<&'a String> {
961 credentials.get(provider_id)
962}
963
964fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
965 let headers = model.headers.get_or_insert_with(BTreeMap::new);
966 for (name, value) in auth_headers {
967 headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
968 headers.insert(name.clone(), value.clone());
969 }
970}
971
972struct SplitModelPattern {
973 provider: Option<String>,
974 model_id: String,
975 raw_model_id: String,
976 thinking: Option<ThinkingLevel>,
977 inferred_provider: bool,
978}
979
980fn split_model_pattern(
989 value: &str,
990 cli_provider: Option<&str>,
991 catalog: &[Model],
992 cfg: &config::ModelsConfig,
993) -> Result<SplitModelPattern, ResolveError> {
994 let mut provider = cli_provider.map(str::to_string);
995 let mut model_id = value.to_string();
996 let mut inferred_provider = false;
997
998 if let Some((prefix, remainder)) = value.split_once('/') {
999 if !prefix.is_empty() && !remainder.is_empty() {
1000 let matches_explicit = cli_provider
1001 .and_then(|requested| {
1002 canonicalize_cli_provider(prefix, cfg)
1003 .ok()
1004 .map(|canonical| canonical == requested)
1005 })
1006 .unwrap_or(false);
1007 if matches_explicit {
1008 model_id = remainder.to_string();
1009 } else if cli_provider.is_none() {
1010 match canonicalize_cli_provider(prefix, cfg) {
1011 Ok(canonical) => {
1012 provider = Some(canonical);
1013 model_id = remainder.to_string();
1014 inferred_provider = true;
1015 }
1016 Err(ResolveError::UnknownProvider(_)) => {}
1017 Err(error) => return Err(error),
1018 }
1019 }
1020 }
1021 }
1022
1023 let has_full_exact_match = catalog.iter().any(|model| {
1026 model.id.eq_ignore_ascii_case(&model_id)
1027 && provider
1028 .as_deref()
1029 .map_or(true, |requested| provider_matches(model, requested, cfg))
1030 });
1031 let thinking = if has_full_exact_match {
1032 None
1033 } else if let Some((head, suffix)) = model_id.rsplit_once(':') {
1034 if let Some(level) = parse_thinking_level(suffix) {
1035 model_id = head.to_string();
1036 Some(level)
1037 } else {
1038 None
1039 }
1040 } else {
1041 None
1042 };
1043
1044 Ok(SplitModelPattern {
1045 provider,
1046 model_id,
1047 raw_model_id: value.to_string(),
1048 thinking,
1049 inferred_provider,
1050 })
1051}
1052
1053fn find_model(
1055 pattern: &str,
1056 provider: Option<&str>,
1057 catalog: &[Model],
1058 cfg: &config::ModelsConfig,
1059) -> Option<Model> {
1060 catalog
1061 .iter()
1062 .find(|model| {
1063 model.id.eq_ignore_ascii_case(pattern)
1064 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1065 })
1066 .cloned()
1067}
1068
1069fn find_cli_model(
1074 pattern: &str,
1075 provider: Option<&str>,
1076 catalog: &[Model],
1077 cfg: &config::ModelsConfig,
1078 anthropic_credentials: &AnthropicCredentials,
1079 openai_credentials: &OpenAiCredentials,
1080) -> Result<Option<Model>, ResolveError> {
1081 let exact_matches: Vec<&Model> = catalog
1082 .iter()
1083 .filter(|model| {
1084 model.id.eq_ignore_ascii_case(pattern)
1085 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1086 })
1087 .collect();
1088
1089 match exact_matches.as_slice() {
1090 [] => Ok(None),
1091 [model] => Ok(Some((*model).clone())),
1092 _ => {
1093 let authenticated: Vec<&Model> = exact_matches
1094 .iter()
1095 .copied()
1096 .filter(|model| {
1097 model_is_authed_for_resolution(
1098 model,
1099 anthropic_credentials,
1100 openai_credentials,
1101 false,
1102 )
1103 })
1104 .collect();
1105 if let [model] = authenticated.as_slice() {
1106 return Ok(Some((*model).clone()));
1107 }
1108
1109 let mut matches = exact_matches
1110 .iter()
1111 .map(|model| format!("{}/{}", model.provider, model.id))
1112 .collect::<Vec<_>>();
1113 matches.sort();
1114 let auth_hint = if authenticated.is_empty() {
1115 "No matching provider is authenticated."
1116 } else {
1117 "More than one matching provider is authenticated."
1118 };
1119 Err(ResolveError::AmbiguousModel {
1120 pattern: pattern.to_string(),
1121 matches: matches.join(", "),
1122 auth_hint,
1123 })
1124 }
1125 }
1126}
1127
1128fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
1129 cfg.providers.contains_key(requested)
1130 || requested.eq_ignore_ascii_case("anthropic")
1131 || requested.eq_ignore_ascii_case("openai")
1132 || requested.eq_ignore_ascii_case("openai-completions")
1133 || requested.eq_ignore_ascii_case("openai-responses")
1134}
1135
1136fn canonicalize_cli_provider(
1141 requested: &str,
1142 cfg: &config::ModelsConfig,
1143) -> Result<String, ResolveError> {
1144 if cfg.providers.contains_key(requested) {
1145 return Ok(requested.to_string());
1146 }
1147
1148 const BUILTIN_PROVIDER_IDS: &[&str] = &[
1149 "anthropic",
1150 "openai",
1151 "openai-completions",
1152 "openai-responses",
1153 ];
1154 if BUILTIN_PROVIDER_IDS.contains(&requested) {
1155 return Ok(requested.to_string());
1156 }
1157
1158 let mut matches = cfg
1159 .providers
1160 .keys()
1161 .filter(|provider| provider.eq_ignore_ascii_case(requested))
1162 .cloned()
1163 .collect::<Vec<_>>();
1164 matches.extend(
1165 BUILTIN_PROVIDER_IDS
1166 .iter()
1167 .filter(|provider| provider.eq_ignore_ascii_case(requested))
1168 .map(|provider| (*provider).to_string()),
1169 );
1170 matches.sort();
1171 matches.dedup();
1172
1173 match matches.as_slice() {
1174 [provider] => return Ok(provider.clone()),
1175 [] => {}
1176 _ => {
1177 return Err(ResolveError::AmbiguousProvider {
1178 requested: requested.to_string(),
1179 matches: matches.join(", "),
1180 });
1181 }
1182 }
1183
1184 Err(ResolveError::UnknownProvider(requested.to_string()))
1185}
1186
1187fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
1188 if model.provider == requested {
1192 return true;
1193 }
1194 if cfg.providers.contains_key(requested) {
1195 return false;
1196 }
1197 if requested.eq_ignore_ascii_case("anthropic") {
1198 return model.provider == DEFAULT_PROVIDER_ID;
1199 }
1200 if requested.eq_ignore_ascii_case("openai") {
1201 return model.provider == "openai";
1202 }
1203 if requested.eq_ignore_ascii_case("openai-completions") {
1204 return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
1205 }
1206 if requested.eq_ignore_ascii_case("openai-responses") {
1207 return matches!(model.api, rpi_ai::Api::OpenaiResponses);
1208 }
1209 false
1210}
1211
1212fn model_belongs_to_default_provider(
1217 model: &Model,
1218 requested: &str,
1219 _cfg: &config::ModelsConfig,
1220) -> bool {
1221 model.provider == requested
1222}
1223
1224fn model_is_authed(m: &Model, has_provider_key: bool) -> bool {
1232 model_has_header_auth(m) || has_provider_key
1233}
1234
1235fn model_is_authed_for_resolution(
1236 model: &Model,
1237 anthropic_credentials: &AnthropicCredentials,
1238 openai_credentials: &OpenAiCredentials,
1239 has_cli_key: bool,
1240) -> bool {
1241 model_has_header_auth(model)
1242 || has_cli_key
1243 || match model.api {
1244 rpi_ai::Api::AnthropicMessages => {
1245 anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
1246 }
1247 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
1248 openai_credential_for(openai_credentials, &model.provider).is_some()
1249 }
1250 _ => false,
1251 }
1252}
1253
1254fn model_has_header_auth(m: &Model) -> bool {
1258 let Some(h) = &m.headers else { return false };
1259 const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
1260 h.keys()
1261 .any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
1262}
1263
1264fn pick_default_model(
1274 catalog: &[Model],
1275 models_cfg: &config::ModelsConfig,
1276 anthropic_credentials: &AnthropicCredentials,
1277 openai_credentials: &OpenAiCredentials,
1278 has_cli_key: bool,
1279) -> Model {
1280 for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
1282 if let Some(model) = catalog.iter().find(|model| {
1283 model.id.eq_ignore_ascii_case(model_id)
1284 && model_belongs_to_default_provider(model, provider, models_cfg)
1285 && model_is_authed_for_resolution(
1286 model,
1287 anthropic_credentials,
1288 openai_credentials,
1289 has_cli_key,
1290 )
1291 }) {
1292 return model.clone();
1293 }
1294 }
1295
1296 if let Some(m) = catalog.iter().find(|m| {
1300 model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_key)
1301 }) {
1302 return m.clone();
1303 }
1304 catalog
1309 .iter()
1310 .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
1311 .or_else(|| catalog.first())
1312 .expect("catalog is never empty (built-in anthropic_models)")
1313 .clone()
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318 use super::*;
1319 use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
1320 use crate::config::test_support::env_lock;
1321
1322 struct TestEnv {
1327 _guard: std::sync::MutexGuard<'static, ()>,
1328 prev_key: Option<std::ffi::OsString>,
1329 prev_tok: Option<std::ffi::OsString>,
1330 prev_base: Option<std::ffi::OsString>,
1331 prev_openai_key: Option<std::ffi::OsString>,
1332 prev_dir: Option<std::ffi::OsString>,
1333 _tmp: tempfile::TempDir,
1334 }
1335 impl TestEnv {
1336 fn new() -> Self {
1337 let guard = env_lock().lock().unwrap();
1338 let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
1339 let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
1340 let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
1341 let prev_openai_key = std::env::var_os(OPENAI_API_KEY_ENV);
1342 let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
1343 std::env::remove_var(ANTHROPIC_API_KEY_ENV);
1344 std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
1345 std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
1346 std::env::remove_var(OPENAI_API_KEY_ENV);
1347 let tmp = tempfile::TempDir::new().unwrap();
1348 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
1349 Self {
1350 _guard: guard,
1351 prev_key,
1352 prev_tok,
1353 prev_base,
1354 prev_openai_key,
1355 prev_dir,
1356 _tmp: tmp,
1357 }
1358 }
1359 }
1360 impl Drop for TestEnv {
1361 fn drop(&mut self) {
1362 restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
1363 restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
1364 restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
1365 restore(OPENAI_API_KEY_ENV, self.prev_openai_key.take());
1366 restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
1367 }
1368 }
1369 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
1370 match prev {
1371 Some(v) => std::env::set_var(name, v),
1372 None => std::env::remove_var(name),
1373 }
1374 }
1375
1376 fn resolve_with_key(
1382 provider: Option<&str>,
1383 model: Option<&str>,
1384 thinking: Option<ThinkingLevel>,
1385 ) -> Result<ResolvedModel, ResolveError> {
1386 let _env = TestEnv::new();
1387 std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
1388 resolve(provider, model, thinking, None, None)
1389 }
1390
1391 #[test]
1392 fn default_model_matches_native_anthropic_default() {
1393 let r = resolve_with_key(None, None, None).unwrap();
1394 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1395 assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
1396 assert_eq!(r.provider.id(), "anthropic");
1397 }
1398
1399 #[test]
1400 fn settings_default_model_wins_when_authed() {
1401 let _env = TestEnv::new();
1407 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1408 let path = config::settings_path().unwrap();
1409 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1410 std::fs::write(
1411 &path,
1412 r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
1413 )
1414 .unwrap();
1415 let r = resolve(None, None, None, None, None).unwrap();
1416 assert_eq!(r.model.id, "claude-haiku-4-5");
1417 assert_eq!(r.thinking_level, ThinkingLevel::High);
1418 std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
1420 let r = resolve(None, None, None, None, None).unwrap();
1421 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1422 }
1423
1424 #[test]
1425 fn explicit_id_match() {
1426 let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
1427 assert_eq!(r.model.id, "claude-haiku-4-5");
1428 }
1429
1430 #[test]
1431 fn case_insensitive_id() {
1432 let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
1433 assert_eq!(r.model.id, "claude-opus-5");
1434 }
1435
1436 #[test]
1437 fn provider_prefix_stripped() {
1438 let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
1439 assert_eq!(r.model.id, "claude-sonnet-5");
1440 }
1441
1442 #[test]
1443 fn custom_provider_prefix_stripped() {
1444 let _env = TestEnv::new();
1447 std::env::set_var(ANTHROPIC_API_KEY_ENV, "official-key");
1448 std::fs::write(
1449 config::models_path().unwrap(),
1450 r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "apiKey": "gateway-key", "models": [{"id":"custom-claude"}] } } }"#,
1451 )
1452 .unwrap();
1453 let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
1454 assert_eq!(r.model.id, "custom-claude");
1455 }
1456
1457 #[test]
1458 fn unknown_slash_prefix_remains_part_of_the_raw_model_id() {
1459 let _env = TestEnv::new();
1460 std::fs::write(
1461 config::models_path().unwrap(),
1462 r#"{
1463 "providers": {
1464 "gateway": {
1465 "api": "openai-completions",
1466 "apiKey": "gateway-key",
1467 "models": [{"id":"meta-llama/llama-3.3"}]
1468 }
1469 }
1470}"#,
1471 )
1472 .unwrap();
1473
1474 let resolved = resolve(None, Some("meta-llama/llama-3.3:high"), None, None, None).unwrap();
1475 assert_eq!(resolved.model.provider, "gateway");
1476 assert_eq!(resolved.model.id, "meta-llama/llama-3.3");
1477 assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1478 }
1479
1480 #[test]
1481 fn complete_model_id_wins_before_parsing_a_thinking_suffix() {
1482 let _env = TestEnv::new();
1483 std::fs::write(
1484 config::models_path().unwrap(),
1485 r#"{
1486 "providers": {
1487 "gateway": {
1488 "api": "openai-completions",
1489 "apiKey": "gateway-key",
1490 "models": [
1491 {"id":"vendor/model"},
1492 {"id":"vendor/model:high"}
1493 ]
1494 }
1495 }
1496}"#,
1497 )
1498 .unwrap();
1499
1500 let resolved = resolve(None, Some("gateway/vendor/model:high"), None, None, None).unwrap();
1501 assert_eq!(resolved.model.provider, "gateway");
1502 assert_eq!(resolved.model.id, "vendor/model:high");
1503 assert_eq!(resolved.thinking_level, DEFAULT_THINKING_LEVEL);
1504 }
1505
1506 #[test]
1507 fn authenticated_inferred_provider_beats_a_matching_raw_model_id() {
1508 let _env = TestEnv::new();
1509 std::fs::write(
1510 config::models_path().unwrap(),
1511 r#"{
1512 "providers": {
1513 "alpha": {
1514 "api": "openai-completions",
1515 "baseUrl": "https://alpha.example.com",
1516 "apiKey": "alpha-key",
1517 "models": [{"id":"target"}]
1518 },
1519 "gateway": {
1520 "api": "openai-completions",
1521 "baseUrl": "https://gateway.example.com",
1522 "apiKey": "gateway-key",
1523 "models": [{"id":"alpha/target"}]
1524 }
1525 }
1526}"#,
1527 )
1528 .unwrap();
1529
1530 let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1531 assert_eq!(resolved.model.provider, "alpha");
1532 assert_eq!(resolved.model.id, "target");
1533 assert_eq!(resolved.model.base_url, "https://alpha.example.com");
1534 }
1535
1536 #[test]
1537 fn authenticated_raw_model_beats_an_unauthenticated_inferred_provider() {
1538 let _env = TestEnv::new();
1539 std::fs::write(
1540 config::models_path().unwrap(),
1541 r#"{
1542 "providers": {
1543 "alpha": {
1544 "api": "openai-completions",
1545 "baseUrl": "https://alpha.example.com",
1546 "models": [{"id":"target"}]
1547 },
1548 "gateway": {
1549 "api": "openai-completions",
1550 "baseUrl": "https://gateway.example.com",
1551 "apiKey": "gateway-key",
1552 "models": [{"id":"alpha/target"}]
1553 }
1554 }
1555}"#,
1556 )
1557 .unwrap();
1558
1559 let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1560 assert_eq!(resolved.model.provider, "gateway");
1561 assert_eq!(resolved.model.id, "alpha/target");
1562 assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1563 }
1564
1565 #[test]
1566 fn thinking_suffix_raw_model_beats_an_unauthenticated_inferred_provider() {
1567 let _env = TestEnv::new();
1568 std::fs::write(
1569 config::models_path().unwrap(),
1570 r#"{
1571 "providers": {
1572 "alpha": {
1573 "api": "openai-completions",
1574 "baseUrl": "https://alpha.example.com",
1575 "models": [{"id":"target"}]
1576 },
1577 "gateway": {
1578 "api": "openai-completions",
1579 "baseUrl": "https://gateway.example.com",
1580 "apiKey": "gateway-key",
1581 "models": [{"id":"alpha/target"}]
1582 }
1583 }
1584}"#,
1585 )
1586 .unwrap();
1587
1588 let resolved = resolve(None, Some("alpha/target:high"), None, None, None).unwrap();
1589 assert_eq!(resolved.model.provider, "gateway");
1590 assert_eq!(resolved.model.id, "alpha/target");
1591 assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1592 assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1593 }
1594
1595 #[test]
1596 fn thinking_suffix_in_model() {
1597 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
1598 assert_eq!(r.model.id, "claude-sonnet-5");
1599 assert_eq!(r.thinking_level, ThinkingLevel::High);
1600 }
1601
1602 #[test]
1603 fn thinking_flag_overrides_suffix() {
1604 let r =
1606 resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
1607 assert_eq!(r.thinking_level, ThinkingLevel::Low);
1608 }
1609
1610 #[test]
1611 fn explicit_provider_anthropic_ok() {
1612 let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
1613 assert_eq!(r.model.id, "claude-sonnet-5");
1614
1615 let r = resolve_with_key(Some("ANTHROPIC"), Some("claude-sonnet-5"), None).unwrap();
1616 assert_eq!(r.provider.id(), DEFAULT_PROVIDER_ID);
1617 }
1618
1619 #[test]
1620 fn unknown_provider_rejected() {
1621 let err = resolve_with_key(Some("unsupported-provider"), None, None).unwrap_err();
1622 assert!(matches!(err, ResolveError::UnknownProvider(_)));
1623 }
1624
1625 #[test]
1626 fn no_match_lists_available() {
1627 let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
1628 match err {
1629 ResolveError::NoMatch { pattern, available } => {
1630 assert_eq!(pattern, "claude-does-not-exist");
1631 assert!(available.contains("claude-sonnet-5"));
1632 }
1633 other => panic!("expected NoMatch, got {other:?}"),
1634 }
1635 }
1636
1637 #[test]
1638 fn colon_not_a_thinking_level_kept_in_id() {
1639 let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
1642 assert!(matches!(err, ResolveError::NoMatch { .. }));
1643 }
1644
1645 #[test]
1646 fn parse_thinking_level_roundtrip() {
1647 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
1648 assert_eq!(parse_thinking_level("bogus"), None);
1649 for lvl in VALID_THINKING_LEVELS {
1651 assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
1652 }
1653 }
1654
1655 #[test]
1656 fn no_api_key_errors_with_hint() {
1657 let _env = TestEnv::new();
1658 let err = resolve(None, None, None, None, None).unwrap_err();
1659 match err {
1660 ResolveError::NoApiKey { hint } => {
1661 assert!(hint.contains("ANTHROPIC_API_KEY"));
1662 assert!(hint.contains("auth login"));
1663 }
1664 other => panic!("expected NoApiKey, got {other:?}"),
1665 }
1666 }
1667
1668 #[test]
1669 fn stored_credential_satisfies_auth() {
1670 let _env = TestEnv::new();
1671 config::upsert_credential(
1672 DEFAULT_PROVIDER_ID,
1673 Credential::ApiKey {
1674 key: Some("stored-key".into()),
1675 env: None,
1676 },
1677 )
1678 .unwrap();
1679 let r = resolve(None, None, None, None, None).unwrap();
1680 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1681 assert!(
1684 r.model
1685 .headers
1686 .as_ref()
1687 .and_then(|h| h.get("authorization"))
1688 .is_none(),
1689 "x-api-key path should not synthesize a Bearer header"
1690 );
1691 }
1692
1693 #[test]
1694 fn gateway_models_key_wins_over_unrelated_anthropic_auth() {
1695 let _env = TestEnv::new();
1696 config::upsert_credential(
1697 DEFAULT_PROVIDER_ID,
1698 Credential::ApiKey {
1699 key: Some("official-key".into()),
1700 env: None,
1701 },
1702 )
1703 .unwrap();
1704 std::fs::write(
1705 config::models_path().unwrap(),
1706 r#"{
1707 "providers": {
1708 "gateway": {
1709 "api": "anthropic-messages",
1710 "baseUrl": "https://gateway.example.com",
1711 "apiKey": "gateway-key",
1712 "models": [{"id":"gateway-model"}]
1713 }
1714 }
1715}"#,
1716 )
1717 .unwrap();
1718
1719 let resolved = resolve(None, Some("gateway/gateway-model"), None, None, None).unwrap();
1720 assert_eq!(resolved.provider.id(), "gateway");
1721 assert_eq!(resolved.model.provider, "gateway");
1722 assert!(resolved.has_provider_key);
1723
1724 let cfg = config::load_models_config().unwrap();
1725 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
1726 assert_eq!(
1727 anthropic_credential_for(&credentials, DEFAULT_PROVIDER_ID),
1728 Some(&AnthropicCredential::ProviderKey("official-key".into()))
1729 );
1730 assert_eq!(
1731 anthropic_credential_for(&credentials, "gateway"),
1732 Some(&AnthropicCredential::ProviderKey("gateway-key".into()))
1733 );
1734 }
1735
1736 #[test]
1737 fn gateway_auth_json_credential_is_usable() {
1738 let _env = TestEnv::new();
1739 config::upsert_credential(
1740 "gateway",
1741 Credential::ApiKey {
1742 key: Some("stored-gateway-key".into()),
1743 env: None,
1744 },
1745 )
1746 .unwrap();
1747 std::fs::write(
1748 config::models_path().unwrap(),
1749 r#"{
1750 "providers": {
1751 "gateway": {
1752 "api": "anthropic-messages",
1753 "baseUrl": "https://gateway.example.com",
1754 "models": [{"id":"gateway-model"}]
1755 }
1756 }
1757}"#,
1758 )
1759 .unwrap();
1760
1761 let resolved = resolve(Some("gateway"), None, None, None, None).unwrap();
1762 assert_eq!(resolved.model.id, "gateway-model");
1763 assert_eq!(resolved.provider.id(), "gateway");
1764 assert!(resolved.has_provider_key);
1765 }
1766
1767 #[test]
1768 fn anthropic_auth_does_not_authenticate_an_unkeyed_gateway() {
1769 let _env = TestEnv::new();
1770 config::upsert_credential(
1771 DEFAULT_PROVIDER_ID,
1772 Credential::ApiKey {
1773 key: Some("official-key".into()),
1774 env: None,
1775 },
1776 )
1777 .unwrap();
1778 std::fs::write(
1779 config::models_path().unwrap(),
1780 r#"{
1781 "providers": {
1782 "gateway": {
1783 "api": "anthropic-messages",
1784 "baseUrl": "https://gateway.example.com",
1785 "models": [{"id":"gateway-model"}]
1786 }
1787 }
1788}"#,
1789 )
1790 .unwrap();
1791
1792 let error = resolve(
1793 Some("gateway"),
1794 Some("gateway/gateway-model"),
1795 None,
1796 None,
1797 None,
1798 )
1799 .unwrap_err();
1800 assert!(matches!(error, ResolveError::NoApiKey { .. }));
1801
1802 let default = resolve(None, None, None, None, None).unwrap();
1803 assert_eq!(default.provider.id(), DEFAULT_PROVIDER_ID);
1804 assert_eq!(default.model.id, DEFAULT_MODEL_ID);
1805 }
1806
1807 #[test]
1808 fn two_anthropic_gateways_keep_credentials_isolated_by_id() {
1809 let _env = TestEnv::new();
1810 std::fs::write(
1811 config::models_path().unwrap(),
1812 r#"{
1813 "providers": {
1814 "alpha": {
1815 "api": "anthropic-messages",
1816 "baseUrl": "https://shared.example.com",
1817 "apiKey": "alpha-key",
1818 "models": [{"id":"shared-model"}]
1819 },
1820 "beta": {
1821 "api": "anthropic-messages",
1822 "baseUrl": "https://shared.example.com",
1823 "apiKey": "beta-key",
1824 "models": [{"id":"shared-model"}]
1825 }
1826 }
1827}"#,
1828 )
1829 .unwrap();
1830
1831 let cfg = config::load_models_config().unwrap();
1832 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
1833 assert_eq!(
1834 anthropic_credential_for(&credentials, "alpha"),
1835 Some(&AnthropicCredential::ProviderKey("alpha-key".into()))
1836 );
1837 assert_eq!(
1838 anthropic_credential_for(&credentials, "beta"),
1839 Some(&AnthropicCredential::ProviderKey("beta-key".into()))
1840 );
1841 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1842 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
1843 assert_eq!(alpha.provider.id(), "alpha");
1844 assert_eq!(beta.provider.id(), "beta");
1845 assert!(alpha.has_provider_key && beta.has_provider_key);
1846 }
1847
1848 #[test]
1849 fn case_distinct_provider_ids_keep_endpoints_and_credentials_isolated() {
1850 let _env = TestEnv::new();
1851 std::fs::write(
1852 config::models_path().unwrap(),
1853 r#"{
1854 "providers": {
1855 "alpha": {
1856 "api": "anthropic-messages",
1857 "baseUrl": "https://lower.example.com",
1858 "authHeader": true,
1859 "models": [{"id":"shared-model"}]
1860 },
1861 "ALPHA": {
1862 "api": "anthropic-messages",
1863 "baseUrl": "https://upper.example.com",
1864 "authHeader": true,
1865 "models": [{"id":"shared-model"}]
1866 }
1867 }
1868}"#,
1869 )
1870 .unwrap();
1871 config::upsert_credential(
1872 "alpha",
1873 Credential::ApiKey {
1874 key: Some("lower-key".into()),
1875 env: None,
1876 },
1877 )
1878 .unwrap();
1879 config::upsert_credential(
1880 "ALPHA",
1881 Credential::ApiKey {
1882 key: Some("upper-key".into()),
1883 env: None,
1884 },
1885 )
1886 .unwrap();
1887
1888 let lower = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1889 assert_eq!(lower.provider.id(), "alpha");
1890 assert_eq!(lower.model.base_url, "https://lower.example.com");
1891 assert_eq!(
1892 lower
1893 .model
1894 .headers
1895 .as_ref()
1896 .and_then(|headers| headers.get("authorization"))
1897 .map(String::as_str),
1898 Some("Bearer lower-key")
1899 );
1900
1901 let upper = resolve(None, Some("ALPHA/shared-model"), None, None, None).unwrap();
1902 assert_eq!(upper.provider.id(), "ALPHA");
1903 assert_eq!(upper.model.base_url, "https://upper.example.com");
1904 assert_eq!(
1905 upper
1906 .model
1907 .headers
1908 .as_ref()
1909 .and_then(|headers| headers.get("authorization"))
1910 .map(String::as_str),
1911 Some("Bearer upper-key")
1912 );
1913
1914 let error = resolve(Some("Alpha"), None, None, None, None).unwrap_err();
1915 assert!(matches!(
1916 error,
1917 ResolveError::AmbiguousProvider {
1918 requested,
1919 matches
1920 } if requested == "Alpha" && matches == "ALPHA, alpha"
1921 ));
1922
1923 let prefix_error = resolve(None, Some("Alpha/shared-model"), None, None, None).unwrap_err();
1924 assert!(matches!(
1925 prefix_error,
1926 ResolveError::AmbiguousProvider {
1927 requested,
1928 matches
1929 } if requested == "Alpha" && matches == "ALPHA, alpha"
1930 ));
1931 }
1932
1933 #[test]
1934 fn cli_provider_case_insensitively_selects_one_canonical_custom_id() {
1935 let _env = TestEnv::new();
1936 std::fs::write(
1937 config::models_path().unwrap(),
1938 r#"{
1939 "providers": {
1940 "routeryo-copy": {
1941 "api": "openai-completions",
1942 "baseUrl": "https://routeryo-copy.example.com",
1943 "apiKey": "copy-key",
1944 "models": [{"id":"copy-model"}]
1945 }
1946 }
1947}"#,
1948 )
1949 .unwrap();
1950
1951 let resolved =
1952 resolve(Some("ROUTERYO-COPY"), Some("copy-model"), None, None, None).unwrap();
1953 assert_eq!(resolved.model.provider, "routeryo-copy");
1954 assert_eq!(resolved.provider.id(), "routeryo-copy");
1955 assert_eq!(resolved.model.base_url, "https://routeryo-copy.example.com");
1956
1957 let prefixed = resolve(None, Some("ROUTERYO-COPY/copy-model"), None, None, None).unwrap();
1958 assert_eq!(prefixed.model.provider, "routeryo-copy");
1959 assert_eq!(prefixed.model.id, "copy-model");
1960 }
1961
1962 #[test]
1963 fn cli_provider_canonicalization_keeps_builtin_and_custom_case_ids_distinct() {
1964 let _env = TestEnv::new();
1965 std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
1966 std::fs::write(
1967 config::models_path().unwrap(),
1968 r#"{
1969 "providers": {
1970 "OpenAI": {
1971 "api": "openai-completions",
1972 "baseUrl": "https://custom-openai.example.com",
1973 "apiKey": "custom-openai-key",
1974 "models": [{"id":"custom-model"}]
1975 }
1976 }
1977}"#,
1978 )
1979 .unwrap();
1980
1981 let custom = resolve(Some("OpenAI"), Some("custom-model"), None, None, None).unwrap();
1982 assert_eq!(custom.model.provider, "OpenAI");
1983 assert_eq!(custom.model.base_url, "https://custom-openai.example.com");
1984
1985 let builtin = resolve(Some("openai"), Some("gpt-6-astra"), None, None, None).unwrap();
1986 assert_eq!(builtin.model.provider, "openai");
1987
1988 let error = resolve(Some("OPENAI"), None, None, None, None).unwrap_err();
1989 assert!(matches!(
1990 error,
1991 ResolveError::AmbiguousProvider {
1992 requested,
1993 matches
1994 } if requested == "OPENAI" && matches == "OpenAI, openai"
1995 ));
1996 }
1997
1998 #[test]
1999 fn cli_api_key_overrides_only_the_selected_anthropic_provider() {
2000 let _env = TestEnv::new();
2001 std::fs::write(
2002 config::models_path().unwrap(),
2003 r#"{
2004 "providers": {
2005 "gateway": {
2006 "api": "anthropic-messages",
2007 "baseUrl": "https://gateway.example.com",
2008 "authHeader": true,
2009 "apiKey": "configured-key",
2010 "models": [{"id":"gateway-model"}]
2011 }
2012 }
2013}"#,
2014 )
2015 .unwrap();
2016
2017 let cfg = config::load_models_config().unwrap();
2018 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
2019 assert_eq!(
2020 credential_for_selected_anthropic_provider(
2021 Some("cli-key"),
2022 &credentials,
2023 "gateway",
2024 true,
2025 ),
2026 Some(AnthropicCredential::Headers(BTreeMap::from([(
2027 "authorization".into(),
2028 "Bearer cli-key".into(),
2029 )])))
2030 );
2031 assert_eq!(
2032 anthropic_credential_for(&credentials, "gateway"),
2033 Some(&AnthropicCredential::Headers(BTreeMap::from([(
2034 "authorization".into(),
2035 "Bearer configured-key".into(),
2036 )])))
2037 );
2038
2039 let resolved = resolve(
2040 Some("gateway"),
2041 Some("gateway/gateway-model"),
2042 None,
2043 Some("cli-key"),
2044 None,
2045 )
2046 .unwrap();
2047 assert!(!resolved.has_provider_key);
2048 assert_eq!(
2049 resolved
2050 .model
2051 .headers
2052 .as_ref()
2053 .and_then(|headers| headers.get("authorization"))
2054 .map(String::as_str),
2055 Some("Bearer cli-key")
2056 );
2057 }
2058
2059 #[test]
2060 fn auth_token_routes_via_bearer_header() {
2061 let _env = TestEnv::new();
2062 std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
2063 let r = resolve(None, None, None, None, None).unwrap();
2064 let headers = r.model.headers.as_ref().expect("bearer header on model");
2066 assert_eq!(
2067 headers.get("authorization").map(|s| s.as_str()),
2068 Some("Bearer tok-123")
2069 );
2070 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
2074 }
2075
2076 #[test]
2077 fn api_key_flag_beats_env_and_stored() {
2078 let _env = TestEnv::new();
2079 std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
2080 config::upsert_credential(
2081 DEFAULT_PROVIDER_ID,
2082 Credential::ApiKey {
2083 key: Some("stored-key".into()),
2084 env: None,
2085 },
2086 )
2087 .unwrap();
2088 let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
2091 assert!(
2092 r.model
2093 .headers
2094 .as_ref()
2095 .and_then(|h| h.get("authorization"))
2096 .is_none(),
2097 "--api-key should take the x-api-key path, not Bearer"
2098 );
2099 }
2100
2101 #[test]
2102 fn base_url_override_applies_to_model() {
2103 let _env = TestEnv::new();
2104 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2105 let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
2106 assert_eq!(r.model.base_url, "https://gw.example.com");
2107 }
2108
2109 #[test]
2110 fn base_url_env_is_fallback_for_flag() {
2111 let _env = TestEnv::new();
2112 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2113 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
2114 let r = resolve(None, None, None, None, None).unwrap();
2115 assert_eq!(r.model.base_url, "https://env-gw.example.com");
2116 }
2117
2118 #[test]
2119 fn anthropic_base_url_env_does_not_redirect_a_custom_provider() {
2120 let _env = TestEnv::new();
2121 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://ambient-proxy.example.com");
2122 std::fs::write(
2123 config::models_path().unwrap(),
2124 r#"{
2125 "providers": {
2126 "gateway": {
2127 "api": "anthropic-messages",
2128 "baseUrl": "https://gateway.example.com",
2129 "apiKey": "gateway-secret",
2130 "models": [{"id":"gateway-model"}]
2131 }
2132 }
2133}"#,
2134 )
2135 .unwrap();
2136
2137 let r = resolve(
2138 Some("gateway"),
2139 Some("gateway/gateway-model"),
2140 None,
2141 None,
2142 None,
2143 )
2144 .unwrap();
2145 assert_eq!(r.model.base_url, "https://gateway.example.com");
2146 assert!(r.has_provider_key);
2147 }
2148
2149 #[test]
2150 fn models_json_adds_custom_model() {
2151 let _env = TestEnv::new();
2152 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2153 std::fs::write(
2154 config::models_path().unwrap(),
2155 r#"{
2156 "providers": {
2157 "gateway": {
2158 "baseUrl": "https://gw.example.com",
2159 "authHeader": true,
2160 "apiKey": "gw-secret",
2161 "models": [
2162 { "id": "custom-claude", "name": "Custom" }
2163 ]
2164 }
2165 }
2166}"#,
2167 )
2168 .unwrap();
2169 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2170 assert_eq!(r.model.id, "custom-claude");
2171 assert_eq!(r.model.base_url, "https://gw.example.com");
2172 assert_eq!(r.model.provider, "gateway");
2173 assert_eq!(r.provider.id(), "gateway");
2174 let headers = r.model.headers.as_ref().expect("headers merged");
2176 assert_eq!(
2177 headers.get("authorization").map(|s| s.as_str()),
2178 Some("Bearer gw-secret")
2179 );
2180 }
2181
2182 #[test]
2183 fn openai_completions_models_json_is_a_complete_provider_config() {
2184 let _env = TestEnv::new();
2185 std::fs::write(
2186 config::models_path().unwrap(),
2187 r#"{
2188 "providers": {
2189 "routeryo": {
2190 "baseUrl": "https://api.routeryo.com",
2191 "api": "openai-completions",
2192 "apiKey": "router-secret",
2193 "models": [
2194 {
2195 "id": "gpt-5.6-sol",
2196 "name": "GPT 5.6",
2197 "reasoning": true,
2198 "contextWindow": 200000,
2199 "maxTokens": 32768
2200 }
2201 ]
2202 }
2203 }
2204}"#,
2205 )
2206 .unwrap();
2207
2208 let resolved = resolve(None, None, None, None, None).unwrap();
2209 assert_eq!(resolved.model.id, "gpt-5.6-sol");
2210 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2211 assert_eq!(resolved.model.provider, "routeryo");
2212 assert_eq!(resolved.provider.id(), "routeryo");
2213 assert!(resolved.has_provider_key);
2214 assert_eq!(
2215 resolved
2216 .model
2217 .headers
2218 .as_ref()
2219 .and_then(|headers| headers.get("authorization"))
2220 .map(String::as_str),
2221 Some("Bearer router-secret")
2222 );
2223
2224 let explicit = resolve(
2225 Some("routeryo"),
2226 Some("routeryo/gpt-5.6-sol"),
2227 None,
2228 None,
2229 None,
2230 )
2231 .unwrap();
2232 assert_eq!(explicit.provider.id(), "routeryo");
2233 assert_eq!(explicit.model.id, "gpt-5.6-sol");
2234 }
2235
2236 #[test]
2237 fn openai_completions_provider_alias_excludes_responses_models() {
2238 let _env = TestEnv::new();
2239 std::fs::write(
2240 config::models_path().unwrap(),
2241 r#"{
2242 "providers": {
2243 "chat-gateway": {
2244 "api": "openai-completions",
2245 "baseUrl": "https://chat.example.com/v1",
2246 "apiKey": "chat-key",
2247 "models": [{"id":"chat-model"}]
2248 },
2249 "responses-gateway": {
2250 "api": "openai-responses",
2251 "baseUrl": "https://responses.example.com/v1",
2252 "apiKey": "responses-key",
2253 "models": [{"id":"responses-model"}]
2254 }
2255 }
2256}"#,
2257 )
2258 .unwrap();
2259
2260 let resolved = resolve(Some("openai-completions"), None, None, None, None).unwrap();
2261 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2262 assert_eq!(resolved.model.provider, "chat-gateway");
2263 assert_eq!(resolved.model.id, "chat-model");
2264 }
2265
2266 #[test]
2267 fn openai_model_prefix_disambiguates_providers_with_the_same_model_id() {
2268 let _env = TestEnv::new();
2269 std::fs::write(
2270 config::models_path().unwrap(),
2271 r#"{
2272 "providers": {
2273 "alpha": {
2274 "api": "openai-completions",
2275 "baseUrl": "https://alpha.example.com",
2276 "apiKey": "alpha-secret",
2277 "models": [{"id":"shared-model"}]
2278 },
2279 "beta": {
2280 "api": "openai-completions",
2281 "baseUrl": "https://beta.example.com",
2282 "apiKey": "beta-secret",
2283 "models": [{"id":"shared-model"}]
2284 }
2285 }
2286}"#,
2287 )
2288 .unwrap();
2289
2290 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2291 assert_eq!(alpha.provider.id(), "alpha");
2292 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2293 assert_eq!(
2294 alpha
2295 .model
2296 .headers
2297 .as_ref()
2298 .and_then(|headers| headers.get("authorization"))
2299 .map(String::as_str),
2300 Some("Bearer alpha-secret")
2301 );
2302
2303 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2304 assert_eq!(beta.provider.id(), "beta");
2305 assert_eq!(beta.model.base_url, "https://beta.example.com");
2306 assert_eq!(
2307 beta.model
2308 .headers
2309 .as_ref()
2310 .and_then(|headers| headers.get("authorization"))
2311 .map(String::as_str),
2312 Some("Bearer beta-secret")
2313 );
2314 }
2315
2316 #[test]
2317 fn bare_duplicate_model_id_prefers_the_only_authenticated_provider() {
2318 let _env = TestEnv::new();
2319 std::fs::write(
2320 config::models_path().unwrap(),
2321 r#"{
2322 "providers": {
2323 "alpha": {
2324 "api": "openai-completions",
2325 "baseUrl": "https://alpha.example.com",
2326 "models": [{"id":"shared-model"}]
2327 },
2328 "beta": {
2329 "api": "openai-completions",
2330 "baseUrl": "https://beta.example.com",
2331 "apiKey": "beta-secret",
2332 "models": [{"id":"shared-model"}]
2333 }
2334 }
2335}"#,
2336 )
2337 .unwrap();
2338
2339 let resolved = resolve(None, Some("shared-model"), None, None, None).unwrap();
2340 assert_eq!(resolved.model.provider, "beta");
2341 assert_eq!(resolved.model.base_url, "https://beta.example.com");
2342 }
2343
2344 #[test]
2345 fn bare_duplicate_model_id_rejects_multiple_authenticated_providers() {
2346 let _env = TestEnv::new();
2347 std::fs::write(
2348 config::models_path().unwrap(),
2349 r#"{
2350 "providers": {
2351 "alpha": {
2352 "api": "openai-completions",
2353 "apiKey": "alpha-secret",
2354 "models": [{"id":"shared-model"}]
2355 },
2356 "beta": {
2357 "api": "openai-completions",
2358 "apiKey": "beta-secret",
2359 "models": [{"id":"shared-model"}]
2360 }
2361 }
2362}"#,
2363 )
2364 .unwrap();
2365
2366 let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2367 match error {
2368 ResolveError::AmbiguousModel {
2369 pattern,
2370 matches,
2371 auth_hint,
2372 } => {
2373 assert_eq!(pattern, "shared-model");
2374 assert_eq!(matches, "alpha/shared-model, beta/shared-model");
2375 assert_eq!(
2376 auth_hint,
2377 "More than one matching provider is authenticated."
2378 );
2379 }
2380 other => panic!("expected AmbiguousModel, got {other:?}"),
2381 }
2382 }
2383
2384 #[test]
2385 fn bare_duplicate_model_id_rejects_when_no_provider_is_authenticated() {
2386 let _env = TestEnv::new();
2387 std::fs::write(
2388 config::models_path().unwrap(),
2389 r#"{
2390 "providers": {
2391 "alpha": {
2392 "api": "openai-completions",
2393 "models": [{"id":"shared-model"}]
2394 },
2395 "beta": {
2396 "api": "openai-completions",
2397 "models": [{"id":"shared-model"}]
2398 }
2399 }
2400}"#,
2401 )
2402 .unwrap();
2403
2404 let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2405 assert!(matches!(
2406 error,
2407 ResolveError::AmbiguousModel {
2408 auth_hint: "No matching provider is authenticated.",
2409 ..
2410 }
2411 ));
2412 }
2413
2414 #[test]
2415 fn openai_auth_json_overrides_config_for_both_protocols() {
2416 let _env = TestEnv::new();
2417 config::upsert_credential(
2418 "chat-gateway",
2419 Credential::ApiKey {
2420 key: Some("stored-chat-key".into()),
2421 env: None,
2422 },
2423 )
2424 .unwrap();
2425 config::upsert_credential(
2426 "responses-gateway",
2427 Credential::ApiKey {
2428 key: Some("stored-responses-key".into()),
2429 env: None,
2430 },
2431 )
2432 .unwrap();
2433 std::fs::write(
2434 config::models_path().unwrap(),
2435 r#"{
2436 "providers": {
2437 "chat-gateway": {
2438 "api": "openai-completions",
2439 "baseUrl": "https://chat.example.com/v1",
2440 "apiKey": "configured-chat-key",
2441 "models": [{"id":"chat-model"}]
2442 },
2443 "responses-gateway": {
2444 "api": "openai-responses",
2445 "baseUrl": "https://responses.example.com/v1",
2446 "apiKey": "configured-responses-key",
2447 "models": [{"id":"responses-model"}]
2448 }
2449 }
2450}"#,
2451 )
2452 .unwrap();
2453
2454 let chat = resolve(
2455 Some("chat-gateway"),
2456 Some("chat-gateway/chat-model"),
2457 None,
2458 None,
2459 None,
2460 )
2461 .unwrap();
2462 assert!(chat.has_provider_key);
2463 assert_eq!(
2464 chat.model
2465 .headers
2466 .as_ref()
2467 .and_then(|headers| headers.get("authorization"))
2468 .map(String::as_str),
2469 Some("Bearer stored-chat-key")
2470 );
2471
2472 let responses = resolve(
2473 Some("responses-gateway"),
2474 Some("responses-gateway/responses-model"),
2475 None,
2476 None,
2477 None,
2478 )
2479 .unwrap();
2480 assert!(responses.has_provider_key);
2481 assert_eq!(
2482 responses
2483 .model
2484 .headers
2485 .as_ref()
2486 .and_then(|headers| headers.get("authorization"))
2487 .map(String::as_str),
2488 Some("Bearer stored-responses-key")
2489 );
2490 }
2491
2492 #[test]
2493 fn openai_env_does_not_authenticate_custom_providers() {
2494 let _env = TestEnv::new();
2495 std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
2496 std::fs::write(
2497 config::models_path().unwrap(),
2498 r#"{
2499 "providers": {
2500 "chat-gateway": {
2501 "api": "openai-completions",
2502 "baseUrl": "https://chat.example.com/v1",
2503 "models": [{"id":"chat-model"}]
2504 },
2505 "responses-gateway": {
2506 "api": "openai-responses",
2507 "baseUrl": "https://responses.example.com/v1",
2508 "models": [{"id":"responses-model"}]
2509 },
2510 "openai-completions": {
2511 "api": "openai-completions",
2512 "baseUrl": "https://alias-chat.example.com/v1",
2513 "models": [{"id":"alias-chat-model"}]
2514 },
2515 "openai-responses": {
2516 "api": "openai-responses",
2517 "baseUrl": "https://alias-responses.example.com/v1",
2518 "models": [{"id":"alias-responses-model"}]
2519 },
2520 "OpenAI": {
2521 "api": "openai-responses",
2522 "baseUrl": "https://case-distinct.example.com/v1",
2523 "models": [{"id":"case-distinct-model"}]
2524 }
2525 }
2526}"#,
2527 )
2528 .unwrap();
2529
2530 for (provider, model) in [
2531 ("chat-gateway", "chat-gateway/chat-model"),
2532 ("responses-gateway", "responses-gateway/responses-model"),
2533 ("openai-completions", "openai-completions/alias-chat-model"),
2534 ("openai-responses", "openai-responses/alias-responses-model"),
2535 ("OpenAI", "OpenAI/case-distinct-model"),
2536 ] {
2537 let error = resolve(Some(provider), Some(model), None, None, None).unwrap_err();
2538 assert!(matches!(error, ResolveError::NoApiKey { .. }));
2539 }
2540
2541 let official = resolve(None, None, None, None, None).unwrap();
2542 assert_eq!(official.provider.id(), "openai");
2543 assert_eq!(official.model.id, "gpt-6-astra");
2544 assert!(official.has_provider_key);
2545 }
2546
2547 #[test]
2548 fn cli_api_key_overrides_selected_openai_provider() {
2549 let _env = TestEnv::new();
2550 std::fs::write(
2551 config::models_path().unwrap(),
2552 r#"{
2553 "providers": {
2554 "chat-gateway": {
2555 "api": "openai-completions",
2556 "baseUrl": "https://chat.example.com/v1",
2557 "apiKey": "configured-chat-key",
2558 "models": [{"id":"chat-model"}]
2559 },
2560 "responses-gateway": {
2561 "api": "openai-responses",
2562 "baseUrl": "https://responses.example.com/v1",
2563 "apiKey": "configured-responses-key",
2564 "models": [{"id":"responses-model"}]
2565 }
2566 }
2567}"#,
2568 )
2569 .unwrap();
2570
2571 for (provider, model) in [
2572 ("chat-gateway", "chat-gateway/chat-model"),
2573 ("responses-gateway", "responses-gateway/responses-model"),
2574 ] {
2575 let resolved =
2576 resolve(Some(provider), Some(model), None, Some("cli-key"), None).unwrap();
2577 assert_eq!(resolved.provider.id(), provider);
2578 assert!(resolved.has_provider_key);
2579 assert_eq!(
2580 resolved
2581 .model
2582 .headers
2583 .as_ref()
2584 .and_then(|headers| headers.get("authorization"))
2585 .map(String::as_str),
2586 Some("Bearer cli-key")
2587 );
2588 }
2589 }
2590
2591 #[test]
2592 fn anthropic_model_prefix_disambiguates_providers_with_the_same_model_id() {
2593 let _env = TestEnv::new();
2594 std::fs::write(
2595 config::models_path().unwrap(),
2596 r#"{
2597 "providers": {
2598 "alpha": {
2599 "api": "anthropic-messages",
2600 "baseUrl": "https://alpha.example.com",
2601 "apiKey": "alpha-secret",
2602 "models": [{"id":"shared-model"}]
2603 },
2604 "beta": {
2605 "api": "anthropic-messages",
2606 "baseUrl": "https://beta.example.com",
2607 "authHeader": true,
2608 "apiKey": "beta-secret",
2609 "models": [{"id":"shared-model"}]
2610 }
2611 }
2612}"#,
2613 )
2614 .unwrap();
2615
2616 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2617 assert_eq!(alpha.provider.id(), "alpha");
2618 assert_eq!(alpha.model.provider, "alpha");
2619 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2620 assert!(alpha.has_provider_key);
2621 assert!(alpha.model.headers.as_ref().map_or(true, |headers| {
2622 headers
2623 .keys()
2624 .all(|name| !name.eq_ignore_ascii_case("x-api-key"))
2625 }));
2626
2627 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2628 assert_eq!(beta.provider.id(), "beta");
2629 assert_eq!(beta.model.provider, "beta");
2630 assert_eq!(beta.model.base_url, "https://beta.example.com");
2631 assert_eq!(
2632 beta.model
2633 .headers
2634 .as_ref()
2635 .and_then(|headers| headers.get("authorization"))
2636 .map(String::as_str),
2637 Some("Bearer beta-secret")
2638 );
2639 }
2640
2641 #[test]
2642 fn trusted_project_defaults_override_global_defaults_for_resolution() {
2643 let _env = TestEnv::new();
2644 std::fs::write(
2645 config::models_path().unwrap(),
2646 r#"{
2647 "providers": {
2648 "global": {
2649 "api": "anthropic-messages",
2650 "apiKey": "global-secret",
2651 "models": [{"id":"global-model"}]
2652 },
2653 "project": {
2654 "api": "anthropic-messages",
2655 "apiKey": "project-secret",
2656 "models": [{"id":"project-model"}]
2657 }
2658 }
2659}"#,
2660 )
2661 .unwrap();
2662 std::fs::write(
2663 config::settings_path().unwrap(),
2664 r#"{"defaultProvider":"global","defaultModel":"global-model"}"#,
2665 )
2666 .unwrap();
2667 let project = tempfile::tempdir().unwrap();
2668 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
2669 std::fs::write(
2670 project.path().join(".rpi/settings.json"),
2671 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
2672 )
2673 .unwrap();
2674
2675 let trusted = resolve_for_cwd(None, None, None, None, None, project.path(), true).unwrap();
2676 assert_eq!(trusted.provider.id(), "project");
2677 assert_eq!(trusted.model.id, "project-model");
2678
2679 let untrusted =
2680 resolve_for_cwd(None, None, None, None, None, project.path(), false).unwrap();
2681 assert_eq!(untrusted.provider.id(), "global");
2682 assert_eq!(untrusted.model.id, "global-model");
2683 }
2684
2685 #[test]
2686 fn unknown_model_prefix_without_a_raw_match_returns_no_match() {
2687 let _env = TestEnv::new();
2688 std::fs::write(
2689 config::models_path().unwrap(),
2690 r#"{
2691 "providers": {
2692 "routeryo": {
2693 "api": "openai-completions",
2694 "apiKey": "secret",
2695 "models": [{"id":"gpt-test"}]
2696 }
2697 }
2698}"#,
2699 )
2700 .unwrap();
2701
2702 let error = resolve(None, Some("misspelled/gpt-test"), None, None, None).unwrap_err();
2703 assert!(matches!(
2704 error,
2705 ResolveError::NoMatch { pattern, .. } if pattern == "misspelled/gpt-test"
2706 ));
2707 }
2708
2709 #[test]
2716 fn models_json_auth_header_satisfies_auth_without_env() {
2717 let _env = TestEnv::new();
2718 std::fs::write(
2720 config::models_path().unwrap(),
2721 r#"{
2722 "providers": {
2723 "gateway": {
2724 "baseUrl": "https://gw.example.com",
2725 "api": "anthropic-messages",
2726 "authHeader": true,
2727 "apiKey": "gw-secret",
2728 "models": [
2729 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2730 ]
2731 }
2732 }
2733}"#,
2734 )
2735 .unwrap();
2736 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2737 assert_eq!(r.model.id, "custom-claude");
2738 assert_eq!(r.model.base_url, "https://gw.example.com");
2739 let headers = r.model.headers.as_ref().expect("bearer folded onto model");
2740 assert_eq!(
2741 headers.get("authorization").map(|s| s.as_str()),
2742 Some("Bearer gw-secret")
2743 );
2744 }
2745
2746 #[test]
2755 fn default_prefers_gateway_when_only_gateway_configured() {
2756 let _env = TestEnv::new();
2759 std::fs::write(
2760 config::models_path().unwrap(),
2761 r#"{
2762 "providers": {
2763 "gateway": {
2764 "baseUrl": "https://gw.example.com",
2765 "api": "anthropic-messages",
2766 "authHeader": true,
2767 "apiKey": "gw-secret",
2768 "models": [
2769 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2770 ]
2771 }
2772 }
2773}"#,
2774 )
2775 .unwrap();
2776 let r = resolve(None, None, None, None, None).unwrap();
2780 assert_eq!(r.model.id, "custom-claude");
2781 assert_eq!(r.model.base_url, "https://gw.example.com");
2782 let headers = r.model.headers.as_ref().expect("bearer on gateway model");
2784 assert_eq!(
2785 headers.get("authorization").map(|s| s.as_str()),
2786 Some("Bearer gw-secret")
2787 );
2788 }
2789
2790 #[test]
2791 fn api_key_flag_honors_models_json_auth_header() {
2792 let _env = TestEnv::new();
2793 std::fs::write(
2794 config::models_path().unwrap(),
2795 r#"{
2796 "providers": {
2797 "gateway": {
2798 "baseUrl": "https://gw.example.com",
2799 "authHeader": true,
2800 "apiKey": "gw-secret",
2801 "models": [ { "id": "custom-claude" } ]
2802 }
2803 }
2804}"#,
2805 )
2806 .unwrap();
2807 let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
2808 assert_eq!(
2809 r.model
2810 .headers
2811 .as_ref()
2812 .and_then(|h| h.get("authorization"))
2813 .map(String::as_str),
2814 Some("Bearer flag-key")
2815 );
2816 assert!(!r.has_provider_key);
2817 }
2818
2819 #[test]
2826 fn models_json_bare_apikey_satisfies_auth_without_env() {
2827 let _env = TestEnv::new();
2828 std::fs::write(
2830 config::models_path().unwrap(),
2831 r#"{
2832 "providers": {
2833 "gateway": {
2834 "baseUrl": "https://gw.example.com",
2835 "api": "anthropic-messages",
2836 "apiKey": "gw-secret",
2837 "models": [
2838 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2839 ]
2840 }
2841 }
2842}"#,
2843 )
2844 .unwrap();
2845 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2846 assert_eq!(r.model.id, "custom-claude");
2847 assert_eq!(r.model.base_url, "https://gw.example.com");
2848 assert!(r.has_provider_key);
2849 assert!(r.model.headers.as_ref().map_or(true, |headers| {
2850 headers.keys().all(|name| {
2851 !name.eq_ignore_ascii_case("x-api-key")
2852 && !name.eq_ignore_ascii_case("authorization")
2853 })
2854 }));
2855 }
2856
2857 #[test]
2864 fn default_prefers_gateway_when_only_bare_apikey_configured() {
2865 let _env = TestEnv::new();
2866 std::fs::write(
2867 config::models_path().unwrap(),
2868 r#"{
2869 "providers": {
2870 "gateway": {
2871 "baseUrl": "https://gw.example.com",
2872 "api": "anthropic-messages",
2873 "apiKey": "gw-secret",
2874 "models": [
2875 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2876 ]
2877 }
2878 }
2879}"#,
2880 )
2881 .unwrap();
2882 let r = resolve(None, None, None, None, None).unwrap();
2884 assert_eq!(r.model.id, "custom-claude");
2885 assert_eq!(r.model.base_url, "https://gw.example.com");
2886 assert!(r.has_provider_key);
2887 }
2888
2889 #[test]
2894 fn models_json_bare_apikey_env_template_resolves() {
2895 let _env = TestEnv::new();
2896 std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
2898 std::fs::write(
2899 config::models_path().unwrap(),
2900 r#"{
2901 "providers": {
2902 "gateway": {
2903 "baseUrl": "https://gw.example.com",
2904 "api": "anthropic-messages",
2905 "apiKey": "$RPI_TEST_GATEWAY_KEY",
2906 "models": [
2907 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2908 ]
2909 }
2910 }
2911}"#,
2912 )
2913 .unwrap();
2914 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2915 assert!(r.has_provider_key);
2916 let cfg = config::load_models_config().unwrap();
2917 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
2918 assert_eq!(
2919 anthropic_credential_for(&credentials, "gateway"),
2920 Some(&AnthropicCredential::ProviderKey(
2921 "env-resolved-secret".into()
2922 ))
2923 );
2924 std::env::remove_var("RPI_TEST_GATEWAY_KEY");
2925 }
2926
2927 #[test]
2937 fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
2938 let _env = TestEnv::new();
2939 std::fs::write(
2940 config::models_path().unwrap(),
2941 r#"{
2942 "providers": {
2943 "bearer-gw": {
2944 "baseUrl": "https://bearer.example.com",
2945 "api": "anthropic-messages",
2946 "authHeader": true,
2947 "apiKey": "bearer-secret",
2948 "models": [ { "id": "bearer-model" } ]
2949 },
2950 "xkey-gw": {
2951 "baseUrl": "https://xkey.example.com",
2952 "api": "anthropic-messages",
2953 "apiKey": "xkey-secret",
2954 "models": [ { "id": "xkey-model" } ]
2955 }
2956 }
2957}"#,
2958 )
2959 .unwrap();
2960 let r = resolve(None, None, None, None, None).unwrap();
2963 assert_eq!(r.model.id, "bearer-model");
2964
2965 let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
2967 let h = r.model.headers.as_ref().expect("bearer folded");
2968 assert_eq!(
2969 h.get("authorization").map(|s| s.as_str()),
2970 Some("Bearer bearer-secret")
2971 );
2972 assert!(
2973 h.get("x-api-key").is_none(),
2974 "authHeader path must not synthesize x-api-key"
2975 );
2976
2977 let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
2979 assert!(r2.has_provider_key);
2980 assert!(r2.model.headers.as_ref().map_or(true, |headers| {
2981 headers.keys().all(|name| {
2982 !name.eq_ignore_ascii_case("x-api-key")
2983 && !name.eq_ignore_ascii_case("authorization")
2984 })
2985 }));
2986
2987 let catalog = available_catalog(&r);
2990 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
2991 assert_eq!(ids, vec!["bearer-model"]);
2992 let other_catalog = available_catalog(&r2);
2993 let other_ids: Vec<&str> = other_catalog.iter().map(|m| m.id.as_str()).collect();
2994 assert_eq!(other_ids, vec!["xkey-model"]);
2995 }
2996
2997 #[test]
3004 fn settings_default_model_honored_for_models_json_provider() {
3005 let _env = TestEnv::new();
3006 std::fs::write(
3007 config::models_path().unwrap(),
3008 r#"{
3009 "providers": {
3010 "beta-gw": {
3011 "baseUrl": "https://beta.example.com",
3012 "api": "anthropic-messages",
3013 "apiKey": "beta-secret",
3014 "models": [ { "id": "beta-model" } ]
3015 },
3016 "alpha-gw": {
3017 "baseUrl": "https://alpha.example.com",
3018 "api": "anthropic-messages",
3019 "apiKey": "alpha-secret",
3020 "models": [ { "id": "alpha-model" } ]
3021 }
3022 }
3023}"#,
3024 )
3025 .unwrap();
3026 std::fs::write(
3030 config::settings_path().unwrap(),
3031 r#"{"defaultProvider":"alpha-gw","defaultModel":"alpha-model"}"#,
3032 )
3033 .unwrap();
3034 let r = resolve(None, None, None, None, None).unwrap();
3035 assert_eq!(r.model.id, "alpha-model");
3036 std::fs::write(
3038 config::settings_path().unwrap(),
3039 r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
3040 )
3041 .unwrap();
3042 let r = resolve(None, None, None, None, None).unwrap();
3043 assert_eq!(r.model.id, "beta-model");
3044 }
3045
3046 #[test]
3047 fn models_json_fallback_preserves_provider_and_model_declaration_order() {
3048 let _env = TestEnv::new();
3049 std::fs::write(
3050 config::models_path().unwrap(),
3051 r#"{
3052 "providers": {
3053 "routeryo-copy": {
3054 "api": "openai-completions",
3055 "baseUrl": "https://router.example.com/v1",
3056 "apiKey": "router-key",
3057 "models": [
3058 { "id": "gpt-5.6-sol" },
3059 { "id": "gpt-5.6-terra" }
3060 ]
3061 },
3062 "alpha-gw": {
3063 "api": "openai-completions",
3064 "baseUrl": "https://alpha.example.com/v1",
3065 "apiKey": "alpha-key",
3066 "models": [ { "id": "alpha-model" } ]
3067 }
3068 }
3069}"#,
3070 )
3071 .unwrap();
3072
3073 let resolved = resolve(None, None, None, None, None).unwrap();
3074 assert_eq!(resolved.model.provider, "routeryo-copy");
3075 assert_eq!(resolved.model.id, "gpt-5.6-sol");
3076 }
3077
3078 #[test]
3079 fn native_known_provider_default_beats_first_model_in_array() {
3080 let _env = TestEnv::new();
3081 std::fs::write(
3082 config::models_path().unwrap(),
3083 r#"{
3084 "providers": {
3085 "deepseek": {
3086 "api": "openai-completions",
3087 "baseUrl": "https://api.deepseek.com",
3088 "apiKey": "deepseek-key",
3089 "models": [
3090 { "id": "deepseek-chat" },
3091 { "id": "deepseek-v4-pro" }
3092 ]
3093 }
3094 }
3095}"#,
3096 )
3097 .unwrap();
3098
3099 let resolved = resolve(None, None, None, None, None).unwrap();
3100 assert_eq!(resolved.model.provider, "deepseek");
3101 assert_eq!(resolved.model.id, "deepseek-v4-pro");
3102 }
3103
3104 #[test]
3112 fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
3113 let _env = TestEnv::new();
3114 std::fs::write(
3115 config::models_path().unwrap(),
3116 r#"{
3117 "providers": {
3118 "gateway": {
3119 "baseUrl": "https://gw.example.com",
3120 "api": "anthropic-messages",
3121 "apiKey": "gw-secret",
3122 "models": [
3123 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3124 ]
3125 }
3126 }
3127}"#,
3128 )
3129 .unwrap();
3130 let r = resolve(None, None, None, None, None).unwrap();
3131 assert!(r.has_provider_key);
3133 let catalog = available_catalog(&r);
3134 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
3137 assert_eq!(
3138 ids,
3139 vec!["custom-claude"],
3140 "selector must only list authed models"
3141 );
3142 assert_eq!(r.provider.models().len(), catalog.len());
3145 }
3146
3147 #[test]
3151 fn available_catalog_keeps_all_models_on_provider_key_path() {
3152 let _env = TestEnv::new();
3153 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
3154 let r = resolve(None, None, None, None, None).unwrap();
3155 assert!(r.has_provider_key);
3156 let catalog = available_catalog(&r);
3157 assert_eq!(catalog.len(), r.provider.models().len());
3158 assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
3159 }
3160}