zeroclaw_providers/
openrouter_catalog.rs1use 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
41pub(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
49pub(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
66pub 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 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}