1use anyhow::Result;
14
15#[must_use]
20pub fn catalog_source_for(family: &str) -> Option<(Option<&'static str>, Option<&'static str>)> {
21 let pair: (Option<&'static str>, Option<&'static str>) = match family {
22 "openai" => (Some("openai"), Some("openai")),
24 "anthropic" => (Some("anthropic"), Some("anthropic")),
25 "azure" => (Some("azure"), None),
26 "bedrock" => (Some("amazon-bedrock"), None),
27 "gemini" => (Some("google"), Some("google")),
28 "gemini_cli" => (Some("google"), Some("google")),
29 "openrouter" => (Some("openrouter"), Some("openrouter")),
30 "copilot" => (Some("github-copilot"), None),
31 "minimax" => (Some("minimax"), Some("minimax")),
32 "lmstudio" => (Some("lmstudio"), None),
33 "kilocli" => (Some("kilo"), None),
34 "kilo" => (Some("kilo"), None),
35 "ovh" => (Some("ovhcloud"), None),
36 "moonshot" => (Some("moonshotai"), Some("moonshotai")),
38 "qwen" => (Some("alibaba"), Some("qwen")),
39 "glm" => (Some("zhipuai"), None),
40 "zai" => (Some("zai"), Some("z-ai")),
41 "doubao" => (None, Some("bytedance")),
42 "hunyuan" => (None, Some("tencent")),
43 "qianfan" => (None, Some("baidu")),
44 "groq" => (Some("groq"), None),
45 "mistral" => (Some("mistral"), Some("mistralai")),
46 "deepseek" => (Some("deepseek"), Some("deepseek")),
47 "together" => (Some("togetherai"), None),
48 "fireworks" => (Some("fireworks-ai"), None),
49 "cohere" => (Some("cohere"), Some("cohere")),
50 "perplexity" => (Some("perplexity"), Some("perplexity")),
51 "xai" => (Some("xai"), Some("x-ai")),
52 "cerebras" => (Some("cerebras"), None),
53 "deepinfra" => (Some("deepinfra"), None),
54 "huggingface" => (Some("huggingface"), None),
55 "ai21" => (None, Some("ai21")),
56 "reka" => (None, Some("rekaai")),
57 "baseten" => (Some("baseten"), None),
58 "nebius" => (Some("nebius"), None),
59 "friendli" => (Some("friendli"), None),
60 "stepfun" => (Some("stepfun"), Some("stepfun")),
61 "aihubmix" => (Some("aihubmix"), None),
62 "siliconflow" => (Some("siliconflow"), None),
63 "venice" => (Some("venice"), None),
64 "novita" => (Some("novita-ai"), None),
65 "nvidia" => (Some("nvidia"), Some("nvidia")),
66 "vercel" => (Some("vercel"), None),
67 "cloudflare" => (Some("cloudflare-ai-gateway"), None),
68 "synthetic" => (Some("synthetic"), None),
69 "opencode" => (Some("opencode"), None),
70 "atomic_chat" => (Some("atomic-chat"), None),
71 "telnyx" => (None, None),
72 "sambanova" | "hyperbolic" | "anyscale" | "nscale" | "lepton" | "yi" | "baichuan"
77 | "avian" | "deepmyst" | "astrai" | "sglang" | "vllm" | "osaurus" | "litellm"
78 | "llamacpp" | "ollama" | "morph" | "github_models" | "upstage" | "featherless"
79 | "arcee" | "lambda_ai" | "inception" | "custom" => (None, None),
80 _ => return None,
81 };
82 Some(pair)
83}
84
85pub async fn list_models_for_family(family: &str) -> Result<Vec<String>> {
89 let Some((md_key, or_prefix)) = catalog_source_for(family) else {
90 anyhow::bail!("unknown provider family {family:?}");
91 };
92 if let Some(k) = md_key
93 && let Ok(ms) = crate::models_dev::list_models_for(k).await
94 && !ms.is_empty()
95 {
96 return Ok(ms);
97 }
98 if let Some(p) = or_prefix {
99 return crate::openrouter_catalog::list_models_for_vendor(p).await;
100 }
101 anyhow::bail!("no public catalog for family {family:?}")
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
114 fn every_canonical_family_has_a_catalog_table_entry() {
115 macro_rules! collect_family_names {
116 ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
117 vec![$($type_str),+]
118 };
119 }
120 let families: Vec<&str> =
121 zeroclaw_config::for_each_model_provider_slot!(collect_family_names);
122 let mut missing: Vec<&str> = Vec::new();
123 for family in &families {
124 if catalog_source_for(family).is_none() {
125 missing.push(family);
126 }
127 }
128 assert!(
129 missing.is_empty(),
130 "catalog_source_for is missing entries for: {missing:?}"
131 );
132 }
133
134 #[test]
135 fn unknown_family_returns_none() {
136 assert!(catalog_source_for("not_a_real_provider").is_none());
137 }
138
139 #[test]
140 fn known_family_with_dual_sources_returns_both() {
141 let (md, or) = catalog_source_for("xai").expect("xai is canonical");
142 assert_eq!(md, Some("xai"));
143 assert_eq!(or, Some("x-ai"));
144 }
145
146 #[test]
147 fn local_only_family_returns_no_sources() {
148 let (md, or) = catalog_source_for("llamacpp").expect("llamacpp is canonical");
149 assert_eq!(md, None);
150 assert_eq!(or, None);
151 }
152
153 #[test]
154 fn bespoke_family_with_only_models_dev() {
155 let (md, or) = catalog_source_for("azure").expect("azure is canonical");
156 assert_eq!(md, Some("azure"));
157 assert_eq!(or, None);
158 }
159
160 #[test]
161 fn bespoke_family_with_only_openrouter() {
162 let (md, or) = catalog_source_for("ai21").expect("ai21 is canonical");
163 assert_eq!(md, None);
164 assert_eq!(or, Some("ai21"));
165 }
166}