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/onboard/catalog/models` endpoint and the TUI's
7//! `onboard` 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        "ovh" => (Some("ovhcloud"), None),
35        // Compat families — mirrors the consts in CompatFamilySpec impls.
36        "moonshot" => (Some("moonshotai"), Some("moonshotai")),
37        "qwen" => (Some("alibaba"), Some("qwen")),
38        "glm" => (Some("zhipuai"), None),
39        "zai" => (Some("zai"), Some("z-ai")),
40        "doubao" => (None, Some("bytedance")),
41        "hunyuan" => (None, Some("tencent")),
42        "qianfan" => (None, Some("baidu")),
43        "groq" => (Some("groq"), None),
44        "mistral" => (Some("mistral"), Some("mistralai")),
45        "deepseek" => (Some("deepseek"), Some("deepseek")),
46        "together" => (Some("togetherai"), None),
47        "fireworks" => (Some("fireworks-ai"), None),
48        "cohere" => (Some("cohere"), Some("cohere")),
49        "perplexity" => (Some("perplexity"), Some("perplexity")),
50        "xai" => (Some("xai"), Some("x-ai")),
51        "cerebras" => (Some("cerebras"), None),
52        "deepinfra" => (Some("deepinfra"), None),
53        "huggingface" => (Some("huggingface"), None),
54        "ai21" => (None, Some("ai21")),
55        "reka" => (None, Some("rekaai")),
56        "baseten" => (Some("baseten"), None),
57        "nebius" => (Some("nebius"), None),
58        "friendli" => (Some("friendli"), None),
59        "stepfun" => (Some("stepfun"), Some("stepfun")),
60        "aihubmix" => (Some("aihubmix"), None),
61        "siliconflow" => (Some("siliconflow"), None),
62        "venice" => (Some("venice"), None),
63        "novita" => (Some("novita-ai"), None),
64        "nvidia" => (Some("nvidia"), Some("nvidia")),
65        "vercel" => (Some("vercel"), None),
66        "cloudflare" => (Some("cloudflare-ai-gateway"), None),
67        "synthetic" => (Some("synthetic"), None),
68        "opencode" => (Some("opencode"), None),
69        "atomic_chat" => (Some("atomic-chat"), None),
70        "telnyx" => (None, None),
71        // Families with no public catalog: local-only servers (no public
72        // /models index without a running server) or credential-required
73        // APIs with no published catalog. Operator pastes a credential and
74        // the provider's `/models` endpoint serves the list directly.
75        "sambanova" | "hyperbolic" | "anyscale" | "nscale" | "lepton" | "yi" | "baichuan"
76        | "avian" | "deepmyst" | "astrai" | "sglang" | "vllm" | "osaurus" | "litellm"
77        | "llamacpp" | "ollama" | "custom" => (None, None),
78        _ => return None,
79    };
80    Some(pair)
81}
82
83/// Probe the catalog for `family` without constructing a live provider.
84/// Returns the union of every known public catalog source. Errors if
85/// `family` is unknown or has no public catalog source set.
86pub async fn list_models_for_family(family: &str) -> Result<Vec<String>> {
87    let Some((md_key, or_prefix)) = catalog_source_for(family) else {
88        anyhow::bail!("unknown provider family {family:?}");
89    };
90    if let Some(k) = md_key
91        && let Ok(ms) = crate::models_dev::list_models_for(k).await
92        && !ms.is_empty()
93    {
94        return Ok(ms);
95    }
96    if let Some(p) = or_prefix {
97        return crate::openrouter_catalog::list_models_for_vendor(p).await;
98    }
99    anyhow::bail!("no public catalog for family {family:?}")
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    /// `catalog_source_for` must classify every canonical family the
107    /// `for_each_model_provider_slot!` macro emits. Drift catches a new
108    /// slot added to the macro without a matching catalog-table entry —
109    /// `catalog_source_for` would return `None` and the gateway endpoint
110    /// would surface `unknown provider family` for that family.
111    #[test]
112    fn every_canonical_family_has_a_catalog_table_entry() {
113        macro_rules! collect_family_names {
114            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
115                vec![$($type_str),+]
116            };
117        }
118        let families: Vec<&str> =
119            zeroclaw_config::for_each_model_provider_slot!(collect_family_names);
120        let mut missing: Vec<&str> = Vec::new();
121        for family in &families {
122            if catalog_source_for(family).is_none() {
123                missing.push(family);
124            }
125        }
126        assert!(
127            missing.is_empty(),
128            "catalog_source_for is missing entries for: {missing:?}"
129        );
130    }
131
132    #[test]
133    fn unknown_family_returns_none() {
134        assert!(catalog_source_for("not_a_real_provider").is_none());
135    }
136
137    #[test]
138    fn known_family_with_dual_sources_returns_both() {
139        let (md, or) = catalog_source_for("xai").expect("xai is canonical");
140        assert_eq!(md, Some("xai"));
141        assert_eq!(or, Some("x-ai"));
142    }
143
144    #[test]
145    fn local_only_family_returns_no_sources() {
146        let (md, or) = catalog_source_for("llamacpp").expect("llamacpp is canonical");
147        assert_eq!(md, None);
148        assert_eq!(or, None);
149    }
150
151    #[test]
152    fn bespoke_family_with_only_models_dev() {
153        let (md, or) = catalog_source_for("azure").expect("azure is canonical");
154        assert_eq!(md, Some("azure"));
155        assert_eq!(or, None);
156    }
157
158    #[test]
159    fn bespoke_family_with_only_openrouter() {
160        let (md, or) = catalog_source_for("ai21").expect("ai21 is canonical");
161        assert_eq!(md, None);
162        assert_eq!(or, Some("ai21"));
163    }
164}