Skip to main content

aptu_core/ai/registry/
parsing.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HTTP model parsing and caching logic.
4
5use 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/// Error type for model registry operations.
19#[derive(Debug, Error)]
20pub enum RegistryError {
21    /// HTTP request failed.
22    #[error("HTTP request failed: {0}")]
23    HttpError(String),
24
25    /// Failed to parse API response.
26    #[error("Failed to parse API response: {0}")]
27    ParseError(String),
28
29    /// Provider not found.
30    #[error("Provider not found: {0}")]
31    ProviderNotFound(String),
32
33    /// Cache error.
34    #[error("Cache error: {0}")]
35    CacheError(String),
36
37    /// IO error.
38    #[error("IO error: {0}")]
39    IoError(#[from] std::io::Error),
40
41    /// Model validation error - invalid model ID.
42    #[error("Invalid model ID: {model_id}")]
43    ModelValidation {
44        /// The invalid model ID provided by the user.
45        model_id: String,
46    },
47}
48
49/// Model capability indicators.
50#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52pub enum Capability {
53    /// Model supports image/vision inputs.
54    Vision,
55    /// Model supports function/tool calling.
56    FunctionCalling,
57    /// Model has extended reasoning capabilities.
58    Reasoning,
59}
60
61/// Raw pricing information for a model (cost per token in USD).
62///
63/// `f64` is used because these values are display-only (never used for
64/// arithmetic or financial calculations). Precision matches what the API
65/// returns in its JSON responses. If cost estimation or budget tracking is
66/// added in the future, migrate to a decimal type such as `rust_decimal`.
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct PricingInfo {
69    /// Cost per prompt token in USD. None if unavailable.
70    pub prompt_per_token: Option<f64>,
71    /// Cost per completion token in USD. None if unavailable.
72    pub completion_per_token: Option<f64>,
73}
74
75/// Cached model information from API responses.
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct CachedModel {
78    /// Model identifier from the provider API.
79    pub id: String,
80    /// Human-readable model name.
81    pub name: Option<String>,
82    /// Whether the model is free to use.
83    pub is_free: Option<bool>,
84    /// Maximum context window size in tokens.
85    pub context_window: Option<u32>,
86    /// Provider name this model belongs to.
87    pub provider: String,
88    /// Model capabilities (e.g., `Vision`, `FunctionCalling`).
89    #[serde(default)]
90    pub capabilities: Vec<Capability>,
91    /// Pricing information for this model.
92    #[serde(default)]
93    pub pricing: Option<PricingInfo>,
94}
95
96/// Trait for runtime model validation and listing.
97#[async_trait]
98pub trait ModelRegistry: Send + Sync {
99    /// List all available models for a provider.
100    async fn list_models(&self, provider: &str) -> Result<Vec<CachedModel>, RegistryError>;
101
102    /// Check if a model exists for a provider.
103    async fn model_exists(&self, provider: &str, model_id: &str) -> Result<bool, RegistryError>;
104
105    /// Validate that a model ID exists for a provider.
106    async fn validate_model(&self, provider: &str, model_id: &str) -> Result<(), RegistryError>;
107}
108
109/// Cached model registry with HTTP client and TTL support.
110#[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    /// Create a new cached model registry.
120    ///
121    /// # Arguments
122    ///
123    /// * `cache_dir` - Directory for storing cached model lists (None to disable caching)
124    /// * `ttl_seconds` - Time-to-live for cache entries (see `DEFAULT_MODEL_TTL_SECS`)
125    /// * `token_provider` - Token provider for API credentials
126    #[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    /// Parse `OpenRouter` API response into models.
148    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                        // Derive capabilities from architecture field defensively
186                        let arch = m.get("architecture");
187                        let capabilities = {
188                            // Check input_modalities array first
189                            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                            // Fall back to modalities string
196                            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    /// Parse Gemini API response into models.
231    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    /// Parse generic OpenAI-compatible API response into models.
259    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    /// Fetch models from provider API.
281    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        // Get API key from token provider
293        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        // Build request incrementally with provider-specific authentication
298        let request = match provider {
299            PROVIDER_GEMINI => {
300                // Gemini uses header authentication
301                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                // These providers use Bearer token authentication
308                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        // Parse based on provider API format
327        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        // Try fresh cache first
345        if let Ok(Some(models)) = self.cache.get(provider).await {
346            return Ok(models);
347        }
348
349        // Fetch from API with stale fallback
350        match self.fetch_from_api(provider).await {
351            Ok(models) => {
352                // Save to cache (ignore errors)
353                let _ = self.cache.set(provider, &models).await;
354                Ok(models)
355            }
356            Err(api_error) => {
357                // Try stale cache as fallback
358                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                        // No stale cache available, return original API error
369                        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}