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    /// Default path: `upgrade-cache.json` under the CAR state root —
99    /// `~/.car/upgrade-cache.json` unless `CAR_HOME` moves the root, in which
100    /// case the cache moves with it. The cache is keyed to the installed-model
101    /// set *this* daemon sees, so it follows the daemon's own state rather than
102    /// being shared with an unrelated install.
103    pub fn default_path() -> PathBuf {
104        car_home::root_or_relative().join("upgrade-cache.json")
105    }
106
107    pub fn load_from(path: &Path) -> Self {
108        std::fs::read_to_string(path)
109            .ok()
110            .and_then(|s| serde_json::from_str(&s).ok())
111            .unwrap_or_default()
112    }
113
114    pub fn save_to(&self, path: &Path) -> Result<(), String> {
115        if let Some(parent) = path.parent() {
116            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
117        }
118        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
119        std::fs::write(path, json).map_err(|e| e.to_string())
120    }
121
122    /// Fresh if the last check was within `ttl_secs` of `now_secs`.
123    pub fn is_fresh(&self, now_secs: u64, ttl_secs: u64) -> bool {
124        self.checked_at_secs != 0 && now_secs.saturating_sub(self.checked_at_secs) < ttl_secs
125    }
126}
127
128/// Default cache TTL: re-probe the Hub at most once a day.
129pub const DEFAULT_TTL_SECS: u64 = 24 * 60 * 60;
130
131/// Detect upgrades for `installed` models, combining curated rules with
132/// upstream discovery. Pure-ish: caller supplies the curated rules, the probe,
133/// the cache path, and `now_secs`, so it's fully testable offline.
134///
135/// - Curated findings are always included (the trusted tier).
136/// - Upstream probing runs only when `prefs.channel == Latest`, is cached for
137///   `ttl_secs`, and degrades to the cached/empty set on any probe failure.
138pub async fn detect_upgrades<P: UpstreamProbe>(
139    curated: Vec<ModelUpgrade>,
140    installed: &[&ModelSchema],
141    prefs: &UpdatePreferences,
142    probe: &P,
143    cache_path: &Path,
144    now_secs: u64,
145    ttl_secs: u64,
146) -> Vec<UpgradeFinding> {
147    let mut findings: Vec<UpgradeFinding> = curated
148        .into_iter()
149        .map(UpgradeFinding::from_curated)
150        .collect();
151
152    if prefs.channel == UpdateChannel::Latest && prefs.checks_enabled() {
153        let upstream = upstream_findings(installed, probe, cache_path, now_secs, ttl_secs).await;
154        // Dedup: a curated rule for the same from_id wins (it's verified).
155        for f in upstream {
156            if !findings.iter().any(|c| c.from_id == f.from_id) {
157                findings.push(f);
158            }
159        }
160    }
161
162    findings.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
163    findings.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
164    findings
165}
166
167/// Upstream findings, served from cache when fresh, else re-probed and cached.
168async fn upstream_findings<P: UpstreamProbe>(
169    installed: &[&ModelSchema],
170    probe: &P,
171    cache_path: &Path,
172    now_secs: u64,
173    ttl_secs: u64,
174) -> Vec<UpgradeFinding> {
175    let fingerprint = installed_fingerprint(installed);
176    let cache = UpgradeCache::load_from(cache_path);
177    // Serve the cache only when it's both fresh AND built for the same
178    // installed set — so installing a new model re-probes immediately.
179    if cache.is_fresh(now_secs, ttl_secs) && cache.models_fingerprint == fingerprint {
180        return cache.upstream;
181    }
182
183    // Probes run sequentially (one Hub request at a time) and only on a
184    // cache miss (≤ once per TTL window), so the Hub request rate is inherently
185    // bounded by the installed-model count, not the call rate.
186    let mut found = Vec::new();
187    for schema in installed {
188        // Only locally-installed models with a Hub repo can have an upstream.
189        if !schema.has_installed_weights() || repo_of(schema).is_none() {
190            continue;
191        }
192        if let Some(reason) = probe.newer_revision(schema).await {
193            found.push(UpgradeFinding {
194                from_id: schema.id.clone(),
195                from_name: schema.name.clone(),
196                // Upstream = same model line, newer revision; target is the
197                // same id (re-pull refreshes the cache to the new revision).
198                to_id: schema.id.clone(),
199                to_name: schema.name.clone(),
200                reason,
201                trust_tier: TrustTier::Community,
202                source: UpgradeSource::Upstream,
203                target_pullable: matches!(
204                    schema.source,
205                    ModelSource::Local { .. } | ModelSource::Mlx { .. }
206                ),
207            });
208        }
209    }
210
211    // Persist (best-effort; a write failure must not break detection).
212    // Empty results are cached too — a fresh empty cache suppresses re-probing.
213    let _ = UpgradeCache {
214        checked_at_secs: now_secs,
215        models_fingerprint: fingerprint,
216        upstream: found.clone(),
217    }
218    .save_to(cache_path);
219    found
220}
221
222/// Stable fingerprint of the physically installed downloadable-model set.
223/// Runtime availability is deliberately not installation evidence.
224fn installed_fingerprint(installed: &[&ModelSchema]) -> String {
225    use std::collections::hash_map::DefaultHasher;
226    use std::hash::{Hash, Hasher};
227    let mut ids: Vec<&str> = installed
228        .iter()
229        .filter(|m| m.has_installed_weights())
230        .map(|m| m.id.as_str())
231        .collect();
232    ids.sort_unstable();
233    let mut h = DefaultHasher::new();
234    ids.hash(&mut h);
235    format!("{:x}", h.finish())
236}
237
238/// The Hub repo for a model, if it has one.
239fn repo_of(schema: &ModelSchema) -> Option<&str> {
240    match &schema.source {
241        ModelSource::Local { hf_repo, .. } | ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
242        _ => None,
243    }
244}
245
246// --- real Hub probe --------------------------------------------------------
247
248/// Probes the HuggingFace Hub for a newer commit than the one cached locally.
249/// Compares the locally cached `refs/main` sha against the repo's current sha
250/// from the Hub model-info API. Fully offline-safe: any error → `None`.
251pub struct HuggingFaceProbe {
252    client: reqwest::Client,
253}
254
255impl Default for HuggingFaceProbe {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl HuggingFaceProbe {
262    /// Infallible, and offline-safe by construction. If the OS trust store
263    /// cannot be loaded the client degrades (with a warning, once) rather than
264    /// panicking, and the `Option`-returning probe methods below then report
265    /// "unknown" — the same answer they already give when the machine is
266    /// offline.
267    pub fn new() -> Self {
268        let (client, _degradation) = crate::tls_client::build_client_with_degradation(
269            &crate::tls_client::HUGGINGFACE_PROBE,
270            || reqwest::Client::builder().timeout(std::time::Duration::from_secs(8)),
271        );
272        HuggingFaceProbe { client }
273    }
274
275    async fn remote_sha(&self, repo: &str) -> Option<String> {
276        let url = format!("https://huggingface.co/api/models/{repo}");
277        let resp = self.client.get(&url).send().await.ok()?;
278        if !resp.status().is_success() {
279            return None;
280        }
281        let json: serde_json::Value = resp.json().await.ok()?;
282        json.get("sha")?.as_str().map(|s| s.to_string())
283    }
284}
285
286impl UpstreamProbe for HuggingFaceProbe {
287    async fn newer_revision(&self, schema: &ModelSchema) -> Option<String> {
288        let repo = repo_of(schema)?;
289        let local_sha = local_main_sha(repo)?; // not cached ⇒ can't compare
290        let remote_sha = self.remote_sha(repo).await?; // offline ⇒ None
291        if remote_sha != local_sha {
292            Some(format!(
293                "A newer revision of {repo} is available on Hugging Face."
294            ))
295        } else {
296            None
297        }
298    }
299}
300
301/// Read the locally cached `refs/main` sha for a Hub repo, if present.
302fn local_main_sha(repo: &str) -> Option<String> {
303    let cache_root = std::env::var("HF_HOME")
304        .map(PathBuf::from)
305        .unwrap_or_else(|_| {
306            std::env::var_os("HOME")
307                .or_else(|| std::env::var_os("USERPROFILE"))
308                .map(PathBuf::from)
309                .unwrap_or_else(|| PathBuf::from("."))
310                .join(".cache")
311                .join("huggingface")
312        })
313        .join("hub");
314    let ref_path = cache_root
315        .join(format!("models--{}", repo.replace('/', "--")))
316        .join("refs")
317        .join("main");
318    std::fs::read_to_string(ref_path)
319        .ok()
320        .map(|s| s.trim().to_string())
321        .filter(|s| !s.is_empty())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::schema::{CostModel, ModelCapability, PerformanceEnvelope};
328
329    fn local_schema(id: &str, available: bool) -> ModelSchema {
330        ModelSchema {
331            id: id.into(),
332            name: id.into(),
333            provider: "qwen".into(),
334            family: "qwen3".into(),
335            version: String::new(),
336            capabilities: vec![ModelCapability::Generate],
337            context_length: 8192,
338            max_output_tokens: None,
339            param_count: "4B".into(),
340            quantization: None,
341            performance: PerformanceEnvelope::default(),
342            cost: CostModel::default(),
343            source: ModelSource::Local {
344                hf_repo: format!("org/{id}"),
345                hf_filename: "m.gguf".into(),
346                tokenizer_repo: format!("org/{id}"),
347            },
348            tags: vec![],
349            supported_params: vec![],
350            public_benchmarks: vec![],
351            trust_tier: TrustTier::Curated,
352            deprecated: false,
353            available,
354            weights_ready: available,
355        }
356    }
357
358    struct FakeProbe {
359        newer: bool,
360    }
361    impl UpstreamProbe for FakeProbe {
362        async fn newer_revision(&self, _schema: &ModelSchema) -> Option<String> {
363            if self.newer {
364                Some("newer upstream".into())
365            } else {
366                None
367            }
368        }
369    }
370
371    /// A probe that panics if called — proves we didn't hit the network.
372    struct NeverProbe;
373    impl UpstreamProbe for NeverProbe {
374        async fn newer_revision(&self, _schema: &ModelSchema) -> Option<String> {
375            panic!("probe must not be called");
376        }
377    }
378
379    fn tmp_cache(tag: &str) -> PathBuf {
380        std::env::temp_dir().join(format!("car-upgrade-{tag}-{}.json", std::process::id()))
381    }
382
383    #[tokio::test]
384    async fn stable_channel_is_curated_only_and_never_probes() {
385        let prefs = UpdatePreferences::default(); // Stable
386        let installed = local_schema("qwen3-4b", true);
387        let cache = tmp_cache("stable");
388        let findings = detect_upgrades(
389            vec![],
390            &[&installed],
391            &prefs,
392            &NeverProbe, // would panic if probed
393            &cache,
394            1000,
395            DEFAULT_TTL_SECS,
396        )
397        .await;
398        assert!(findings.is_empty());
399        let _ = std::fs::remove_file(&cache);
400    }
401
402    #[tokio::test]
403    async fn latest_channel_adds_upstream_findings() {
404        let prefs = UpdatePreferences {
405            channel: UpdateChannel::Latest,
406            ..Default::default()
407        };
408        let installed = local_schema("qwen3-4b", true);
409        let cache = tmp_cache("latest");
410        let _ = std::fs::remove_file(&cache);
411        let findings = detect_upgrades(
412            vec![],
413            &[&installed],
414            &prefs,
415            &FakeProbe { newer: true },
416            &cache,
417            1000,
418            DEFAULT_TTL_SECS,
419        )
420        .await;
421        assert_eq!(findings.len(), 1);
422        assert_eq!(findings[0].source, UpgradeSource::Upstream);
423        assert_eq!(findings[0].trust_tier, TrustTier::Community);
424        let _ = std::fs::remove_file(&cache);
425    }
426
427    #[tokio::test]
428    async fn uninstalled_models_are_not_probed() {
429        let prefs = UpdatePreferences {
430            channel: UpdateChannel::Latest,
431            ..Default::default()
432        };
433        let installed = local_schema("qwen3-4b", false); // not installed
434        let cache = tmp_cache("uninstalled");
435        let _ = std::fs::remove_file(&cache);
436        let findings = detect_upgrades(
437            vec![],
438            &[&installed],
439            &prefs,
440            &NeverProbe, // skipped before probe because !available
441            &cache,
442            1000,
443            DEFAULT_TTL_SECS,
444        )
445        .await;
446        assert!(findings.is_empty());
447        let _ = std::fs::remove_file(&cache);
448    }
449
450    #[tokio::test]
451    async fn lazy_available_model_without_weights_is_not_probed_as_installed() {
452        let prefs = UpdatePreferences {
453            channel: UpdateChannel::Latest,
454            ..Default::default()
455        };
456        let mut model = local_schema("qwen3-4b", false);
457        model.available = true;
458        model.weights_ready = false;
459        let cache = tmp_cache("lazy-available");
460        let _ = std::fs::remove_file(&cache);
461
462        let findings = detect_upgrades(
463            vec![],
464            &[&model],
465            &prefs,
466            &NeverProbe,
467            &cache,
468            1000,
469            DEFAULT_TTL_SECS,
470        )
471        .await;
472
473        assert!(findings.is_empty());
474        let _ = std::fs::remove_file(&cache);
475    }
476
477    #[tokio::test]
478    async fn fresh_cache_is_served_without_probing() {
479        let prefs = UpdatePreferences {
480            channel: UpdateChannel::Latest,
481            ..Default::default()
482        };
483        let installed = local_schema("qwen3-4b", true);
484        let cache = tmp_cache("fresh");
485        // Seed a fresh cache with a finding, matching the installed-set
486        // fingerprint so it isn't invalidated.
487        UpgradeCache {
488            checked_at_secs: 1000,
489            models_fingerprint: installed_fingerprint(&[&installed]),
490            upstream: vec![UpgradeFinding {
491                from_id: "qwen3-4b".into(),
492                from_name: "qwen3-4b".into(),
493                to_id: "qwen3-4b".into(),
494                to_name: "qwen3-4b".into(),
495                reason: "cached".into(),
496                trust_tier: TrustTier::Community,
497                source: UpgradeSource::Upstream,
498                target_pullable: true,
499            }],
500        }
501        .save_to(&cache)
502        .unwrap();
503        // now within TTL of checked_at ⇒ NeverProbe must not be called.
504        let findings = detect_upgrades(
505            vec![],
506            &[&installed],
507            &prefs,
508            &NeverProbe,
509            &cache,
510            1500,
511            DEFAULT_TTL_SECS,
512        )
513        .await;
514        assert_eq!(findings.len(), 1);
515        assert_eq!(findings[0].reason, "cached");
516        let _ = std::fs::remove_file(&cache);
517    }
518
519    #[tokio::test]
520    async fn fresh_cache_for_a_different_model_set_is_invalidated() {
521        // A fresh cache built for a DIFFERENT installed set must not be served;
522        // the newly installed model has to be probed.
523        let prefs = UpdatePreferences {
524            channel: UpdateChannel::Latest,
525            ..Default::default()
526        };
527        let installed = local_schema("qwen3-8b", true); // different model
528        let cache = tmp_cache("fingerprint");
529        UpgradeCache {
530            checked_at_secs: 1000,
531            models_fingerprint: "stale-different-set".into(),
532            upstream: vec![],
533        }
534        .save_to(&cache)
535        .unwrap();
536        let findings = detect_upgrades(
537            vec![],
538            &[&installed],
539            &prefs,
540            &FakeProbe { newer: true }, // must be called → fingerprint mismatch
541            &cache,
542            1500,
543            DEFAULT_TTL_SECS,
544        )
545        .await;
546        assert_eq!(findings.len(), 1, "stale-fingerprint cache must re-probe");
547        let _ = std::fs::remove_file(&cache);
548    }
549
550    #[tokio::test]
551    async fn curated_wins_over_upstream_for_same_model() {
552        let prefs = UpdatePreferences {
553            channel: UpdateChannel::Latest,
554            ..Default::default()
555        };
556        let installed = local_schema("qwen3-4b", true);
557        let cache = tmp_cache("dedup");
558        let _ = std::fs::remove_file(&cache);
559        let curated = vec![ModelUpgrade {
560            from_id: "qwen3-4b".into(),
561            from_name: "qwen3-4b".into(),
562            to_id: "qwen3-8b".into(),
563            to_name: "qwen3-8b".into(),
564            reason: "curated replacement".into(),
565            target_runtime: None,
566            target_runtime_requirement: None,
567            minimum_runtimes: vec![],
568            target_available: true,
569            target_pullable: true,
570            remove_old_supported: true,
571        }];
572        let findings = detect_upgrades(
573            curated,
574            &[&installed],
575            &prefs,
576            &FakeProbe { newer: true },
577            &cache,
578            1000,
579            DEFAULT_TTL_SECS,
580        )
581        .await;
582        // Only the curated finding for qwen3-4b; upstream for same from_id dropped.
583        assert_eq!(findings.len(), 1);
584        assert_eq!(findings[0].source, UpgradeSource::Curated);
585        let _ = std::fs::remove_file(&cache);
586    }
587
588    #[tokio::test]
589    async fn probe_construction_degrades_instead_of_panicking() {
590        use crate::tls_client::test_seam::{TrustStoreScope, UNPARSEABLE_CERT_PEM};
591        use crate::tls_client::{TrustFallback, HUGGINGFACE_PROBE, REMOTE_BACKEND};
592
593        let scope = TrustStoreScope::acquire_async(UNPARSEABLE_CERT_PEM).await;
594        scope.assert_breaks_client_construction();
595        crate::tls_client::reset_sites_for_test();
596
597        // Construction only. `remote_sha` hardcodes `https://huggingface.co/...`
598        // with no endpoint parameter and no mock seam, so any assertion about
599        // what the probe *returns* would either hit the real network or assert
600        // nothing at all.
601        let _probe = HuggingFaceProbe::new();
602        let _defaulted = HuggingFaceProbe::default();
603
604        // The record is the assertion that matters: a degraded probe's "None"
605        // is indistinguishable from an ordinary offline "None", which is
606        // exactly the masking this guards against.
607        let record = crate::tls_client::last_degradation(&HUGGINGFACE_PROBE)
608            .expect("a degraded probe must leave a readable record");
609        assert_eq!(record.fallback, TrustFallback::PublicCaOnly);
610        assert!(!record.source.is_empty());
611        assert!(
612            crate::tls_client::last_degradation(&REMOTE_BACKEND).is_none(),
613            "records are per site — this one must not be attributed elsewhere"
614        );
615    }
616}