1use async_trait::async_trait;
26use secrecy::ExposeSecret;
27use serde::{Deserialize, Serialize};
28use std::path::PathBuf;
29use thiserror::Error;
30
31use crate::auth::TokenProvider;
32use crate::cache::FileCache;
33
34#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct ProviderConfig {
37 pub name: &'static str,
39
40 pub display_name: &'static str,
42
43 pub api_url: &'static str,
45
46 pub api_key_env: &'static str,
48
49 pub model: &'static str,
51
52 pub max_tokens: u32,
54
55 pub temperature: f32,
57}
58
59pub const PROVIDER_ANTHROPIC: &str = "anthropic";
68
69pub static PROVIDERS: &[ProviderConfig] = &[
71 ProviderConfig {
72 name: "gemini",
73 display_name: "Google Gemini",
74 api_url: "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
75 api_key_env: "GEMINI_API_KEY",
76 model: "gemini-3.1-flash-lite-preview",
77 max_tokens: 4096,
78 temperature: 0.3,
79 },
80 ProviderConfig {
81 name: "openrouter",
82 display_name: "OpenRouter",
83 api_url: "https://openrouter.ai/api/v1/chat/completions",
84 api_key_env: "OPENROUTER_API_KEY",
85 model: "mistralai/mistral-small-2603",
86 max_tokens: 4096,
87 temperature: 0.3,
88 },
89 ProviderConfig {
90 name: "groq",
91 display_name: "Groq",
92 api_url: "https://api.groq.com/openai/v1/chat/completions",
93 api_key_env: "GROQ_API_KEY",
94 model: "llama3-70b-8192",
95 max_tokens: 4096,
96 temperature: 0.3,
97 },
98 ProviderConfig {
99 name: "cerebras",
100 display_name: "Cerebras",
101 api_url: "https://api.cerebras.ai/v1/chat/completions",
102 api_key_env: "CEREBRAS_API_KEY",
103 model: "llama3.1-8b",
104 max_tokens: 4096,
105 temperature: 0.3,
106 },
107 ProviderConfig {
108 name: "zenmux",
109 display_name: "Zenmux",
110 api_url: "https://zenmux.ai/api/v1/chat/completions",
111 api_key_env: "ZENMUX_API_KEY",
112 model: "gpt-4o-mini",
113 max_tokens: 4096,
114 temperature: 0.3,
115 },
116 ProviderConfig {
117 name: "zai",
118 display_name: "Z.AI (Zhipu)",
119 api_url: "https://api.z.ai/api/paas/v4/chat/completions",
120 api_key_env: "ZAI_API_KEY",
121 model: "glm-4-plus",
122 max_tokens: 4096,
123 temperature: 0.3,
124 },
125 ProviderConfig {
126 name: PROVIDER_ANTHROPIC,
127 display_name: "Anthropic",
128 api_url: "https://api.anthropic.com/v1/chat/completions",
129 api_key_env: "ANTHROPIC_API_KEY",
130 model: "claude-sonnet-4-6",
131 max_tokens: 4096,
132 temperature: 0.3,
133 },
134];
135
136#[must_use]
156pub fn get_provider(name: &str) -> Option<&'static ProviderConfig> {
157 PROVIDERS.iter().find(|p| p.name == name)
158}
159
160#[must_use]
175pub fn all_providers() -> &'static [ProviderConfig] {
176 PROVIDERS
177}
178
179#[derive(Debug, Error)]
185pub enum RegistryError {
186 #[error("HTTP request failed: {0}")]
188 HttpError(String),
189
190 #[error("Failed to parse API response: {0}")]
192 ParseError(String),
193
194 #[error("Provider not found: {0}")]
196 ProviderNotFound(String),
197
198 #[error("Cache error: {0}")]
200 CacheError(String),
201
202 #[error("IO error: {0}")]
204 IoError(#[from] std::io::Error),
205
206 #[error("Invalid model ID: {model_id}")]
208 ModelValidation {
209 model_id: String,
211 },
212}
213
214#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "snake_case")]
217pub enum Capability {
218 Vision,
220 FunctionCalling,
222 Reasoning,
224}
225
226#[derive(Clone, Debug, Serialize, Deserialize)]
233pub struct PricingInfo {
234 pub prompt_per_token: Option<f64>,
236 pub completion_per_token: Option<f64>,
238}
239
240#[derive(Clone, Debug, Serialize, Deserialize)]
242pub struct CachedModel {
243 pub id: String,
245 pub name: Option<String>,
247 pub is_free: Option<bool>,
249 pub context_window: Option<u32>,
251 pub provider: String,
253 #[serde(default)]
255 pub capabilities: Vec<Capability>,
256 #[serde(default)]
258 pub pricing: Option<PricingInfo>,
259}
260
261#[async_trait]
263pub trait ModelRegistry: Send + Sync {
264 async fn list_models(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError>;
266
267 async fn model_exists(&self, provider: &str, model_id: &str) -> Result<bool, RegistryError>;
269
270 async fn validate_model(&self, provider: &str, model_id: &str) -> Result<(), RegistryError>;
272}
273
274#[cfg(not(target_arch = "wasm32"))]
276pub struct CachedModelRegistry<'a> {
277 cache: crate::cache::FileCacheImpl<Vec<CachedModel>>,
278 client: reqwest::Client,
279 token_provider: &'a dyn TokenProvider,
280}
281
282#[cfg(not(target_arch = "wasm32"))]
283impl CachedModelRegistry<'_> {
284 #[must_use]
292 pub fn new(
293 cache_dir: Option<PathBuf>,
294 ttl_seconds: u64,
295 token_provider: &dyn TokenProvider,
296 ) -> CachedModelRegistry<'_> {
297 let ttl = chrono::Duration::seconds(
298 ttl_seconds
299 .try_into()
300 .unwrap_or(crate::cache::DEFAULT_MODEL_TTL_SECS.cast_signed()),
301 );
302 CachedModelRegistry {
303 cache: crate::cache::FileCacheImpl::with_dir(cache_dir, "models", ttl),
304 client: reqwest::Client::builder()
305 .timeout(std::time::Duration::from_secs(10))
306 .build()
307 .unwrap_or_else(|_| reqwest::Client::new()),
308 token_provider,
309 }
310 }
311
312 fn parse_openrouter_models(data: &serde_json::Value, provider: &str) -> Vec<CachedModel> {
314 data.get("data")
315 .and_then(|d| d.as_array())
316 .map(|arr| {
317 arr.iter()
318 .filter_map(|m| {
319 let pricing_obj = m.get("pricing");
320 let prompt_per_token = pricing_obj
321 .and_then(|p| p.get("prompt"))
322 .and_then(|p| p.as_str())
323 .and_then(|s| s.parse::<f64>().ok());
324 let completion_per_token = pricing_obj
325 .and_then(|p| p.get("completion"))
326 .and_then(|p| p.as_str())
327 .and_then(|s| s.parse::<f64>().ok());
328
329 let is_free = match (prompt_per_token, completion_per_token) {
330 (Some(prompt), Some(completion)) => {
331 Some(prompt == 0.0 && completion == 0.0)
332 }
333 (Some(prompt), None) => Some(prompt == 0.0),
334 _ => pricing_obj
335 .and_then(|p| p.get("prompt"))
336 .and_then(|p| p.as_str())
337 .map(|p| p == "0"),
338 };
339
340 let pricing =
341 if prompt_per_token.is_some() || completion_per_token.is_some() {
342 Some(PricingInfo {
343 prompt_per_token,
344 completion_per_token,
345 })
346 } else {
347 None
348 };
349
350 let arch = m.get("architecture");
352 let capabilities = {
353 let from_input_modalities = arch
355 .and_then(|a| a.get("input_modalities"))
356 .and_then(|im| im.as_array())
357 .map(|arr| {
358 arr.iter().filter_map(|v| v.as_str()).any(|s| s == "image")
359 });
360 let from_modalities_str = arch
362 .and_then(|a| a.get("modalities"))
363 .and_then(|m| m.as_str())
364 .map(|s| s.contains("image"));
365
366 let has_vision = from_input_modalities
367 .or(from_modalities_str)
368 .unwrap_or(false);
369
370 if has_vision {
371 vec![Capability::Vision]
372 } else {
373 vec![]
374 }
375 };
376
377 Some(CachedModel {
378 id: m.get("id")?.as_str()?.to_string(),
379 name: m.get("name").and_then(|n| n.as_str()).map(String::from),
380 is_free,
381 context_window: m
382 .get("context_length")
383 .and_then(serde_json::Value::as_u64)
384 .and_then(|c| u32::try_from(c).ok()),
385 provider: provider.to_string(),
386 capabilities,
387 pricing,
388 })
389 })
390 .collect()
391 })
392 .unwrap_or_default()
393 }
394
395 fn parse_gemini_models(data: &serde_json::Value, provider: &str) -> Vec<CachedModel> {
397 data.get("models")
398 .and_then(|d| d.as_array())
399 .map(|arr| {
400 arr.iter()
401 .filter_map(|m| {
402 Some(CachedModel {
403 id: m.get("name")?.as_str()?.to_string(),
404 name: m
405 .get("displayName")
406 .and_then(|n| n.as_str())
407 .map(String::from),
408 is_free: None,
409 context_window: m
410 .get("inputTokenLimit")
411 .and_then(serde_json::Value::as_u64)
412 .and_then(|c| u32::try_from(c).ok()),
413 provider: provider.to_string(),
414 capabilities: vec![],
415 pricing: None,
416 })
417 })
418 .collect()
419 })
420 .unwrap_or_default()
421 }
422
423 fn parse_generic_models(data: &serde_json::Value, provider: &str) -> Vec<CachedModel> {
425 data.get("data")
426 .and_then(|d| d.as_array())
427 .map(|arr| {
428 arr.iter()
429 .filter_map(|m| {
430 Some(CachedModel {
431 id: m.get("id")?.as_str()?.to_string(),
432 name: None,
433 is_free: None,
434 context_window: None,
435 provider: provider.to_string(),
436 capabilities: vec![],
437 pricing: None,
438 })
439 })
440 .collect()
441 })
442 .unwrap_or_default()
443 }
444
445 async fn fetch_from_api(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError> {
447 let url = match provider {
448 "openrouter" => "https://openrouter.ai/api/v1/models",
449 "gemini" => "https://generativelanguage.googleapis.com/v1beta/models",
450 "groq" => "https://api.groq.com/openai/v1/models",
451 "cerebras" => "https://api.cerebras.ai/v1/models",
452 "zenmux" => "https://zenmux.ai/api/v1/models",
453 "zai" => "https://api.z.ai/api/paas/v4/models",
454 _ => return Err(RegistryError::ProviderNotFound(provider.to_string())),
455 };
456
457 let api_key = self.token_provider.ai_api_key(provider).ok_or_else(|| {
459 RegistryError::HttpError(format!("No API key available for {provider}"))
460 })?;
461
462 let request = match provider {
464 "gemini" => {
465 self.client
467 .get(url)
468 .header("x-goog-api-key", api_key.expose_secret())
469 }
470 "openrouter" | "groq" | "cerebras" | "zenmux" | "zai" => {
471 self.client.get(url).header(
473 "Authorization",
474 format!("Bearer {}", api_key.expose_secret()),
475 )
476 }
477 _ => self.client.get(url),
478 };
479
480 let response = request
481 .send()
482 .await
483 .map_err(|e| RegistryError::HttpError(e.to_string()))?;
484
485 let data = response
486 .json::<serde_json::Value>()
487 .await
488 .map_err(|e| RegistryError::HttpError(e.to_string()))?;
489
490 let models = match provider {
492 "openrouter" => Self::parse_openrouter_models(&data, provider),
493 "gemini" => Self::parse_gemini_models(&data, provider),
494 "groq" | "cerebras" | "zenmux" | "zai" => Self::parse_generic_models(&data, provider),
495 _ => vec![],
496 };
497
498 Ok(models)
499 }
500}
501
502#[cfg(not(target_arch = "wasm32"))]
503#[async_trait]
504impl ModelRegistry for CachedModelRegistry<'_> {
505 async fn list_models(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError> {
506 if let Ok(Some(models)) = self.cache.get(provider).await {
508 return Ok(models);
509 }
510
511 match self.fetch_from_api(provider).await {
513 Ok(models) => {
514 let _ = self.cache.set(provider, &models).await;
516 Ok(models)
517 }
518 Err(api_error) => {
519 match self.cache.get_stale(provider).await {
521 Ok(Some(models)) => {
522 tracing::warn!(
523 provider = provider,
524 error = %api_error,
525 "API request failed, returning stale cached models"
526 );
527 Ok(models)
528 }
529 _ => {
530 Err(api_error)
532 }
533 }
534 }
535 }
536 }
537
538 async fn model_exists(&self, provider: &str, model_id: &str) -> Result<bool, RegistryError> {
539 let models = self.list_models(provider).await?;
540 Ok(models.iter().any(|m| m.id == model_id))
541 }
542
543 async fn validate_model(&self, provider: &str, model_id: &str) -> Result<(), RegistryError> {
544 if self.model_exists(provider, model_id).await? {
545 Ok(())
546 } else {
547 Err(RegistryError::ModelValidation {
548 model_id: model_id.to_string(),
549 })
550 }
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 #[test]
559 fn test_get_provider_gemini() {
560 let provider = get_provider("gemini");
561 assert!(provider.is_some());
562 let provider = provider.unwrap();
563 assert_eq!(provider.display_name, "Google Gemini");
564 assert_eq!(provider.api_key_env, "GEMINI_API_KEY");
565 }
566
567 #[test]
568 fn test_get_provider_openrouter() {
569 let provider = get_provider("openrouter");
570 assert!(provider.is_some());
571 let provider = provider.unwrap();
572 assert_eq!(provider.display_name, "OpenRouter");
573 assert_eq!(provider.api_key_env, "OPENROUTER_API_KEY");
574 }
575
576 #[test]
577 fn test_get_provider_groq() {
578 let provider = get_provider("groq");
579 assert!(provider.is_some());
580 let provider = provider.unwrap();
581 assert_eq!(provider.display_name, "Groq");
582 assert_eq!(provider.api_key_env, "GROQ_API_KEY");
583 }
584
585 #[test]
586 fn test_get_provider_cerebras() {
587 let provider = get_provider("cerebras");
588 assert!(provider.is_some());
589 let provider = provider.unwrap();
590 assert_eq!(provider.display_name, "Cerebras");
591 assert_eq!(provider.api_key_env, "CEREBRAS_API_KEY");
592 }
593
594 #[test]
595 fn test_get_provider_not_found() {
596 let provider = get_provider("nonexistent");
597 assert!(provider.is_none());
598 }
599
600 #[test]
601 fn test_get_provider_case_sensitive() {
602 let provider = get_provider("OpenRouter");
603 assert!(
604 provider.is_none(),
605 "Provider lookup should be case-sensitive"
606 );
607 }
608
609 #[test]
610 fn test_all_providers_count() {
611 let providers = all_providers();
612 assert_eq!(providers.len(), 7, "Should have exactly 7 providers");
613 }
614
615 #[test]
616 fn test_all_providers_have_unique_names() {
617 let providers = all_providers();
618 let mut names = Vec::new();
619 for provider in providers {
620 assert!(
621 !names.contains(&provider.name),
622 "Duplicate provider name: {}",
623 provider.name
624 );
625 names.push(provider.name);
626 }
627 }
628
629 #[test]
630 fn test_get_provider_zenmux() {
631 let provider = get_provider("zenmux");
632 assert!(provider.is_some());
633 let provider = provider.unwrap();
634 assert_eq!(provider.display_name, "Zenmux");
635 assert_eq!(provider.api_key_env, "ZENMUX_API_KEY");
636 }
637
638 #[test]
639 fn test_get_provider_zai() {
640 let provider = get_provider("zai");
641 assert!(provider.is_some());
642 let provider = provider.unwrap();
643 assert_eq!(provider.display_name, "Z.AI (Zhipu)");
644 assert_eq!(provider.api_key_env, "ZAI_API_KEY");
645 }
646
647 #[test]
648 fn test_provider_api_urls_valid() {
649 let providers = all_providers();
650 for provider in providers {
651 assert!(
652 provider.api_url.starts_with("https://"),
653 "Provider {} API URL should use HTTPS",
654 provider.name
655 );
656 }
657 }
658
659 #[test]
660 fn test_provider_api_key_env_not_empty() {
661 let providers = all_providers();
662 for provider in providers {
663 assert!(
664 !provider.api_key_env.is_empty(),
665 "Provider {} should have API key env var",
666 provider.name
667 );
668 }
669 }
670
671 #[test]
672 fn test_parse_openrouter_models_with_pricing() {
673 let data = serde_json::json!({
674 "data": [
675 {
676 "id": "openai/gpt-4o",
677 "name": "GPT-4o",
678 "context_length": 128_000,
679 "pricing": {
680 "prompt": "0.000005",
681 "completion": "0.000015"
682 },
683 "architecture": {
684 "input_modalities": ["text", "image"],
685 "output_modalities": ["text"]
686 }
687 }
688 ]
689 });
690
691 let models = CachedModelRegistry::parse_openrouter_models(&data, "openrouter");
692 assert_eq!(models.len(), 1);
693 let m = &models[0];
694 assert_eq!(m.id, "openai/gpt-4o");
695 assert_eq!(m.is_free, Some(false));
696 let pricing = m.pricing.as_ref().expect("pricing should be present");
697 assert_eq!(pricing.prompt_per_token, Some(0.000_005));
698 assert_eq!(pricing.completion_per_token, Some(0.000_015));
699 assert!(m.capabilities.contains(&Capability::Vision));
700 }
701
702 #[test]
703 fn test_parse_openrouter_models_missing_capabilities() {
704 let data = serde_json::json!({
705 "data": [
706 {
707 "id": "some/text-only-model",
708 "name": "Text Only",
709 "context_length": 32000,
710 "pricing": {
711 "prompt": "0",
712 "completion": "0"
713 }
714 }
715 ]
716 });
717
718 let models = CachedModelRegistry::parse_openrouter_models(&data, "openrouter");
719 assert_eq!(models.len(), 1);
720 let m = &models[0];
721 assert!(
722 m.capabilities.is_empty(),
723 "no vision if architecture missing"
724 );
725 assert_eq!(m.is_free, Some(true));
726 }
727}