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#[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#[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#[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;