Skip to main content

relay_knowledge/model_provider/
catalog.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use tokio::fs;
4
5use super::{
6    ModelCapabilities, ModelProviderConfigService, ModelProviderError, ModelProviderKind,
7    connectivity::{now_millis, status_error_code},
8    persistence::write_json,
9};
10use crate::net::{
11    http::{HttpConfig, send_request_with_qos},
12    qos::{QosPolicy, QosRuntime},
13};
14
15/// Public model catalog provider entry.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct ModelCatalogProvider {
18    pub id: String,
19    pub name: String,
20    pub runtime_provider: ModelProviderKind,
21    pub api: Option<String>,
22    pub doc: Option<String>,
23    pub env: Vec<String>,
24    pub models: Vec<ModelCatalogModel>,
25}
26
27/// Public model catalog model entry.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct ModelCatalogModel {
30    pub id: String,
31    pub name: String,
32    pub family: Option<String>,
33    pub context_window: Option<u32>,
34    pub output_limit: Option<u32>,
35    pub capabilities: ModelCapabilities,
36}
37
38/// Catalog fetch result with cache provenance.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct ModelCatalogResult {
41    pub ok: bool,
42    pub source_url: String,
43    pub fetched_at_ms: Option<u64>,
44    pub cache_age_seconds: Option<u64>,
45    pub stale: bool,
46    pub providers: Vec<ModelCatalogProvider>,
47    pub error_code: Option<String>,
48    pub error_message: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub(super) struct ModelCatalogCache {
53    pub(super) source_url: String,
54    pub(super) fetched_at_ms: u64,
55    pub(super) providers: Vec<ModelCatalogProvider>,
56}
57
58impl ModelProviderConfigService {
59    pub async fn catalog(
60        &self,
61        http: &HttpConfig,
62        refresh: bool,
63    ) -> Result<ModelCatalogResult, ModelProviderError> {
64        let qos = QosRuntime::default();
65        let policy = QosPolicy::new(
66            crate::net::qos::DEFAULT_MAX_CONNECTIONS,
67            crate::net::qos::DEFAULT_MAX_IN_FLIGHT_REQUESTS,
68            crate::net::qos::DEFAULT_MAX_QUEUE_DEPTH,
69        )
70        .expect("default QoS policy should validate");
71        self.catalog_with_qos(http, &qos, &policy, refresh).await
72    }
73
74    pub async fn catalog_with_qos(
75        &self,
76        http: &HttpConfig,
77        qos: &QosRuntime,
78        policy: &QosPolicy,
79        refresh: bool,
80    ) -> Result<ModelCatalogResult, ModelProviderError> {
81        let cached = self.load_catalog_cache().await?;
82        if !refresh {
83            return Ok(cached
84                .map(|cache| catalog_result_from_cache(cache, true, None, None))
85                .unwrap_or_else(builtin_catalog_result));
86        }
87
88        let fetched = self.fetch_catalog(http, qos, policy).await;
89        match fetched {
90            Ok(result) if result.ok => {
91                let cache = ModelCatalogCache {
92                    source_url: result.source_url.clone(),
93                    fetched_at_ms: result.fetched_at_ms.unwrap_or_else(now_millis),
94                    providers: result.providers.clone(),
95                };
96                let _ = self.write_catalog_cache(&cache).await;
97                Ok(result)
98            }
99            Ok(result) => {
100                let fallback_error_code = result.error_code.clone();
101                let fallback_error_message = result.error_message.clone();
102                let source_url = result.source_url.clone();
103                let fetched_at_ms = result.fetched_at_ms;
104                Ok(cached
105                    .map(|cache| {
106                        catalog_result_from_cache(
107                            cache,
108                            false,
109                            fallback_error_code.clone(),
110                            fallback_error_message.clone(),
111                        )
112                    })
113                    .unwrap_or_else(|| ModelCatalogResult {
114                        ok: false,
115                        source_url,
116                        fetched_at_ms,
117                        cache_age_seconds: None,
118                        stale: true,
119                        providers: builtin_catalog_providers(),
120                        error_code: fallback_error_code,
121                        error_message: fallback_error_message,
122                    }))
123            }
124            Err(error) => Ok(cached
125                .map(|cache| {
126                    catalog_result_from_cache(
127                        cache,
128                        false,
129                        Some("network_error".to_owned()),
130                        Some(error.to_string()),
131                    )
132                })
133                .unwrap_or_else(|| ModelCatalogResult {
134                    ok: false,
135                    source_url: self.catalog_source_url.clone(),
136                    fetched_at_ms: None,
137                    cache_age_seconds: None,
138                    stale: true,
139                    providers: builtin_catalog_providers(),
140                    error_code: Some("network_error".to_owned()),
141                    error_message: Some(error.to_string()),
142                })),
143        }
144    }
145
146    async fn load_catalog_cache(&self) -> Result<Option<ModelCatalogCache>, ModelProviderError> {
147        match fs::read_to_string(self.paths.model_catalog_cache_file()).await {
148            Ok(raw) => serde_json::from_str(&raw)
149                .map(Some)
150                .map_err(ModelProviderError::from),
151            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
152            Err(error) => Err(ModelProviderError::from(error)),
153        }
154    }
155
156    pub(super) async fn write_catalog_cache(
157        &self,
158        cache: &ModelCatalogCache,
159    ) -> Result<(), ModelProviderError> {
160        write_json(self.paths.model_catalog_cache_file(), cache).await
161    }
162
163    async fn fetch_catalog(
164        &self,
165        http: &HttpConfig,
166        qos: &QosRuntime,
167        policy: &QosPolicy,
168    ) -> Result<ModelCatalogResult, ModelProviderError> {
169        let client = crate::net::http::outbound_json_client(http)
170            .map_err(|error| ModelProviderError::Network(error.to_string()))?;
171        let response = send_request_with_qos(
172            qos,
173            policy,
174            client
175                .get(&self.catalog_source_url)
176                .timeout(http.request_timeout),
177        )
178        .await
179        .map_err(|error| ModelProviderError::Network(error.to_string()))?;
180        if !response.status().is_success() {
181            return Ok(ModelCatalogResult {
182                ok: false,
183                source_url: self.catalog_source_url.clone(),
184                fetched_at_ms: None,
185                cache_age_seconds: None,
186                stale: true,
187                providers: Vec::new(),
188                error_code: Some(status_error_code(response.status().as_u16()).to_owned()),
189                error_message: Some(format!("catalog returned HTTP {}", response.status())),
190            });
191        }
192        let payload = response
193            .json::<Value>()
194            .await
195            .map_err(|error| ModelProviderError::Json(error.to_string()))?;
196        Ok(ModelCatalogResult {
197            ok: true,
198            source_url: self.catalog_source_url.clone(),
199            fetched_at_ms: Some(now_millis()),
200            cache_age_seconds: Some(0),
201            stale: false,
202            providers: parse_catalog_payload(&payload),
203            error_code: None,
204            error_message: None,
205        })
206    }
207}
208pub(super) fn parse_catalog_payload(payload: &Value) -> Vec<ModelCatalogProvider> {
209    let providers = payload
210        .get("providers")
211        .and_then(Value::as_array)
212        .cloned()
213        .unwrap_or_default();
214    let parsed = providers
215        .iter()
216        .filter_map(parse_catalog_provider)
217        .collect::<Vec<_>>();
218    if parsed.is_empty() {
219        builtin_catalog_providers()
220    } else {
221        parsed
222    }
223}
224
225pub(super) fn parse_catalog_provider(value: &Value) -> Option<ModelCatalogProvider> {
226    let id = value.get("id").and_then(Value::as_str)?.to_owned();
227    let name = value
228        .get("name")
229        .and_then(Value::as_str)
230        .unwrap_or(&id)
231        .to_owned();
232    let runtime_provider = match value
233        .get("runtime_provider")
234        .or_else(|| value.get("provider"))
235        .and_then(Value::as_str)
236        .unwrap_or("openai_compatible")
237    {
238        "anthropic" => ModelProviderKind::Anthropic,
239        "bigmodel" => ModelProviderKind::Bigmodel,
240        "minimax" => ModelProviderKind::Minimax,
241        "maas" => ModelProviderKind::Maas,
242        "codeagent" => ModelProviderKind::Codeagent,
243        "echo" => ModelProviderKind::Echo,
244        _ => ModelProviderKind::OpenAiCompatible,
245    };
246    let models = value
247        .get("models")
248        .and_then(Value::as_array)
249        .into_iter()
250        .flatten()
251        .filter_map(parse_catalog_model)
252        .collect();
253    Some(ModelCatalogProvider {
254        id,
255        name,
256        runtime_provider,
257        api: value
258            .get("api")
259            .and_then(Value::as_str)
260            .map(ToOwned::to_owned),
261        doc: value
262            .get("doc")
263            .and_then(Value::as_str)
264            .map(ToOwned::to_owned),
265        env: value
266            .get("env")
267            .and_then(Value::as_array)
268            .into_iter()
269            .flatten()
270            .filter_map(Value::as_str)
271            .map(ToOwned::to_owned)
272            .collect(),
273        models,
274    })
275}
276
277pub(super) fn parse_catalog_model(value: &Value) -> Option<ModelCatalogModel> {
278    let id = value
279        .get("id")
280        .or_else(|| value.get("model"))
281        .and_then(Value::as_str)?
282        .to_owned();
283    Some(ModelCatalogModel {
284        name: value
285            .get("name")
286            .and_then(Value::as_str)
287            .unwrap_or(&id)
288            .to_owned(),
289        id,
290        family: value
291            .get("family")
292            .and_then(Value::as_str)
293            .map(ToOwned::to_owned),
294        context_window: value
295            .get("context_window")
296            .and_then(Value::as_u64)
297            .and_then(|value| u32::try_from(value).ok()),
298        output_limit: value
299            .get("output_limit")
300            .and_then(Value::as_u64)
301            .and_then(|value| u32::try_from(value).ok()),
302        capabilities: ModelCapabilities::default(),
303    })
304}
305
306pub(super) fn builtin_catalog_result() -> ModelCatalogResult {
307    ModelCatalogResult {
308        ok: true,
309        source_url: "builtin".to_owned(),
310        fetched_at_ms: Some(now_millis()),
311        cache_age_seconds: Some(0),
312        stale: false,
313        providers: builtin_catalog_providers(),
314        error_code: None,
315        error_message: None,
316    }
317}
318
319pub(super) fn builtin_catalog_providers() -> Vec<ModelCatalogProvider> {
320    vec![
321        catalog_provider(
322            "openai",
323            "OpenAI-compatible",
324            ModelProviderKind::OpenAiCompatible,
325            &["gpt-4.1", "gpt-4.1-mini", "text-embedding-3-small"],
326        ),
327        catalog_provider(
328            "anthropic",
329            "Anthropic",
330            ModelProviderKind::Anthropic,
331            &["claude-sonnet-4-5", "claude-haiku-4-5"],
332        ),
333        catalog_provider("echo", "Echo", ModelProviderKind::Echo, &["echo"]),
334    ]
335}
336
337pub(super) fn catalog_provider(
338    id: &str,
339    name: &str,
340    runtime_provider: ModelProviderKind,
341    models: &[&str],
342) -> ModelCatalogProvider {
343    ModelCatalogProvider {
344        id: id.to_owned(),
345        name: name.to_owned(),
346        runtime_provider,
347        api: None,
348        doc: None,
349        env: Vec::new(),
350        models: models
351            .iter()
352            .map(|model| ModelCatalogModel {
353                id: (*model).to_owned(),
354                name: (*model).to_owned(),
355                family: None,
356                context_window: None,
357                output_limit: None,
358                capabilities: ModelCapabilities::default(),
359            })
360            .collect(),
361    }
362}
363
364pub(super) fn catalog_result_from_cache(
365    cache: ModelCatalogCache,
366    ok: bool,
367    error_code: Option<String>,
368    error_message: Option<String>,
369) -> ModelCatalogResult {
370    let age = now_millis().saturating_sub(cache.fetched_at_ms) / 1000;
371    ModelCatalogResult {
372        ok,
373        source_url: cache.source_url,
374        fetched_at_ms: Some(cache.fetched_at_ms),
375        cache_age_seconds: Some(age),
376        stale: !ok,
377        providers: cache.providers,
378        error_code,
379        error_message,
380    }
381}
382
383#[cfg(test)]
384#[path = "catalog_tests.rs"]
385mod tests;