e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
466 lines
16 KiB
Python
466 lines
16 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
"""Tests for model-update detection and the GGUF force-download helper.
|
|
|
|
Covers:
|
|
* GGUF variant listing computes update_available from the already-fetched
|
|
sibling metadata instead of a second Hub call.
|
|
* hf_hub_download_with_xet_fallback forwards force_download through the shim to the
|
|
shared unsloth_zoo helper (which owns the cache-first early-return and its bypass).
|
|
|
|
The cache "Update" action now runs through the download manager as a normal
|
|
managed download (so it shows in the Downloads panel with progress + cancel),
|
|
so the old POST /api/models/update endpoint and its tests are gone. Update
|
|
*detection* — the "Update available" cue — is still exercised here.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import types
|
|
from types import SimpleNamespace
|
|
|
|
if "structlog" not in sys.modules:
|
|
|
|
class _DummyLogger:
|
|
def __getattr__(self, _name):
|
|
return lambda *a, **k: None
|
|
|
|
sys.modules["structlog"] = types.SimpleNamespace(
|
|
BoundLogger = _DummyLogger, get_logger = lambda *a, **k: _DummyLogger()
|
|
)
|
|
|
|
import pytest
|
|
from hub.services.models import cache_inventory as CI
|
|
from hub.services.models import deletion as D
|
|
from hub.services.models import gguf_variants as GV
|
|
|
|
|
|
def _variants():
|
|
return [
|
|
SimpleNamespace(
|
|
filename = "model-Q4_K_M.gguf",
|
|
quant = "Q4_K_M",
|
|
display_label = None,
|
|
size_bytes = 1000,
|
|
),
|
|
SimpleNamespace(
|
|
filename = "model-Q8_0.gguf",
|
|
quant = "Q8_0",
|
|
display_label = None,
|
|
size_bytes = 2000,
|
|
),
|
|
]
|
|
|
|
|
|
def _seed_cache(tmp_path, repo_id, blob_ids, gguf_files):
|
|
repo = tmp_path / f"models--{repo_id.replace('/', '--')}"
|
|
snap = repo / "snapshots" / ("a" * 40)
|
|
snap.mkdir(parents = True, exist_ok = True)
|
|
for name, size in gguf_files.items():
|
|
(snap / name).write_bytes(b"\0" * size)
|
|
blobs = repo / "blobs"
|
|
blobs.mkdir(exist_ok = True)
|
|
for b in blob_ids:
|
|
(blobs / b).write_bytes(b"x")
|
|
return repo, snap, blobs
|
|
|
|
|
|
@pytest.fixture
|
|
def patch_hub_gguf(monkeypatch):
|
|
"""Patch GGUF listing and cache scans for sibling-derived update checks."""
|
|
|
|
def _sibling(
|
|
path: str,
|
|
size: int,
|
|
sha = None,
|
|
*,
|
|
lfs_dict = False,
|
|
blob_id = None,
|
|
):
|
|
if lfs_dict:
|
|
lfs = {"sha256": sha} if sha else {}
|
|
else:
|
|
lfs = SimpleNamespace(sha256 = sha) if sha else None
|
|
return SimpleNamespace(rfilename = path, size = size, lfs = lfs, blob_id = blob_id)
|
|
|
|
def _repo_info(repo_id: str, repo_path, files: list[tuple[str, str]]):
|
|
return SimpleNamespace(
|
|
repo_id = repo_id,
|
|
repo_type = "model",
|
|
repo_path = repo_path,
|
|
revisions = [
|
|
SimpleNamespace(
|
|
files = [
|
|
SimpleNamespace(
|
|
file_name = name,
|
|
blob_path = str(repo_path / "blobs" / blob),
|
|
)
|
|
for name, blob in files
|
|
]
|
|
)
|
|
],
|
|
)
|
|
|
|
def _apply(tmp_path, repo_id: str, *, local_blob: str, remote_sibling):
|
|
with GV._VARIANT_HASH_LOCK:
|
|
GV._VARIANT_HASH_CACHE.clear()
|
|
GV._VARIANT_REQUIREMENT_CACHE.clear()
|
|
GV._VARIANT_REQUIREMENT_NEG_CACHE.clear()
|
|
repo, snap, _blobs = _seed_cache(
|
|
tmp_path,
|
|
repo_id,
|
|
blob_ids = [local_blob],
|
|
gguf_files = {"model-Q4_K_M.gguf": 1000},
|
|
)
|
|
monkeypatch.setattr(
|
|
GV,
|
|
"list_gguf_variants",
|
|
lambda r, hf_token = None: (_variants(), False, [remote_sibling]),
|
|
raising = True,
|
|
)
|
|
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
|
monkeypatch.setattr(
|
|
CI,
|
|
"all_hf_cache_scans",
|
|
lambda: [
|
|
SimpleNamespace(
|
|
repos = [
|
|
_repo_info(
|
|
repo_id,
|
|
repo,
|
|
[("model-Q4_K_M.gguf", local_blob)],
|
|
)
|
|
]
|
|
)
|
|
],
|
|
)
|
|
|
|
return SimpleNamespace(apply = _apply, sibling = _sibling)
|
|
|
|
|
|
def _call(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ── GGUF variant update detection ───────────────────────────────
|
|
|
|
|
|
def test_variant_update_check_missing_remote_blob_id_is_not_phantom_update(
|
|
tmp_path, patch_hub_gguf
|
|
):
|
|
"""Missing sha/blob metadata is unknown, not update_available=True."""
|
|
repo = "unsloth/gemma-3-4b-it-GGUF"
|
|
patch_hub_gguf.apply(
|
|
tmp_path,
|
|
repo,
|
|
local_blob = "oldsha",
|
|
remote_sibling = patch_hub_gguf.sibling("model-Q4_K_M.gguf", 1000, None),
|
|
)
|
|
resp = _call(GV.get_gguf_variants_response(repo))
|
|
assert len(resp.variants) == 2
|
|
q4 = next(v for v in resp.variants if v.quant == "Q4_K_M")
|
|
assert q4.downloaded is True
|
|
assert q4.update_available is False
|
|
|
|
|
|
def test_variant_update_check_detects_update_from_existing_siblings(tmp_path, patch_hub_gguf):
|
|
repo = "unsloth/gemma-3-4b-it-GGUF"
|
|
patch_hub_gguf.apply(
|
|
tmp_path,
|
|
repo,
|
|
local_blob = "oldsha",
|
|
remote_sibling = patch_hub_gguf.sibling("model-Q4_K_M.gguf", 1000, "NEWsha"),
|
|
)
|
|
resp = _call(GV.get_gguf_variants_response(repo))
|
|
q4 = next(v for v in resp.variants if v.quant == "Q4_K_M")
|
|
assert q4.update_available is True
|
|
|
|
|
|
def test_variant_update_check_no_update_when_blob_matches(tmp_path, patch_hub_gguf):
|
|
repo = "unsloth/gemma-3-4b-it-GGUF"
|
|
patch_hub_gguf.apply(
|
|
tmp_path,
|
|
repo,
|
|
local_blob = "samesha",
|
|
remote_sibling = patch_hub_gguf.sibling("model-Q4_K_M.gguf", 1000, "samesha"),
|
|
)
|
|
resp = _call(GV.get_gguf_variants_response(repo))
|
|
q4 = next(v for v in resp.variants if v.quant == "Q4_K_M")
|
|
assert q4.update_available is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("companion_path", "has_vision"),
|
|
[
|
|
("mmproj-F16.gguf", True),
|
|
("mtp-drafter-Q8_0.gguf", False),
|
|
],
|
|
)
|
|
def test_variant_update_check_detects_companion_only_update(
|
|
monkeypatch, tmp_path, patch_hub_gguf, companion_path, has_vision
|
|
):
|
|
repo_id = "unsloth/gemma-4-GGUF"
|
|
with GV._VARIANT_HASH_LOCK:
|
|
GV._VARIANT_HASH_CACHE.clear()
|
|
GV._VARIANT_REQUIREMENT_CACHE.clear()
|
|
GV._VARIANT_REQUIREMENT_NEG_CACHE.clear()
|
|
repo, snap, _blobs = _seed_cache(
|
|
tmp_path,
|
|
repo_id,
|
|
blob_ids = ["mainsha", "old-companion"],
|
|
gguf_files = {
|
|
"model-Q4_K_M.gguf": 1000,
|
|
companion_path: 100,
|
|
},
|
|
)
|
|
siblings = [
|
|
patch_hub_gguf.sibling("model-Q4_K_M.gguf", 1000, "mainsha"),
|
|
patch_hub_gguf.sibling(companion_path, 100, "new-companion"),
|
|
]
|
|
monkeypatch.setattr(
|
|
GV,
|
|
"list_gguf_variants",
|
|
lambda r, hf_token = None: (_variants(), has_vision, siblings),
|
|
raising = True,
|
|
)
|
|
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
|
monkeypatch.setattr(
|
|
CI,
|
|
"all_hf_cache_scans",
|
|
lambda: [
|
|
SimpleNamespace(
|
|
repos = [
|
|
SimpleNamespace(
|
|
repo_id = repo_id,
|
|
repo_type = "model",
|
|
repo_path = repo,
|
|
revisions = [
|
|
SimpleNamespace(
|
|
files = [
|
|
SimpleNamespace(
|
|
file_name = "model-Q4_K_M.gguf",
|
|
blob_path = str(repo / "blobs" / "mainsha"),
|
|
),
|
|
SimpleNamespace(
|
|
file_name = companion_path,
|
|
blob_path = str(repo / "blobs" / "old-companion"),
|
|
),
|
|
]
|
|
)
|
|
],
|
|
)
|
|
]
|
|
)
|
|
],
|
|
)
|
|
|
|
resp = _call(GV.get_gguf_variants_response(repo_id))
|
|
q4 = next(v for v in resp.variants if v.quant == "Q4_K_M")
|
|
|
|
assert q4.downloaded is True
|
|
assert q4.update_available is True
|
|
|
|
|
|
def test_variant_update_check_accepts_lfs_dict_and_blob_id_fallback(tmp_path, patch_hub_gguf):
|
|
repo = "unsloth/gemma-3-4b-it-GGUF"
|
|
patch_hub_gguf.apply(
|
|
tmp_path,
|
|
repo,
|
|
local_blob = "dictsha",
|
|
remote_sibling = patch_hub_gguf.sibling(
|
|
"model-Q4_K_M.gguf",
|
|
1000,
|
|
"dictsha",
|
|
lfs_dict = True,
|
|
),
|
|
)
|
|
resp = _call(GV.get_gguf_variants_response(repo))
|
|
assert next(v for v in resp.variants if v.quant == "Q4_K_M").update_available is False
|
|
|
|
patch_hub_gguf.apply(
|
|
tmp_path,
|
|
repo,
|
|
local_blob = "blobid",
|
|
remote_sibling = patch_hub_gguf.sibling(
|
|
"model-Q4_K_M.gguf",
|
|
1000,
|
|
None,
|
|
blob_id = "blobid",
|
|
),
|
|
)
|
|
resp = _call(GV.get_gguf_variants_response(repo))
|
|
assert next(v for v in resp.variants if v.quant == "Q4_K_M").update_available is False
|
|
|
|
|
|
def test_cached_model_scan_keeps_local_safetensors_repo(monkeypatch, tmp_path):
|
|
repo_path = tmp_path / "models--Org--SafeTensorRepo"
|
|
repo = SimpleNamespace(
|
|
repo_id = "Org/SafeTensorRepo",
|
|
repo_type = "model",
|
|
repo_path = repo_path,
|
|
revisions = [
|
|
SimpleNamespace(
|
|
files = [
|
|
SimpleNamespace(
|
|
file_name = "config.json",
|
|
size_on_disk = 10,
|
|
blob_path = None,
|
|
),
|
|
SimpleNamespace(
|
|
file_name = "model.safetensors",
|
|
size_on_disk = 100,
|
|
blob_path = str(repo_path / "blobs" / "modelsha"),
|
|
),
|
|
]
|
|
)
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
CI,
|
|
"all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [repo])],
|
|
)
|
|
monkeypatch.setattr(
|
|
CI.hf_cache_scan,
|
|
"is_snapshot_partial",
|
|
lambda *args, **kwargs: False,
|
|
)
|
|
|
|
rows = CI._scan_cached_models()
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["repo_id"] == "Org/SafeTensorRepo"
|
|
assert rows[0]["model_format"] == "safetensors"
|
|
assert rows[0]["size_bytes"] == 100
|
|
|
|
|
|
# ── hf_hub_download_with_xet_fallback force_download bypass (X2/F2) ───
|
|
|
|
|
|
def test_force_download_is_forwarded_through_the_shim(monkeypatch):
|
|
"""The shim's contract is to forward force_download unchanged to the shared helper (which owns the
|
|
cache-first early-return and bypass). Verify both False and True reach it (X2/F2)."""
|
|
import utils.hf_xet_fallback as X
|
|
|
|
seen = []
|
|
|
|
def fake_shared(repo_id, filename, token, **kwargs):
|
|
seen.append(kwargs.get("force_download"))
|
|
return "/downloaded/path"
|
|
|
|
monkeypatch.setattr(X, "_shared_hf_hub_download_with_xet_fallback", fake_shared, raising = True)
|
|
|
|
X.hf_hub_download_with_xet_fallback(
|
|
"unsloth/repo", "model.gguf", token = None, force_download = False
|
|
)
|
|
X.hf_hub_download_with_xet_fallback(
|
|
"unsloth/repo", "model.gguf", token = None, force_download = True
|
|
)
|
|
assert seen == [False, True] # the shim forwards force_download to the shared helper unchanged
|
|
|
|
|
|
# ── multi-revision GGUF blob comparison and update reclaim ──
|
|
#
|
|
# Regression for the phantom "Update available" cue that lingered AFTER a model
|
|
# was already updated. A re-download leaves BOTH the old and new revision
|
|
# snapshots in the HF cache, so the same gguf file resolves to several blobs.
|
|
# The local collection must keep ALL of them (a set per file), and stale hashes
|
|
# must be pruned only after the replacement revision verifies.
|
|
|
|
|
|
def _rev(*files):
|
|
return SimpleNamespace(
|
|
files = [SimpleNamespace(file_name = name, blob_path = f"/blobs/{blob}") for name, blob in files]
|
|
)
|
|
|
|
|
|
def test_repo_gguf_blob_map_collects_all_revision_blobs():
|
|
"""Every cached revision's blob for a gguf file is kept as a set, not
|
|
collapsed to one arbitrary blob."""
|
|
repo_info = SimpleNamespace(
|
|
revisions = [
|
|
_rev(("lfm2-350m-q4_k_m.gguf", "OLDsha")),
|
|
_rev(("lfm2-350m-q4_k_m.gguf", "NEWsha")),
|
|
]
|
|
)
|
|
assert CI._repo_gguf_blob_map(repo_info) == {"lfm2-350m-q4_k_m.gguf": {"OLDsha", "NEWsha"}}
|
|
|
|
|
|
def test_reclaim_replaced_gguf_variant_prunes_old_revision_only(monkeypatch, tmp_path):
|
|
"""After a verified update, stale same-variant files/blobs are removed while
|
|
the freshly downloaded hash and sibling variants remain cached."""
|
|
repo_id = "org/repo-GGUF"
|
|
repo_path = tmp_path / "models--org--repo-GGUF"
|
|
old_snap = repo_path / "snapshots" / ("a" * 40) / "model-Q4_K_M.gguf"
|
|
new_snap = repo_path / "snapshots" / ("b" * 40) / "model-Q4_K_M.gguf"
|
|
sibling_snap = repo_path / "snapshots" / ("b" * 40) / "model-Q8_0.gguf"
|
|
old_blob = repo_path / "blobs" / "OLDsha"
|
|
new_blob = repo_path / "blobs" / "NEWsha"
|
|
sibling_blob = repo_path / "blobs" / "Q8sha"
|
|
for path, payload in (
|
|
(old_snap, b"old"),
|
|
(new_snap, b"new"),
|
|
(sibling_snap, b"sibling"),
|
|
(old_blob, b"old-blob"),
|
|
(new_blob, b"new-blob"),
|
|
(sibling_blob, b"sibling-blob"),
|
|
):
|
|
path.parent.mkdir(parents = True, exist_ok = True)
|
|
path.write_bytes(payload)
|
|
|
|
repo_info = SimpleNamespace(
|
|
repo_id = repo_id,
|
|
repo_type = "model",
|
|
repo_path = repo_path,
|
|
revisions = [
|
|
SimpleNamespace(
|
|
files = [
|
|
SimpleNamespace(
|
|
file_name = "model-Q4_K_M.gguf",
|
|
file_path = str(old_snap),
|
|
blob_path = str(old_blob),
|
|
)
|
|
]
|
|
),
|
|
SimpleNamespace(
|
|
files = [
|
|
SimpleNamespace(
|
|
file_name = "model-Q4_K_M.gguf",
|
|
file_path = str(new_snap),
|
|
blob_path = str(new_blob),
|
|
),
|
|
SimpleNamespace(
|
|
file_name = "model-Q8_0.gguf",
|
|
file_path = str(sibling_snap),
|
|
blob_path = str(sibling_blob),
|
|
),
|
|
]
|
|
),
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
CI,
|
|
"all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [repo_info])],
|
|
)
|
|
invalidated = []
|
|
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: invalidated.append(True))
|
|
|
|
result = D.reclaim_replaced_gguf_variant(repo_id, "Q4_K_M", frozenset({"NEWsha"}))
|
|
|
|
assert result["removed_snapshots"] == 1
|
|
assert result["deleted_blobs"] == 1
|
|
assert result["removed_dirs"] == 1
|
|
assert old_snap.exists() is False
|
|
assert old_snap.parent.exists() is False
|
|
assert old_blob.exists() is False
|
|
assert new_snap.exists() is True
|
|
assert new_blob.exists() is True
|
|
assert sibling_snap.exists() is True
|
|
assert sibling_blob.exists() is True
|
|
assert invalidated == [True]
|