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 =
653 provider_http_client(&provider_models, "https://api.anthropic.com", &settings);
654 let inner = if selected_provider == DEFAULT_PROVIDER_ID
655 && !matches!(
656 selected_anthropic_credential,
657 Some(AnthropicCredential::Headers(_))
658 ) {
659 AnthropicProvider::with_models(provider_key, http, provider_models)
660 } else {
661 AnthropicProvider::with_models_without_env_api_key(
662 provider_key,
663 http,
664 provider_models,
665 )
666 };
667 (
668 Arc::new(NamedAnthropicProvider {
669 id: selected_provider,
670 inner,
671 }),
672 has_key,
673 )
674 }
675 rpi_ai::Api::OpenaiCompletions => {
676 let has_key = selected_openai_key.is_some();
677 let http =
678 provider_http_client(&provider_models, "https://api.openai.com/v1", &settings);
679 let inner = if selected_provider == "openai" {
680 OpenAiCompletionsProvider::with_models(
681 selected_provider,
682 selected_openai_key,
683 http,
684 provider_models,
685 )
686 } else {
687 OpenAiCompletionsProvider::with_models_without_env_api_key(
688 selected_provider,
689 selected_openai_key,
690 http,
691 provider_models,
692 )
693 };
694 (Arc::new(inner), has_key)
695 }
696 rpi_ai::Api::OpenaiResponses => {
697 let has_key = selected_openai_key.is_some();
698 let http =
699 provider_http_client(&provider_models, "https://api.openai.com/v1", &settings);
700 let inner = if selected_provider == "openai" {
701 OpenAiResponsesProvider::with_models(
702 selected_provider,
703 selected_openai_key,
704 http,
705 provider_models,
706 )
707 } else {
708 OpenAiResponsesProvider::with_models_without_env_api_key(
709 selected_provider,
710 selected_openai_key,
711 http,
712 provider_models,
713 )
714 };
715 (Arc::new(inner), has_key)
716 }
717 _ => unreachable!("unsupported APIs are filtered while loading models.json"),
718 };
719
720 Ok(ResolvedModel {
721 provider,
722 model,
723 thinking_level,
724 has_provider_key,
725 theme: settings.theme.clone(),
726 })
727}
728
729pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
742 let selected_api = &resolved.model.api;
743 let selected_provider = &resolved.model.provider;
744 let mut seen = std::collections::HashSet::new();
745 resolved
746 .provider
747 .models()
748 .iter()
749 .filter(|m| m.api == *selected_api && m.provider == *selected_provider)
753 .filter(|m| model_is_authed(m, resolved.has_provider_key))
754 .filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
755 .cloned()
756 .collect()
757}
758
759pub fn catalog_all() -> Result<Vec<Model>, config::ConfigError> {
763 let cfg = config::load_models_config()?;
764 let mut catalog = anthropic_models();
765 catalog.extend(openai_responses_models());
766 merge_user_catalog(&mut catalog, &cfg);
767 catalog.sort_by(|a, b| {
768 a.provider
769 .to_ascii_lowercase()
770 .cmp(&b.provider.to_ascii_lowercase())
771 .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
772 });
773 Ok(catalog)
774}
775
776fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
781 for (provider_id, provider_cfg) in &cfg.providers {
782 let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
783 continue;
785 };
786 for m in models {
787 if let Some(existing) = catalog.iter_mut().find(|candidate| {
788 candidate.api == m.api
789 && candidate.provider == m.provider
790 && candidate.id.eq_ignore_ascii_case(&m.id)
791 }) {
792 *existing = m;
793 } else {
794 catalog.push(m);
795 }
796 }
797 }
798}
799
800#[derive(Clone, Debug, PartialEq, Eq)]
801enum AnthropicCredential {
802 ProviderKey(String),
804 Headers(BTreeMap<String, String>),
806}
807
808impl AnthropicCredential {
809 fn provider_key(&self) -> Option<&str> {
810 match self {
811 Self::ProviderKey(key) => Some(key),
812 Self::Headers(_) => None,
813 }
814 }
815}
816
817type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
818type OpenAiCredentials = BTreeMap<String, String>;
819
820fn resolve_anthropic_credentials(
824 cfg: &config::ModelsConfig,
825 auth_store: &config::AuthStore,
826) -> AnthropicCredentials {
827 let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
828 for (provider_id, provider_cfg) in &cfg.providers {
829 if config::provider_is_anthropic_compatible(provider_cfg)
830 && !provider_ids.iter().any(|known| known == provider_id)
831 {
832 provider_ids.push(provider_id.clone());
833 }
834 }
835
836 let mut credentials = BTreeMap::new();
837 for provider_id in provider_ids {
838 let provider_cfg = cfg.providers.get(&provider_id);
839 let auth_header = provider_cfg
840 .and_then(|provider| provider.auth_header)
841 .unwrap_or(false);
842 let stored_key = stored_api_key(auth_store, &provider_id);
843 let configured_key = provider_cfg.and_then(|provider| {
844 provider
845 .api_key
846 .as_deref()
847 .filter(|raw| !raw.is_empty())
848 .and_then(|raw| config::resolve_config_value(raw, None))
849 .filter(|key| !key.is_empty())
850 });
851
852 let credential = stored_key
853 .or(configured_key)
854 .map(|key| anthropic_credential_from_key(key, auth_header))
855 .or_else(|| {
856 if provider_id != DEFAULT_PROVIDER_ID {
857 return None;
858 }
859 std::env::var(ANTHROPIC_AUTH_TOKEN_ENV)
860 .ok()
861 .filter(|token| !token.is_empty())
862 .map(|token| {
863 AnthropicCredential::Headers(BTreeMap::from([(
864 "authorization".to_string(),
865 format!("Bearer {token}"),
866 )]))
867 })
868 .or_else(|| {
869 std::env::var(ANTHROPIC_API_KEY_ENV)
870 .ok()
871 .filter(|key| !key.is_empty())
872 .map(|key| anthropic_credential_from_key(key, auth_header))
873 })
874 });
875 if let Some(credential) = credential {
876 credentials.insert(provider_id, credential);
877 }
878 }
879 credentials
880}
881
882fn stored_api_key(auth_store: &config::AuthStore, provider_id: &str) -> Option<String> {
883 auth_store
884 .get(provider_id)
885 .and_then(|credential| match credential {
886 Credential::ApiKey {
887 key: Some(key),
888 env,
889 } => config::resolve_config_value(key, env.as_ref()).filter(|key| !key.is_empty()),
890 _ => None,
891 })
892}
893
894fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
895 if auth_header {
896 AnthropicCredential::Headers(BTreeMap::from([(
897 "authorization".to_string(),
898 format!("Bearer {key}"),
899 )]))
900 } else {
901 AnthropicCredential::ProviderKey(key)
902 }
903}
904
905fn anthropic_credential_for<'a>(
906 credentials: &'a AnthropicCredentials,
907 provider_id: &str,
908) -> Option<&'a AnthropicCredential> {
909 credentials.get(provider_id)
910}
911
912fn credential_for_selected_anthropic_provider(
913 cli_api_key: Option<&str>,
914 credentials: &AnthropicCredentials,
915 provider_id: &str,
916 auth_header: bool,
917) -> Option<AnthropicCredential> {
918 cli_api_key
919 .filter(|key| !key.is_empty())
920 .map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
921 .or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
922}
923
924fn resolve_openai_credentials(
925 cfg: &config::ModelsConfig,
926 auth_store: &config::AuthStore,
927) -> OpenAiCredentials {
928 let mut provider_ids = vec!["openai".to_string()];
929 for (provider_id, provider_cfg) in &cfg.providers {
930 if (config::provider_is_openai_completions(provider_cfg)
931 || config::provider_is_openai_responses(provider_cfg))
932 && !provider_ids.iter().any(|known| known == provider_id)
933 {
934 provider_ids.push(provider_id.clone());
935 }
936 }
937
938 let mut credentials = BTreeMap::new();
939 for provider_id in provider_ids {
940 let provider_cfg = cfg.providers.get(&provider_id);
941 let key = stored_api_key(auth_store, &provider_id)
942 .or_else(|| configured_openai_api_key(&provider_id, provider_cfg));
943 if let Some(key) = key {
944 credentials.insert(provider_id, key);
945 }
946 }
947 credentials
948}
949
950fn configured_openai_api_key(
951 provider_id: &str,
952 provider_cfg: Option<&config::ProviderConfig>,
953) -> Option<String> {
954 if let Some(provider_cfg) = provider_cfg {
955 return config::openai_provider_api_key(provider_id, provider_cfg);
956 }
957
958 (provider_id == "openai")
959 .then(|| std::env::var(OPENAI_API_KEY_ENV).ok())
960 .flatten()
961 .filter(|value| !value.is_empty())
962}
963
964fn openai_credential_for<'a>(
965 credentials: &'a OpenAiCredentials,
966 provider_id: &str,
967) -> Option<&'a String> {
968 credentials.get(provider_id)
969}
970
971fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
972 let headers = model.headers.get_or_insert_with(BTreeMap::new);
973 for (name, value) in auth_headers {
974 headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
975 headers.insert(name.clone(), value.clone());
976 }
977}
978
979struct SplitModelPattern {
980 provider: Option<String>,
981 model_id: String,
982 raw_model_id: String,
983 thinking: Option<ThinkingLevel>,
984 inferred_provider: bool,
985}
986
987fn split_model_pattern(
996 value: &str,
997 cli_provider: Option<&str>,
998 catalog: &[Model],
999 cfg: &config::ModelsConfig,
1000) -> Result<SplitModelPattern, ResolveError> {
1001 let mut provider = cli_provider.map(str::to_string);
1002 let mut model_id = value.to_string();
1003 let mut inferred_provider = false;
1004
1005 if let Some((prefix, remainder)) = value.split_once('/') {
1006 if !prefix.is_empty() && !remainder.is_empty() {
1007 let matches_explicit = cli_provider
1008 .and_then(|requested| {
1009 canonicalize_cli_provider(prefix, cfg)
1010 .ok()
1011 .map(|canonical| canonical == requested)
1012 })
1013 .unwrap_or(false);
1014 if matches_explicit {
1015 model_id = remainder.to_string();
1016 } else if cli_provider.is_none() {
1017 match canonicalize_cli_provider(prefix, cfg) {
1018 Ok(canonical) => {
1019 provider = Some(canonical);
1020 model_id = remainder.to_string();
1021 inferred_provider = true;
1022 }
1023 Err(ResolveError::UnknownProvider(_)) => {}
1024 Err(error) => return Err(error),
1025 }
1026 }
1027 }
1028 }
1029
1030 let has_full_exact_match = catalog.iter().any(|model| {
1033 model.id.eq_ignore_ascii_case(&model_id)
1034 && provider
1035 .as_deref()
1036 .map_or(true, |requested| provider_matches(model, requested, cfg))
1037 });
1038 let thinking = if has_full_exact_match {
1039 None
1040 } else if let Some((head, suffix)) = model_id.rsplit_once(':') {
1041 if let Some(level) = parse_thinking_level(suffix) {
1042 model_id = head.to_string();
1043 Some(level)
1044 } else {
1045 None
1046 }
1047 } else {
1048 None
1049 };
1050
1051 Ok(SplitModelPattern {
1052 provider,
1053 model_id,
1054 raw_model_id: value.to_string(),
1055 thinking,
1056 inferred_provider,
1057 })
1058}
1059
1060fn find_model(
1062 pattern: &str,
1063 provider: Option<&str>,
1064 catalog: &[Model],
1065 cfg: &config::ModelsConfig,
1066) -> Option<Model> {
1067 catalog
1068 .iter()
1069 .find(|model| {
1070 model.id.eq_ignore_ascii_case(pattern)
1071 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1072 })
1073 .cloned()
1074}
1075
1076fn find_cli_model(
1081 pattern: &str,
1082 provider: Option<&str>,
1083 catalog: &[Model],
1084 cfg: &config::ModelsConfig,
1085 anthropic_credentials: &AnthropicCredentials,
1086 openai_credentials: &OpenAiCredentials,
1087) -> Result<Option<Model>, ResolveError> {
1088 let exact_matches: Vec<&Model> = catalog
1089 .iter()
1090 .filter(|model| {
1091 model.id.eq_ignore_ascii_case(pattern)
1092 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
1093 })
1094 .collect();
1095
1096 match exact_matches.as_slice() {
1097 [] => Ok(None),
1098 [model] => Ok(Some((*model).clone())),
1099 _ => {
1100 let authenticated: Vec<&Model> = exact_matches
1101 .iter()
1102 .copied()
1103 .filter(|model| {
1104 model_is_authed_for_resolution(
1105 model,
1106 anthropic_credentials,
1107 openai_credentials,
1108 false,
1109 )
1110 })
1111 .collect();
1112 if let [model] = authenticated.as_slice() {
1113 return Ok(Some((*model).clone()));
1114 }
1115
1116 let mut matches = exact_matches
1117 .iter()
1118 .map(|model| format!("{}/{}", model.provider, model.id))
1119 .collect::<Vec<_>>();
1120 matches.sort();
1121 let auth_hint = if authenticated.is_empty() {
1122 "No matching provider is authenticated."
1123 } else {
1124 "More than one matching provider is authenticated."
1125 };
1126 Err(ResolveError::AmbiguousModel {
1127 pattern: pattern.to_string(),
1128 matches: matches.join(", "),
1129 auth_hint,
1130 })
1131 }
1132 }
1133}
1134
1135fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
1136 cfg.providers.contains_key(requested)
1137 || requested.eq_ignore_ascii_case("anthropic")
1138 || requested.eq_ignore_ascii_case("openai")
1139 || requested.eq_ignore_ascii_case("openai-completions")
1140 || requested.eq_ignore_ascii_case("openai-responses")
1141}
1142
1143fn canonicalize_cli_provider(
1148 requested: &str,
1149 cfg: &config::ModelsConfig,
1150) -> Result<String, ResolveError> {
1151 if cfg.providers.contains_key(requested) {
1152 return Ok(requested.to_string());
1153 }
1154
1155 const BUILTIN_PROVIDER_IDS: &[&str] = &[
1156 "anthropic",
1157 "openai",
1158 "openai-completions",
1159 "openai-responses",
1160 ];
1161 if BUILTIN_PROVIDER_IDS.contains(&requested) {
1162 return Ok(requested.to_string());
1163 }
1164
1165 let mut matches = cfg
1166 .providers
1167 .keys()
1168 .filter(|provider| provider.eq_ignore_ascii_case(requested))
1169 .cloned()
1170 .collect::<Vec<_>>();
1171 matches.extend(
1172 BUILTIN_PROVIDER_IDS
1173 .iter()
1174 .filter(|provider| provider.eq_ignore_ascii_case(requested))
1175 .map(|provider| (*provider).to_string()),
1176 );
1177 matches.sort();
1178 matches.dedup();
1179
1180 match matches.as_slice() {
1181 [provider] => return Ok(provider.clone()),
1182 [] => {}
1183 _ => {
1184 return Err(ResolveError::AmbiguousProvider {
1185 requested: requested.to_string(),
1186 matches: matches.join(", "),
1187 });
1188 }
1189 }
1190
1191 Err(ResolveError::UnknownProvider(requested.to_string()))
1192}
1193
1194fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
1195 if model.provider == requested {
1199 return true;
1200 }
1201 if cfg.providers.contains_key(requested) {
1202 return false;
1203 }
1204 if requested.eq_ignore_ascii_case("anthropic") {
1205 return model.provider == DEFAULT_PROVIDER_ID;
1206 }
1207 if requested.eq_ignore_ascii_case("openai") {
1208 return model.provider == "openai";
1209 }
1210 if requested.eq_ignore_ascii_case("openai-completions") {
1211 return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
1212 }
1213 if requested.eq_ignore_ascii_case("openai-responses") {
1214 return matches!(model.api, rpi_ai::Api::OpenaiResponses);
1215 }
1216 false
1217}
1218
1219fn model_belongs_to_default_provider(
1224 model: &Model,
1225 requested: &str,
1226 _cfg: &config::ModelsConfig,
1227) -> bool {
1228 model.provider == requested
1229}
1230
1231fn model_is_authed(m: &Model, has_provider_key: bool) -> bool {
1239 model_has_header_auth(m) || has_provider_key
1240}
1241
1242fn model_is_authed_for_resolution(
1243 model: &Model,
1244 anthropic_credentials: &AnthropicCredentials,
1245 openai_credentials: &OpenAiCredentials,
1246 has_cli_key: bool,
1247) -> bool {
1248 model_has_header_auth(model)
1249 || has_cli_key
1250 || match model.api {
1251 rpi_ai::Api::AnthropicMessages => {
1252 anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
1253 }
1254 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
1255 openai_credential_for(openai_credentials, &model.provider).is_some()
1256 }
1257 _ => false,
1258 }
1259}
1260
1261fn model_has_header_auth(m: &Model) -> bool {
1265 let Some(h) = &m.headers else { return false };
1266 const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
1267 h.keys()
1268 .any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
1269}
1270
1271fn pick_default_model(
1281 catalog: &[Model],
1282 models_cfg: &config::ModelsConfig,
1283 anthropic_credentials: &AnthropicCredentials,
1284 openai_credentials: &OpenAiCredentials,
1285 has_cli_key: bool,
1286) -> Model {
1287 for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
1289 if let Some(model) = catalog.iter().find(|model| {
1290 model.id.eq_ignore_ascii_case(model_id)
1291 && model_belongs_to_default_provider(model, provider, models_cfg)
1292 && model_is_authed_for_resolution(
1293 model,
1294 anthropic_credentials,
1295 openai_credentials,
1296 has_cli_key,
1297 )
1298 }) {
1299 return model.clone();
1300 }
1301 }
1302
1303 if let Some(m) = catalog.iter().find(|m| {
1307 model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_key)
1308 }) {
1309 return m.clone();
1310 }
1311 catalog
1316 .iter()
1317 .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
1318 .or_else(|| catalog.first())
1319 .expect("catalog is never empty (built-in anthropic_models)")
1320 .clone()
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use super::*;
1326 use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
1327 use crate::config::test_support::env_lock;
1328
1329 struct TestEnv {
1334 _guard: std::sync::MutexGuard<'static, ()>,
1335 prev_key: Option<std::ffi::OsString>,
1336 prev_tok: Option<std::ffi::OsString>,
1337 prev_base: Option<std::ffi::OsString>,
1338 prev_openai_key: Option<std::ffi::OsString>,
1339 prev_dir: Option<std::ffi::OsString>,
1340 _tmp: tempfile::TempDir,
1341 }
1342 impl TestEnv {
1343 fn new() -> Self {
1344 let guard = env_lock().lock().unwrap();
1345 let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
1346 let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
1347 let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
1348 let prev_openai_key = std::env::var_os(OPENAI_API_KEY_ENV);
1349 let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
1350 std::env::remove_var(ANTHROPIC_API_KEY_ENV);
1351 std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
1352 std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
1353 std::env::remove_var(OPENAI_API_KEY_ENV);
1354 let tmp = tempfile::TempDir::new().unwrap();
1355 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
1356 Self {
1357 _guard: guard,
1358 prev_key,
1359 prev_tok,
1360 prev_base,
1361 prev_openai_key,
1362 prev_dir,
1363 _tmp: tmp,
1364 }
1365 }
1366 }
1367 impl Drop for TestEnv {
1368 fn drop(&mut self) {
1369 restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
1370 restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
1371 restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
1372 restore(OPENAI_API_KEY_ENV, self.prev_openai_key.take());
1373 restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
1374 }
1375 }
1376 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
1377 match prev {
1378 Some(v) => std::env::set_var(name, v),
1379 None => std::env::remove_var(name),
1380 }
1381 }
1382
1383 fn resolve_with_key(
1389 provider: Option<&str>,
1390 model: Option<&str>,
1391 thinking: Option<ThinkingLevel>,
1392 ) -> Result<ResolvedModel, ResolveError> {
1393 let _env = TestEnv::new();
1394 std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
1395 resolve(provider, model, thinking, None, None)
1396 }
1397
1398 #[test]
1399 fn default_model_matches_native_anthropic_default() {
1400 let r = resolve_with_key(None, None, None).unwrap();
1401 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1402 assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
1403 assert_eq!(r.provider.id(), "anthropic");
1404 }
1405
1406 #[test]
1407 fn settings_default_model_wins_when_authed() {
1408 let _env = TestEnv::new();
1414 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1415 let path = config::settings_path().unwrap();
1416 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1417 std::fs::write(
1418 &path,
1419 r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
1420 )
1421 .unwrap();
1422 let r = resolve(None, None, None, None, None).unwrap();
1423 assert_eq!(r.model.id, "claude-haiku-4-5");
1424 assert_eq!(r.thinking_level, ThinkingLevel::High);
1425 std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
1427 let r = resolve(None, None, None, None, None).unwrap();
1428 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1429 }
1430
1431 #[test]
1432 fn explicit_id_match() {
1433 let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
1434 assert_eq!(r.model.id, "claude-haiku-4-5");
1435 }
1436
1437 #[test]
1438 fn case_insensitive_id() {
1439 let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
1440 assert_eq!(r.model.id, "claude-opus-5");
1441 }
1442
1443 #[test]
1444 fn provider_prefix_stripped() {
1445 let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
1446 assert_eq!(r.model.id, "claude-sonnet-5");
1447 }
1448
1449 #[test]
1450 fn custom_provider_prefix_stripped() {
1451 let _env = TestEnv::new();
1454 std::env::set_var(ANTHROPIC_API_KEY_ENV, "official-key");
1455 std::fs::write(
1456 config::models_path().unwrap(),
1457 r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "apiKey": "gateway-key", "models": [{"id":"custom-claude"}] } } }"#,
1458 )
1459 .unwrap();
1460 let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
1461 assert_eq!(r.model.id, "custom-claude");
1462 }
1463
1464 #[test]
1465 fn unknown_slash_prefix_remains_part_of_the_raw_model_id() {
1466 let _env = TestEnv::new();
1467 std::fs::write(
1468 config::models_path().unwrap(),
1469 r#"{
1470 "providers": {
1471 "gateway": {
1472 "api": "openai-completions",
1473 "apiKey": "gateway-key",
1474 "models": [{"id":"meta-llama/llama-3.3"}]
1475 }
1476 }
1477}"#,
1478 )
1479 .unwrap();
1480
1481 let resolved = resolve(None, Some("meta-llama/llama-3.3:high"), None, None, None).unwrap();
1482 assert_eq!(resolved.model.provider, "gateway");
1483 assert_eq!(resolved.model.id, "meta-llama/llama-3.3");
1484 assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1485 }
1486
1487 #[test]
1488 fn complete_model_id_wins_before_parsing_a_thinking_suffix() {
1489 let _env = TestEnv::new();
1490 std::fs::write(
1491 config::models_path().unwrap(),
1492 r#"{
1493 "providers": {
1494 "gateway": {
1495 "api": "openai-completions",
1496 "apiKey": "gateway-key",
1497 "models": [
1498 {"id":"vendor/model"},
1499 {"id":"vendor/model:high"}
1500 ]
1501 }
1502 }
1503}"#,
1504 )
1505 .unwrap();
1506
1507 let resolved = resolve(None, Some("gateway/vendor/model:high"), None, None, None).unwrap();
1508 assert_eq!(resolved.model.provider, "gateway");
1509 assert_eq!(resolved.model.id, "vendor/model:high");
1510 assert_eq!(resolved.thinking_level, DEFAULT_THINKING_LEVEL);
1511 }
1512
1513 #[test]
1514 fn authenticated_inferred_provider_beats_a_matching_raw_model_id() {
1515 let _env = TestEnv::new();
1516 std::fs::write(
1517 config::models_path().unwrap(),
1518 r#"{
1519 "providers": {
1520 "alpha": {
1521 "api": "openai-completions",
1522 "baseUrl": "https://alpha.example.com",
1523 "apiKey": "alpha-key",
1524 "models": [{"id":"target"}]
1525 },
1526 "gateway": {
1527 "api": "openai-completions",
1528 "baseUrl": "https://gateway.example.com",
1529 "apiKey": "gateway-key",
1530 "models": [{"id":"alpha/target"}]
1531 }
1532 }
1533}"#,
1534 )
1535 .unwrap();
1536
1537 let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1538 assert_eq!(resolved.model.provider, "alpha");
1539 assert_eq!(resolved.model.id, "target");
1540 assert_eq!(resolved.model.base_url, "https://alpha.example.com");
1541 }
1542
1543 #[test]
1544 fn authenticated_raw_model_beats_an_unauthenticated_inferred_provider() {
1545 let _env = TestEnv::new();
1546 std::fs::write(
1547 config::models_path().unwrap(),
1548 r#"{
1549 "providers": {
1550 "alpha": {
1551 "api": "openai-completions",
1552 "baseUrl": "https://alpha.example.com",
1553 "models": [{"id":"target"}]
1554 },
1555 "gateway": {
1556 "api": "openai-completions",
1557 "baseUrl": "https://gateway.example.com",
1558 "apiKey": "gateway-key",
1559 "models": [{"id":"alpha/target"}]
1560 }
1561 }
1562}"#,
1563 )
1564 .unwrap();
1565
1566 let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
1567 assert_eq!(resolved.model.provider, "gateway");
1568 assert_eq!(resolved.model.id, "alpha/target");
1569 assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1570 }
1571
1572 #[test]
1573 fn thinking_suffix_raw_model_beats_an_unauthenticated_inferred_provider() {
1574 let _env = TestEnv::new();
1575 std::fs::write(
1576 config::models_path().unwrap(),
1577 r#"{
1578 "providers": {
1579 "alpha": {
1580 "api": "openai-completions",
1581 "baseUrl": "https://alpha.example.com",
1582 "models": [{"id":"target"}]
1583 },
1584 "gateway": {
1585 "api": "openai-completions",
1586 "baseUrl": "https://gateway.example.com",
1587 "apiKey": "gateway-key",
1588 "models": [{"id":"alpha/target"}]
1589 }
1590 }
1591}"#,
1592 )
1593 .unwrap();
1594
1595 let resolved = resolve(None, Some("alpha/target:high"), None, None, None).unwrap();
1596 assert_eq!(resolved.model.provider, "gateway");
1597 assert_eq!(resolved.model.id, "alpha/target");
1598 assert_eq!(resolved.model.base_url, "https://gateway.example.com");
1599 assert_eq!(resolved.thinking_level, ThinkingLevel::High);
1600 }
1601
1602 #[test]
1603 fn thinking_suffix_in_model() {
1604 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
1605 assert_eq!(r.model.id, "claude-sonnet-5");
1606 assert_eq!(r.thinking_level, ThinkingLevel::High);
1607 }
1608
1609 #[test]
1610 fn thinking_flag_overrides_suffix() {
1611 let r =
1613 resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
1614 assert_eq!(r.thinking_level, ThinkingLevel::Low);
1615 }
1616
1617 #[test]
1618 fn explicit_provider_anthropic_ok() {
1619 let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
1620 assert_eq!(r.model.id, "claude-sonnet-5");
1621
1622 let r = resolve_with_key(Some("ANTHROPIC"), Some("claude-sonnet-5"), None).unwrap();
1623 assert_eq!(r.provider.id(), DEFAULT_PROVIDER_ID);
1624 }
1625
1626 #[test]
1627 fn unknown_provider_rejected() {
1628 let err = resolve_with_key(Some("unsupported-provider"), None, None).unwrap_err();
1629 assert!(matches!(err, ResolveError::UnknownProvider(_)));
1630 }
1631
1632 #[test]
1633 fn no_match_lists_available() {
1634 let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
1635 match err {
1636 ResolveError::NoMatch { pattern, available } => {
1637 assert_eq!(pattern, "claude-does-not-exist");
1638 assert!(available.contains("claude-sonnet-5"));
1639 }
1640 other => panic!("expected NoMatch, got {other:?}"),
1641 }
1642 }
1643
1644 #[test]
1645 fn colon_not_a_thinking_level_kept_in_id() {
1646 let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
1649 assert!(matches!(err, ResolveError::NoMatch { .. }));
1650 }
1651
1652 #[test]
1653 fn parse_thinking_level_roundtrip() {
1654 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
1655 assert_eq!(parse_thinking_level("bogus"), None);
1656 for lvl in VALID_THINKING_LEVELS {
1658 assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
1659 }
1660 }
1661
1662 #[test]
1663 fn no_api_key_errors_with_hint() {
1664 let _env = TestEnv::new();
1665 let err = resolve(None, None, None, None, None).unwrap_err();
1666 match err {
1667 ResolveError::NoApiKey { hint } => {
1668 assert!(hint.contains("ANTHROPIC_API_KEY"));
1669 assert!(hint.contains("auth login"));
1670 }
1671 other => panic!("expected NoApiKey, got {other:?}"),
1672 }
1673 }
1674
1675 #[test]
1676 fn stored_credential_satisfies_auth() {
1677 let _env = TestEnv::new();
1678 config::upsert_credential(
1679 DEFAULT_PROVIDER_ID,
1680 Credential::ApiKey {
1681 key: Some("stored-key".into()),
1682 env: None,
1683 },
1684 )
1685 .unwrap();
1686 let r = resolve(None, None, None, None, None).unwrap();
1687 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1688 assert!(
1691 r.model
1692 .headers
1693 .as_ref()
1694 .and_then(|h| h.get("authorization"))
1695 .is_none(),
1696 "x-api-key path should not synthesize a Bearer header"
1697 );
1698 }
1699
1700 #[test]
1701 fn gateway_models_key_wins_over_unrelated_anthropic_auth() {
1702 let _env = TestEnv::new();
1703 config::upsert_credential(
1704 DEFAULT_PROVIDER_ID,
1705 Credential::ApiKey {
1706 key: Some("official-key".into()),
1707 env: None,
1708 },
1709 )
1710 .unwrap();
1711 std::fs::write(
1712 config::models_path().unwrap(),
1713 r#"{
1714 "providers": {
1715 "gateway": {
1716 "api": "anthropic-messages",
1717 "baseUrl": "https://gateway.example.com",
1718 "apiKey": "gateway-key",
1719 "models": [{"id":"gateway-model"}]
1720 }
1721 }
1722}"#,
1723 )
1724 .unwrap();
1725
1726 let resolved = resolve(None, Some("gateway/gateway-model"), None, None, None).unwrap();
1727 assert_eq!(resolved.provider.id(), "gateway");
1728 assert_eq!(resolved.model.provider, "gateway");
1729 assert!(resolved.has_provider_key);
1730
1731 let cfg = config::load_models_config().unwrap();
1732 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
1733 assert_eq!(
1734 anthropic_credential_for(&credentials, DEFAULT_PROVIDER_ID),
1735 Some(&AnthropicCredential::ProviderKey("official-key".into()))
1736 );
1737 assert_eq!(
1738 anthropic_credential_for(&credentials, "gateway"),
1739 Some(&AnthropicCredential::ProviderKey("gateway-key".into()))
1740 );
1741 }
1742
1743 #[test]
1744 fn gateway_auth_json_credential_is_usable() {
1745 let _env = TestEnv::new();
1746 config::upsert_credential(
1747 "gateway",
1748 Credential::ApiKey {
1749 key: Some("stored-gateway-key".into()),
1750 env: None,
1751 },
1752 )
1753 .unwrap();
1754 std::fs::write(
1755 config::models_path().unwrap(),
1756 r#"{
1757 "providers": {
1758 "gateway": {
1759 "api": "anthropic-messages",
1760 "baseUrl": "https://gateway.example.com",
1761 "models": [{"id":"gateway-model"}]
1762 }
1763 }
1764}"#,
1765 )
1766 .unwrap();
1767
1768 let resolved = resolve(Some("gateway"), None, None, None, None).unwrap();
1769 assert_eq!(resolved.model.id, "gateway-model");
1770 assert_eq!(resolved.provider.id(), "gateway");
1771 assert!(resolved.has_provider_key);
1772 }
1773
1774 #[test]
1775 fn anthropic_auth_does_not_authenticate_an_unkeyed_gateway() {
1776 let _env = TestEnv::new();
1777 config::upsert_credential(
1778 DEFAULT_PROVIDER_ID,
1779 Credential::ApiKey {
1780 key: Some("official-key".into()),
1781 env: None,
1782 },
1783 )
1784 .unwrap();
1785 std::fs::write(
1786 config::models_path().unwrap(),
1787 r#"{
1788 "providers": {
1789 "gateway": {
1790 "api": "anthropic-messages",
1791 "baseUrl": "https://gateway.example.com",
1792 "models": [{"id":"gateway-model"}]
1793 }
1794 }
1795}"#,
1796 )
1797 .unwrap();
1798
1799 let error = resolve(
1800 Some("gateway"),
1801 Some("gateway/gateway-model"),
1802 None,
1803 None,
1804 None,
1805 )
1806 .unwrap_err();
1807 assert!(matches!(error, ResolveError::NoApiKey { .. }));
1808
1809 let default = resolve(None, None, None, None, None).unwrap();
1810 assert_eq!(default.provider.id(), DEFAULT_PROVIDER_ID);
1811 assert_eq!(default.model.id, DEFAULT_MODEL_ID);
1812 }
1813
1814 #[test]
1815 fn two_anthropic_gateways_keep_credentials_isolated_by_id() {
1816 let _env = TestEnv::new();
1817 std::fs::write(
1818 config::models_path().unwrap(),
1819 r#"{
1820 "providers": {
1821 "alpha": {
1822 "api": "anthropic-messages",
1823 "baseUrl": "https://shared.example.com",
1824 "apiKey": "alpha-key",
1825 "models": [{"id":"shared-model"}]
1826 },
1827 "beta": {
1828 "api": "anthropic-messages",
1829 "baseUrl": "https://shared.example.com",
1830 "apiKey": "beta-key",
1831 "models": [{"id":"shared-model"}]
1832 }
1833 }
1834}"#,
1835 )
1836 .unwrap();
1837
1838 let cfg = config::load_models_config().unwrap();
1839 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
1840 assert_eq!(
1841 anthropic_credential_for(&credentials, "alpha"),
1842 Some(&AnthropicCredential::ProviderKey("alpha-key".into()))
1843 );
1844 assert_eq!(
1845 anthropic_credential_for(&credentials, "beta"),
1846 Some(&AnthropicCredential::ProviderKey("beta-key".into()))
1847 );
1848 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1849 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
1850 assert_eq!(alpha.provider.id(), "alpha");
1851 assert_eq!(beta.provider.id(), "beta");
1852 assert!(alpha.has_provider_key && beta.has_provider_key);
1853 }
1854
1855 #[test]
1856 fn case_distinct_provider_ids_keep_endpoints_and_credentials_isolated() {
1857 let _env = TestEnv::new();
1858 std::fs::write(
1859 config::models_path().unwrap(),
1860 r#"{
1861 "providers": {
1862 "alpha": {
1863 "api": "anthropic-messages",
1864 "baseUrl": "https://lower.example.com",
1865 "authHeader": true,
1866 "models": [{"id":"shared-model"}]
1867 },
1868 "ALPHA": {
1869 "api": "anthropic-messages",
1870 "baseUrl": "https://upper.example.com",
1871 "authHeader": true,
1872 "models": [{"id":"shared-model"}]
1873 }
1874 }
1875}"#,
1876 )
1877 .unwrap();
1878 config::upsert_credential(
1879 "alpha",
1880 Credential::ApiKey {
1881 key: Some("lower-key".into()),
1882 env: None,
1883 },
1884 )
1885 .unwrap();
1886 config::upsert_credential(
1887 "ALPHA",
1888 Credential::ApiKey {
1889 key: Some("upper-key".into()),
1890 env: None,
1891 },
1892 )
1893 .unwrap();
1894
1895 let lower = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1896 assert_eq!(lower.provider.id(), "alpha");
1897 assert_eq!(lower.model.base_url, "https://lower.example.com");
1898 assert_eq!(
1899 lower
1900 .model
1901 .headers
1902 .as_ref()
1903 .and_then(|headers| headers.get("authorization"))
1904 .map(String::as_str),
1905 Some("Bearer lower-key")
1906 );
1907
1908 let upper = resolve(None, Some("ALPHA/shared-model"), None, None, None).unwrap();
1909 assert_eq!(upper.provider.id(), "ALPHA");
1910 assert_eq!(upper.model.base_url, "https://upper.example.com");
1911 assert_eq!(
1912 upper
1913 .model
1914 .headers
1915 .as_ref()
1916 .and_then(|headers| headers.get("authorization"))
1917 .map(String::as_str),
1918 Some("Bearer upper-key")
1919 );
1920
1921 let error = resolve(Some("Alpha"), None, None, None, None).unwrap_err();
1922 assert!(matches!(
1923 error,
1924 ResolveError::AmbiguousProvider {
1925 requested,
1926 matches
1927 } if requested == "Alpha" && matches == "ALPHA, alpha"
1928 ));
1929
1930 let prefix_error = resolve(None, Some("Alpha/shared-model"), None, None, None).unwrap_err();
1931 assert!(matches!(
1932 prefix_error,
1933 ResolveError::AmbiguousProvider {
1934 requested,
1935 matches
1936 } if requested == "Alpha" && matches == "ALPHA, alpha"
1937 ));
1938 }
1939
1940 #[test]
1941 fn cli_provider_case_insensitively_selects_one_canonical_custom_id() {
1942 let _env = TestEnv::new();
1943 std::fs::write(
1944 config::models_path().unwrap(),
1945 r#"{
1946 "providers": {
1947 "routeryo-copy": {
1948 "api": "openai-completions",
1949 "baseUrl": "https://routeryo-copy.example.com",
1950 "apiKey": "copy-key",
1951 "models": [{"id":"copy-model"}]
1952 }
1953 }
1954}"#,
1955 )
1956 .unwrap();
1957
1958 let resolved =
1959 resolve(Some("ROUTERYO-COPY"), Some("copy-model"), None, None, None).unwrap();
1960 assert_eq!(resolved.model.provider, "routeryo-copy");
1961 assert_eq!(resolved.provider.id(), "routeryo-copy");
1962 assert_eq!(resolved.model.base_url, "https://routeryo-copy.example.com");
1963
1964 let prefixed = resolve(None, Some("ROUTERYO-COPY/copy-model"), None, None, None).unwrap();
1965 assert_eq!(prefixed.model.provider, "routeryo-copy");
1966 assert_eq!(prefixed.model.id, "copy-model");
1967 }
1968
1969 #[test]
1970 fn cli_provider_canonicalization_keeps_builtin_and_custom_case_ids_distinct() {
1971 let _env = TestEnv::new();
1972 std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
1973 std::fs::write(
1974 config::models_path().unwrap(),
1975 r#"{
1976 "providers": {
1977 "OpenAI": {
1978 "api": "openai-completions",
1979 "baseUrl": "https://custom-openai.example.com",
1980 "apiKey": "custom-openai-key",
1981 "models": [{"id":"custom-model"}]
1982 }
1983 }
1984}"#,
1985 )
1986 .unwrap();
1987
1988 let custom = resolve(Some("OpenAI"), Some("custom-model"), None, None, None).unwrap();
1989 assert_eq!(custom.model.provider, "OpenAI");
1990 assert_eq!(custom.model.base_url, "https://custom-openai.example.com");
1991
1992 let builtin = resolve(Some("openai"), Some("gpt-6-astra"), None, None, None).unwrap();
1993 assert_eq!(builtin.model.provider, "openai");
1994
1995 let error = resolve(Some("OPENAI"), None, None, None, None).unwrap_err();
1996 assert!(matches!(
1997 error,
1998 ResolveError::AmbiguousProvider {
1999 requested,
2000 matches
2001 } if requested == "OPENAI" && matches == "OpenAI, openai"
2002 ));
2003 }
2004
2005 #[test]
2006 fn cli_api_key_overrides_only_the_selected_anthropic_provider() {
2007 let _env = TestEnv::new();
2008 std::fs::write(
2009 config::models_path().unwrap(),
2010 r#"{
2011 "providers": {
2012 "gateway": {
2013 "api": "anthropic-messages",
2014 "baseUrl": "https://gateway.example.com",
2015 "authHeader": true,
2016 "apiKey": "configured-key",
2017 "models": [{"id":"gateway-model"}]
2018 }
2019 }
2020}"#,
2021 )
2022 .unwrap();
2023
2024 let cfg = config::load_models_config().unwrap();
2025 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
2026 assert_eq!(
2027 credential_for_selected_anthropic_provider(
2028 Some("cli-key"),
2029 &credentials,
2030 "gateway",
2031 true,
2032 ),
2033 Some(AnthropicCredential::Headers(BTreeMap::from([(
2034 "authorization".into(),
2035 "Bearer cli-key".into(),
2036 )])))
2037 );
2038 assert_eq!(
2039 anthropic_credential_for(&credentials, "gateway"),
2040 Some(&AnthropicCredential::Headers(BTreeMap::from([(
2041 "authorization".into(),
2042 "Bearer configured-key".into(),
2043 )])))
2044 );
2045
2046 let resolved = resolve(
2047 Some("gateway"),
2048 Some("gateway/gateway-model"),
2049 None,
2050 Some("cli-key"),
2051 None,
2052 )
2053 .unwrap();
2054 assert!(!resolved.has_provider_key);
2055 assert_eq!(
2056 resolved
2057 .model
2058 .headers
2059 .as_ref()
2060 .and_then(|headers| headers.get("authorization"))
2061 .map(String::as_str),
2062 Some("Bearer cli-key")
2063 );
2064 }
2065
2066 #[test]
2067 fn auth_token_routes_via_bearer_header() {
2068 let _env = TestEnv::new();
2069 std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
2070 let r = resolve(None, None, None, None, None).unwrap();
2071 let headers = r.model.headers.as_ref().expect("bearer header on model");
2073 assert_eq!(
2074 headers.get("authorization").map(|s| s.as_str()),
2075 Some("Bearer tok-123")
2076 );
2077 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
2081 }
2082
2083 #[test]
2084 fn api_key_flag_beats_env_and_stored() {
2085 let _env = TestEnv::new();
2086 std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
2087 config::upsert_credential(
2088 DEFAULT_PROVIDER_ID,
2089 Credential::ApiKey {
2090 key: Some("stored-key".into()),
2091 env: None,
2092 },
2093 )
2094 .unwrap();
2095 let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
2098 assert!(
2099 r.model
2100 .headers
2101 .as_ref()
2102 .and_then(|h| h.get("authorization"))
2103 .is_none(),
2104 "--api-key should take the x-api-key path, not Bearer"
2105 );
2106 }
2107
2108 #[test]
2109 fn base_url_override_applies_to_model() {
2110 let _env = TestEnv::new();
2111 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2112 let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
2113 assert_eq!(r.model.base_url, "https://gw.example.com");
2114 }
2115
2116 #[test]
2117 fn base_url_env_is_fallback_for_flag() {
2118 let _env = TestEnv::new();
2119 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2120 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
2121 let r = resolve(None, None, None, None, None).unwrap();
2122 assert_eq!(r.model.base_url, "https://env-gw.example.com");
2123 }
2124
2125 #[test]
2126 fn anthropic_base_url_env_does_not_redirect_a_custom_provider() {
2127 let _env = TestEnv::new();
2128 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://ambient-proxy.example.com");
2129 std::fs::write(
2130 config::models_path().unwrap(),
2131 r#"{
2132 "providers": {
2133 "gateway": {
2134 "api": "anthropic-messages",
2135 "baseUrl": "https://gateway.example.com",
2136 "apiKey": "gateway-secret",
2137 "models": [{"id":"gateway-model"}]
2138 }
2139 }
2140}"#,
2141 )
2142 .unwrap();
2143
2144 let r = resolve(
2145 Some("gateway"),
2146 Some("gateway/gateway-model"),
2147 None,
2148 None,
2149 None,
2150 )
2151 .unwrap();
2152 assert_eq!(r.model.base_url, "https://gateway.example.com");
2153 assert!(r.has_provider_key);
2154 }
2155
2156 #[test]
2157 fn models_json_adds_custom_model() {
2158 let _env = TestEnv::new();
2159 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
2160 std::fs::write(
2161 config::models_path().unwrap(),
2162 r#"{
2163 "providers": {
2164 "gateway": {
2165 "baseUrl": "https://gw.example.com",
2166 "authHeader": true,
2167 "apiKey": "gw-secret",
2168 "models": [
2169 { "id": "custom-claude", "name": "Custom" }
2170 ]
2171 }
2172 }
2173}"#,
2174 )
2175 .unwrap();
2176 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2177 assert_eq!(r.model.id, "custom-claude");
2178 assert_eq!(r.model.base_url, "https://gw.example.com");
2179 assert_eq!(r.model.provider, "gateway");
2180 assert_eq!(r.provider.id(), "gateway");
2181 let headers = r.model.headers.as_ref().expect("headers merged");
2183 assert_eq!(
2184 headers.get("authorization").map(|s| s.as_str()),
2185 Some("Bearer gw-secret")
2186 );
2187 }
2188
2189 #[test]
2190 fn openai_completions_models_json_is_a_complete_provider_config() {
2191 let _env = TestEnv::new();
2192 std::fs::write(
2193 config::models_path().unwrap(),
2194 r#"{
2195 "providers": {
2196 "routeryo": {
2197 "baseUrl": "https://api.routeryo.com",
2198 "api": "openai-completions",
2199 "apiKey": "router-secret",
2200 "models": [
2201 {
2202 "id": "gpt-5.6-sol",
2203 "name": "GPT 5.6",
2204 "reasoning": true,
2205 "contextWindow": 200000,
2206 "maxTokens": 32768
2207 }
2208 ]
2209 }
2210 }
2211}"#,
2212 )
2213 .unwrap();
2214
2215 let resolved = resolve(None, None, None, None, None).unwrap();
2216 assert_eq!(resolved.model.id, "gpt-5.6-sol");
2217 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2218 assert_eq!(resolved.model.provider, "routeryo");
2219 assert_eq!(resolved.provider.id(), "routeryo");
2220 assert!(resolved.has_provider_key);
2221 assert_eq!(
2222 resolved
2223 .model
2224 .headers
2225 .as_ref()
2226 .and_then(|headers| headers.get("authorization"))
2227 .map(String::as_str),
2228 Some("Bearer router-secret")
2229 );
2230
2231 let explicit = resolve(
2232 Some("routeryo"),
2233 Some("routeryo/gpt-5.6-sol"),
2234 None,
2235 None,
2236 None,
2237 )
2238 .unwrap();
2239 assert_eq!(explicit.provider.id(), "routeryo");
2240 assert_eq!(explicit.model.id, "gpt-5.6-sol");
2241 }
2242
2243 #[test]
2244 fn openai_completions_provider_alias_excludes_responses_models() {
2245 let _env = TestEnv::new();
2246 std::fs::write(
2247 config::models_path().unwrap(),
2248 r#"{
2249 "providers": {
2250 "chat-gateway": {
2251 "api": "openai-completions",
2252 "baseUrl": "https://chat.example.com/v1",
2253 "apiKey": "chat-key",
2254 "models": [{"id":"chat-model"}]
2255 },
2256 "responses-gateway": {
2257 "api": "openai-responses",
2258 "baseUrl": "https://responses.example.com/v1",
2259 "apiKey": "responses-key",
2260 "models": [{"id":"responses-model"}]
2261 }
2262 }
2263}"#,
2264 )
2265 .unwrap();
2266
2267 let resolved = resolve(Some("openai-completions"), None, None, None, None).unwrap();
2268 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
2269 assert_eq!(resolved.model.provider, "chat-gateway");
2270 assert_eq!(resolved.model.id, "chat-model");
2271 }
2272
2273 #[test]
2274 fn openai_model_prefix_disambiguates_providers_with_the_same_model_id() {
2275 let _env = TestEnv::new();
2276 std::fs::write(
2277 config::models_path().unwrap(),
2278 r#"{
2279 "providers": {
2280 "alpha": {
2281 "api": "openai-completions",
2282 "baseUrl": "https://alpha.example.com",
2283 "apiKey": "alpha-secret",
2284 "models": [{"id":"shared-model"}]
2285 },
2286 "beta": {
2287 "api": "openai-completions",
2288 "baseUrl": "https://beta.example.com",
2289 "apiKey": "beta-secret",
2290 "models": [{"id":"shared-model"}]
2291 }
2292 }
2293}"#,
2294 )
2295 .unwrap();
2296
2297 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2298 assert_eq!(alpha.provider.id(), "alpha");
2299 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2300 assert_eq!(
2301 alpha
2302 .model
2303 .headers
2304 .as_ref()
2305 .and_then(|headers| headers.get("authorization"))
2306 .map(String::as_str),
2307 Some("Bearer alpha-secret")
2308 );
2309
2310 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2311 assert_eq!(beta.provider.id(), "beta");
2312 assert_eq!(beta.model.base_url, "https://beta.example.com");
2313 assert_eq!(
2314 beta.model
2315 .headers
2316 .as_ref()
2317 .and_then(|headers| headers.get("authorization"))
2318 .map(String::as_str),
2319 Some("Bearer beta-secret")
2320 );
2321 }
2322
2323 #[test]
2324 fn bare_duplicate_model_id_prefers_the_only_authenticated_provider() {
2325 let _env = TestEnv::new();
2326 std::fs::write(
2327 config::models_path().unwrap(),
2328 r#"{
2329 "providers": {
2330 "alpha": {
2331 "api": "openai-completions",
2332 "baseUrl": "https://alpha.example.com",
2333 "models": [{"id":"shared-model"}]
2334 },
2335 "beta": {
2336 "api": "openai-completions",
2337 "baseUrl": "https://beta.example.com",
2338 "apiKey": "beta-secret",
2339 "models": [{"id":"shared-model"}]
2340 }
2341 }
2342}"#,
2343 )
2344 .unwrap();
2345
2346 let resolved = resolve(None, Some("shared-model"), None, None, None).unwrap();
2347 assert_eq!(resolved.model.provider, "beta");
2348 assert_eq!(resolved.model.base_url, "https://beta.example.com");
2349 }
2350
2351 #[test]
2352 fn bare_duplicate_model_id_rejects_multiple_authenticated_providers() {
2353 let _env = TestEnv::new();
2354 std::fs::write(
2355 config::models_path().unwrap(),
2356 r#"{
2357 "providers": {
2358 "alpha": {
2359 "api": "openai-completions",
2360 "apiKey": "alpha-secret",
2361 "models": [{"id":"shared-model"}]
2362 },
2363 "beta": {
2364 "api": "openai-completions",
2365 "apiKey": "beta-secret",
2366 "models": [{"id":"shared-model"}]
2367 }
2368 }
2369}"#,
2370 )
2371 .unwrap();
2372
2373 let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2374 match error {
2375 ResolveError::AmbiguousModel {
2376 pattern,
2377 matches,
2378 auth_hint,
2379 } => {
2380 assert_eq!(pattern, "shared-model");
2381 assert_eq!(matches, "alpha/shared-model, beta/shared-model");
2382 assert_eq!(
2383 auth_hint,
2384 "More than one matching provider is authenticated."
2385 );
2386 }
2387 other => panic!("expected AmbiguousModel, got {other:?}"),
2388 }
2389 }
2390
2391 #[test]
2392 fn bare_duplicate_model_id_rejects_when_no_provider_is_authenticated() {
2393 let _env = TestEnv::new();
2394 std::fs::write(
2395 config::models_path().unwrap(),
2396 r#"{
2397 "providers": {
2398 "alpha": {
2399 "api": "openai-completions",
2400 "models": [{"id":"shared-model"}]
2401 },
2402 "beta": {
2403 "api": "openai-completions",
2404 "models": [{"id":"shared-model"}]
2405 }
2406 }
2407}"#,
2408 )
2409 .unwrap();
2410
2411 let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
2412 assert!(matches!(
2413 error,
2414 ResolveError::AmbiguousModel {
2415 auth_hint: "No matching provider is authenticated.",
2416 ..
2417 }
2418 ));
2419 }
2420
2421 #[test]
2422 fn openai_auth_json_overrides_config_for_both_protocols() {
2423 let _env = TestEnv::new();
2424 config::upsert_credential(
2425 "chat-gateway",
2426 Credential::ApiKey {
2427 key: Some("stored-chat-key".into()),
2428 env: None,
2429 },
2430 )
2431 .unwrap();
2432 config::upsert_credential(
2433 "responses-gateway",
2434 Credential::ApiKey {
2435 key: Some("stored-responses-key".into()),
2436 env: None,
2437 },
2438 )
2439 .unwrap();
2440 std::fs::write(
2441 config::models_path().unwrap(),
2442 r#"{
2443 "providers": {
2444 "chat-gateway": {
2445 "api": "openai-completions",
2446 "baseUrl": "https://chat.example.com/v1",
2447 "apiKey": "configured-chat-key",
2448 "models": [{"id":"chat-model"}]
2449 },
2450 "responses-gateway": {
2451 "api": "openai-responses",
2452 "baseUrl": "https://responses.example.com/v1",
2453 "apiKey": "configured-responses-key",
2454 "models": [{"id":"responses-model"}]
2455 }
2456 }
2457}"#,
2458 )
2459 .unwrap();
2460
2461 let chat = resolve(
2462 Some("chat-gateway"),
2463 Some("chat-gateway/chat-model"),
2464 None,
2465 None,
2466 None,
2467 )
2468 .unwrap();
2469 assert!(chat.has_provider_key);
2470 assert_eq!(
2471 chat.model
2472 .headers
2473 .as_ref()
2474 .and_then(|headers| headers.get("authorization"))
2475 .map(String::as_str),
2476 Some("Bearer stored-chat-key")
2477 );
2478
2479 let responses = resolve(
2480 Some("responses-gateway"),
2481 Some("responses-gateway/responses-model"),
2482 None,
2483 None,
2484 None,
2485 )
2486 .unwrap();
2487 assert!(responses.has_provider_key);
2488 assert_eq!(
2489 responses
2490 .model
2491 .headers
2492 .as_ref()
2493 .and_then(|headers| headers.get("authorization"))
2494 .map(String::as_str),
2495 Some("Bearer stored-responses-key")
2496 );
2497 }
2498
2499 #[test]
2500 fn openai_env_does_not_authenticate_custom_providers() {
2501 let _env = TestEnv::new();
2502 std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
2503 std::fs::write(
2504 config::models_path().unwrap(),
2505 r#"{
2506 "providers": {
2507 "chat-gateway": {
2508 "api": "openai-completions",
2509 "baseUrl": "https://chat.example.com/v1",
2510 "models": [{"id":"chat-model"}]
2511 },
2512 "responses-gateway": {
2513 "api": "openai-responses",
2514 "baseUrl": "https://responses.example.com/v1",
2515 "models": [{"id":"responses-model"}]
2516 },
2517 "openai-completions": {
2518 "api": "openai-completions",
2519 "baseUrl": "https://alias-chat.example.com/v1",
2520 "models": [{"id":"alias-chat-model"}]
2521 },
2522 "openai-responses": {
2523 "api": "openai-responses",
2524 "baseUrl": "https://alias-responses.example.com/v1",
2525 "models": [{"id":"alias-responses-model"}]
2526 },
2527 "OpenAI": {
2528 "api": "openai-responses",
2529 "baseUrl": "https://case-distinct.example.com/v1",
2530 "models": [{"id":"case-distinct-model"}]
2531 }
2532 }
2533}"#,
2534 )
2535 .unwrap();
2536
2537 for (provider, model) in [
2538 ("chat-gateway", "chat-gateway/chat-model"),
2539 ("responses-gateway", "responses-gateway/responses-model"),
2540 ("openai-completions", "openai-completions/alias-chat-model"),
2541 ("openai-responses", "openai-responses/alias-responses-model"),
2542 ("OpenAI", "OpenAI/case-distinct-model"),
2543 ] {
2544 let error = resolve(Some(provider), Some(model), None, None, None).unwrap_err();
2545 assert!(matches!(error, ResolveError::NoApiKey { .. }));
2546 }
2547
2548 let official = resolve(None, None, None, None, None).unwrap();
2549 assert_eq!(official.provider.id(), "openai");
2550 assert_eq!(official.model.id, "gpt-6-astra");
2551 assert!(official.has_provider_key);
2552 }
2553
2554 #[test]
2555 fn cli_api_key_overrides_selected_openai_provider() {
2556 let _env = TestEnv::new();
2557 std::fs::write(
2558 config::models_path().unwrap(),
2559 r#"{
2560 "providers": {
2561 "chat-gateway": {
2562 "api": "openai-completions",
2563 "baseUrl": "https://chat.example.com/v1",
2564 "apiKey": "configured-chat-key",
2565 "models": [{"id":"chat-model"}]
2566 },
2567 "responses-gateway": {
2568 "api": "openai-responses",
2569 "baseUrl": "https://responses.example.com/v1",
2570 "apiKey": "configured-responses-key",
2571 "models": [{"id":"responses-model"}]
2572 }
2573 }
2574}"#,
2575 )
2576 .unwrap();
2577
2578 for (provider, model) in [
2579 ("chat-gateway", "chat-gateway/chat-model"),
2580 ("responses-gateway", "responses-gateway/responses-model"),
2581 ] {
2582 let resolved =
2583 resolve(Some(provider), Some(model), None, Some("cli-key"), None).unwrap();
2584 assert_eq!(resolved.provider.id(), provider);
2585 assert!(resolved.has_provider_key);
2586 assert_eq!(
2587 resolved
2588 .model
2589 .headers
2590 .as_ref()
2591 .and_then(|headers| headers.get("authorization"))
2592 .map(String::as_str),
2593 Some("Bearer cli-key")
2594 );
2595 }
2596 }
2597
2598 #[test]
2599 fn anthropic_model_prefix_disambiguates_providers_with_the_same_model_id() {
2600 let _env = TestEnv::new();
2601 std::fs::write(
2602 config::models_path().unwrap(),
2603 r#"{
2604 "providers": {
2605 "alpha": {
2606 "api": "anthropic-messages",
2607 "baseUrl": "https://alpha.example.com",
2608 "apiKey": "alpha-secret",
2609 "models": [{"id":"shared-model"}]
2610 },
2611 "beta": {
2612 "api": "anthropic-messages",
2613 "baseUrl": "https://beta.example.com",
2614 "authHeader": true,
2615 "apiKey": "beta-secret",
2616 "models": [{"id":"shared-model"}]
2617 }
2618 }
2619}"#,
2620 )
2621 .unwrap();
2622
2623 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
2624 assert_eq!(alpha.provider.id(), "alpha");
2625 assert_eq!(alpha.model.provider, "alpha");
2626 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
2627 assert!(alpha.has_provider_key);
2628 assert!(alpha.model.headers.as_ref().map_or(true, |headers| {
2629 headers
2630 .keys()
2631 .all(|name| !name.eq_ignore_ascii_case("x-api-key"))
2632 }));
2633
2634 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
2635 assert_eq!(beta.provider.id(), "beta");
2636 assert_eq!(beta.model.provider, "beta");
2637 assert_eq!(beta.model.base_url, "https://beta.example.com");
2638 assert_eq!(
2639 beta.model
2640 .headers
2641 .as_ref()
2642 .and_then(|headers| headers.get("authorization"))
2643 .map(String::as_str),
2644 Some("Bearer beta-secret")
2645 );
2646 }
2647
2648 #[test]
2649 fn trusted_project_defaults_override_global_defaults_for_resolution() {
2650 let _env = TestEnv::new();
2651 std::fs::write(
2652 config::models_path().unwrap(),
2653 r#"{
2654 "providers": {
2655 "global": {
2656 "api": "anthropic-messages",
2657 "apiKey": "global-secret",
2658 "models": [{"id":"global-model"}]
2659 },
2660 "project": {
2661 "api": "anthropic-messages",
2662 "apiKey": "project-secret",
2663 "models": [{"id":"project-model"}]
2664 }
2665 }
2666}"#,
2667 )
2668 .unwrap();
2669 std::fs::write(
2670 config::settings_path().unwrap(),
2671 r#"{"defaultProvider":"global","defaultModel":"global-model"}"#,
2672 )
2673 .unwrap();
2674 let project = tempfile::tempdir().unwrap();
2675 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
2676 std::fs::write(
2677 project.path().join(".rpi/settings.json"),
2678 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
2679 )
2680 .unwrap();
2681
2682 let trusted = resolve_for_cwd(None, None, None, None, None, project.path(), true).unwrap();
2683 assert_eq!(trusted.provider.id(), "project");
2684 assert_eq!(trusted.model.id, "project-model");
2685
2686 let untrusted =
2687 resolve_for_cwd(None, None, None, None, None, project.path(), false).unwrap();
2688 assert_eq!(untrusted.provider.id(), "global");
2689 assert_eq!(untrusted.model.id, "global-model");
2690 }
2691
2692 #[test]
2693 fn unknown_model_prefix_without_a_raw_match_returns_no_match() {
2694 let _env = TestEnv::new();
2695 std::fs::write(
2696 config::models_path().unwrap(),
2697 r#"{
2698 "providers": {
2699 "routeryo": {
2700 "api": "openai-completions",
2701 "apiKey": "secret",
2702 "models": [{"id":"gpt-test"}]
2703 }
2704 }
2705}"#,
2706 )
2707 .unwrap();
2708
2709 let error = resolve(None, Some("misspelled/gpt-test"), None, None, None).unwrap_err();
2710 assert!(matches!(
2711 error,
2712 ResolveError::NoMatch { pattern, .. } if pattern == "misspelled/gpt-test"
2713 ));
2714 }
2715
2716 #[test]
2723 fn models_json_auth_header_satisfies_auth_without_env() {
2724 let _env = TestEnv::new();
2725 std::fs::write(
2727 config::models_path().unwrap(),
2728 r#"{
2729 "providers": {
2730 "gateway": {
2731 "baseUrl": "https://gw.example.com",
2732 "api": "anthropic-messages",
2733 "authHeader": true,
2734 "apiKey": "gw-secret",
2735 "models": [
2736 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2737 ]
2738 }
2739 }
2740}"#,
2741 )
2742 .unwrap();
2743 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2744 assert_eq!(r.model.id, "custom-claude");
2745 assert_eq!(r.model.base_url, "https://gw.example.com");
2746 let headers = r.model.headers.as_ref().expect("bearer folded onto model");
2747 assert_eq!(
2748 headers.get("authorization").map(|s| s.as_str()),
2749 Some("Bearer gw-secret")
2750 );
2751 }
2752
2753 #[test]
2762 fn default_prefers_gateway_when_only_gateway_configured() {
2763 let _env = TestEnv::new();
2766 std::fs::write(
2767 config::models_path().unwrap(),
2768 r#"{
2769 "providers": {
2770 "gateway": {
2771 "baseUrl": "https://gw.example.com",
2772 "api": "anthropic-messages",
2773 "authHeader": true,
2774 "apiKey": "gw-secret",
2775 "models": [
2776 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2777 ]
2778 }
2779 }
2780}"#,
2781 )
2782 .unwrap();
2783 let r = resolve(None, None, None, None, None).unwrap();
2787 assert_eq!(r.model.id, "custom-claude");
2788 assert_eq!(r.model.base_url, "https://gw.example.com");
2789 let headers = r.model.headers.as_ref().expect("bearer on gateway model");
2791 assert_eq!(
2792 headers.get("authorization").map(|s| s.as_str()),
2793 Some("Bearer gw-secret")
2794 );
2795 }
2796
2797 #[test]
2798 fn api_key_flag_honors_models_json_auth_header() {
2799 let _env = TestEnv::new();
2800 std::fs::write(
2801 config::models_path().unwrap(),
2802 r#"{
2803 "providers": {
2804 "gateway": {
2805 "baseUrl": "https://gw.example.com",
2806 "authHeader": true,
2807 "apiKey": "gw-secret",
2808 "models": [ { "id": "custom-claude" } ]
2809 }
2810 }
2811}"#,
2812 )
2813 .unwrap();
2814 let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
2815 assert_eq!(
2816 r.model
2817 .headers
2818 .as_ref()
2819 .and_then(|h| h.get("authorization"))
2820 .map(String::as_str),
2821 Some("Bearer flag-key")
2822 );
2823 assert!(!r.has_provider_key);
2824 }
2825
2826 #[test]
2833 fn models_json_bare_apikey_satisfies_auth_without_env() {
2834 let _env = TestEnv::new();
2835 std::fs::write(
2837 config::models_path().unwrap(),
2838 r#"{
2839 "providers": {
2840 "gateway": {
2841 "baseUrl": "https://gw.example.com",
2842 "api": "anthropic-messages",
2843 "apiKey": "gw-secret",
2844 "models": [
2845 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2846 ]
2847 }
2848 }
2849}"#,
2850 )
2851 .unwrap();
2852 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2853 assert_eq!(r.model.id, "custom-claude");
2854 assert_eq!(r.model.base_url, "https://gw.example.com");
2855 assert!(r.has_provider_key);
2856 assert!(r.model.headers.as_ref().map_or(true, |headers| {
2857 headers.keys().all(|name| {
2858 !name.eq_ignore_ascii_case("x-api-key")
2859 && !name.eq_ignore_ascii_case("authorization")
2860 })
2861 }));
2862 }
2863
2864 #[test]
2871 fn default_prefers_gateway_when_only_bare_apikey_configured() {
2872 let _env = TestEnv::new();
2873 std::fs::write(
2874 config::models_path().unwrap(),
2875 r#"{
2876 "providers": {
2877 "gateway": {
2878 "baseUrl": "https://gw.example.com",
2879 "api": "anthropic-messages",
2880 "apiKey": "gw-secret",
2881 "models": [
2882 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2883 ]
2884 }
2885 }
2886}"#,
2887 )
2888 .unwrap();
2889 let r = resolve(None, None, None, None, None).unwrap();
2891 assert_eq!(r.model.id, "custom-claude");
2892 assert_eq!(r.model.base_url, "https://gw.example.com");
2893 assert!(r.has_provider_key);
2894 }
2895
2896 #[test]
2901 fn models_json_bare_apikey_env_template_resolves() {
2902 let _env = TestEnv::new();
2903 std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
2905 std::fs::write(
2906 config::models_path().unwrap(),
2907 r#"{
2908 "providers": {
2909 "gateway": {
2910 "baseUrl": "https://gw.example.com",
2911 "api": "anthropic-messages",
2912 "apiKey": "$RPI_TEST_GATEWAY_KEY",
2913 "models": [
2914 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
2915 ]
2916 }
2917 }
2918}"#,
2919 )
2920 .unwrap();
2921 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
2922 assert!(r.has_provider_key);
2923 let cfg = config::load_models_config().unwrap();
2924 let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
2925 assert_eq!(
2926 anthropic_credential_for(&credentials, "gateway"),
2927 Some(&AnthropicCredential::ProviderKey(
2928 "env-resolved-secret".into()
2929 ))
2930 );
2931 std::env::remove_var("RPI_TEST_GATEWAY_KEY");
2932 }
2933
2934 #[test]
2944 fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
2945 let _env = TestEnv::new();
2946 std::fs::write(
2947 config::models_path().unwrap(),
2948 r#"{
2949 "providers": {
2950 "bearer-gw": {
2951 "baseUrl": "https://bearer.example.com",
2952 "api": "anthropic-messages",
2953 "authHeader": true,
2954 "apiKey": "bearer-secret",
2955 "models": [ { "id": "bearer-model" } ]
2956 },
2957 "xkey-gw": {
2958 "baseUrl": "https://xkey.example.com",
2959 "api": "anthropic-messages",
2960 "apiKey": "xkey-secret",
2961 "models": [ { "id": "xkey-model" } ]
2962 }
2963 }
2964}"#,
2965 )
2966 .unwrap();
2967 let r = resolve(None, None, None, None, None).unwrap();
2970 assert_eq!(r.model.id, "bearer-model");
2971
2972 let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
2974 let h = r.model.headers.as_ref().expect("bearer folded");
2975 assert_eq!(
2976 h.get("authorization").map(|s| s.as_str()),
2977 Some("Bearer bearer-secret")
2978 );
2979 assert!(
2980 h.get("x-api-key").is_none(),
2981 "authHeader path must not synthesize x-api-key"
2982 );
2983
2984 let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
2986 assert!(r2.has_provider_key);
2987 assert!(r2.model.headers.as_ref().map_or(true, |headers| {
2988 headers.keys().all(|name| {
2989 !name.eq_ignore_ascii_case("x-api-key")
2990 && !name.eq_ignore_ascii_case("authorization")
2991 })
2992 }));
2993
2994 let catalog = available_catalog(&r);
2997 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
2998 assert_eq!(ids, vec!["bearer-model"]);
2999 let other_catalog = available_catalog(&r2);
3000 let other_ids: Vec<&str> = other_catalog.iter().map(|m| m.id.as_str()).collect();
3001 assert_eq!(other_ids, vec!["xkey-model"]);
3002 }
3003
3004 #[test]
3011 fn settings_default_model_honored_for_models_json_provider() {
3012 let _env = TestEnv::new();
3013 std::fs::write(
3014 config::models_path().unwrap(),
3015 r#"{
3016 "providers": {
3017 "beta-gw": {
3018 "baseUrl": "https://beta.example.com",
3019 "api": "anthropic-messages",
3020 "apiKey": "beta-secret",
3021 "models": [ { "id": "beta-model" } ]
3022 },
3023 "alpha-gw": {
3024 "baseUrl": "https://alpha.example.com",
3025 "api": "anthropic-messages",
3026 "apiKey": "alpha-secret",
3027 "models": [ { "id": "alpha-model" } ]
3028 }
3029 }
3030}"#,
3031 )
3032 .unwrap();
3033 std::fs::write(
3037 config::settings_path().unwrap(),
3038 r#"{"defaultProvider":"alpha-gw","defaultModel":"alpha-model"}"#,
3039 )
3040 .unwrap();
3041 let r = resolve(None, None, None, None, None).unwrap();
3042 assert_eq!(r.model.id, "alpha-model");
3043 std::fs::write(
3045 config::settings_path().unwrap(),
3046 r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
3047 )
3048 .unwrap();
3049 let r = resolve(None, None, None, None, None).unwrap();
3050 assert_eq!(r.model.id, "beta-model");
3051 }
3052
3053 #[test]
3054 fn models_json_fallback_preserves_provider_and_model_declaration_order() {
3055 let _env = TestEnv::new();
3056 std::fs::write(
3057 config::models_path().unwrap(),
3058 r#"{
3059 "providers": {
3060 "routeryo-copy": {
3061 "api": "openai-completions",
3062 "baseUrl": "https://router.example.com/v1",
3063 "apiKey": "router-key",
3064 "models": [
3065 { "id": "gpt-5.6-sol" },
3066 { "id": "gpt-5.6-terra" }
3067 ]
3068 },
3069 "alpha-gw": {
3070 "api": "openai-completions",
3071 "baseUrl": "https://alpha.example.com/v1",
3072 "apiKey": "alpha-key",
3073 "models": [ { "id": "alpha-model" } ]
3074 }
3075 }
3076}"#,
3077 )
3078 .unwrap();
3079
3080 let resolved = resolve(None, None, None, None, None).unwrap();
3081 assert_eq!(resolved.model.provider, "routeryo-copy");
3082 assert_eq!(resolved.model.id, "gpt-5.6-sol");
3083 }
3084
3085 #[test]
3086 fn native_known_provider_default_beats_first_model_in_array() {
3087 let _env = TestEnv::new();
3088 std::fs::write(
3089 config::models_path().unwrap(),
3090 r#"{
3091 "providers": {
3092 "deepseek": {
3093 "api": "openai-completions",
3094 "baseUrl": "https://api.deepseek.com",
3095 "apiKey": "deepseek-key",
3096 "models": [
3097 { "id": "deepseek-chat" },
3098 { "id": "deepseek-v4-pro" }
3099 ]
3100 }
3101 }
3102}"#,
3103 )
3104 .unwrap();
3105
3106 let resolved = resolve(None, None, None, None, None).unwrap();
3107 assert_eq!(resolved.model.provider, "deepseek");
3108 assert_eq!(resolved.model.id, "deepseek-v4-pro");
3109 }
3110
3111 #[test]
3119 fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
3120 let _env = TestEnv::new();
3121 std::fs::write(
3122 config::models_path().unwrap(),
3123 r#"{
3124 "providers": {
3125 "gateway": {
3126 "baseUrl": "https://gw.example.com",
3127 "api": "anthropic-messages",
3128 "apiKey": "gw-secret",
3129 "models": [
3130 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
3131 ]
3132 }
3133 }
3134}"#,
3135 )
3136 .unwrap();
3137 let r = resolve(None, None, None, None, None).unwrap();
3138 assert!(r.has_provider_key);
3140 let catalog = available_catalog(&r);
3141 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
3144 assert_eq!(
3145 ids,
3146 vec!["custom-claude"],
3147 "selector must only list authed models"
3148 );
3149 assert_eq!(r.provider.models().len(), catalog.len());
3152 }
3153
3154 #[test]
3158 fn available_catalog_keeps_all_models_on_provider_key_path() {
3159 let _env = TestEnv::new();
3160 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
3161 let r = resolve(None, None, None, None, None).unwrap();
3162 assert!(r.has_provider_key);
3163 let catalog = available_catalog(&r);
3164 assert_eq!(catalog.len(), r.provider.models().len());
3165 assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
3166 }
3167}