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