1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum UpgradeSource {
34 Curated,
36 Upstream,
38}
39
40#[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 pub reason: String,
50 pub trust_tier: TrustTier,
52 pub source: UpgradeSource,
53 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
72pub trait UpstreamProbe {
76 fn newer_revision(&self, schema: &ModelSchema) -> impl Future<Output = Option<String>> + Send;
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct UpgradeCache {
85 #[serde(default)]
87 pub checked_at_secs: u64,
88 #[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 {
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 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
128pub const DEFAULT_TTL_SECS: u64 = 24 * 60 * 60;
130
131pub 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 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
167async 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 if cache.is_fresh(now_secs, ttl_secs) && cache.models_fingerprint == fingerprint {
180 return cache.upstream;
181 }
182
183 let mut found = Vec::new();
187 for schema in installed {
188 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 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 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
222fn 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
238fn 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
246pub 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 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)?; let remote_sha = self.remote_sha(repo).await?; 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
301fn 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 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(); 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, &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); 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, &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 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 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 let prefs = UpdatePreferences {
524 channel: UpdateChannel::Latest,
525 ..Default::default()
526 };
527 let installed = local_schema("qwen3-8b", true); 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 }, &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 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 let _probe = HuggingFaceProbe::new();
602 let _defaulted = HuggingFaceProbe::default();
603
604 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}