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