1use std::collections::BTreeMap;
75use std::sync::Arc;
76
77use rpi_ai::providers::anthropic::models::anthropic_models;
78use rpi_ai::providers::anthropic::AnthropicProvider;
79use rpi_ai::providers::openai_completions::OpenAiCompletionsProvider;
80use rpi_ai::providers::openai_responses::openai_responses_models;
81use rpi_ai::providers::openai_responses::OpenAiResponsesProvider;
82use rpi_ai::{Model, Provider, ThinkingLevel};
83
84use crate::args::parse_thinking_level;
85use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
86use crate::settings;
87
88pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
91
92const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] = &[
96 ("amazon-bedrock", "us.anthropic.claude-opus-4-6-v1"),
97 ("ant-ling", "Ring-2.6-1T"),
98 ("anthropic", DEFAULT_MODEL_ID),
99 ("openai", "gpt-5.5"),
100 ("azure-openai-responses", "gpt-5.4"),
101 ("openai-codex", "gpt-5.5"),
102 ("radius", "auto"),
103 ("nvidia", "nvidia/nemotron-3-super-120b-a12b"),
104 ("deepseek", "deepseek-v4-pro"),
105 ("google", "gemini-3.1-pro-preview"),
106 ("google-vertex", "gemini-3.1-pro-preview"),
107 ("github-copilot", "gpt-5.4"),
108 ("openrouter", "moonshotai/kimi-k2.6"),
109 ("vercel-ai-gateway", "zai/glm-5.1"),
110 ("xai", "grok-4.6"),
111 ("groq", "openai/gpt-oss-120b"),
112 ("cerebras", "gpt-oss-120b"),
113 ("zai", "glm-5.3"),
114 ("zai-coding-cn", "glm-5.3"),
115 ("mistral", "devstral-medium-latest"),
116 ("minimax", "MiniMax-M2.7"),
117 ("minimax-cn", "MiniMax-M2.7"),
118 ("moonshotai", "kimi-k2.6"),
119 ("moonshotai-cn", "kimi-k2.6"),
120 ("huggingface", "moonshotai/Kimi-K2.6"),
121 ("fireworks", "accounts/fireworks/models/kimi-k2p6"),
122 ("together", "moonshotai/Kimi-K2.6"),
123 ("baseten", "zai-org/GLM-5.2"),
124 ("opencode", "kimi-k2.6"),
125 ("opencode-go", "kimi-k2.6"),
126 ("kimi-coding", "kimi-for-coding"),
127 ("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"),
128 (
129 "cloudflare-ai-gateway",
130 "workers-ai/@cf/moonshotai/kimi-k2.6",
131 ),
132 ("qwen-token-plan", "qwen3.7-max"),
133 ("qwen-token-plan-cn", "qwen3.7-max"),
134 ("qwen-token-plan-individual", "qwen3.8-max"),
135 ("xiaomi", "mimo-v2.5-pro"),
136 ("xiaomi-token-plan-cn", "mimo-v2.5-pro"),
137 ("xiaomi-token-plan-ams", "mimo-v2.5-pro"),
138 ("xiaomi-token-plan-sgp", "mimo-v2.5-pro"),
139];
140
141pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
145
146#[derive(Clone)]
149pub struct ResolvedModel {
150 pub provider: Arc<dyn Provider>,
154 pub model: Model,
156 pub thinking_level: ThinkingLevel,
159 pub has_provider_key: bool,
170 pub theme: Option<String>,
174}
175
176impl std::fmt::Debug for ResolvedModel {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.debug_struct("ResolvedModel")
179 .field("provider", &self.provider.id())
180 .field("model", &self.model.id)
181 .field("thinking_level", &self.thinking_level)
182 .field("has_provider_key", &self.has_provider_key)
183 .field("theme", &self.theme)
184 .finish()
185 }
186}
187
188pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
190
191pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
197
198pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
202
203pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
206
207pub const NO_API_KEY_HINT: &str =
210 "models.json apiKey, OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login`";
211
212#[derive(Debug, thiserror::Error)]
216pub enum ResolveError {
217 #[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, openai-responses, or a models.json provider id")]
218 UnknownProvider(String),
219 #[error("No model matches \"{pattern}\". Available: {available}")]
220 NoMatch { pattern: String, available: String },
221 #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
222 InvalidThinkingLevel(String, String),
223 #[error("No API key. Set one of: {hint}")]
224 NoApiKey { hint: &'static str },
225 #[error("Could not read config: {0}")]
226 Config(#[from] config::ConfigError),
227}
228
229pub fn resolve(
239 cli_provider: Option<&str>,
240 cli_model: Option<&str>,
241 cli_thinking: Option<ThinkingLevel>,
242 cli_api_key: Option<&str>,
243 cli_base_url: Option<&str>,
244) -> Result<ResolvedModel, ResolveError> {
245 let mut provider_key: Option<String> = None;
247 let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
248 let mut auth_from_models_json = false;
254
255 let models_cfg = config::load_models_config()?;
264 if let Some(requested) = cli_provider {
265 if !provider_is_known(requested, &models_cfg) {
266 return Err(ResolveError::UnknownProvider(requested.to_string()));
267 }
268 }
269 let openai_provider_key = cli_api_key
270 .filter(|key| !key.is_empty())
271 .map(str::to_string)
272 .or_else(|| {
273 std::env::var(OPENAI_API_KEY_ENV)
274 .ok()
275 .filter(|key| !key.is_empty())
276 });
277
278 if let Some(k) = cli_api_key.filter(|s| !s.is_empty()) {
280 provider_key = Some(k.to_string());
281 }
282 if provider_key.is_none() {
289 if let Ok(store) = config::read_auth() {
290 if let Some(Credential::ApiKey { key: Some(k), env }) = store.get(DEFAULT_PROVIDER_ID) {
291 if let Some(resolved) = config::resolve_config_value(k, env.as_ref()) {
292 if !resolved.is_empty() {
293 provider_key = Some(resolved);
294 }
295 }
296 }
297 }
298 }
299 let models_json_auth = models_json_provider_auth(&models_cfg);
311 if provider_key.is_none() && auth_headers.is_empty() && !models_json_auth.is_empty() {
312 auth_from_models_json = true;
318 }
319 if provider_key.is_none() && auth_headers.is_empty() {
321 if let Ok(tok) = std::env::var(ANTHROPIC_AUTH_TOKEN_ENV) {
322 if !tok.is_empty() {
323 auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
324 }
325 }
326 }
327 if provider_key.is_none() && auth_headers.is_empty() {
329 if let Ok(k) = std::env::var(ANTHROPIC_API_KEY_ENV) {
330 if !k.is_empty() {
331 provider_key = Some(k);
332 }
333 }
334 }
335 let has_configured_model_auth = models_cfg
340 .providers
341 .iter()
342 .filter_map(|(id, cfg)| config::provider_to_models(id, cfg))
343 .flatten()
344 .any(|model| model_has_header_auth(&model));
345 if provider_key.is_none()
346 && openai_provider_key.is_none()
347 && auth_headers.is_empty()
348 && models_json_auth.is_empty()
349 && !has_configured_model_auth
350 {
351 return Err(ResolveError::NoApiKey {
352 hint: NO_API_KEY_HINT,
353 });
354 }
355
356 let cli_base_url_override = cli_base_url.map(str::to_string);
358 let anthropic_base_url_override = std::env::var(ANTHROPIC_BASE_URL_ENV)
359 .ok()
360 .filter(|value| !value.is_empty());
361
362 let settings = settings::load_settings().unwrap_or_default();
366
367 let mut catalog = anthropic_models();
370 catalog.extend(openai_responses_models());
371 merge_user_catalog(&mut catalog, &models_cfg);
372
373 for model in &mut catalog {
376 if let Some(base) = &cli_base_url_override {
377 model.base_url = base.clone();
378 } else if matches!(model.api, rpi_ai::Api::AnthropicMessages) {
379 if let Some(base) = &anthropic_base_url_override {
380 model.base_url = base.clone();
381 }
382 }
383 }
384
385 if let Some(requested) = cli_provider {
386 catalog.retain(|model| provider_matches(model, requested, &models_cfg));
387 }
388
389 if !auth_headers.is_empty() && !auth_from_models_json {
419 for m in catalog.iter_mut() {
421 let headers = m.headers.get_or_insert_with(BTreeMap::new);
422 for (k, v) in &auth_headers {
423 headers.insert(k.clone(), v.clone());
424 }
425 }
426 } else if auth_from_models_json {
427 let override_active =
433 cli_base_url_override.is_some() || anthropic_base_url_override.is_some();
434 for m in catalog.iter_mut() {
435 if !matches!(m.api, rpi_ai::Api::AnthropicMessages) {
436 continue;
437 }
438 let is_gateway = override_active || m.base_url != config::ANTHROPIC_DEFAULT_BASE_URL;
439 if !is_gateway {
440 continue;
441 }
442 let provider_auth = if override_active {
443 models_json_auth.values().next()
444 } else {
445 models_json_auth.get(&m.base_url)
446 };
447 let Some(provider_auth) = provider_auth else {
448 continue;
449 };
450 let headers = m.headers.get_or_insert_with(BTreeMap::new);
451 for (k, v) in provider_auth {
452 headers.insert(k.clone(), v.clone());
453 }
454 }
455 }
456
457 let available = catalog
458 .iter()
459 .map(|m| m.id.clone())
460 .collect::<Vec<_>>()
461 .join(", ");
462 if catalog.is_empty() {
463 return Err(ResolveError::NoMatch {
464 pattern: cli_provider.unwrap_or("default").to_string(),
465 available,
466 });
467 }
468
469 let (model, thinking_level) = match cli_model {
478 Some(raw) => {
479 let (pattern_provider, pattern, pattern_thinking) = split_model_pattern(raw);
480 if let Some(provider) = pattern_provider.as_deref() {
481 if !provider_is_known(provider, &models_cfg) {
482 return Err(ResolveError::UnknownProvider(provider.to_string()));
483 }
484 }
485 let thinking_level = cli_thinking
487 .or(pattern_thinking)
488 .unwrap_or(DEFAULT_THINKING_LEVEL);
489 let model =
490 match find_model(&pattern, pattern_provider.as_deref(), &catalog, &models_cfg) {
491 Some(m) => m,
492 None => {
493 return Err(ResolveError::NoMatch {
494 pattern: pattern.clone(),
495 available,
496 });
497 }
498 };
499 (model, thinking_level)
500 }
501 None => {
502 let settings_thinking = settings
508 .default_thinking_level
509 .as_deref()
510 .and_then(parse_thinking_level);
511
512 let saved_provider = settings
522 .default_provider
523 .as_deref()
524 .filter(|provider| provider_is_known(provider, &models_cfg));
525 let saved = settings.default_model.as_deref().and_then(|id| {
526 if settings.default_provider.is_some() && saved_provider.is_none() {
527 return None;
528 }
529 find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
530 model_is_authed_for_resolution(
531 m,
532 provider_key.is_some(),
533 openai_provider_key.is_some(),
534 )
535 })
536 });
537 if let Some(model) = saved {
538 let thinking_level = cli_thinking
539 .or(settings_thinking)
540 .unwrap_or(DEFAULT_THINKING_LEVEL);
541 (model, thinking_level)
542 } else {
543 let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
545 let model = pick_default_model(
546 &catalog,
547 &models_cfg,
548 provider_key.is_some(),
549 openai_provider_key.is_some(),
550 );
551 (model, thinking_level)
552 }
553 }
554 };
555
556 let selected_api = model.api.clone();
558 let selected_provider = model.provider.clone();
559 let provider_models: Vec<Model> = catalog
560 .into_iter()
561 .filter(|candidate| {
562 candidate.api == selected_api
563 && (matches!(selected_api, rpi_ai::Api::AnthropicMessages)
564 || candidate.provider == selected_provider)
565 })
566 .collect();
567 let (provider, has_provider_key): (Arc<dyn Provider>, bool) = match selected_api {
568 rpi_ai::Api::AnthropicMessages => {
569 let has_key = provider_key.is_some();
570 (
571 Arc::new(AnthropicProvider::with_models(
572 provider_key,
573 reqwest::Client::new(),
574 provider_models,
575 )),
576 has_key,
577 )
578 }
579 rpi_ai::Api::OpenaiCompletions => {
580 let has_key = openai_provider_key.is_some();
581 (
582 Arc::new(OpenAiCompletionsProvider::with_models(
583 selected_provider,
584 openai_provider_key,
585 reqwest::Client::new(),
586 provider_models,
587 )),
588 has_key,
589 )
590 }
591 rpi_ai::Api::OpenaiResponses => {
592 let has_key = openai_provider_key.is_some();
593 (
594 Arc::new(OpenAiResponsesProvider::with_models(
595 selected_provider,
596 openai_provider_key,
597 reqwest::Client::new(),
598 provider_models,
599 )),
600 has_key,
601 )
602 }
603 _ => unreachable!("unsupported APIs are filtered while loading models.json"),
604 };
605
606 Ok(ResolvedModel {
607 provider,
608 model,
609 thinking_level,
610 has_provider_key,
611 theme: settings.theme.clone(),
612 })
613}
614
615pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
633 let selected_api = &resolved.model.api;
634 let selected_provider = &resolved.model.provider;
635 let mut seen = std::collections::HashSet::new();
636 resolved
637 .provider
638 .models()
639 .iter()
640 .filter(|m| {
644 m.api == *selected_api
645 && (matches!(m.api, rpi_ai::Api::AnthropicMessages)
646 || m.provider.eq_ignore_ascii_case(selected_provider))
647 })
648 .filter(|m| model_is_authed(m, resolved.has_provider_key))
649 .filter(|m| {
650 seen.insert((
651 m.api.clone(),
652 m.provider.to_ascii_lowercase(),
653 m.id.to_ascii_lowercase(),
654 ))
655 })
656 .cloned()
657 .collect()
658}
659
660pub fn catalog_all() -> Result<Vec<Model>, config::ConfigError> {
664 let cfg = config::load_models_config()?;
665 let mut catalog = anthropic_models();
666 catalog.extend(openai_responses_models());
667 merge_user_catalog(&mut catalog, &cfg);
668 catalog.sort_by(|a, b| {
669 a.provider
670 .to_ascii_lowercase()
671 .cmp(&b.provider.to_ascii_lowercase())
672 .then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
673 });
674 Ok(catalog)
675}
676
677fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
682 for (provider_id, provider_cfg) in &cfg.providers {
683 let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
684 continue;
686 };
687 for m in models {
688 if let Some(existing) = catalog.iter_mut().find(|candidate| {
689 candidate.api == m.api
690 && candidate.provider.eq_ignore_ascii_case(&m.provider)
691 && candidate.id.eq_ignore_ascii_case(&m.id)
692 }) {
693 *existing = m;
694 } else {
695 catalog.push(m);
696 }
697 }
698 }
699}
700
701fn models_json_provider_auth(
724 cfg: &config::ModelsConfig,
725) -> BTreeMap<String, BTreeMap<String, String>> {
726 let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
727 for (_provider_id, provider_cfg) in &cfg.providers {
728 if !config::provider_is_anthropic_compatible(provider_cfg) {
729 continue;
730 }
731 let Some(raw) = provider_cfg.api_key.as_deref().filter(|s| !s.is_empty()) else {
732 continue;
733 };
734 let Some(resolved) = config::resolve_config_value(raw, None) else {
736 continue;
737 };
738 if resolved.is_empty() {
739 continue;
740 }
741 let base = provider_cfg
742 .base_url
743 .clone()
744 .unwrap_or_else(config::default_anthropic_base_url);
745 let mut headers = BTreeMap::new();
746 if provider_cfg.auth_header.unwrap_or(false) {
747 headers.insert("authorization".to_string(), format!("Bearer {resolved}"));
748 } else {
749 headers.insert("x-api-key".to_string(), resolved);
750 }
751 out.entry(base).or_insert(headers);
752 }
753 out
754}
755
756fn split_model_pattern(value: &str) -> (Option<String>, String, Option<ThinkingLevel>) {
764 let (without_thinking, thinking) = if let Some(idx) = value.rfind(':') {
766 let (head, tail) = value.split_at(idx);
767 let suffix = &tail[1..]; if let Some(level) = parse_thinking_level(suffix) {
769 (head, Some(level))
770 } else {
771 (value, None)
772 }
773 } else {
774 (value, None)
775 };
776
777 match without_thinking.split_once('/') {
778 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
779 (Some(provider.to_string()), model.to_string(), thinking)
780 }
781 _ => (None, without_thinking.to_string(), thinking),
782 }
783}
784
785fn find_model(
787 pattern: &str,
788 provider: Option<&str>,
789 catalog: &[Model],
790 cfg: &config::ModelsConfig,
791) -> Option<Model> {
792 catalog
793 .iter()
794 .find(|model| {
795 model.id.eq_ignore_ascii_case(pattern)
796 && provider.map_or(true, |requested| provider_matches(model, requested, cfg))
797 })
798 .cloned()
799}
800
801fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
802 requested.eq_ignore_ascii_case("anthropic")
803 || requested.eq_ignore_ascii_case("openai")
804 || requested.eq_ignore_ascii_case("openai-completions")
805 || requested.eq_ignore_ascii_case("openai-responses")
806 || cfg
807 .providers
808 .keys()
809 .any(|id| id.eq_ignore_ascii_case(requested))
810}
811
812fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
813 if requested.eq_ignore_ascii_case("anthropic") {
814 return matches!(model.api, rpi_ai::Api::AnthropicMessages);
815 }
816 if requested.eq_ignore_ascii_case("openai")
817 || requested.eq_ignore_ascii_case("openai-completions")
818 {
819 return matches!(
820 model.api,
821 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses
822 );
823 }
824 if requested.eq_ignore_ascii_case("openai-responses") {
825 return matches!(model.api, rpi_ai::Api::OpenaiResponses);
826 }
827 if model.provider.eq_ignore_ascii_case(requested) {
828 return true;
829 }
830 cfg.providers
831 .iter()
832 .find(|(id, _)| id.eq_ignore_ascii_case(requested))
833 .map(|(_, provider)| {
834 config::provider_is_anthropic_compatible(provider)
835 && matches!(model.api, rpi_ai::Api::AnthropicMessages)
836 && provider
837 .models
838 .iter()
839 .any(|configured| configured.id.eq_ignore_ascii_case(&model.id))
840 })
841 .unwrap_or(false)
842}
843
844fn model_belongs_to_default_provider(
849 model: &Model,
850 requested: &str,
851 cfg: &config::ModelsConfig,
852) -> bool {
853 if model.provider.eq_ignore_ascii_case(requested) {
854 return true;
855 }
856 cfg.providers
857 .iter()
858 .find(|(id, _)| id.eq_ignore_ascii_case(requested))
859 .and_then(|(_, provider)| config::provider_to_models(requested, provider))
860 .is_some_and(|configured| {
861 configured.iter().any(|candidate| {
862 candidate.api == model.api && candidate.id.eq_ignore_ascii_case(&model.id)
863 })
864 })
865}
866
867fn model_is_authed(m: &Model, has_provider_key: bool) -> bool {
885 model_has_header_auth(m) || has_provider_key
886}
887
888fn model_is_authed_for_resolution(
889 model: &Model,
890 has_anthropic_key: bool,
891 has_openai_key: bool,
892) -> bool {
893 model_has_header_auth(model)
894 || match model.api {
895 rpi_ai::Api::AnthropicMessages => has_anthropic_key,
896 rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => has_openai_key,
897 _ => false,
898 }
899}
900
901fn model_has_header_auth(m: &Model) -> bool {
905 let Some(h) = &m.headers else { return false };
906 const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
907 h.keys()
908 .any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
909}
910
911fn pick_default_model(
922 catalog: &[Model],
923 models_cfg: &config::ModelsConfig,
924 has_anthropic_key: bool,
925 has_openai_key: bool,
926) -> Model {
927 for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
929 if let Some(model) = catalog.iter().find(|model| {
930 model.id.eq_ignore_ascii_case(model_id)
931 && model_belongs_to_default_provider(model, provider, models_cfg)
932 && model_is_authed_for_resolution(model, has_anthropic_key, has_openai_key)
933 }) {
934 return model.clone();
935 }
936 }
937
938 if let Some(m) = catalog
942 .iter()
943 .find(|m| model_is_authed_for_resolution(m, has_anthropic_key, has_openai_key))
944 {
945 return m.clone();
946 }
947 catalog
952 .iter()
953 .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
954 .or_else(|| catalog.first())
955 .expect("catalog is never empty (built-in anthropic_models)")
956 .clone()
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
963 use crate::config::test_support::env_lock;
964
965 struct TestEnv {
970 _guard: std::sync::MutexGuard<'static, ()>,
971 prev_key: Option<std::ffi::OsString>,
972 prev_tok: Option<std::ffi::OsString>,
973 prev_base: Option<std::ffi::OsString>,
974 prev_openai_key: Option<std::ffi::OsString>,
975 prev_dir: Option<std::ffi::OsString>,
976 _tmp: tempfile::TempDir,
977 }
978 impl TestEnv {
979 fn new() -> Self {
980 let guard = env_lock().lock().unwrap();
981 let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
982 let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
983 let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
984 let prev_openai_key = std::env::var_os(OPENAI_API_KEY_ENV);
985 let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
986 std::env::remove_var(ANTHROPIC_API_KEY_ENV);
987 std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
988 std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
989 std::env::remove_var(OPENAI_API_KEY_ENV);
990 let tmp = tempfile::TempDir::new().unwrap();
991 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
992 Self {
993 _guard: guard,
994 prev_key,
995 prev_tok,
996 prev_base,
997 prev_openai_key,
998 prev_dir,
999 _tmp: tmp,
1000 }
1001 }
1002 }
1003 impl Drop for TestEnv {
1004 fn drop(&mut self) {
1005 restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
1006 restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
1007 restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
1008 restore(OPENAI_API_KEY_ENV, self.prev_openai_key.take());
1009 restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
1010 }
1011 }
1012 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
1013 match prev {
1014 Some(v) => std::env::set_var(name, v),
1015 None => std::env::remove_var(name),
1016 }
1017 }
1018
1019 fn resolve_with_key(
1025 provider: Option<&str>,
1026 model: Option<&str>,
1027 thinking: Option<ThinkingLevel>,
1028 ) -> Result<ResolvedModel, ResolveError> {
1029 let _env = TestEnv::new();
1030 std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
1031 resolve(provider, model, thinking, None, None)
1032 }
1033
1034 #[test]
1035 fn default_model_matches_native_anthropic_default() {
1036 let r = resolve_with_key(None, None, None).unwrap();
1037 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1038 assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
1039 assert_eq!(r.provider.id(), "anthropic");
1040 }
1041
1042 #[test]
1043 fn settings_default_model_wins_when_authed() {
1044 let _env = TestEnv::new();
1050 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1051 let path = config::settings_path().unwrap();
1052 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1053 std::fs::write(
1054 &path,
1055 r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
1056 )
1057 .unwrap();
1058 let r = resolve(None, None, None, None, None).unwrap();
1059 assert_eq!(r.model.id, "claude-haiku-4-5");
1060 assert_eq!(r.thinking_level, ThinkingLevel::High);
1061 std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
1063 let r = resolve(None, None, None, None, None).unwrap();
1064 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1065 }
1066
1067 #[test]
1068 fn explicit_id_match() {
1069 let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
1070 assert_eq!(r.model.id, "claude-haiku-4-5");
1071 }
1072
1073 #[test]
1074 fn case_insensitive_id() {
1075 let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
1076 assert_eq!(r.model.id, "claude-opus-5");
1077 }
1078
1079 #[test]
1080 fn provider_prefix_stripped() {
1081 let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
1082 assert_eq!(r.model.id, "claude-sonnet-5");
1083 }
1084
1085 #[test]
1086 fn custom_provider_prefix_stripped() {
1087 let _env = TestEnv::new();
1090 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1091 std::fs::write(
1092 config::models_path().unwrap(),
1093 r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "models": [{"id":"custom-claude"}] } } }"#,
1094 )
1095 .unwrap();
1096 let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
1097 assert_eq!(r.model.id, "custom-claude");
1098 }
1099
1100 #[test]
1101 fn thinking_suffix_in_model() {
1102 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
1103 assert_eq!(r.model.id, "claude-sonnet-5");
1104 assert_eq!(r.thinking_level, ThinkingLevel::High);
1105 }
1106
1107 #[test]
1108 fn thinking_flag_overrides_suffix() {
1109 let r =
1111 resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
1112 assert_eq!(r.thinking_level, ThinkingLevel::Low);
1113 }
1114
1115 #[test]
1116 fn explicit_provider_anthropic_ok() {
1117 let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
1118 assert_eq!(r.model.id, "claude-sonnet-5");
1119 }
1120
1121 #[test]
1122 fn unknown_provider_rejected() {
1123 let err = resolve_with_key(Some("unsupported-provider"), None, None).unwrap_err();
1124 assert!(matches!(err, ResolveError::UnknownProvider(_)));
1125 }
1126
1127 #[test]
1128 fn no_match_lists_available() {
1129 let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
1130 match err {
1131 ResolveError::NoMatch { pattern, available } => {
1132 assert_eq!(pattern, "claude-does-not-exist");
1133 assert!(available.contains("claude-sonnet-5"));
1134 }
1135 other => panic!("expected NoMatch, got {other:?}"),
1136 }
1137 }
1138
1139 #[test]
1140 fn colon_not_a_thinking_level_kept_in_id() {
1141 let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
1144 assert!(matches!(err, ResolveError::NoMatch { .. }));
1145 }
1146
1147 #[test]
1148 fn parse_thinking_level_roundtrip() {
1149 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
1150 assert_eq!(parse_thinking_level("bogus"), None);
1151 for lvl in VALID_THINKING_LEVELS {
1153 assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
1154 }
1155 }
1156
1157 #[test]
1158 fn no_api_key_errors_with_hint() {
1159 let _env = TestEnv::new();
1160 let err = resolve(None, None, None, None, None).unwrap_err();
1161 match err {
1162 ResolveError::NoApiKey { hint } => {
1163 assert!(hint.contains("ANTHROPIC_API_KEY"));
1164 assert!(hint.contains("auth login"));
1165 }
1166 other => panic!("expected NoApiKey, got {other:?}"),
1167 }
1168 }
1169
1170 #[test]
1171 fn stored_credential_satisfies_auth() {
1172 let _env = TestEnv::new();
1173 config::upsert_credential(
1174 DEFAULT_PROVIDER_ID,
1175 Credential::ApiKey {
1176 key: Some("stored-key".into()),
1177 env: None,
1178 },
1179 )
1180 .unwrap();
1181 let r = resolve(None, None, None, None, None).unwrap();
1182 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1183 assert!(
1186 r.model
1187 .headers
1188 .as_ref()
1189 .and_then(|h| h.get("authorization"))
1190 .is_none(),
1191 "x-api-key path should not synthesize a Bearer header"
1192 );
1193 }
1194
1195 #[test]
1196 fn auth_token_routes_via_bearer_header() {
1197 let _env = TestEnv::new();
1198 std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
1199 let r = resolve(None, None, None, None, None).unwrap();
1200 let headers = r.model.headers.as_ref().expect("bearer header on model");
1202 assert_eq!(
1203 headers.get("authorization").map(|s| s.as_str()),
1204 Some("Bearer tok-123")
1205 );
1206 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
1210 }
1211
1212 #[test]
1213 fn api_key_flag_beats_env_and_stored() {
1214 let _env = TestEnv::new();
1215 std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
1216 config::upsert_credential(
1217 DEFAULT_PROVIDER_ID,
1218 Credential::ApiKey {
1219 key: Some("stored-key".into()),
1220 env: None,
1221 },
1222 )
1223 .unwrap();
1224 let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
1227 assert!(
1228 r.model
1229 .headers
1230 .as_ref()
1231 .and_then(|h| h.get("authorization"))
1232 .is_none(),
1233 "--api-key should take the x-api-key path, not Bearer"
1234 );
1235 }
1236
1237 #[test]
1238 fn base_url_override_applies_to_model() {
1239 let _env = TestEnv::new();
1240 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1241 let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
1242 assert_eq!(r.model.base_url, "https://gw.example.com");
1243 }
1244
1245 #[test]
1246 fn base_url_env_is_fallback_for_flag() {
1247 let _env = TestEnv::new();
1248 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1249 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
1250 let r = resolve(None, None, None, None, None).unwrap();
1251 assert_eq!(r.model.base_url, "https://env-gw.example.com");
1252 }
1253
1254 #[test]
1255 fn models_json_adds_custom_model() {
1256 let _env = TestEnv::new();
1257 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1258 std::fs::write(
1259 config::models_path().unwrap(),
1260 r#"{
1261 "providers": {
1262 "gateway": {
1263 "baseUrl": "https://gw.example.com",
1264 "authHeader": true,
1265 "apiKey": "gw-secret",
1266 "models": [
1267 { "id": "custom-claude", "name": "Custom" }
1268 ]
1269 }
1270 }
1271}"#,
1272 )
1273 .unwrap();
1274 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1275 assert_eq!(r.model.id, "custom-claude");
1276 assert_eq!(r.model.base_url, "https://gw.example.com");
1277 assert_eq!(r.model.provider, DEFAULT_PROVIDER_ID);
1280 let headers = r.model.headers.as_ref().expect("headers merged");
1282 assert_eq!(
1283 headers.get("authorization").map(|s| s.as_str()),
1284 Some("Bearer gw-secret")
1285 );
1286 }
1287
1288 #[test]
1289 fn openai_completions_models_json_is_a_complete_provider_config() {
1290 let _env = TestEnv::new();
1291 std::fs::write(
1292 config::models_path().unwrap(),
1293 r#"{
1294 "providers": {
1295 "routeryo": {
1296 "baseUrl": "https://api.routeryo.com",
1297 "api": "openai-completions",
1298 "apiKey": "router-secret",
1299 "models": [
1300 {
1301 "id": "gpt-5.6-sol",
1302 "name": "GPT 5.6",
1303 "reasoning": true,
1304 "contextWindow": 200000,
1305 "maxTokens": 32768
1306 }
1307 ]
1308 }
1309 }
1310}"#,
1311 )
1312 .unwrap();
1313
1314 let resolved = resolve(None, None, None, None, None).unwrap();
1315 assert_eq!(resolved.model.id, "gpt-5.6-sol");
1316 assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
1317 assert_eq!(resolved.model.provider, "routeryo");
1318 assert_eq!(resolved.provider.id(), "routeryo");
1319 assert!(!resolved.has_provider_key);
1320 assert_eq!(
1321 resolved
1322 .model
1323 .headers
1324 .as_ref()
1325 .and_then(|headers| headers.get("authorization"))
1326 .map(String::as_str),
1327 Some("Bearer router-secret")
1328 );
1329
1330 let explicit = resolve(
1331 Some("routeryo"),
1332 Some("routeryo/gpt-5.6-sol"),
1333 None,
1334 None,
1335 None,
1336 )
1337 .unwrap();
1338 assert_eq!(explicit.provider.id(), "routeryo");
1339 assert_eq!(explicit.model.id, "gpt-5.6-sol");
1340 }
1341
1342 #[test]
1343 fn openai_model_prefix_disambiguates_providers_with_the_same_model_id() {
1344 let _env = TestEnv::new();
1345 std::fs::write(
1346 config::models_path().unwrap(),
1347 r#"{
1348 "providers": {
1349 "alpha": {
1350 "api": "openai-completions",
1351 "baseUrl": "https://alpha.example.com",
1352 "apiKey": "alpha-secret",
1353 "models": [{"id":"shared-model"}]
1354 },
1355 "beta": {
1356 "api": "openai-completions",
1357 "baseUrl": "https://beta.example.com",
1358 "apiKey": "beta-secret",
1359 "models": [{"id":"shared-model"}]
1360 }
1361 }
1362}"#,
1363 )
1364 .unwrap();
1365
1366 let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
1367 assert_eq!(alpha.provider.id(), "alpha");
1368 assert_eq!(alpha.model.base_url, "https://alpha.example.com");
1369
1370 let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
1371 assert_eq!(beta.provider.id(), "beta");
1372 assert_eq!(beta.model.base_url, "https://beta.example.com");
1373 }
1374
1375 #[test]
1376 fn openai_model_prefix_rejects_unknown_provider() {
1377 let _env = TestEnv::new();
1378 std::fs::write(
1379 config::models_path().unwrap(),
1380 r#"{
1381 "providers": {
1382 "routeryo": {
1383 "api": "openai-completions",
1384 "apiKey": "secret",
1385 "models": [{"id":"gpt-test"}]
1386 }
1387 }
1388}"#,
1389 )
1390 .unwrap();
1391
1392 let error = resolve(None, Some("misspelled/gpt-test"), None, None, None).unwrap_err();
1393 assert!(
1394 matches!(error, ResolveError::UnknownProvider(provider) if provider == "misspelled")
1395 );
1396 }
1397
1398 #[test]
1405 fn models_json_auth_header_satisfies_auth_without_env() {
1406 let _env = TestEnv::new();
1407 std::fs::write(
1409 config::models_path().unwrap(),
1410 r#"{
1411 "providers": {
1412 "gateway": {
1413 "baseUrl": "https://gw.example.com",
1414 "api": "anthropic-messages",
1415 "authHeader": true,
1416 "apiKey": "gw-secret",
1417 "models": [
1418 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1419 ]
1420 }
1421 }
1422}"#,
1423 )
1424 .unwrap();
1425 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1426 assert_eq!(r.model.id, "custom-claude");
1427 assert_eq!(r.model.base_url, "https://gw.example.com");
1428 let headers = r.model.headers.as_ref().expect("bearer folded onto model");
1429 assert_eq!(
1430 headers.get("authorization").map(|s| s.as_str()),
1431 Some("Bearer gw-secret")
1432 );
1433 }
1434
1435 #[test]
1445 fn default_prefers_gateway_when_only_gateway_configured() {
1446 let _env = TestEnv::new();
1449 std::fs::write(
1450 config::models_path().unwrap(),
1451 r#"{
1452 "providers": {
1453 "gateway": {
1454 "baseUrl": "https://gw.example.com",
1455 "api": "anthropic-messages",
1456 "authHeader": true,
1457 "apiKey": "gw-secret",
1458 "models": [
1459 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1460 ]
1461 }
1462 }
1463}"#,
1464 )
1465 .unwrap();
1466 let r = resolve(None, None, None, None, None).unwrap();
1470 assert_eq!(r.model.id, "custom-claude");
1471 assert_eq!(r.model.base_url, "https://gw.example.com");
1472 let headers = r.model.headers.as_ref().expect("bearer on gateway model");
1474 assert_eq!(
1475 headers.get("authorization").map(|s| s.as_str()),
1476 Some("Bearer gw-secret")
1477 );
1478 }
1479
1480 #[test]
1481 fn api_key_flag_beats_models_json_bearer() {
1482 let _env = TestEnv::new();
1483 std::fs::write(
1484 config::models_path().unwrap(),
1485 r#"{
1486 "providers": {
1487 "gateway": {
1488 "baseUrl": "https://gw.example.com",
1489 "authHeader": true,
1490 "apiKey": "gw-secret",
1491 "models": [ { "id": "custom-claude" } ]
1492 }
1493 }
1494}"#,
1495 )
1496 .unwrap();
1497 let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
1498 assert!(
1500 r.model
1501 .headers
1502 .as_ref()
1503 .and_then(|h| h.get("authorization"))
1504 .is_none(),
1505 "--api-key should win over the models.json gateway bearer"
1506 );
1507 }
1508
1509 #[test]
1518 fn models_json_bare_apikey_satisfies_auth_without_env() {
1519 let _env = TestEnv::new();
1520 std::fs::write(
1522 config::models_path().unwrap(),
1523 r#"{
1524 "providers": {
1525 "gateway": {
1526 "baseUrl": "https://gw.example.com",
1527 "api": "anthropic-messages",
1528 "apiKey": "gw-secret",
1529 "models": [
1530 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1531 ]
1532 }
1533 }
1534}"#,
1535 )
1536 .unwrap();
1537 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1538 assert_eq!(r.model.id, "custom-claude");
1539 assert_eq!(r.model.base_url, "https://gw.example.com");
1540 let headers = r
1542 .model
1543 .headers
1544 .as_ref()
1545 .expect("x-api-key folded onto model");
1546 assert_eq!(
1547 headers.get("x-api-key").map(|s| s.as_str()),
1548 Some("gw-secret")
1549 );
1550 assert!(
1552 headers.get("authorization").is_none(),
1553 "bare apiKey must NOT synthesize a Bearer (that is the authHeader path)"
1554 );
1555 }
1556
1557 #[test]
1564 fn default_prefers_gateway_when_only_bare_apikey_configured() {
1565 let _env = TestEnv::new();
1566 std::fs::write(
1567 config::models_path().unwrap(),
1568 r#"{
1569 "providers": {
1570 "gateway": {
1571 "baseUrl": "https://gw.example.com",
1572 "api": "anthropic-messages",
1573 "apiKey": "gw-secret",
1574 "models": [
1575 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1576 ]
1577 }
1578 }
1579}"#,
1580 )
1581 .unwrap();
1582 let r = resolve(None, None, None, None, None).unwrap();
1584 assert_eq!(r.model.id, "custom-claude");
1585 assert_eq!(r.model.base_url, "https://gw.example.com");
1586 let headers = r
1588 .model
1589 .headers
1590 .as_ref()
1591 .expect("x-api-key on gateway model");
1592 assert_eq!(
1593 headers.get("x-api-key").map(|s| s.as_str()),
1594 Some("gw-secret")
1595 );
1596 }
1597
1598 #[test]
1603 fn models_json_bare_apikey_env_template_resolves() {
1604 let _env = TestEnv::new();
1605 std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
1607 std::fs::write(
1608 config::models_path().unwrap(),
1609 r#"{
1610 "providers": {
1611 "gateway": {
1612 "baseUrl": "https://gw.example.com",
1613 "api": "anthropic-messages",
1614 "apiKey": "$RPI_TEST_GATEWAY_KEY",
1615 "models": [
1616 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1617 ]
1618 }
1619 }
1620}"#,
1621 )
1622 .unwrap();
1623 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
1624 let headers = r.model.headers.as_ref().expect("x-api-key folded");
1625 assert_eq!(
1626 headers.get("x-api-key").map(|s| s.as_str()),
1627 Some("env-resolved-secret")
1628 );
1629 std::env::remove_var("RPI_TEST_GATEWAY_KEY");
1630 }
1631
1632 #[test]
1642 fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
1643 let _env = TestEnv::new();
1644 std::fs::write(
1645 config::models_path().unwrap(),
1646 r#"{
1647 "providers": {
1648 "bearer-gw": {
1649 "baseUrl": "https://bearer.example.com",
1650 "api": "anthropic-messages",
1651 "authHeader": true,
1652 "apiKey": "bearer-secret",
1653 "models": [ { "id": "bearer-model" } ]
1654 },
1655 "xkey-gw": {
1656 "baseUrl": "https://xkey.example.com",
1657 "api": "anthropic-messages",
1658 "apiKey": "xkey-secret",
1659 "models": [ { "id": "xkey-model" } ]
1660 }
1661 }
1662}"#,
1663 )
1664 .unwrap();
1665 let r = resolve(None, None, None, None, None).unwrap();
1668 assert_eq!(r.model.id, "bearer-model");
1669
1670 let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
1672 let h = r.model.headers.as_ref().expect("bearer folded");
1673 assert_eq!(
1674 h.get("authorization").map(|s| s.as_str()),
1675 Some("Bearer bearer-secret")
1676 );
1677 assert!(
1678 h.get("x-api-key").is_none(),
1679 "authHeader path must not synthesize x-api-key"
1680 );
1681
1682 let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
1685 let h2 = r2.model.headers.as_ref().expect("x-api-key folded");
1686 assert_eq!(h2.get("x-api-key").map(|s| s.as_str()), Some("xkey-secret"));
1687 assert!(
1688 h2.get("authorization").is_none(),
1689 "xkey-gw has no authHeader"
1690 );
1691
1692 let catalog = available_catalog(&r);
1696 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
1697 assert_eq!(ids, vec!["bearer-model", "xkey-model"]);
1698 }
1699
1700 #[test]
1707 fn settings_default_model_honored_for_models_json_provider() {
1708 let _env = TestEnv::new();
1709 std::fs::write(
1710 config::models_path().unwrap(),
1711 r#"{
1712 "providers": {
1713 "beta-gw": {
1714 "baseUrl": "https://beta.example.com",
1715 "api": "anthropic-messages",
1716 "apiKey": "beta-secret",
1717 "models": [ { "id": "beta-model" } ]
1718 },
1719 "alpha-gw": {
1720 "baseUrl": "https://alpha.example.com",
1721 "api": "anthropic-messages",
1722 "apiKey": "alpha-secret",
1723 "models": [ { "id": "alpha-model" } ]
1724 }
1725 }
1726}"#,
1727 )
1728 .unwrap();
1729 std::fs::write(
1733 config::settings_path().unwrap(),
1734 r#"{"defaultProvider":"alpha-gw","defaultModel":"alpha-model"}"#,
1735 )
1736 .unwrap();
1737 let r = resolve(None, None, None, None, None).unwrap();
1738 assert_eq!(r.model.id, "alpha-model");
1739 std::fs::write(
1741 config::settings_path().unwrap(),
1742 r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
1743 )
1744 .unwrap();
1745 let r = resolve(None, None, None, None, None).unwrap();
1746 assert_eq!(r.model.id, "beta-model");
1747 }
1748
1749 #[test]
1750 fn models_json_fallback_preserves_provider_and_model_declaration_order() {
1751 let _env = TestEnv::new();
1752 std::fs::write(
1753 config::models_path().unwrap(),
1754 r#"{
1755 "providers": {
1756 "routeryo-copy": {
1757 "api": "openai-completions",
1758 "baseUrl": "https://router.example.com/v1",
1759 "apiKey": "router-key",
1760 "models": [
1761 { "id": "gpt-5.6-sol" },
1762 { "id": "gpt-5.6-terra" }
1763 ]
1764 },
1765 "alpha-gw": {
1766 "api": "openai-completions",
1767 "baseUrl": "https://alpha.example.com/v1",
1768 "apiKey": "alpha-key",
1769 "models": [ { "id": "alpha-model" } ]
1770 }
1771 }
1772}"#,
1773 )
1774 .unwrap();
1775
1776 let resolved = resolve(None, None, None, None, None).unwrap();
1777 assert_eq!(resolved.model.provider, "routeryo-copy");
1778 assert_eq!(resolved.model.id, "gpt-5.6-sol");
1779 }
1780
1781 #[test]
1782 fn native_known_provider_default_beats_first_model_in_array() {
1783 let _env = TestEnv::new();
1784 std::fs::write(
1785 config::models_path().unwrap(),
1786 r#"{
1787 "providers": {
1788 "deepseek": {
1789 "api": "openai-completions",
1790 "baseUrl": "https://api.deepseek.com",
1791 "apiKey": "deepseek-key",
1792 "models": [
1793 { "id": "deepseek-chat" },
1794 { "id": "deepseek-v4-pro" }
1795 ]
1796 }
1797 }
1798}"#,
1799 )
1800 .unwrap();
1801
1802 let resolved = resolve(None, None, None, None, None).unwrap();
1803 assert_eq!(resolved.model.provider, "deepseek");
1804 assert_eq!(resolved.model.id, "deepseek-v4-pro");
1805 }
1806
1807 #[test]
1815 fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
1816 let _env = TestEnv::new();
1817 std::fs::write(
1818 config::models_path().unwrap(),
1819 r#"{
1820 "providers": {
1821 "gateway": {
1822 "baseUrl": "https://gw.example.com",
1823 "api": "anthropic-messages",
1824 "apiKey": "gw-secret",
1825 "models": [
1826 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
1827 ]
1828 }
1829 }
1830}"#,
1831 )
1832 .unwrap();
1833 let r = resolve(None, None, None, None, None).unwrap();
1834 assert!(!r.has_provider_key);
1836 let catalog = available_catalog(&r);
1837 let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
1840 assert_eq!(
1841 ids,
1842 vec!["custom-claude"],
1843 "selector must only list authed models"
1844 );
1845 assert!(r.provider.models().len() > catalog.len());
1848 }
1849
1850 #[test]
1854 fn available_catalog_keeps_all_models_on_provider_key_path() {
1855 let _env = TestEnv::new();
1856 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
1857 let r = resolve(None, None, None, None, None).unwrap();
1858 assert!(r.has_provider_key);
1859 let catalog = available_catalog(&r);
1860 assert_eq!(catalog.len(), r.provider.models().len());
1861 assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
1862 }
1863}