Skip to main content

zeroclaw_providers/
openrouter_catalog.rs

1//! Cross-vendor model catalog via OpenRouter's public `/api/v1/models` endpoint.
2//!
3//! Fallback for compat providers that don't have a `models.dev` entry and
4//! can't reach their native `/models` endpoint without a credential. Each
5//! OpenRouter model id is `<vendor>/<slug>`; we filter by vendor prefix
6//! (e.g. `x-ai/` for xAI, `tencent/` for Hunyuan) and return the slug list.
7//!
8//! Cached once per process (`OnceCell`) and shared across all callers.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use anyhow::Result;
14use serde::Deserialize;
15use tokio::sync::OnceCell;
16
17const CATALOG_URL: &str = "https://openrouter.ai/api/v1/models";
18const FETCH_TIMEOUT_SECS: u64 = 10;
19
20#[derive(Debug, Deserialize)]
21struct CatalogResponse {
22    data: Vec<ModelEntry>,
23}
24
25#[derive(Debug, Deserialize, Clone)]
26struct ModelEntry {
27    id: String,
28}
29
30static CACHED_CATALOG: OnceCell<Arc<Vec<String>>> = OnceCell::const_new();
31
32async fn fetch_catalog() -> Result<Arc<Vec<String>>> {
33    let client = reqwest::Client::builder()
34        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
35        .build()?;
36    let response = client.get(CATALOG_URL).send().await?.error_for_status()?;
37    let bytes = response.bytes().await?;
38    Ok(Arc::new(parse_catalog(&bytes)?))
39}
40
41/// Parse the OpenRouter JSON into a flat list of model ids. Pure — unit
42/// tests construct minimal JSON byte slices and assert filter logic
43/// without any network call.
44pub(crate) fn parse_catalog(bytes: &[u8]) -> Result<Vec<String>> {
45    let body: CatalogResponse = serde_json::from_slice(bytes)?;
46    Ok(body.data.into_iter().map(|m| m.id).collect())
47}
48
49/// Filter a parsed catalog by vendor prefix, returning the slug portion of
50/// each match. Sorted and deduped. Errors if nothing matches. Pure —
51/// separated from the live fetch so it can be unit-tested.
52pub(crate) fn filter_by_vendor(catalog: &[String], vendor_prefix: &str) -> Result<Vec<String>> {
53    let needle = format!("{vendor_prefix}/");
54    let mut slugs: Vec<String> = catalog
55        .iter()
56        .filter_map(|id| id.strip_prefix(&needle).map(ToString::to_string))
57        .collect();
58    if slugs.is_empty() {
59        anyhow::bail!("OpenRouter catalog has no entries under vendor prefix {vendor_prefix:?}");
60    }
61    slugs.sort();
62    slugs.dedup();
63    Ok(slugs)
64}
65
66/// Return the slug portion of every OpenRouter model id whose vendor prefix
67/// matches `vendor_prefix`. The vendor prefix is the segment before `/` in
68/// the id (e.g. `x-ai`, `tencent`, `rekaai`). The returned slugs are sorted
69/// and deduplicated.
70pub async fn list_models_for_vendor(vendor_prefix: &str) -> Result<Vec<String>> {
71    let catalog = CACHED_CATALOG.get_or_try_init(fetch_catalog).await?;
72    filter_by_vendor(catalog, vendor_prefix)
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    const TINY_CATALOG: &str = r#"{
80        "data": [
81            {"id": "x-ai/grok-4.3"},
82            {"id": "x-ai/grok-2-vision"},
83            {"id": "anthropic/claude-sonnet-4-6"},
84            {"id": "tencent/hunyuan-t1"},
85            {"id": "tencent/hunyuan-turbos"}
86        ]
87    }"#;
88
89    #[test]
90    fn parses_catalog_into_flat_id_list() {
91        let ids = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
92        assert_eq!(ids.len(), 5);
93        assert!(ids.contains(&"x-ai/grok-4.3".to_string()));
94    }
95
96    #[test]
97    fn filter_strips_vendor_prefix() {
98        let catalog = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
99        let slugs = filter_by_vendor(&catalog, "x-ai").unwrap();
100        assert_eq!(slugs, vec!["grok-2-vision", "grok-4.3"]);
101    }
102
103    #[test]
104    fn filter_handles_multi_match() {
105        let catalog = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
106        let slugs = filter_by_vendor(&catalog, "tencent").unwrap();
107        assert_eq!(slugs, vec!["hunyuan-t1", "hunyuan-turbos"]);
108    }
109
110    #[test]
111    fn filter_errors_when_no_match() {
112        let catalog = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
113        let err = filter_by_vendor(&catalog, "missing").expect_err("must error");
114        assert!(err.to_string().contains("missing"));
115    }
116
117    #[test]
118    fn filter_dedups() {
119        // OpenRouter could (theoretically) list the same model id twice;
120        // dedup keeps the picker clean.
121        let raw = r#"{"data": [{"id":"v/m"},{"id":"v/m"},{"id":"v/n"}]}"#;
122        let catalog = parse_catalog(raw.as_bytes()).unwrap();
123        let slugs = filter_by_vendor(&catalog, "v").unwrap();
124        assert_eq!(slugs, vec!["m", "n"]);
125    }
126
127    #[test]
128    fn parse_errors_on_malformed_json() {
129        assert!(parse_catalog(b"not json").is_err());
130    }
131}