* Studio: fix Downloaded model list disappearing and order it by last download The chat model picker scan for cached GGUF and safetensors models aborted whenever an auxiliary Hugging Face cache dir (such as ~/.cache/huggingface/hub) was unreadable, returning an empty list. That hid the Downloaded section and let already downloaded models appear under Recommended. Isolate each cache probe so an inaccessible directory is skipped instead of failing the scan. Also order Downloaded newest-first using cached blob mtimes (multi-quant repos group by their most recent quant), keep the section visible while searching, and make the per-quant downloaded check per-snapshot and mmproj aware so a Recommended quant is never falsely marked downloaded. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: harden gguf-variants scan and dedupe by newest timestamp Guard f.stat() per file so a broken symlink or unreadable file in a snapshot no longer aborts the downloaded check early, and match quant labels case-insensitively. When the same repo is present in multiple caches with equal size, keep the newest last_modified so Downloaded ordering reflects the most recent copy. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: apply cache-scan guards to sibling endpoints found in review Extend the inaccessible-cache guard and mmproj/stat hardening to the parallel HF cache code paths flagged in review: - list_local_models and the Hub inventory scan now skip an unreadable auxiliary cache instead of returning 500. - The GGUF download-progress endpoint excludes mmproj adapters and guards f.stat() so one bad file does not zero a repo's progress. - The offline snapshot scanner guards its is_dir() probes. - The chat-only picker no longer renders a blank list when a search matches only cached non-GGUF models. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: danielhanchen <michaelhan2050@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
526 lines
16 KiB
Python
526 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
|
|
|
|
import asyncio
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
# Keep this test runnable without optional logging deps.
|
|
if "structlog" not in sys.modules:
|
|
|
|
class _DummyLogger:
|
|
def __getattr__(self, _name):
|
|
return lambda *args, **kwargs: None
|
|
|
|
sys.modules["structlog"] = types.SimpleNamespace(
|
|
BoundLogger = _DummyLogger,
|
|
get_logger = lambda *args, **kwargs: _DummyLogger(),
|
|
)
|
|
|
|
import routes.models as models_route
|
|
|
|
|
|
def _repo(
|
|
repo_id: str,
|
|
files: list[SimpleNamespace],
|
|
repo_path: Path,
|
|
*,
|
|
revisions: list[SimpleNamespace] | None = None,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
repo_id = repo_id,
|
|
repo_type = "model",
|
|
repo_path = repo_path,
|
|
revisions = revisions or [SimpleNamespace(files = files)],
|
|
)
|
|
|
|
|
|
def _file(
|
|
name: str,
|
|
size_on_disk: int,
|
|
*,
|
|
blob_path: str | None = None,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
file_name = name,
|
|
size_on_disk = size_on_disk,
|
|
blob_path = blob_path,
|
|
)
|
|
|
|
|
|
def test_iter_gguf_paths_matches_extension_case_insensitively(tmp_path):
|
|
nested = tmp_path / "snapshots" / "rev"
|
|
nested.mkdir(parents = True)
|
|
lower = nested / "Q4_K_M.gguf"
|
|
upper = nested / "Q8_0.GGUF"
|
|
other = nested / "README.md"
|
|
lower.write_text("a")
|
|
upper.write_text("b")
|
|
other.write_text("c")
|
|
|
|
result = sorted(path.name for path in models_route._iter_gguf_paths(tmp_path))
|
|
|
|
assert result == ["Q4_K_M.gguf", "Q8_0.GGUF"]
|
|
|
|
|
|
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monkeypatch, tmp_path):
|
|
repo = _repo(
|
|
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
|
[_file("Q4_K_M.gguf", 5_000), _file("README.md", 10)],
|
|
tmp_path / "models--HauhauCS--Gemma",
|
|
)
|
|
scan = SimpleNamespace(repos = [repo])
|
|
|
|
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(repo.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_path):
|
|
repo = _repo(
|
|
"Org/Model-Without-Suffix",
|
|
[_file("Q8_0.GGUF", 7_000)],
|
|
tmp_path / "models--Org--Model-Without-Suffix",
|
|
)
|
|
scan = SimpleNamespace(repos = [repo])
|
|
|
|
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/Model-Without-Suffix",
|
|
"size_bytes": 7_000,
|
|
"cache_path": str(repo.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_skips_repos_without_positive_gguf_size(monkeypatch, tmp_path):
|
|
missing = _repo(
|
|
"Org/ReadmeOnly",
|
|
[_file("README.md", 10)],
|
|
tmp_path / "models--Org--ReadmeOnly",
|
|
)
|
|
zero = _repo(
|
|
"Org/ZeroSize",
|
|
[_file("Q4_K_M.gguf", 0)],
|
|
tmp_path / "models--Org--ZeroSize",
|
|
)
|
|
scan = SimpleNamespace(repos = [missing, zero])
|
|
|
|
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == []
|
|
|
|
|
|
def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(monkeypatch, tmp_path):
|
|
smaller = _repo(
|
|
"Org/Dupe",
|
|
[_file("Q4_K_M.gguf", 2_000)],
|
|
tmp_path / "models--Org--Dupe-a",
|
|
)
|
|
larger = _repo(
|
|
"org/dupe",
|
|
[_file("Q4_K_M.gguf", 5_000), _file("Q6_K.gguf", 1_000)],
|
|
tmp_path / "models--Org--Dupe-b",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [
|
|
SimpleNamespace(repos = [smaller]),
|
|
SimpleNamespace(repos = [larger]),
|
|
],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "org/dupe",
|
|
"size_bytes": 6_000,
|
|
"cache_path": str(larger.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp_path):
|
|
shared = "blobs/shared-q4"
|
|
repo = _repo(
|
|
"Org/SharedBlobRepo",
|
|
[],
|
|
tmp_path / "models--Org--SharedBlobRepo",
|
|
revisions = [
|
|
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
|
|
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
|
|
],
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [repo])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/SharedBlobRepo",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(repo.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_models_skips_non_suffix_repo_when_gguf_files_exist(monkeypatch, tmp_path):
|
|
mixed = _repo(
|
|
"Org/MixedRepo",
|
|
[
|
|
_file("Q4_K_M.gguf", 5_000),
|
|
_file("model.safetensors", 10_000),
|
|
],
|
|
tmp_path / "models--Org--MixedRepo",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [mixed])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == []
|
|
|
|
|
|
def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypatch, tmp_path):
|
|
"""Mixed repo still surfaces in cached-gguf as a GGUF download."""
|
|
mixed = _repo(
|
|
"Org/MixedRepo",
|
|
[
|
|
_file("Q4_K_M.gguf", 5_000),
|
|
_file("model.safetensors", 10_000),
|
|
],
|
|
tmp_path / "models--Org--MixedRepo",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [mixed])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/MixedRepo",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(mixed.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
|
|
"""``size_on_disk = None`` (partial download) is treated as zero, not a
|
|
TypeError from ``sum()`` that wipes the response."""
|
|
partial = _repo(
|
|
"Org/PartialDownload",
|
|
[_file("Q4_K_M.gguf", None), _file("Q6_K.gguf", 5_000)],
|
|
tmp_path / "models--Org--PartialDownload",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [partial])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/PartialDownload",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(partial.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypatch, tmp_path):
|
|
"""One repo raising during classification must not poison the response."""
|
|
|
|
class _ExplodingRepo:
|
|
repo_id = "Org/Broken"
|
|
repo_type = "model"
|
|
repo_path = tmp_path / "models--Org--Broken"
|
|
|
|
@property
|
|
def revisions(self):
|
|
raise RuntimeError("boom")
|
|
|
|
healthy = _repo(
|
|
"Org/Healthy",
|
|
[_file("Q4_K_M.gguf", 5_000)],
|
|
tmp_path / "models--Org--Healthy",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [_ExplodingRepo(), healthy])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/Healthy",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(healthy.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_skips_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
|
|
"""A repo whose only ``.gguf`` is an mmproj vision adapter is not a GGUF
|
|
repo: mmproj is filtered out, leaving zero variants."""
|
|
mmproj_only = _repo(
|
|
"Org/MmprojOnly",
|
|
[
|
|
_file("mmproj-Q8_0.gguf", 5_000),
|
|
_file("model.safetensors", 10_000),
|
|
],
|
|
tmp_path / "models--Org--MmprojOnly",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [mmproj_only])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == []
|
|
|
|
|
|
def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
|
|
"""A safetensors repo with an auxiliary mmproj adapter still surfaces in
|
|
cached-models as a normal model."""
|
|
mmproj_aux = _repo(
|
|
"Org/MmprojAux",
|
|
[
|
|
_file("mmproj-Q8_0.gguf", 5_000),
|
|
_file("model.safetensors", 10_000),
|
|
],
|
|
tmp_path / "models--Org--MmprojAux",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [mmproj_aux])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [{"repo_id": "Org/MmprojAux", "size_bytes": 15_000}]
|
|
|
|
|
|
def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeypatch, tmp_path):
|
|
"""A vision GGUF repo (main weight + mmproj) is a GGUF repo; reported size
|
|
is the main weight only, since mmproj is filtered at classification."""
|
|
vision_repo = _repo(
|
|
"Org/VisionGguf",
|
|
[
|
|
_file("Q4_K_M.gguf", 5_000),
|
|
_file("mmproj-Q8_0.gguf", 1_000),
|
|
],
|
|
tmp_path / "models--Org--VisionGguf",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [vision_repo])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/VisionGguf",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(vision_repo.repo_path),
|
|
}
|
|
]
|
|
|
|
|
|
def _gfile(name: str, size: int, mtime: float) -> SimpleNamespace:
|
|
"""A cached file carrying a Hugging Face ``blob_last_modified`` timestamp."""
|
|
return SimpleNamespace(
|
|
file_name = name,
|
|
size_on_disk = size,
|
|
blob_path = None,
|
|
blob_last_modified = mtime,
|
|
)
|
|
|
|
|
|
def test_all_hf_cache_scans_survives_inaccessible_aux_cache(monkeypatch, tmp_path):
|
|
"""An unreadable auxiliary cache (e.g. an inaccessible
|
|
``~/.cache/huggingface/hub``) must be skipped, not abort the scan.
|
|
Regression guard for ``extra.is_dir()`` raising and wiping the response.
|
|
"""
|
|
import huggingface_hub
|
|
import utils.paths as paths_mod
|
|
|
|
active = SimpleNamespace(
|
|
repos = [_repo("Org/Active", [_file("Q4_K_M.gguf", 5_000)], tmp_path / "active")]
|
|
)
|
|
|
|
def _fake_scan(cache_dir = None):
|
|
if cache_dir is None:
|
|
return active
|
|
raise AssertionError("auxiliary scan should have been skipped")
|
|
|
|
class _Boom:
|
|
def is_dir(self):
|
|
raise PermissionError(13, "Permission denied")
|
|
|
|
def resolve(self):
|
|
raise PermissionError(13, "Permission denied")
|
|
|
|
monkeypatch.setattr(huggingface_hub, "scan_cache_dir", _fake_scan)
|
|
monkeypatch.setattr(paths_mod, "legacy_hf_cache_dir", lambda: _Boom())
|
|
monkeypatch.setattr(paths_mod, "hf_default_cache_dir", lambda: _Boom())
|
|
|
|
scans = models_route._all_hf_cache_scans()
|
|
assert scans == [active]
|
|
|
|
# End-to-end: the endpoint still returns the active cache's repo.
|
|
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [active])
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
assert result["cached"] == [
|
|
{
|
|
"repo_id": "Org/Active",
|
|
"size_bytes": 5_000,
|
|
"cache_path": str(tmp_path / "active"),
|
|
}
|
|
]
|
|
|
|
|
|
def test_list_cached_gguf_sorts_newest_first_grouping_by_latest_quant(monkeypatch, tmp_path):
|
|
"""Downloaded is ordered newest-first, and a multi-quant repo is placed by
|
|
its most recently downloaded quant (``last_modified`` = newest quant)."""
|
|
older = _repo(
|
|
"Org/Older",
|
|
[_gfile("Older-Q4_K_M.gguf", 5_000, 1_000.0)],
|
|
tmp_path / "models--Org--Older",
|
|
)
|
|
newer = _repo(
|
|
"Org/Newer",
|
|
[
|
|
_gfile("Newer-Q4_K_M.gguf", 5_000, 2_000.0),
|
|
_gfile("Newer-Q8_0.gguf", 9_000, 3_000.0), # newest quant in the repo
|
|
],
|
|
tmp_path / "models--Org--Newer",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda: [SimpleNamespace(repos = [older, newer])],
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
assert [c["repo_id"] for c in result["cached"]] == ["Org/Newer", "Org/Older"]
|
|
assert result["cached"][0]["last_modified"] == 3_000.0
|
|
assert result["cached"][1]["last_modified"] == 1_000.0
|
|
|
|
|
|
def test_list_cached_gguf_dedupe_keeps_newest_timestamp(monkeypatch, tmp_path):
|
|
"""Same repo in two caches with equal size keeps the newest last_modified,
|
|
regardless of scan order."""
|
|
older = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 1_000.0)], tmp_path / "a")
|
|
newer = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 9_000.0)], tmp_path / "b")
|
|
for scans in ([older, newer], [newer, older]): # both orders
|
|
monkeypatch.setattr(
|
|
models_route,
|
|
"_all_hf_cache_scans",
|
|
lambda s = scans: [SimpleNamespace(repos = [s[0]]), SimpleNamespace(repos = [s[1]])],
|
|
)
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "t"))
|
|
assert len(result["cached"]) == 1
|
|
assert result["cached"][0]["last_modified"] == 9_000.0
|
|
|
|
|
|
def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_path):
|
|
"""The per-quant 'downloaded' flag is driven by the real weight file in a
|
|
single snapshot; an mmproj vision adapter (matching a quant label) must
|
|
not make that quant appear downloaded."""
|
|
import huggingface_hub.constants as hf_constants
|
|
|
|
variants = [
|
|
SimpleNamespace(filename = "model-Q4_K_M.gguf", quant = "Q4_K_M", size_bytes = 10_000),
|
|
SimpleNamespace(filename = "model-F16.gguf", quant = "F16", size_bytes = 20_000),
|
|
]
|
|
monkeypatch.setattr(
|
|
models_route, "list_gguf_variants", lambda repo_id, hf_token = None: (variants, True)
|
|
)
|
|
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
|
|
|
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
|
snap.mkdir(parents = True)
|
|
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10_000) # real weight, fully present
|
|
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # mmproj adapter, label "F16"
|
|
|
|
result = asyncio.run(
|
|
models_route.get_gguf_variants(
|
|
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
|
|
)
|
|
)
|
|
|
|
flags = {v.quant: v.downloaded for v in result.variants}
|
|
assert flags["Q4_K_M"] is True
|
|
assert flags["F16"] is False
|
|
|
|
|
|
def test_gguf_download_progress_excludes_mmproj(monkeypatch, tmp_path):
|
|
"""A cached mmproj adapter must not count toward a same-label main
|
|
variant's download progress (mmproj-F16 vs an F16 weight)."""
|
|
import huggingface_hub.constants as hf_constants
|
|
|
|
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
|
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
|
snap.mkdir(parents = True)
|
|
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # only the adapter on disk
|
|
|
|
result = asyncio.run(
|
|
models_route.get_gguf_download_progress(
|
|
repo_id = "org/repo",
|
|
variant = "F16",
|
|
expected_bytes = 20_000,
|
|
current_subject = "test-user",
|
|
)
|
|
)
|
|
|
|
assert result["downloaded_bytes"] == 0
|
|
assert result["progress"] == 0
|