1use async_trait::async_trait;
6use secrecy::ExposeSecret;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use thiserror::Error;
10
11use super::consts::{
12 PROVIDER_CEREBRAS, PROVIDER_GEMINI, PROVIDER_GROQ, PROVIDER_OPENROUTER, PROVIDER_ZAI,
13 PROVIDER_ZENMUX,
14};
15use crate::auth::TokenProvider;
16use crate::cache::FileCache;
17
18#[derive(Debug, Error)]
20pub enum RegistryError {
21 #[error("HTTP request failed: {0}")]
23 HttpError(String),
24
25 #[error("Failed to parse API response: {0}")]
27 ParseError(String),
28
29 #[error("Provider not found: {0}")]
31 ProviderNotFound(String),
32
33 #[error("Cache error: {0}")]
35 CacheError(String),
36
37 #[error("IO error: {0}")]
39 IoError(#[from] std::io::Error),
40
41 #[error("Invalid model ID: {model_id}")]
43 ModelValidation {
44 model_id: String,
46 },
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52pub enum Capability {
53 Vision,
55 FunctionCalling,
57 Reasoning,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct PricingInfo {
69 pub prompt_per_token: Option<f64>,
71 pub completion_per_token: Option<f64>,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct CachedModel {
78 pub id: String,
80 pub name: Option<String>,
82 pub is_free: Option<bool>,
84 pub context_window: Option<u32>,
86 pub provider: String,
88 #[serde(default)]
90 pub capabilities: Vec<Capability>,
91 #[serde(default)]
93 pub pricing: Option<PricingInfo>,
94}
95
96#[async_trait]
98pub trait ModelRegistry: Send + Sync {
99 async fn list_models(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError>;
101
102 async fn model_exists(&self, provider: &str, model_id: &str) -> Result<bool, RegistryError>;
104
105 async fn validate_model(&self, provider: &str, model_id: &str) -> Result<(), RegistryError>;
107}
108
109#[cfg(not(target_arch = "wasm32"))]
111pub struct CachedModelRegistry<'a> {
112 cache: crate::cache::FileCacheImpl<Vec<CachedModel>>,
113 client: reqwest::Client,
114 token_provider: &'a dyn TokenProvider,
115}
116
117#[cfg(not(target_arch = "wasm32"))]
118impl CachedModelRegistry<'_> {
119 #[must_use]
127 pub fn new(
128 cache_dir: Option<PathBuf>,
129 ttl_seconds: u64,
130 token_provider: &dyn TokenProvider,
131 ) -> CachedModelRegistry<'_> {
132 let ttl = chrono::Duration::seconds(
133 ttl_seconds
134 .try_into()
135 .unwrap_or(crate::cache::DEFAULT_MODEL_TTL_SECS.cast_signed()),
136 );
137 CachedModelRegistry {
138 cache: crate::cache::FileCacheImpl::with_dir(cache_dir, "models", ttl),
139 client: reqwest::Client::builder()
140 .timeout(std::time::Duration::from_secs(10))
141 .build()
142 .unwrap_or_else(|_| reqwest::Client::new()),
143 token_provider,
144 }
145 }
146
147 fn parse_openrouter_models(data: &serde_json::Value, provider: &str) -> Vec<CachedModel> {
149 data.get("data")
150 .and_then(|d| d.as_array())
151 .map(|arr| {
152 arr.iter()
153 .filter_map(|m| {
154 let pricing_obj = m.get("pricing");
155 let prompt_per_token = pricing_obj
156 .and_then(|p| p.get("prompt"))
157 .and_then(|p| p.as_str())
158 .and_then(|s| s.parse::<f64>().ok());
159 let completion_per_token = pricing_obj
160 .and_then(|p| p.get("completion"))
161 .and_then(|p| p.as_str())
162 .and_then(|s| s.parse::<f64>().ok());
163
164 let is_free = match (prompt_per_token, completion_per_token) {
165 (Some(prompt), Some(completion)) => {
166 Some(prompt == 0.0 && completion == 0.0)
167 }
168 (Some(prompt), None) => Some(prompt == 0.0),
169 _ => pricing_obj
170 .and_then(|p| p.get("prompt"))
171 .and_then(|p| p.as_str())
172 .map(|p| p == "0"),
173 };
174
175 let pricing =
176 if prompt_per_token.is_some() || completion_per_token.is_some() {
177 Some(PricingInfo {
178 prompt_per_token,
179 completion_per_token,
180 })
181 } else {
182 None
183 };
184
185 let arch = m.get("architecture");
187 let capabilities = {
188 let from_input_modalities = arch
190 .and_then(|a| a.get("input_modalities"))
191 .and_then(|im| im.as_array())
192 .map(|arr| {
193 arr.iter().filter_map(|v| v.as_str()).any(|s| s == "image")
194 });
195 let from_modalities_str = arch
197 .and_then(|a| a.get("modalities"))
198 .and_then(|m| m.as_str())
199 .map(|s| s.contains("image"));
200
201 let has_vision = from_input_modalities
202 .or(from_modalities_str)
203 .unwrap_or(false);
204
205 if has_vision {
206 vec![Capability::Vision]
207 } else {
208 vec![]
209 }
210 };
211
212 Some(CachedModel {
213 id: m.get("id")?.as_str()?.to_string(),
214 name: m.get("name").and_then(|n| n.as_str()).map(String::from),
215 is_free,
216 context_window: m
217 .get("context_length")
218 .and_then(serde_json::Value::as_u64)
219 .and_then(|c| u32::try_from(c).ok()),
220 provider: provider.to_string(),
221 capabilities,
222 pricing,
223 })
224 })
225 .collect()
226 })
227 .unwrap_or_default()
228 }
229
230 fn parse_gemini_models(data: &serde_json::Value, provider: &str) -> Vec<CachedModel> {
232 data.get("models")
233 .and_then(|d| d.as_array())
234 .map(|arr| {
235 arr.iter()
236 .filter_map(|m| {
237 Some(CachedModel {
238 id: m.get("name")?.as_str()?.to_string(),
239 name: m
240 .get("displayName")
241 .and_then(|n| n.as_str())
242 .map(String::from),
243 is_free: None,
244 context_window: m
245 .get("inputTokenLimit")
246 .and_then(serde_json::Value::as_u64)
247 .and_then(|c| u32::try_from(c).ok()),
248 provider: provider.to_string(),
249 capabilities: vec![],
250 pricing: None,
251 })
252 })
253 .collect()
254 })
255 .unwrap_or_default()
256 }
257
258 fn parse_generic_models(data: &serde_json::Value, provider: &str) -> Vec<CachedModel> {
260 data.get("data")
261 .and_then(|d| d.as_array())
262 .map(|arr| {
263 arr.iter()
264 .filter_map(|m| {
265 Some(CachedModel {
266 id: m.get("id")?.as_str()?.to_string(),
267 name: None,
268 is_free: None,
269 context_window: None,
270 provider: provider.to_string(),
271 capabilities: vec![],
272 pricing: None,
273 })
274 })
275 .collect()
276 })
277 .unwrap_or_default()
278 }
279
280 async fn fetch_from_api(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError> {
282 let url = match provider {
283 PROVIDER_OPENROUTER => "https://openrouter.ai/api/v1/models",
284 PROVIDER_GEMINI => "https://generativelanguage.googleapis.com/v1beta/models",
285 PROVIDER_GROQ => "https://api.groq.com/openai/v1/models",
286 PROVIDER_CEREBRAS => "https://api.cerebras.ai/v1/models",
287 PROVIDER_ZENMUX => "https://zenmux.ai/api/v1/models",
288 PROVIDER_ZAI => "https://api.z.ai/api/paas/v4/models",
289 _ => return Err(RegistryError::ProviderNotFound(provider.to_string())),
290 };
291
292 let api_key = self.token_provider.ai_api_key(provider).ok_or_else(|| {
294 RegistryError::HttpError(format!("No API key available for {provider}"))
295 })?;
296
297 let request = match provider {
299 PROVIDER_GEMINI => {
300 self.client
302 .get(url)
303 .header("x-goog-api-key", api_key.expose_secret())
304 }
305 PROVIDER_OPENROUTER | PROVIDER_GROQ | PROVIDER_CEREBRAS | PROVIDER_ZENMUX
306 | PROVIDER_ZAI => {
307 self.client.get(url).header(
309 "Authorization",
310 format!("Bearer {}", api_key.expose_secret()),
311 )
312 }
313 _ => self.client.get(url),
314 };
315
316 let response = request
317 .send()
318 .await
319 .map_err(|e| RegistryError::HttpError(e.to_string()))?;
320
321 let data = response
322 .json::<serde_json::Value>()
323 .await
324 .map_err(|e| RegistryError::HttpError(e.to_string()))?;
325
326 let models = match provider {
328 PROVIDER_OPENROUTER => Self::parse_openrouter_models(&data, provider),
329 PROVIDER_GEMINI => Self::parse_gemini_models(&data, provider),
330 PROVIDER_GROQ | PROVIDER_CEREBRAS | PROVIDER_ZENMUX | PROVIDER_ZAI => {
331 Self::parse_generic_models(&data, provider)
332 }
333 _ => vec![],
334 };
335
336 Ok(models)
337 }
338}
339
340#[cfg(not(target_arch = "wasm32"))]
341#[async_trait]
342impl ModelRegistry for CachedModelRegistry<'_> {
343 async fn list_models(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError> {
344 if let Ok(Some(models)) = self.cache.get(provider).await {
346 return Ok(models);
347 }
348
349 match self.fetch_from_api(provider).await {
351 Ok(models) => {
352 let _ = self.cache.set(provider, &models).await;
354 Ok(models)
355 }
356 Err(api_error) => {
357 match self.cache.get_stale(provider).await {
359 Ok(Some(models)) => {
360 tracing::warn!(
361 provider = provider,
362 error = %api_error,
363 "API request failed, returning stale cached models"
364 );
365 Ok(models)
366 }
367 _ => {
368 Err(api_error)
370 }
371 }
372 }
373 }
374 }
375
376 async fn model_exists(&self, provider: &str, model_id: &str) -> Result<bool, RegistryError> {
377 let models = self.list_models(provider).await?;
378 Ok(models.iter().any(|m| m.id == model_id))
379 }
380
381 async fn validate_model(&self, provider: &str, model_id: &str) -> Result<(), RegistryError> {
382 if self.model_exists(provider, model_id).await? {
383 Ok(())
384 } else {
385 Err(RegistryError::ModelValidation {
386 model_id: model_id.to_string(),
387 })
388 }
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_parse_openrouter_models_with_pricing() {
398 let data = serde_json::json!({
399 "data": [
400 {
401 "id": "openai/gpt-4o",
402 "name": "GPT-4o",
403 "context_length": 128_000,
404 "pricing": {
405 "prompt": "0.000005",
406 "completion": "0.000015"
407 },
408 "architecture": {
409 "input_modalities": ["text", "image"],
410 "output_modalities": ["text"]
411 }
412 }
413 ]
414 });
415
416 let models = CachedModelRegistry::parse_openrouter_models(&data, "openrouter");
417 assert_eq!(models.len(), 1);
418 let m = &models[0];
419 assert_eq!(m.id, "openai/gpt-4o");
420 assert_eq!(m.is_free, Some(false));
421 let pricing = m.pricing.as_ref().expect("pricing should be present");
422 assert_eq!(pricing.prompt_per_token, Some(0.000_005));
423 assert_eq!(pricing.completion_per_token, Some(0.000_015));
424 assert!(m.capabilities.contains(&Capability::Vision));
425 }
426
427 #[test]
428 fn test_parse_openrouter_models_missing_capabilities() {
429 let data = serde_json::json!({
430 "data": [
431 {
432 "id": "some/text-only-model",
433 "name": "Text Only",
434 "context_length": 32000,
435 "pricing": {
436 "prompt": "0",
437 "completion": "0"
438 }
439 }
440 ]
441 });
442
443 let models = CachedModelRegistry::parse_openrouter_models(&data, "openrouter");
444 assert_eq!(models.len(), 1);
445 let m = &models[0];
446 assert!(
447 m.capabilities.is_empty(),
448 "no vision if architecture missing"
449 );
450 assert_eq!(m.is_free, Some(true));
451 }
452}