Skip to main content

car_inference/
upgrade.rs

1//! Upstream-aware upgrade detection.
2//!
3//! The registry's `available_upgrades()` only fires on hand-authored rules in
4//! `model-upgrades.json` — the *curated*, verified tier. This module unifies
5//! that with *upstream* discovery: for an installed model it can ask the Hub
6//! whether a newer revision exists. Findings are tagged by trust tier and
7//! source so the UI (and auto-apply policy) can treat verified curated
8//! upgrades differently from unverified upstream ones.
9//!
10//! Properties required by the design:
11//! - **Channel-aware**: upstream probing runs only on the `Latest` channel;
12//!   `Stable` is curated-only.
13//! - **Offline-safe**: any probe error (no network, Hub down, rate limit)
14//!   degrades silently to curated-only — never an error to the caller.
15//! - **Cached / rate-limited**: upstream results are cached with a TTL so we
16//!   don't hit the Hub on every check.
17//!
18//! The probe is a trait so the orchestration is unit-testable without a
19//! network (inject a fake), and the real Hub implementation stays thin.
20
21use std::future::Future;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use crate::registry::ModelUpgrade;
27use crate::schema::{ModelSchema, ModelSource, TrustTier};
28use crate::update_prefs::{UpdateChannel, UpdatePreferences};
29
30/// Where an upgrade finding came from.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum UpgradeSource {
34    /// A vetted rule in `model-upgrades.json`.
35    Curated,
36    /// A newer revision discovered upstream on the Hub. Unverified.
37    Upstream,
38}
39
40/// A single "something newer is available" result, unifying curated rules and
41/// upstream discoveries.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct UpgradeFinding {
44    pub from_id: String,
45    pub from_name: String,
46    pub to_id: String,
47    pub to_name: String,
48    /// Plain-language reason to show the user.
49    pub reason: String,
50    /// `Curated` (verified) or `Community` (upstream, unverified).
51    pub trust_tier: TrustTier,
52    pub source: UpgradeSource,
53    /// Whether CAR can pull the target directly (local/MLX) vs needs setup.
54    pub target_pullable: bool,
55}
56
57impl UpgradeFinding {
58    fn from_curated(u: ModelUpgrade) -> Self {
59        UpgradeFinding {
60            from_id: u.from_id,
61            from_name: u.from_name,
62            to_id: u.to_id,
63            to_name: u.to_name,
64            reason: u.reason,
65            trust_tier: TrustTier::Curated,
66            source: UpgradeSource::Curated,
67            target_pullable: u.target_pullable,
68        }
69    }
70}
71
72/// Asks whether a newer upstream revision exists for an installed model.
73/// Implementations must be offline-safe: return `None` on any failure rather
74/// than erroring.
75pub trait UpstreamProbe {
76    /// `Some(reason)` if a newer revision exists upstream; `None` if not, or
77    /// if it can't be determined (offline, uncached, error).
78    fn newer_revision(&self, schema: &ModelSchema) -> impl Future<Output = Option<String>> + Send;
79}
80
81/// Cached upstream findings with a freshness timestamp, persisted so repeated
82/// checks within the TTL don't hit the Hub.
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct UpgradeCache {
85    /// Unix seconds of the last successful upstream check.
86    #[serde(default)]
87    pub checked_at_secs: u64,
88    /// Fingerprint of the installed-model set the cache was built for. If the
89    /// user installs/removes a model the fingerprint changes, invalidating the
90    /// cache so the new model gets probed before the TTL expires.
91    #[serde(default)]
92    pub models_fingerprint: String,
93    #[serde(default)]
94    pub upstream: Vec<UpgradeFinding>,
95}
96
97impl UpgradeCache {
98    pub fn default_path() -> PathBuf {
99        std::env::var("HOME")
100            .map(PathBuf::from)
101            .unwrap_or_else(|_| PathBuf::from("."))
102            .join(".car")
103            .join("upgrade-cache.json")
104    }
105
106    pub fn load_from(path: &Path) -> Self {
107        std::fs::read_to_string(path)
108            .ok()
109            .and_then(|s| serde_json::from_str(&s).ok())
110            .unwrap_or_default()
111    }
112
113    pub fn save_to(&self, path: &Path) -> Result<(), String> {
114        if let Some(parent) = path.parent() {
115            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
116        }
117        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
118        std::fs::write(path, json).map_err(|e| e.to_string())
119    }
120
121    /// Fresh if the last check was within `ttl_secs` of `now_secs`.
122    pub fn is_fresh(&self, now_secs: u64, ttl_secs: u64) -> bool {
123        self.checked_at_secs != 0 && now_secs.saturating_sub(self.checked_at_secs) < ttl_secs
124    }
125}
126
127/// Default cache TTL: re-probe the Hub at most once a day.
128pub const DEFAULT_TTL_SECS: u64 = 24 * 60 * 60;
129
130/// Detect upgrades for `installed` models, combining curated rules with
131/// upstream discovery. Pure-ish: caller supplies the curated rules, the probe,
132/// the cache path, and `now_secs`, so it's fully testable offline.
133///
134/// - Curated findings are always included (the trusted tier).
135/// - Upstream probing runs only when `prefs.channel == Latest`, is cached for
136///   `ttl_secs`, and degrades to the cached/empty set on any probe failure.
137pub async fn detect_upgrades<P: UpstreamProbe>(
138    curated: Vec<ModelUpgrade>,
139    installed: &[&ModelSchema],
140    prefs: &UpdatePreferences,
141    probe: &P,
142    cache_path: &Path,
143    now_secs: u64,
144    ttl_secs: u64,
145) -> Vec<UpgradeFinding> {
146    let mut findings: Vec<UpgradeFinding> =
147        curated.into_iter().map(UpgradeFinding::from_curated).collect();
148
149    if prefs.channel == UpdateChannel::Latest && prefs.checks_enabled() {
150        let upstream = upstream_findings(installed, probe, cache_path, now_secs, ttl_secs).await;
151        // Dedup: a curated rule for the same from_id wins (it's verified).
152        for f in upstream {
153            if !findings.iter().any(|c| c.from_id == f.from_id) {
154                findings.push(f);
155            }
156        }
157    }
158
159    findings.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
160    findings.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
161    findings
162}
163
164/// Upstream findings, served from cache when fresh, else re-probed and cached.
165async fn upstream_findings<P: UpstreamProbe>(
166    installed: &[&ModelSchema],
167    probe: &P,
168    cache_path: &Path,
169    now_secs: u64,
170    ttl_secs: u64,
171) -> Vec<UpgradeFinding> {
172    let fingerprint = installed_fingerprint(installed);
173    let cache = UpgradeCache::load_from(cache_path);
174    // Serve the cache only when it's both fresh AND built for the same
175    // installed set — so installing a new model re-probes immediately.
176    if cache.is_fresh(now_secs, ttl_secs) && cache.models_fingerprint == fingerprint {
177        return cache.upstream;
178    }
179
180    // Probes run sequentially (one Hub request at a time) and only on a
181    // cache miss (≤ once per TTL window), so the Hub request rate is inherently
182    // bounded by the installed-model count, not the call rate.
183    let mut found = Vec::new();
184    for schema in installed {
185        // Only locally-installed models with a Hub repo can have an upstream.
186        if !schema.available || repo_of(schema).is_none() {
187            continue;
188        }
189        if let Some(reason) = probe.newer_revision(schema).await {
190            found.push(UpgradeFinding {
191                from_id: schema.id.clone(),
192                from_name: schema.name.clone(),
193                // Upstream = same model line, newer revision; target is the
194                // same id (re-pull refreshes the cache to the new revision).
195                to_id: schema.id.clone(),
196                to_name: schema.name.clone(),
197                reason,
198                trust_tier: TrustTier::Community,
199                source: UpgradeSource::Upstream,
200                target_pullable: matches!(
201                    schema.source,
202                    ModelSource::Local { .. } | ModelSource::Mlx { .. }
203                ),
204            });
205        }
206    }
207
208    // Persist (best-effort; a write failure must not break detection).
209    // Empty results are cached too — a fresh empty cache suppresses re-probing.
210    let _ = UpgradeCache {
211        checked_at_secs: now_secs,
212        models_fingerprint: fingerprint,
213        upstream: found.clone(),
214    }
215    .save_to(cache_path);
216    found
217}
218
219/// Stable fingerprint of the installed-model set (sorted ids of available
220/// models). Changes when a model is installed or removed.
221fn installed_fingerprint(installed: &[&ModelSchema]) -> String {
222    use std::collections::hash_map::DefaultHasher;
223    use std::hash::{Hash, Hasher};
224    let mut ids: Vec<&str> = installed
225        .iter()
226        .filter(|m| m.available)
227        .map(|m| m.id.as_str())
228        .collect();
229    ids.sort_unstable();
230    let mut h = DefaultHasher::new();
231    ids.hash(&mut h);
232    format!("{:x}", h.finish())
233}
234
235/// The Hub repo for a model, if it has one.
236fn repo_of(schema: &ModelSchema) -> Option<&str> {
237    match &schema.source {
238        ModelSource::Local { hf_repo, .. } | ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
239        _ => None,
240    }
241}
242
243// --- real Hub probe --------------------------------------------------------
244
245/// Probes the HuggingFace Hub for a newer commit than the one cached locally.
246/// Compares the locally cached `refs/main` sha against the repo's current sha
247/// from the Hub model-info API. Fully offline-safe: any error → `None`.
248pub struct HuggingFaceProbe {
249    client: reqwest::Client,
250}
251
252impl Default for HuggingFaceProbe {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258impl HuggingFaceProbe {
259    pub fn new() -> Self {
260        let client = reqwest::Client::builder()
261            .timeout(std::time::Duration::from_secs(8))
262            .build()
263            .unwrap_or_default();
264        HuggingFaceProbe { client }
265    }
266
267    async fn remote_sha(&self, repo: &str) -> Option<String> {
268        let url = format!("https://huggingface.co/api/models/{repo}");
269        let resp = self.client.get(&url).send().await.ok()?;
270        if !resp.status().is_success() {
271            return None;
272        }
273        let json: serde_json::Value = resp.json().await.ok()?;
274        json.get("sha")?.as_str().map(|s| s.to_string())
275    }
276}
277
278impl UpstreamProbe for HuggingFaceProbe {
279    async fn newer_revision(&self, schema: &ModelSchema) -> Option<String> {
280        let repo = repo_of(schema)?;
281        let local_sha = local_main_sha(repo)?; // not cached ⇒ can't compare
282        let remote_sha = self.remote_sha(repo).await?; // offline ⇒ None
283        if remote_sha != local_sha {
284            Some(format!(
285                "A newer revision of {repo} is available on Hugging Face."
286            ))
287        } else {
288            None
289        }
290    }
291}
292
293/// Read the locally cached `refs/main` sha for a Hub repo, if present.
294fn local_main_sha(repo: &str) -> Option<String> {
295    let cache_root = std::env::var("HF_HOME")
296        .map(PathBuf::from)
297        .unwrap_or_else(|_| {
298            std::env::var("HOME")
299                .map(PathBuf::from)
300                .unwrap_or_else(|_| PathBuf::from("."))
301                .join(".cache")
302                .join("huggingface")
303        })
304        .join("hub");
305    let ref_path = cache_root
306        .join(format!("models--{}", repo.replace('/', "--")))
307        .join("refs")
308        .join("main");
309    std::fs::read_to_string(ref_path)
310        .ok()
311        .map(|s| s.trim().to_string())
312        .filter(|s| !s.is_empty())
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::schema::{CostModel, ModelCapability, PerformanceEnvelope};
319
320    fn local_schema(id: &str, available: bool) -> ModelSchema {
321        ModelSchema {
322            id: id.into(),
323            name: id.into(),
324            provider: "qwen".into(),
325            family: "qwen3".into(),
326            version: String::new(),
327            capabilities: vec![ModelCapability::Generate],
328            context_length: 8192,
329            param_count: "4B".into(),
330            quantization: None,
331            performance: PerformanceEnvelope::default(),
332            cost: CostModel::default(),
333            source: ModelSource::Local {
334                hf_repo: format!("org/{id}"),
335                hf_filename: "m.gguf".into(),
336                tokenizer_repo: format!("org/{id}"),
337            },
338            tags: vec![],
339            supported_params: vec![],
340            public_benchmarks: vec![],
341            trust_tier: TrustTier::Curated,
342            deprecated: false,
343            available,
344        }
345    }
346
347    struct FakeProbe {
348        newer: bool,
349    }
350    impl UpstreamProbe for FakeProbe {
351        async fn newer_revision(&self, _schema: &ModelSchema) -> Option<String> {
352            if self.newer {
353                Some("newer upstream".into())
354            } else {
355                None
356            }
357        }
358    }
359
360    /// A probe that panics if called — proves we didn't hit the network.
361    struct NeverProbe;
362    impl UpstreamProbe for NeverProbe {
363        async fn newer_revision(&self, _schema: &ModelSchema) -> Option<String> {
364            panic!("probe must not be called");
365        }
366    }
367
368    fn tmp_cache(tag: &str) -> PathBuf {
369        std::env::temp_dir().join(format!("car-upgrade-{tag}-{}.json", std::process::id()))
370    }
371
372    #[tokio::test]
373    async fn stable_channel_is_curated_only_and_never_probes() {
374        let prefs = UpdatePreferences::default(); // Stable
375        let installed = local_schema("qwen3-4b", true);
376        let cache = tmp_cache("stable");
377        let findings = detect_upgrades(
378            vec![],
379            &[&installed],
380            &prefs,
381            &NeverProbe, // would panic if probed
382            &cache,
383            1000,
384            DEFAULT_TTL_SECS,
385        )
386        .await;
387        assert!(findings.is_empty());
388        let _ = std::fs::remove_file(&cache);
389    }
390
391    #[tokio::test]
392    async fn latest_channel_adds_upstream_findings() {
393        let prefs = UpdatePreferences {
394            channel: UpdateChannel::Latest,
395            ..Default::default()
396        };
397        let installed = local_schema("qwen3-4b", true);
398        let cache = tmp_cache("latest");
399        let _ = std::fs::remove_file(&cache);
400        let findings = detect_upgrades(
401            vec![],
402            &[&installed],
403            &prefs,
404            &FakeProbe { newer: true },
405            &cache,
406            1000,
407            DEFAULT_TTL_SECS,
408        )
409        .await;
410        assert_eq!(findings.len(), 1);
411        assert_eq!(findings[0].source, UpgradeSource::Upstream);
412        assert_eq!(findings[0].trust_tier, TrustTier::Community);
413        let _ = std::fs::remove_file(&cache);
414    }
415
416    #[tokio::test]
417    async fn uninstalled_models_are_not_probed() {
418        let prefs = UpdatePreferences {
419            channel: UpdateChannel::Latest,
420            ..Default::default()
421        };
422        let installed = local_schema("qwen3-4b", false); // not installed
423        let cache = tmp_cache("uninstalled");
424        let _ = std::fs::remove_file(&cache);
425        let findings = detect_upgrades(
426            vec![],
427            &[&installed],
428            &prefs,
429            &NeverProbe, // skipped before probe because !available
430            &cache,
431            1000,
432            DEFAULT_TTL_SECS,
433        )
434        .await;
435        assert!(findings.is_empty());
436        let _ = std::fs::remove_file(&cache);
437    }
438
439    #[tokio::test]
440    async fn fresh_cache_is_served_without_probing() {
441        let prefs = UpdatePreferences {
442            channel: UpdateChannel::Latest,
443            ..Default::default()
444        };
445        let installed = local_schema("qwen3-4b", true);
446        let cache = tmp_cache("fresh");
447        // Seed a fresh cache with a finding, matching the installed-set
448        // fingerprint so it isn't invalidated.
449        UpgradeCache {
450            checked_at_secs: 1000,
451            models_fingerprint: installed_fingerprint(&[&installed]),
452            upstream: vec![UpgradeFinding {
453                from_id: "qwen3-4b".into(),
454                from_name: "qwen3-4b".into(),
455                to_id: "qwen3-4b".into(),
456                to_name: "qwen3-4b".into(),
457                reason: "cached".into(),
458                trust_tier: TrustTier::Community,
459                source: UpgradeSource::Upstream,
460                target_pullable: true,
461            }],
462        }
463        .save_to(&cache)
464        .unwrap();
465        // now within TTL of checked_at ⇒ NeverProbe must not be called.
466        let findings = detect_upgrades(
467            vec![],
468            &[&installed],
469            &prefs,
470            &NeverProbe,
471            &cache,
472            1500,
473            DEFAULT_TTL_SECS,
474        )
475        .await;
476        assert_eq!(findings.len(), 1);
477        assert_eq!(findings[0].reason, "cached");
478        let _ = std::fs::remove_file(&cache);
479    }
480
481    #[tokio::test]
482    async fn fresh_cache_for_a_different_model_set_is_invalidated() {
483        // A fresh cache built for a DIFFERENT installed set must not be served;
484        // the newly installed model has to be probed.
485        let prefs = UpdatePreferences {
486            channel: UpdateChannel::Latest,
487            ..Default::default()
488        };
489        let installed = local_schema("qwen3-8b", true); // different model
490        let cache = tmp_cache("fingerprint");
491        UpgradeCache {
492            checked_at_secs: 1000,
493            models_fingerprint: "stale-different-set".into(),
494            upstream: vec![],
495        }
496        .save_to(&cache)
497        .unwrap();
498        let findings = detect_upgrades(
499            vec![],
500            &[&installed],
501            &prefs,
502            &FakeProbe { newer: true }, // must be called → fingerprint mismatch
503            &cache,
504            1500,
505            DEFAULT_TTL_SECS,
506        )
507        .await;
508        assert_eq!(findings.len(), 1, "stale-fingerprint cache must re-probe");
509        let _ = std::fs::remove_file(&cache);
510    }
511
512    #[tokio::test]
513    async fn curated_wins_over_upstream_for_same_model() {
514        let prefs = UpdatePreferences {
515            channel: UpdateChannel::Latest,
516            ..Default::default()
517        };
518        let installed = local_schema("qwen3-4b", true);
519        let cache = tmp_cache("dedup");
520        let _ = std::fs::remove_file(&cache);
521        let curated = vec![ModelUpgrade {
522            from_id: "qwen3-4b".into(),
523            from_name: "qwen3-4b".into(),
524            to_id: "qwen3-8b".into(),
525            to_name: "qwen3-8b".into(),
526            reason: "curated replacement".into(),
527            target_runtime: None,
528            target_runtime_requirement: None,
529            minimum_runtimes: vec![],
530            target_available: true,
531            target_pullable: true,
532            remove_old_supported: true,
533        }];
534        let findings = detect_upgrades(
535            curated,
536            &[&installed],
537            &prefs,
538            &FakeProbe { newer: true },
539            &cache,
540            1000,
541            DEFAULT_TTL_SECS,
542        )
543        .await;
544        // Only the curated finding for qwen3-4b; upstream for same from_id dropped.
545        assert_eq!(findings.len(), 1);
546        assert_eq!(findings[0].source, UpgradeSource::Curated);
547        let _ = std::fs::remove_file(&cache);
548    }
549}