zeroclaw_providers/
openrouter_catalog.rs1use 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
33static CACHED_CATALOG: OnceCell<Arc<Vec<String>>> = OnceCell::const_new();
35static 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
63pub(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
71fn 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
84pub(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
101fn 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
126pub 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
135pub 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
146fn 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
167pub 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 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 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 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 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}