1use crate::discovery::ModelsDevPayload;
15use crate::error::Result;
16use crate::provider::mapping::{self, Mapping};
17use crate::provider::{ModelDescriptor, ServiceDescriptor};
18use serde::{Deserialize, Serialize};
19
20pub fn fetch_models_dev(api_url: Option<&str>) -> Result<ModelsDevPayload> {
25 match api_url {
26 Some(url) => crate::discovery::fetch_from(url),
27 None => crate::discovery::fetch(),
28 }
29}
30
31#[derive(Serialize, Deserialize)]
33struct Envelope {
34 providers: Vec<ServiceDescriptor>,
35}
36
37pub fn parse_catalog(json: &str) -> Result<Vec<ServiceDescriptor>> {
39 let env: Envelope = serde_json::from_str(json)?;
40 Ok(env.providers)
41}
42
43pub fn serialize_catalog(providers: &[ServiceDescriptor]) -> Result<String> {
46 let env = Envelope {
47 providers: providers.to_vec(),
48 };
49 let mut out = serde_json::to_string_pretty(&env)?;
50 out.push('\n');
51 Ok(out)
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct PriceDelta {
57 pub provider_id: String,
59 pub model_id: String,
61 pub input_before: Option<f64>,
63 pub input_after: Option<f64>,
65 pub output_before: Option<f64>,
67 pub output_after: Option<f64>,
69}
70
71#[derive(Debug, Default, Clone)]
73pub struct CatalogDiff {
74 pub providers_seen: usize,
76 pub providers_synced: usize,
78 pub providers_manual: usize,
80 pub models_added: Vec<(String, String)>,
82 pub models_updated: Vec<(String, String)>,
84 pub models_removed: Vec<(String, String)>,
86 pub price_deltas: Vec<PriceDelta>,
88}
89
90impl CatalogDiff {
91 #[must_use]
93 pub fn is_clean(&self) -> bool {
94 self.models_added.is_empty()
95 && self.models_updated.is_empty()
96 && self.price_deltas.is_empty()
97 }
98}
99
100impl std::fmt::Display for CatalogDiff {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 writeln!(
103 f,
104 "providers: {} seen ({} synced, {} manual)",
105 self.providers_seen, self.providers_synced, self.providers_manual
106 )?;
107 writeln!(
108 f,
109 "models: {} added, {} updated, {} catalog-only kept",
110 self.models_added.len(),
111 self.models_updated.len(),
112 self.models_removed.len()
113 )?;
114 if !self.price_deltas.is_empty() {
115 writeln!(f, "pricing changes (USD / 1M tokens):")?;
116 for d in &self.price_deltas {
117 writeln!(
118 f,
119 " {}/{}: input {:?}→{:?}, output {:?}→{:?}",
120 d.provider_id,
121 d.model_id,
122 d.input_before,
123 d.input_after,
124 d.output_before,
125 d.output_after
126 )?;
127 }
128 }
129 Ok(())
130 }
131}
132
133pub fn merge_catalog(
145 current: &[ServiceDescriptor],
146 upstream: &ModelsDevPayload,
147) -> Result<(Vec<ServiceDescriptor>, CatalogDiff)> {
148 let mut diff = CatalogDiff {
149 providers_seen: current.len(),
150 ..CatalogDiff::default()
151 };
152
153 let merged: Vec<ServiceDescriptor> = current
154 .iter()
155 .map(|svc| merge_provider(svc, upstream, &mut diff))
156 .collect();
157
158 Ok((merged, diff))
159}
160
161fn merge_provider(
164 svc: &ServiceDescriptor,
165 upstream: &ModelsDevPayload,
166 diff: &mut CatalogDiff,
167) -> ServiceDescriptor {
168 let resolved = mapping::resolve(&svc.id);
169 match resolved {
170 Mapping::Manual => {
171 diff.providers_manual += 1;
172 for m in &svc.models {
174 diff.models_removed.push((svc.id.clone(), m.id.clone()));
175 }
176 svc.clone()
177 }
178 Mapping::Exact | Mapping::Aliased(_) => {
179 diff.providers_synced += 1;
180 let key = match resolved {
181 Mapping::Aliased(k) => k,
182 _ => svc.id.as_str(),
183 };
184 let upstream_models = upstream.provider_models(key);
185
186 let mut merged_models: Vec<ModelDescriptor> = Vec::with_capacity(upstream_models.len());
187
188 for cm in &svc.models {
191 if let Some(um) = upstream_models.iter().find(|m| m.id == cm.id) {
192 if cm != um {
193 record_delta(&svc.id, cm, um, diff);
194 diff.models_updated.push((svc.id.clone(), cm.id.clone()));
195 }
196 merged_models.push(um.clone());
197 } else {
198 diff.models_removed.push((svc.id.clone(), cm.id.clone()));
200 merged_models.push(cm.clone());
201 }
202 }
203
204 for um in &upstream_models {
206 if !merged_models.iter().any(|m| m.id == um.id) {
207 diff.models_added.push((svc.id.clone(), um.id.clone()));
208 merged_models.push(um.clone());
209 }
210 }
211
212 ServiceDescriptor {
213 models: merged_models,
214 api_base_url: fill_opt(&svc.api_base_url, upstream.provider_api_base(key)),
215 npm_package: fill_opt(&svc.npm_package, upstream.provider_npm(key)),
216 doc_url: fill_opt(&svc.doc_url, upstream.provider_doc(key)),
217 ..svc.clone()
219 }
220 }
221 }
222}
223
224fn fill_opt(catalog: &Option<String>, upstream: Option<&str>) -> Option<String> {
226 if catalog.as_ref().is_some_and(|s| !s.is_empty()) {
227 catalog.clone()
228 } else {
229 upstream.map(Into::into)
230 }
231}
232
233fn record_delta(
235 provider_id: &str,
236 before: &ModelDescriptor,
237 after: &ModelDescriptor,
238 diff: &mut CatalogDiff,
239) {
240 let (ib, ob) = before
241 .cost
242 .as_ref()
243 .map(|c| (Some(c.input), Some(c.output)))
244 .unwrap_or((None, None));
245 let (ia, oa) = after
246 .cost
247 .as_ref()
248 .map(|c| (Some(c.input), Some(c.output)))
249 .unwrap_or((None, None));
250 if ib != ia || ob != oa {
251 diff.price_deltas.push(PriceDelta {
252 provider_id: provider_id.to_string(),
253 model_id: before.id.clone(),
254 input_before: ib,
255 input_after: ia,
256 output_before: ob,
257 output_after: oa,
258 });
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::provider::{ModelCapabilities, ModelCost, ModelLimit, ModelModalities};
266
267 fn upstream_fixture() -> ModelsDevPayload {
269 let raw = r#"{
270 "zai": {
271 "id": "zai", "env": ["ZHIPU_API_KEY"], "api": "https://api.z.ai/api/paas/v4",
272 "npm": "@ai-sdk/zai", "doc": "https://docs.z.ai",
273 "models": {
274 "glm-5": {
275 "id": "glm-5", "name": "GLM-5", "family": "glm",
276 "reasoning": true, "tool_call": true, "temperature": true,
277 "limit": {"context": 204800, "output": 131072},
278 "cost": {"input": 1, "output": 3.2, "cache_read": 0.2, "cache_write": 0}
279 },
280 "glm-5.1": {
281 "id": "glm-5.1", "name": "GLM-5.1",
282 "tool_call": true, "temperature": true,
283 "limit": {"context": 204800, "output": 131072},
284 "cost": {"input": 2, "output": 6}
285 }
286 }
287 },
288 "native": {
289 "id": "native", "models": {}
290 }
291 }"#;
292 serde_json::from_str(raw).unwrap()
293 }
294
295 fn catalog_glm5(cost_input: f64, cost_output: f64) -> ServiceDescriptor {
296 ServiceDescriptor {
297 id: "zai".to_string(),
298 display_name: "Z.AI".to_string(),
299 description: "Z.AI".to_string(),
300 category: "international".to_string(),
301 family: "zai".to_string(),
302 auth_mode: "secret".to_string(),
303 key_var: "ZAI_API_KEY".to_string(),
304 literal_auth_token: String::new(),
305 base_url: "https://z.ai".to_string(),
306 default_model: "glm-5".to_string(),
307 model_tiers: std::collections::HashMap::new(),
308 model_choices: vec![],
309 test_url: "https://z.ai".to_string(),
310 setup: vec![],
311 usage: vec![],
312 api_base_url: Some("https://existing.example/v1".to_string()),
313 npm_package: None,
314 doc_url: None,
315 models: vec![ModelDescriptor {
316 id: "glm-5".to_string(),
317 name: "GLM-5 (stale)".to_string(),
318 family: None,
319 release_date: None,
320 cost: Some(ModelCost {
321 input: cost_input,
322 output: cost_output,
323 cache_read: None,
324 cache_write: None,
325 }),
326 limit: Some(ModelLimit {
327 context: 128_000,
328 output: 4_096,
329 }),
330 modalities: None,
331 capabilities: Some(ModelCapabilities {
332 attachment: false,
333 reasoning: false,
334 temperature: true,
335 tool_call: true,
336 streaming: true,
337 }),
338 knowledge: None,
339 }],
340 }
341 }
342
343 #[test]
344 fn test_price_delta_detected_and_applied() {
345 let current = vec![catalog_glm5(0.5, 0.5)];
346 let (merged, diff) = merge_catalog(¤t, &upstream_fixture()).unwrap();
347 let zai = &merged[0];
348 let glm5 = zai.models.iter().find(|m| m.id == "glm-5").unwrap();
349 assert_eq!(glm5.cost.as_ref().unwrap().input, 1.0);
351 assert_eq!(glm5.cost.as_ref().unwrap().output, 3.2);
352 assert_eq!(diff.price_deltas.len(), 1);
354 let d = &diff.price_deltas[0];
355 assert_eq!(d.model_id, "glm-5");
356 assert_eq!(d.input_before, Some(0.5));
357 assert_eq!(d.input_after, Some(1.0));
358 assert_eq!(d.output_before, Some(0.5));
359 assert_eq!(d.output_after, Some(3.2));
360 }
361
362 #[test]
363 fn test_provider_service_fields_preserved() {
364 let current = vec![catalog_glm5(0.5, 0.5)];
365 let (merged, _) = merge_catalog(¤t, &upstream_fixture()).unwrap();
366 let zai = &merged[0];
367 assert_eq!(zai.key_var, "ZAI_API_KEY"); assert_eq!(zai.auth_mode, "secret");
369 assert_eq!(zai.default_model, "glm-5");
370 assert_eq!(
372 zai.api_base_url.as_deref(),
373 Some("https://existing.example/v1")
374 );
375 assert_eq!(zai.npm_package.as_deref(), Some("@ai-sdk/zai"));
377 assert_eq!(zai.doc_url.as_deref(), Some("https://docs.z.ai"));
378 }
379
380 #[test]
381 fn test_api_base_filled_when_empty() {
382 let mut current = catalog_glm5(0.5, 0.5);
383 current.api_base_url = None; let (merged, _) = merge_catalog(&[current], &upstream_fixture()).unwrap();
385 assert_eq!(
386 merged[0].api_base_url.as_deref(),
387 Some("https://api.z.ai/api/paas/v4")
388 );
389 }
390
391 #[test]
392 fn test_new_model_added() {
393 let current = vec![catalog_glm5(0.5, 0.5)];
394 let (merged, diff) = merge_catalog(¤t, &upstream_fixture()).unwrap();
395 assert!(merged[0].models.iter().any(|m| m.id == "glm-5.1"));
397 assert!(diff.models_added.iter().any(|(_, id)| id == "glm-5.1"));
398 }
399
400 #[test]
401 fn test_catalog_only_model_preserved() {
402 let mut current = catalog_glm5(0.5, 0.5);
403 current.models.push(ModelDescriptor {
405 id: "glm-custom-curate".to_string(),
406 name: "Curated".to_string(),
407 family: None,
408 release_date: None,
409 cost: None,
410 limit: None,
411 modalities: Some(ModelModalities {
412 input: vec!["text".to_string()],
413 output: vec!["text".to_string()],
414 }),
415 capabilities: None,
416 knowledge: None,
417 });
418 let (merged, diff) = merge_catalog(&[current], &upstream_fixture()).unwrap();
419 assert!(merged[0].models.iter().any(|m| m.id == "glm-custom-curate"));
420 assert!(
421 diff.models_removed
422 .iter()
423 .any(|(p, id)| p == "zai" && id == "glm-custom-curate")
424 );
425 }
426
427 #[test]
428 fn test_manual_provider_untouched() {
429 let current = vec![ServiceDescriptor {
431 id: "native".to_string(),
432 models: vec![ModelDescriptor {
433 id: "claude-haiku-3-5".to_string(),
434 name: "Claude".to_string(),
435 family: None,
436 release_date: None,
437 cost: Some(ModelCost {
438 input: 0.8,
439 output: 4.0,
440 cache_read: None,
441 cache_write: None,
442 }),
443 limit: None,
444 modalities: None,
445 capabilities: None,
446 knowledge: None,
447 }],
448 ..catalog_glm5(0.5, 0.5)
449 }];
450 let (merged, diff) = merge_catalog(¤t, &upstream_fixture()).unwrap();
451 assert_eq!(diff.providers_manual, 1);
452 assert_eq!(diff.providers_synced, 0);
453 let native = &merged[0];
455 assert_eq!(native.models[0].cost.as_ref().unwrap().input, 0.8);
456 }
457
458 #[test]
459 fn test_round_trip_envelope() {
460 let providers = vec![catalog_glm5(1.0, 3.0)];
461 let json = serialize_catalog(&providers).unwrap();
462 let parsed = parse_catalog(&json).unwrap();
463 assert_eq!(parsed.len(), 1);
464 assert_eq!(parsed[0].id, "zai");
465 }
466
467 #[test]
468 fn test_is_clean_when_nothing_changes() {
469 let upstream = upstream_fixture();
471 let glm5 = upstream.provider_models("zai");
472 let current = vec![ServiceDescriptor {
473 id: "zai".to_string(),
474 models: glm5,
475 ..catalog_glm5(1.0, 3.2)
476 }];
477 let (_, diff) = merge_catalog(¤t, &upstream).unwrap();
478 assert!(diff.models_added.is_empty());
481 assert!(diff.price_deltas.is_empty());
482 assert!(diff.is_clean());
483 }
484}