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;
16use zeroclaw_api::model_provider::ModelPricing;
17
18const CATALOG_URL: &str = "https://openrouter.ai/api/v1/models";
19const FETCH_TIMEOUT_SECS: u64 = 10;
20
21#[derive(Debug, Deserialize)]
22struct CatalogResponse {
23    data: Vec<ModelEntry>,
24}
25
26#[derive(Debug, Deserialize, Clone)]
27struct ModelEntry {
28    id: String,
29    #[serde(default)]
30    pricing: Option<ModelPricing>,
31}
32
33/// Flat catalog — model IDs only (used by `list_models`).
34static CACHED_CATALOG: OnceCell<Arc<Vec<String>>> = OnceCell::const_new();
35/// Enriched catalog — model IDs with pricing (used by `list_models_with_pricing`).
36static CACHED_CATALOG_WITH_PRICING: OnceCell<Arc<Vec<ModelEntryWithPricing>>> =
37    OnceCell::const_new();
38
39#[derive(Clone)]
40struct ModelEntryWithPricing {
41    id: String,
42    pricing: Option<ModelPricing>,
43}
44
45async fn fetch_catalog() -> Result<Arc<Vec<String>>> {
46    let client = reqwest::Client::builder()
47        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
48        .build()?;
49    let response = client.get(CATALOG_URL).send().await?.error_for_status()?;
50    let bytes = response.bytes().await?;
51    Ok(Arc::new(parse_catalog(&bytes)?))
52}
53
54async fn fetch_catalog_with_pricing() -> Result<Arc<Vec<ModelEntryWithPricing>>> {
55    let client = reqwest::Client::builder()
56        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
57        .build()?;
58    let response = client.get(CATALOG_URL).send().await?.error_for_status()?;
59    let bytes = response.bytes().await?;
60    Ok(Arc::new(parse_catalog_with_pricing(&bytes)?))
61}
62
63/// Parse the OpenRouter JSON into a flat list of model ids. Pure — unit
64/// tests construct minimal JSON byte slices and assert filter logic
65/// without any network call.
66pub(crate) fn parse_catalog(bytes: &[u8]) -> Result<Vec<String>> {
67    let body: CatalogResponse = serde_json::from_slice(bytes)?;
68    Ok(body.data.into_iter().map(|m| m.id).collect())
69}
70
71/// Parse the OpenRouter JSON into a list of model entries with pricing.
72fn parse_catalog_with_pricing(bytes: &[u8]) -> Result<Vec<ModelEntryWithPricing>> {
73    let body: CatalogResponse = serde_json::from_slice(bytes)?;
74    Ok(body
75        .data
76        .into_iter()
77        .map(|m| ModelEntryWithPricing {
78            id: m.id,
79            pricing: m.pricing,
80        })
81        .collect())
82}
83
84/// Filter a parsed catalog by vendor prefix, returning the slug portion of
85/// each match. Sorted and deduped. Errors if nothing matches. Pure —
86/// separated from the live fetch so it can be unit-tested.
87pub(crate) fn filter_by_vendor(catalog: &[String], vendor_prefix: &str) -> Result<Vec<String>> {
88    let needle = format!("{vendor_prefix}/");
89    let mut slugs: Vec<String> = catalog
90        .iter()
91        .filter_map(|id| id.strip_prefix(&needle).map(ToString::to_string))
92        .collect();
93    if slugs.is_empty() {
94        anyhow::bail!("OpenRouter catalog has no entries under vendor prefix {vendor_prefix:?}");
95    }
96    slugs.sort();
97    slugs.dedup();
98    Ok(slugs)
99}
100
101/// Filter an enriched catalog by vendor prefix, returning model entries with
102/// pricing. Sorted and deduped by id.
103fn filter_by_vendor_with_pricing(
104    catalog: &[ModelEntryWithPricing],
105    vendor_prefix: &str,
106) -> Result<Vec<zeroclaw_api::model_provider::ModelInfo>> {
107    use zeroclaw_api::model_provider::ModelInfo;
108    let needle = format!("{vendor_prefix}/");
109    let mut models: Vec<ModelInfo> = catalog
110        .iter()
111        .filter_map(|e| {
112            e.id.strip_prefix(&needle).map(|slug| ModelInfo {
113                id: slug.to_string(),
114                pricing: e.pricing.clone(),
115            })
116        })
117        .collect();
118    if models.is_empty() {
119        anyhow::bail!("OpenRouter catalog has no entries under vendor prefix {vendor_prefix:?}");
120    }
121    models.sort_by(|a, b| a.id.cmp(&b.id));
122    models.dedup_by(|a, b| a.id == b.id);
123    Ok(models)
124}
125
126/// Return the slug portion of every OpenRouter model id whose vendor prefix
127/// matches `vendor_prefix`. The vendor prefix is the segment before `/` in
128/// the id (e.g. `x-ai`, `tencent`, `rekaai`). The returned slugs are sorted
129/// and deduplicated.
130pub async fn list_models_for_vendor(vendor_prefix: &str) -> Result<Vec<String>> {
131    let catalog = CACHED_CATALOG.get_or_try_init(fetch_catalog).await?;
132    filter_by_vendor(catalog, vendor_prefix)
133}
134
135/// Return model entries with pricing for every OpenRouter model id whose
136/// vendor prefix matches `vendor_prefix`. Sorted and deduplicated by id.
137pub async fn list_models_for_vendor_with_pricing(
138    vendor_prefix: &str,
139) -> Result<Vec<zeroclaw_api::model_provider::ModelInfo>> {
140    let catalog = CACHED_CATALOG_WITH_PRICING
141        .get_or_try_init(fetch_catalog_with_pricing)
142        .await?;
143    filter_by_vendor_with_pricing(catalog, vendor_prefix)
144}
145
146/// Map an enriched catalog into `ModelInfo` entries, preserving the full
147/// `<vendor>/<slug>` id (no prefix stripping). Sorted and deduped by id. Pure —
148/// separated from the live fetch so it can be unit-tested. Used by the
149/// first-class `openrouter` provider, which lists the entire catalog rather
150/// than a single vendor's slice.
151fn all_models_with_pricing(
152    catalog: &[ModelEntryWithPricing],
153) -> Vec<zeroclaw_api::model_provider::ModelInfo> {
154    use zeroclaw_api::model_provider::ModelInfo;
155    let mut models: Vec<ModelInfo> = catalog
156        .iter()
157        .map(|e| ModelInfo {
158            id: e.id.clone(),
159            pricing: e.pricing.clone(),
160        })
161        .collect();
162    models.sort_by(|a, b| a.id.cmp(&b.id));
163    models.dedup_by(|a, b| a.id == b.id);
164    models
165}
166
167/// Return every OpenRouter model with pricing, keeping the full
168/// `<vendor>/<slug>` id. Sorted and deduplicated by id. Backs the first-class
169/// `OpenRouterModelProvider::list_models_with_pricing` so the cost-rates editor
170/// can prefill rates from the public catalog.
171pub async fn list_all_models_with_pricing() -> Result<Vec<zeroclaw_api::model_provider::ModelInfo>>
172{
173    let catalog = CACHED_CATALOG_WITH_PRICING
174        .get_or_try_init(fetch_catalog_with_pricing)
175        .await?;
176    Ok(all_models_with_pricing(catalog))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    const TINY_CATALOG: &str = r#"{
184        "data": [
185            {"id": "x-ai/grok-4.3"},
186            {"id": "x-ai/grok-2-vision"},
187            {"id": "anthropic/claude-sonnet-4-6"},
188            {"id": "tencent/hunyuan-t1"},
189            {"id": "tencent/hunyuan-turbos"}
190        ]
191    }"#;
192
193    #[test]
194    fn parses_catalog_into_flat_id_list() {
195        let ids = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
196        assert_eq!(ids.len(), 5);
197        assert!(ids.contains(&"x-ai/grok-4.3".to_string()));
198    }
199
200    #[test]
201    fn filter_strips_vendor_prefix() {
202        let catalog = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
203        let slugs = filter_by_vendor(&catalog, "x-ai").unwrap();
204        assert_eq!(slugs, vec!["grok-2-vision", "grok-4.3"]);
205    }
206
207    #[test]
208    fn filter_handles_multi_match() {
209        let catalog = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
210        let slugs = filter_by_vendor(&catalog, "tencent").unwrap();
211        assert_eq!(slugs, vec!["hunyuan-t1", "hunyuan-turbos"]);
212    }
213
214    #[test]
215    fn filter_errors_when_no_match() {
216        let catalog = parse_catalog(TINY_CATALOG.as_bytes()).unwrap();
217        let err = filter_by_vendor(&catalog, "missing").expect_err("must error");
218        assert!(err.to_string().contains("missing"));
219    }
220
221    #[test]
222    fn filter_dedups() {
223        // OpenRouter could (theoretically) list the same model id twice;
224        // dedup keeps the picker clean.
225        let raw = r#"{"data": [{"id":"v/m"},{"id":"v/m"},{"id":"v/n"}]}"#;
226        let catalog = parse_catalog(raw.as_bytes()).unwrap();
227        let slugs = filter_by_vendor(&catalog, "v").unwrap();
228        assert_eq!(slugs, vec!["m", "n"]);
229    }
230
231    #[test]
232    fn parse_errors_on_malformed_json() {
233        assert!(parse_catalog(b"not json").is_err());
234    }
235
236    const PRICED_CATALOG: &str = r#"{
237        "data": [
238            {"id": "x-ai/grok-4.3", "pricing": {"prompt": "0.000005", "completion": "0.000020"}},
239            {"id": "anthropic/claude-sonnet-4-6", "pricing": {"prompt": "0.000003"}},
240            {"id": "vendor/no-pricing"}
241        ]
242    }"#;
243
244    #[test]
245    fn all_models_preserves_full_id_and_pricing() {
246        let catalog = parse_catalog_with_pricing(PRICED_CATALOG.as_bytes()).unwrap();
247        let models = all_models_with_pricing(&catalog);
248        // Full `<vendor>/<slug>` ids are preserved (no prefix stripping), sorted.
249        let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
250        assert_eq!(
251            ids,
252            vec![
253                "anthropic/claude-sonnet-4-6",
254                "vendor/no-pricing",
255                "x-ai/grok-4.3"
256            ]
257        );
258        // Pricing carried through where the catalog supplies it.
259        let grok = models.iter().find(|m| m.id == "x-ai/grok-4.3").unwrap();
260        assert_eq!(
261            grok.pricing.as_ref().unwrap().prompt.as_deref(),
262            Some("0.000005")
263        );
264        assert_eq!(
265            grok.pricing.as_ref().unwrap().completion.as_deref(),
266            Some("0.000020")
267        );
268        // Entries without a pricing object stay `None`.
269        let bare = models.iter().find(|m| m.id == "vendor/no-pricing").unwrap();
270        assert!(bare.pricing.is_none());
271    }
272
273    #[test]
274    fn all_models_dedups_by_id() {
275        let raw = r#"{"data": [{"id":"v/m"},{"id":"v/m"},{"id":"v/n"}]}"#;
276        let catalog = parse_catalog_with_pricing(raw.as_bytes()).unwrap();
277        let models = all_models_with_pricing(&catalog);
278        let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
279        assert_eq!(ids, vec!["v/m", "v/n"]);
280    }
281}