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