Skip to main content

aptu_core/ai/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Centralized provider configuration registry.
4//!
5//! This module provides a static registry of all AI providers supported by Aptu,
6//! including their metadata, API endpoints, and available models.
7//!
8//! It also provides runtime model validation infrastructure via the `ModelRegistry` trait
9//! with a simple sync implementation using static model lists.
10//!
11//! # Examples
12//!
13//! ```
14//! use aptu_core::ai::registry::{get_provider, all_providers};
15//!
16//! // Get a specific provider
17//! let provider = get_provider("openrouter");
18//! assert!(provider.is_some());
19//!
20//! // Get all providers
21//! let providers = all_providers();
22//! assert_eq!(providers.len(), 7);
23//! ```
24
25use 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/// Configuration for an AI provider.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct ProviderConfig {
37    /// Provider identifier (lowercase, used in config files)
38    pub name: &'static str,
39
40    /// Human-readable provider name for UI display
41    pub display_name: &'static str,
42
43    /// API base URL for this provider
44    pub api_url: &'static str,
45
46    /// Environment variable name for API key
47    pub api_key_env: &'static str,
48
49    /// Default model name for this provider
50    pub model: &'static str,
51
52    /// Default maximum tokens for API responses
53    pub max_tokens: u32,
54
55    /// Default temperature for API requests
56    pub temperature: f32,
57}
58
59// ============================================================================
60// Provider Registry
61// ============================================================================
62
63/// Provider name constant for Anthropic.
64///
65/// Used throughout the codebase to avoid hardcoding the string literal
66/// in multiple places. Replaces all direct "anthropic" comparisons.
67pub const PROVIDER_ANTHROPIC: &str = "anthropic";
68
69/// Static registry of all supported AI providers
70pub 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/// Retrieves a provider configuration by name.
137///
138/// # Arguments
139///
140/// * `name` - The provider name (case-sensitive, lowercase)
141///
142/// # Returns
143///
144/// Some(ProviderConfig) if found, None otherwise.
145///
146/// # Examples
147///
148/// ```
149/// use aptu_core::ai::registry::get_provider;
150///
151/// let provider = get_provider("openrouter");
152/// assert!(provider.is_some());
153/// assert_eq!(provider.unwrap().display_name, "OpenRouter");
154/// ```
155#[must_use]
156pub fn get_provider(name: &str) -> Option<&'static ProviderConfig> {
157    PROVIDERS.iter().find(|p| p.name == name)
158}
159
160/// Returns all available providers.
161///
162/// # Returns
163///
164/// A slice of all `ProviderConfig` entries in the registry.
165///
166/// # Examples
167///
168/// ```
169/// use aptu_core::ai::registry::all_providers;
170///
171/// let providers = all_providers();
172/// assert_eq!(providers.len(), 7);
173/// ```
174#[must_use]
175pub fn all_providers() -> &'static [ProviderConfig] {
176    PROVIDERS
177}
178
179// ============================================================================
180// Runtime Model Validation
181// ============================================================================
182
183/// Error type for model registry operations.
184#[derive(Debug, Error)]
185pub enum RegistryError {
186    /// HTTP request failed.
187    #[error("HTTP request failed: {0}")]
188    HttpError(String),
189
190    /// Failed to parse API response.
191    #[error("Failed to parse API response: {0}")]
192    ParseError(String),
193
194    /// Provider not found.
195    #[error("Provider not found: {0}")]
196    ProviderNotFound(String),
197
198    /// Cache error.
199    #[error("Cache error: {0}")]
200    CacheError(String),
201
202    /// IO error.
203    #[error("IO error: {0}")]
204    IoError(#[from] std::io::Error),
205
206    /// Model validation error - invalid model ID.
207    #[error("Invalid model ID: {model_id}")]
208    ModelValidation {
209        /// The invalid model ID provided by the user.
210        model_id: String,
211    },
212}
213
214/// Model capability indicators.
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "snake_case")]
217pub enum Capability {
218    /// Model supports image/vision inputs.
219    Vision,
220    /// Model supports function/tool calling.
221    FunctionCalling,
222    /// Model has extended reasoning capabilities.
223    Reasoning,
224}
225
226/// Raw pricing information for a model (cost per token in USD).
227///
228/// `f64` is used because these values are display-only (never used for
229/// arithmetic or financial calculations). Precision matches what the API
230/// returns in its JSON responses. If cost estimation or budget tracking is
231/// added in the future, migrate to a decimal type such as `rust_decimal`.
232#[derive(Clone, Debug, Serialize, Deserialize)]
233pub struct PricingInfo {
234    /// Cost per prompt token in USD. None if unavailable.
235    pub prompt_per_token: Option<f64>,
236    /// Cost per completion token in USD. None if unavailable.
237    pub completion_per_token: Option<f64>,
238}
239
240/// Cached model information from API responses.
241#[derive(Clone, Debug, Serialize, Deserialize)]
242pub struct CachedModel {
243    /// Model identifier from the provider API.
244    pub id: String,
245    /// Human-readable model name.
246    pub name: Option<String>,
247    /// Whether the model is free to use.
248    pub is_free: Option<bool>,
249    /// Maximum context window size in tokens.
250    pub context_window: Option<u32>,
251    /// Provider name this model belongs to.
252    pub provider: String,
253    /// Model capabilities (e.g., `Vision`, `FunctionCalling`).
254    #[serde(default)]
255    pub capabilities: Vec<Capability>,
256    /// Pricing information for this model.
257    #[serde(default)]
258    pub pricing: Option<PricingInfo>,
259}
260
261/// Trait for runtime model validation and listing.
262#[async_trait]
263pub trait ModelRegistry: Send + Sync {
264    /// List all available models for a provider.
265    async fn list_models(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError>;
266
267    /// Check if a model exists for a provider.
268    async fn model_exists(&self, provider: &str, model_id: &str) -> Result<bool, RegistryError>;
269
270    /// Validate that a model ID exists for a provider.
271    async fn validate_model(&self, provider: &str, model_id: &str) -> Result<(), RegistryError>;
272}
273
274/// Cached model registry with HTTP client and TTL support.
275#[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    /// Create a new cached model registry.
285    ///
286    /// # Arguments
287    ///
288    /// * `cache_dir` - Directory for storing cached model lists (None to disable caching)
289    /// * `ttl_seconds` - Time-to-live for cache entries (see `DEFAULT_MODEL_TTL_SECS`)
290    /// * `token_provider` - Token provider for API credentials
291    #[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    /// Parse `OpenRouter` API response into models.
313    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                        // Derive capabilities from architecture field defensively
351                        let arch = m.get("architecture");
352                        let capabilities = {
353                            // Check input_modalities array first
354                            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                            // Fall back to modalities string
361                            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    /// Parse Gemini API response into models.
396    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    /// Parse generic OpenAI-compatible API response into models.
424    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    /// Fetch models from provider API.
446    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        // Get API key from token provider
458        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        // Build request incrementally with provider-specific authentication
463        let request = match provider {
464            "gemini" => {
465                // Gemini uses header authentication
466                self.client
467                    .get(url)
468                    .header("x-goog-api-key", api_key.expose_secret())
469            }
470            "openrouter" | "groq" | "cerebras" | "zenmux" | "zai" => {
471                // These providers use Bearer token authentication
472                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        // Parse based on provider API format
491        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        // Try fresh cache first
507        if let Ok(Some(models)) = self.cache.get(provider).await {
508            return Ok(models);
509        }
510
511        // Fetch from API with stale fallback
512        match self.fetch_from_api(provider).await {
513            Ok(models) => {
514                // Save to cache (ignore errors)
515                let _ = self.cache.set(provider, &models).await;
516                Ok(models)
517            }
518            Err(api_error) => {
519                // Try stale cache as fallback
520                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                        // No stale cache available, return original API error
531                        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}