Skip to main content

zeroclaw_providers/
catalog.rs

1//! Per-family catalog source table.
2//!
3//! Reaches the model catalog for any provider family without constructing
4//! a live `ModelProvider` (which would require typed runtime context like
5//! Azure's `resource`/`deployment` or Bedrock's `region`). Used by the
6//! gateway's `/api/config/catalog/models` endpoint and the TUI's
7//! config flow when the operator hasn't supplied a credential yet.
8//!
9//! Each family maps to a tuple `(models_dev_key, openrouter_vendor_prefix)`;
10//! `list_models_for_family` walks them in that order, returning the first
11//! non-empty list.
12
13use anyhow::Result;
14
15/// `(models.dev key, openrouter.ai vendor prefix)` for a family name.
16/// Either or both can be `None` for families with no public catalog
17/// (local-only servers, credential-required APIs without a public
18/// `/models` index).
19#[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        // First-party / bespoke factories.
23        "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        // Compat families — mirrors the consts in CompatFamilySpec impls.
37        "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        // Families with no public catalog: local-only servers (no public
73        // /models index without a running server) or credential-required
74        // APIs with no published catalog. Operator pastes a credential and
75        // the provider's `/models` endpoint serves the list directly.
76        "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
85/// Probe the catalog for `family` without constructing a live provider.
86/// Returns the union of every known public catalog source. Errors if
87/// `family` is unknown or has no public catalog source set.
88pub 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    /// `catalog_source_for` must classify every canonical family the
109    /// `for_each_model_provider_slot!` macro emits. Drift catches a new
110    /// slot added to the macro without a matching catalog-table entry —
111    /// `catalog_source_for` would return `None` and the gateway endpoint
112    /// would surface `unknown provider family` for that family.
113    #[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}