* 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>
700 lines
22 KiB
Python
700 lines
22 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
|
|
from hub.services.models import gguf_variants as GV
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
def test_is_hidden_model_hides_validation_probe_everywhere():
|
|
"""Every picker (model list, local, cached GGUF, cached models) gates on
|
|
_is_hidden_model, so hiding the probe here hides it in the search menu too.
|
|
Cover both forms callers pass: the reconstructed repo id and the on-disk
|
|
snapshot path."""
|
|
assert models_route._is_hidden_model("ggml-org/models")
|
|
assert models_route._is_hidden_model("ggml-org/models/tinyllamas/stories260K.gguf")
|
|
assert models_route._is_hidden_model(
|
|
None, "/hf/models--ggml-org--models/snapshots/abc/tinyllamas/stories260K.gguf"
|
|
)
|
|
assert not models_route._is_hidden_model("unsloth/gemma-3-270m-it-GGUF")
|
|
# The exact-filename needle must not hide a real repo that merely
|
|
# references stories260K in its name.
|
|
assert not models_route._is_hidden_model("user/stories260K-finetune-GGUF")
|
|
|
|
|
|
def test_list_cached_gguf_hides_llama_validation_probe(monkeypatch, tmp_path):
|
|
"""The ggml-org/models / stories260K install validation probe can land in
|
|
the HF cache as a side effect of installing the prebuilt llama-server.
|
|
It is not a chat model (it sorts smallest and would be auto-selected), so
|
|
pickers must hide it while keeping real cached models."""
|
|
probe = _repo(
|
|
"ggml-org/models",
|
|
[_file("tinyllamas/stories260K.gguf", 1_000)],
|
|
tmp_path / "models--ggml-org--models",
|
|
)
|
|
real = _repo(
|
|
"unsloth/gemma-3-270m-it-GGUF",
|
|
[_file("gemma-3-270m-it-UD-Q4_K_XL.gguf", 200_000)],
|
|
tmp_path / "models--unsloth--gemma-3-270m-it-GGUF",
|
|
)
|
|
monkeypatch.setattr(
|
|
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [probe, real])]
|
|
)
|
|
|
|
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
|
|
|
repo_ids = [c["repo_id"] for c in result["cached"]]
|
|
assert "ggml-org/models" not in repo_ids
|
|
assert "unsloth/gemma-3-270m-it-GGUF" in repo_ids
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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),
|
|
"has_vision": True,
|
|
}
|
|
]
|
|
|
|
|
|
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"),
|
|
"has_vision": False,
|
|
}
|
|
]
|
|
|
|
|
|
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."""
|
|
variants = [
|
|
SimpleNamespace(
|
|
filename = "model-Q4_K_M.gguf",
|
|
quant = "Q4_K_M",
|
|
display_label = None,
|
|
size_bytes = 10_000,
|
|
),
|
|
SimpleNamespace(
|
|
filename = "model-F16.gguf",
|
|
quant = "F16",
|
|
display_label = None,
|
|
size_bytes = 20_000,
|
|
),
|
|
]
|
|
monkeypatch.setattr(
|
|
GV,
|
|
"list_gguf_variants",
|
|
lambda repo_id, hf_token = None: (variants, True, []),
|
|
)
|
|
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
|
|
|
|
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"
|
|
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
|
|
|
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_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
|
|
siblings = [
|
|
SimpleNamespace(rfilename = "model-Q4_K_M-be.gguf", size = 100),
|
|
SimpleNamespace(rfilename = "model-Q4_K_M.gguf", size = 10),
|
|
]
|
|
monkeypatch.setattr(
|
|
GV,
|
|
"list_gguf_variants",
|
|
lambda repo_id, hf_token = None: (
|
|
[
|
|
SimpleNamespace(
|
|
filename = "model-Q4_K_M.gguf",
|
|
quant = "Q4_K_M",
|
|
display_label = None,
|
|
size_bytes = 10,
|
|
)
|
|
],
|
|
False,
|
|
siblings,
|
|
),
|
|
)
|
|
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
|
|
|
|
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
|
snap.mkdir(parents = True)
|
|
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10)
|
|
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
|
|
|
result = asyncio.run(
|
|
models_route.get_gguf_variants(
|
|
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
|
|
)
|
|
)
|
|
|
|
assert [(v.quant, v.filename, v.size_bytes, v.downloaded) for v in result.variants] == [
|
|
("Q4_K_M", "model-Q4_K_M.gguf", 10, True)
|
|
]
|
|
|
|
|
|
def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, tmp_path):
|
|
variants = [
|
|
SimpleNamespace(
|
|
filename = "model-Q4_K_M.gguf",
|
|
quant = "Q4_K_M",
|
|
display_label = None,
|
|
size_bytes = 10,
|
|
),
|
|
]
|
|
monkeypatch.setattr(
|
|
GV,
|
|
"list_gguf_variants",
|
|
lambda repo_id, hf_token = None: (variants, False, []),
|
|
)
|
|
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
|
|
|
|
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
|
snap.mkdir(parents = True)
|
|
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 10)
|
|
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
|
|
|
result = asyncio.run(
|
|
models_route.get_gguf_variants(
|
|
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
|
|
)
|
|
)
|
|
|
|
assert result.variants[0].downloaded 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
|
|
|
|
|
|
def test_gguf_download_progress_excludes_big_endian_sibling(monkeypatch, tmp_path):
|
|
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 / "model-Q4_K_M-be.gguf").write_bytes(b"y" * 20_000)
|
|
|
|
result = asyncio.run(
|
|
models_route.get_gguf_download_progress(
|
|
repo_id = "org/repo",
|
|
variant = "Q4_K_M",
|
|
expected_bytes = 20_000,
|
|
current_subject = "test-user",
|
|
)
|
|
)
|
|
|
|
assert result["downloaded_bytes"] == 0
|
|
assert result["progress"] == 0
|
|
|
|
|
|
def test_gguf_download_progress_counts_quant_subdir(monkeypatch, tmp_path):
|
|
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" / "Q4_K_M"
|
|
snap.mkdir(parents = True)
|
|
(snap / "foo.gguf").write_bytes(b"x" * 20_000)
|
|
|
|
result = asyncio.run(
|
|
models_route.get_gguf_download_progress(
|
|
repo_id = "org/repo",
|
|
variant = "Q4_K_M",
|
|
expected_bytes = 20_000,
|
|
current_subject = "test-user",
|
|
)
|
|
)
|
|
|
|
assert result["downloaded_bytes"] == 20_000
|
|
assert result["progress"] == 1.0
|