unsloth/studio/backend/tests/test_model_update_robustness.py
Anish Umale d0f8d40c36
studio: allow updating HF models through UI (#5388)
* add models for /update endpoint

* add logic for identifying out of date hf models

* add endpoint for updating hf models

* add relevant field to GgufVariantDetail

* make exception handling better

* add update_available flag for cached_models, and moved /update endpoint from inference -> models

* hook up /update endpoint on the frontend

* implement update scenarios for the model picker

* fix bug where downloaded flag for an older revision was being wrongly set to false

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix import and make hf calls async

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* remove has_vision from UpdateRequest

* fix ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* clear cancel event before updating gguf variant

* set _cancel_event back if it was set initially

* add hf_token to get_paths_info

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: harden model update endpoint and update checks

- update_hf_model: pass snapshot_download local_dir (local_path is not a
  valid kwarg and 500s when updating bicodec audio models)
- get_gguf_variants: wrap the remote update check so a network, rate-limit,
  gated, or offline failure degrades to "no update info" instead of failing
  the whole variant listing, matching list_cached_models
- add regression tests for both paths

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: HF model update detection and Update action for cached models

Surface an "Update available" cue and a managed Update action for cached
on-device models. /api/hub/update-status compares each cached main GGUF
file's local blobs against the remote main revision using set membership
across all cached revisions, so a repo that was already updated (and still
holds the old snapshot alongside the new one) is not falsely flagged.

The Update action re-downloads through the download manager so it shows in
the Downloads panel with progress and cancel. The frontend wires the Update
button into the GGUF, on-device, and model-selector cards and keeps the
quant label fully visible when the action buttons crowd the row.

Adds regression tests for the multi-revision update check.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: accept force_download kwarg in hf_xet_fallback test double

The download seam now passes force_download to the attempt callable; the _FakeAttempt mock did not accept it, failing 6 tests with TypeError. Add the keyword (default False) so the scripted-results double matches the seam.

* Fix Studio model update regressions

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address Studio update review feedback

* Address Studio update edge cases

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Share GGUF update status helper

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix GGUF update detection and cache cleanup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix cached GGUF update badges

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-07-01 01:54:57 +03:00

483 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(force_download=True) bypasses the
try_to_load_from_cache cache-first early-return.
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_bypasses_cache_first_early_return(monkeypatch):
"""force_download=True skips the try_to_load_from_cache early-return and
proceeds to the real download path; force_download=False returns the cached
path without ever attempting a download (X2/F2)."""
import huggingface_hub as hf
import utils.hf_xet_fallback as X
cached_path = "/cache/blob/cached.gguf"
# Pretend the blob IS cached on disk (try_to_load_from_cache is imported
# inside the function from huggingface_hub, and os.path.exists must agree).
monkeypatch.setattr(hf, "try_to_load_from_cache", lambda *a, **k: cached_path, raising = False)
monkeypatch.setattr(X.os.path, "exists", lambda p: True, raising = False)
attempts = []
def fake_attempt(repo_id, filename, token, **kwargs):
attempts.append(
{"repo_id": repo_id, "filename": filename, "force": kwargs.get("force_download")}
)
return ("ok", "/freshly/downloaded/path")
monkeypatch.setattr(X, "_run_download_attempt", fake_attempt, raising = True)
# force_download=False: cache-first early-return, no download attempt.
out = X.hf_hub_download_with_xet_fallback(
"unsloth/repo", "model.gguf", token = None, force_download = False
)
assert out == cached_path
assert attempts == [] # never reached the real download
# force_download=True: bypass the early-return, run the real download.
out2 = X.hf_hub_download_with_xet_fallback(
"unsloth/repo", "model.gguf", token = None, force_download = True
)
assert out2 == "/freshly/downloaded/path"
assert len(attempts) == 1
assert attempts[0]["force"] is True
# ── 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]