Skip to main content

llm_kernel/provider/
sync.rs

1//! Catalog sync engine — merge a live models.dev payload into the embedded
2//! catalog.
3//!
4//! The catalog is split into two concerns:
5//! - **Connection metadata** (auth, base URL, install-wizard text, model
6//!   tiers): llm-kernel-specific, always kept from the catalog.
7//! - **Model data** (pricing, limits, modalities, capabilities): the source of
8//!   truth is models.dev. `merge_catalog` refreshes it for every provider that
9//!   maps to models.dev ([`crate::provider::mapping`]); providers with no
10//!   upstream counterpart are left untouched.
11//!
12//! `merge_catalog` is pure (no I/O) so it is unit-testable without network.
13
14use crate::discovery::ModelsDevPayload;
15use crate::error::Result;
16use crate::provider::mapping::{self, Mapping};
17use crate::provider::{ModelDescriptor, ServiceDescriptor};
18use serde::{Deserialize, Serialize};
19
20/// Fetch the live models.dev catalog, optionally from a custom URL.
21///
22/// **Trust boundary:** `api_url` is forwarded verbatim to [`crate::discovery::fetch_from`]
23/// — pass only admin-configured values.
24pub 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/// Envelope matching the on-disk `catalog.json` shape.
32#[derive(Serialize, Deserialize)]
33struct Envelope {
34    providers: Vec<ServiceDescriptor>,
35}
36
37/// Parse a `catalog.json` document into its provider list.
38pub fn parse_catalog(json: &str) -> Result<Vec<ServiceDescriptor>> {
39    let env: Envelope = serde_json::from_str(json)?;
40    Ok(env.providers)
41}
42
43/// Serialize a provider list back into the canonical `catalog.json` form
44/// (pretty-printed, 2-space indent, trailing newline).
45pub 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/// A change in a model's per-million-token pricing across a sync.
55#[derive(Debug, Clone, PartialEq)]
56pub struct PriceDelta {
57    /// Catalog provider id hosting the model.
58    pub provider_id: String,
59    /// Model id.
60    pub model_id: String,
61    /// Input price before sync (USD / 1M tokens), if known.
62    pub input_before: Option<f64>,
63    /// Input price after sync.
64    pub input_after: Option<f64>,
65    /// Output price before sync.
66    pub output_before: Option<f64>,
67    /// Output price after sync.
68    pub output_after: Option<f64>,
69}
70
71/// Summary of what a [`merge_catalog`] pass changed.
72#[derive(Debug, Default, Clone)]
73pub struct CatalogDiff {
74    /// Total providers examined.
75    pub providers_seen: usize,
76    /// Providers whose model list was refreshed from models.dev.
77    pub providers_synced: usize,
78    /// Providers left untouched (no models.dev counterpart).
79    pub providers_manual: usize,
80    /// `(provider_id, model_id)` for models added (in models.dev, not catalog).
81    pub models_added: Vec<(String, String)>,
82    /// `(provider_id, model_id)` for existing models refreshed from models.dev.
83    pub models_updated: Vec<(String, String)>,
84    /// `(provider_id, model_id)` for catalog models absent upstream (kept).
85    pub models_removed: Vec<(String, String)>,
86    /// Pricing changes detected while refreshing.
87    pub price_deltas: Vec<PriceDelta>,
88}
89
90impl CatalogDiff {
91    /// `true` when the merge produced no changes (catalog already in sync).
92    #[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
133/// Merge a models.dev payload into the current catalog.
134///
135/// Field precedence (per the sync design):
136/// - **Provider service fields** (auth, base URL, tiers, setup, ...): catalog wins.
137/// - **Provider convenience fields** (`api_base_url`, `npm_package`,
138///   `doc_url`): catalog wins; models.dev fills only when the catalog value is
139///   empty.
140/// - **Model fields** (cost, limit, modalities, capabilities, ...): models.dev
141///   wins; catalog-only models (absent upstream) are preserved in place.
142///
143/// Returns the merged providers and a [`CatalogDiff`]. Pure: no I/O.
144pub 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
161/// Merge one provider: refresh its models from models.dev, keep its service
162/// fields, and best-effort fill empty convenience fields.
163fn 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            // Preserve catalog-only models as "kept" (informational).
173            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            // Walk current models in catalog order: refresh from upstream when
189            // present, otherwise preserve the catalog entry in place.
190            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                    // Catalog-only model not in models.dev → keep as-is.
199                    diff.models_removed.push((svc.id.clone(), cm.id.clone()));
200                    merged_models.push(cm.clone());
201                }
202            }
203
204            // Append upstream models not already present (additive, no deletion).
205            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                // All other provider service fields kept verbatim.
218                ..svc.clone()
219            }
220        }
221    }
222}
223
224/// Return the catalog value when present, else lift the upstream value.
225fn 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
233/// Record a [`PriceDelta`] when input or output pricing changed.
234fn 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    /// Build a minimal models.dev-shaped payload for two providers.
268    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(&current, &upstream_fixture()).unwrap();
347        let zai = &merged[0];
348        let glm5 = zai.models.iter().find(|m| m.id == "glm-5").unwrap();
349        // models.dev pricing won.
350        assert_eq!(glm5.cost.as_ref().unwrap().input, 1.0);
351        assert_eq!(glm5.cost.as_ref().unwrap().output, 3.2);
352        // Delta recorded.
353        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(&current, &upstream_fixture()).unwrap();
366        let zai = &merged[0];
367        assert_eq!(zai.key_var, "ZAI_API_KEY"); // catalog wins
368        assert_eq!(zai.auth_mode, "secret");
369        assert_eq!(zai.default_model, "glm-5");
370        // api_base_url non-empty in catalog → NOT overwritten by models.dev.
371        assert_eq!(
372            zai.api_base_url.as_deref(),
373            Some("https://existing.example/v1")
374        );
375        // npm/doc empty in catalog → filled from models.dev.
376        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; // empty → should be filled
384        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(&current, &upstream_fixture()).unwrap();
395        // glm-5.1 is in models.dev but not the catalog fixture → added.
396        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        // Inject a catalog-only model not present in models.dev zai.
404        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        // `native` is Manual (not in models.dev's mapped set) → kept as-is.
430        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(&current, &upstream_fixture()).unwrap();
451        assert_eq!(diff.providers_manual, 1);
452        assert_eq!(diff.providers_synced, 0);
453        // native's model untouched (pricing not refreshed).
454        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        // Build a "current" catalog that already matches models.dev exactly.
470        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(&current, &upstream).unwrap();
478        // No new models added (glm-5.1 is in current? no — current only has
479        // whatever provider_models returned, which is BOTH glm-5 and glm-5.1).
480        assert!(diff.models_added.is_empty());
481        assert!(diff.price_deltas.is_empty());
482        assert!(diff.is_clean());
483    }
484}