Studio: fix Downloaded model list disappearing and order it by last download (#6247)
* 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>
This commit is contained in:
parent
cd8e6c1e77
commit
3427e3fd62
6 changed files with 380 additions and 57 deletions
|
|
@ -142,13 +142,18 @@ def _compute_all_hf_cache_scans() -> list:
|
|||
logger.warning("Could not scan active HF cache: %s", exc)
|
||||
|
||||
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
|
||||
extra = extra_fn()
|
||||
if extra.is_dir() and str(extra.resolve()) not in seen:
|
||||
seen.add(str(extra.resolve()))
|
||||
try:
|
||||
scans.append(scan_cache_dir(cache_dir = str(extra)))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan HF cache %s: %s", extra, exc)
|
||||
try:
|
||||
extra = extra_fn()
|
||||
# is_dir()/resolve() can raise on an inaccessible path; skip it.
|
||||
if not extra.is_dir():
|
||||
continue
|
||||
resolved = str(extra.resolve())
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
scans.append(scan_cache_dir(cache_dir = str(extra)))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
|
||||
return scans
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ def _is_hidden_model(*values: str | None) -> bool:
|
|||
return any(v and any(n in v.lower() for n in needles) for v in values)
|
||||
|
||||
|
||||
def _safe_resolve(path: Path) -> Optional[str]:
|
||||
"""resolve() to a string, or None when the path is inaccessible."""
|
||||
try:
|
||||
return str(path.resolve())
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
if str(backend_path) not in sys.path:
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
|
@ -676,9 +684,9 @@ async def list_local_models(
|
|||
# trusted Path objects are used for FS access; the user string is
|
||||
# used for matching only, never for path construction.
|
||||
allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir]
|
||||
if legacy_hf.is_dir():
|
||||
if _safe_is_dir(legacy_hf):
|
||||
allowed_roots.append(legacy_hf)
|
||||
if hf_default.is_dir():
|
||||
if _safe_is_dir(hf_default):
|
||||
allowed_roots.append(hf_default)
|
||||
try:
|
||||
from utils.paths import studio_root, outputs_root
|
||||
|
|
@ -702,15 +710,20 @@ async def list_local_models(
|
|||
try:
|
||||
local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
|
||||
|
||||
# Resolve once; an inaccessible aux cache must skip that scan, not 500.
|
||||
hf_cache_real = _safe_resolve(hf_cache_dir)
|
||||
legacy_real = _safe_resolve(legacy_hf)
|
||||
default_real = _safe_resolve(hf_default)
|
||||
|
||||
# Scan legacy Unsloth HF cache for backward compatibility.
|
||||
if legacy_hf.is_dir() and legacy_hf.resolve() != hf_cache_dir.resolve():
|
||||
if _safe_is_dir(legacy_hf) and legacy_real != hf_cache_real:
|
||||
local_models += _scan_hf_cache(legacy_hf)
|
||||
|
||||
# Scan HF system default cache (may differ under env overrides).
|
||||
if (
|
||||
hf_default.is_dir()
|
||||
and hf_default.resolve() != hf_cache_dir.resolve()
|
||||
and hf_default.resolve() != legacy_hf.resolve()
|
||||
_safe_is_dir(hf_default)
|
||||
and default_real != hf_cache_real
|
||||
and default_real != legacy_real
|
||||
):
|
||||
local_models += _scan_hf_cache(hf_default)
|
||||
|
||||
|
|
@ -2069,13 +2082,10 @@ async def get_gguf_variants(
|
|||
best = _pick_best_gguf(filenames)
|
||||
default_variant = _extract_quant_label(best) if best else None
|
||||
|
||||
# Which variants are fully downloaded in the HF cache. For split
|
||||
# GGUFs ALL shards must be present, so sum cached bytes per variant
|
||||
# vs. the expected total. Cache dir casing may differ from the
|
||||
# canonical repo_id, so match case-insensitively.
|
||||
cached_bytes_by_quant: dict[str, int] = {}
|
||||
# Per-snapshot so a split GGUF's shards must all sit in one snapshot;
|
||||
# mmproj adapters are excluded so they can't inflate a quant's bytes.
|
||||
cached_bytes_by_quant_per_snapshot: list[dict[str, int]] = []
|
||||
try:
|
||||
import re as _re
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
|
|
@ -2088,21 +2098,31 @@ async def get_gguf_variants(
|
|||
snapshots = entry / "snapshots"
|
||||
if snapshots.is_dir():
|
||||
for snap in snapshots.iterdir():
|
||||
by_quant: dict[str, int] = {}
|
||||
for f in _iter_gguf_paths(snap):
|
||||
q = _extract_quant_label(f.name)
|
||||
cached_bytes_by_quant[q] = (
|
||||
cached_bytes_by_quant.get(q, 0) + f.stat().st_size
|
||||
)
|
||||
if _is_mmproj_filename(f.name):
|
||||
continue
|
||||
try:
|
||||
size = f.stat().st_size
|
||||
except OSError:
|
||||
continue # broken symlink / unreadable: skip
|
||||
q = _extract_quant_label(f.name).lower()
|
||||
by_quant[q] = by_quant.get(q, 0) + size
|
||||
if by_quant:
|
||||
cached_bytes_by_quant_per_snapshot.append(by_quant)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_fully_downloaded(variant) -> bool:
|
||||
cached = cached_bytes_by_quant.get(variant.quant, 0)
|
||||
if cached == 0 or variant.size_bytes == 0:
|
||||
if variant.size_bytes == 0:
|
||||
return False
|
||||
# Rounding tolerance (symlinks vs real sizes).
|
||||
return cached >= variant.size_bytes * 0.99
|
||||
# Complete within one snapshot (tolerance for symlink size jitter).
|
||||
quant = variant.quant.lower()
|
||||
return any(
|
||||
by_quant.get(quant, 0) >= variant.size_bytes * 0.99
|
||||
for by_quant in cached_bytes_by_quant_per_snapshot
|
||||
)
|
||||
|
||||
return GgufVariantsResponse(
|
||||
repo_id = repo_id,
|
||||
|
|
@ -2157,16 +2177,26 @@ async def get_gguf_download_progress(
|
|||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() == target:
|
||||
# Completed .gguf files for this variant in snapshots.
|
||||
# Exclude mmproj so a vision adapter can't satisfy a same-label
|
||||
# main variant (e.g. mmproj-F16 vs an F16 weight).
|
||||
for f in _iter_gguf_paths(entry):
|
||||
if _is_mmproj_filename(f.name):
|
||||
continue
|
||||
fname = f.name.lower().replace("-", "").replace("_", "")
|
||||
if not variant_lower or variant_lower in fname:
|
||||
downloaded_bytes += f.stat().st_size
|
||||
try:
|
||||
downloaded_bytes += f.stat().st_size
|
||||
except OSError:
|
||||
continue # broken symlink / unreadable: skip
|
||||
# In-progress (.incomplete) downloads in blobs.
|
||||
blobs_dir = entry / "blobs"
|
||||
if blobs_dir.is_dir():
|
||||
for f in blobs_dir.iterdir():
|
||||
if f.is_file() and f.name.endswith(".incomplete"):
|
||||
in_progress_bytes += f.stat().st_size
|
||||
try:
|
||||
in_progress_bytes += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
break
|
||||
|
||||
total_progress_bytes = downloaded_bytes + in_progress_bytes
|
||||
|
|
@ -2300,11 +2330,22 @@ def _get_repo_size_cached(repo_id: str) -> int:
|
|||
|
||||
|
||||
def _all_hf_cache_scans():
|
||||
"""scan_cache_dir results for the active, legacy, and default HF caches."""
|
||||
"""scan_cache_dir for the active, legacy, and default HF caches.
|
||||
|
||||
Each probe is isolated: an unreadable auxiliary cache (permission denied,
|
||||
broken symlink, OS-redirected ~/.cache) is skipped, not fatal, so the
|
||||
Downloaded list never blanks out and downloads never leak into Recommended.
|
||||
"""
|
||||
from huggingface_hub import scan_cache_dir
|
||||
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
|
||||
|
||||
scans = [scan_cache_dir()]
|
||||
scans = []
|
||||
# Guard the active cache too: degrade to "no downloads" instead of raising.
|
||||
try:
|
||||
scans.append(scan_cache_dir())
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan active HF cache: %s", exc)
|
||||
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
# Resolve the active cache dir for dedup.
|
||||
|
|
@ -2314,13 +2355,18 @@ def _all_hf_cache_scans():
|
|||
pass
|
||||
|
||||
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
|
||||
extra = extra_fn()
|
||||
if extra.is_dir() and str(extra.resolve()) not in seen:
|
||||
seen.add(str(extra.resolve()))
|
||||
try:
|
||||
scans.append(scan_cache_dir(cache_dir = str(extra)))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan HF cache %s: %s", extra, exc)
|
||||
try:
|
||||
extra = extra_fn()
|
||||
# is_dir()/resolve() can raise on an inaccessible path; skip it.
|
||||
if not extra.is_dir():
|
||||
continue
|
||||
resolved = str(extra.resolve())
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
scans.append(scan_cache_dir(cache_dir = str(extra)))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
|
||||
return scans
|
||||
|
||||
|
||||
|
|
@ -2379,6 +2425,38 @@ def _repo_has_gguf_files(repo_info) -> bool:
|
|||
return _repo_gguf_size_bytes(repo_info) > 0
|
||||
|
||||
|
||||
def _blob_mtime(f) -> float:
|
||||
"""Blob modification time in epoch seconds (0.0 if unknown).
|
||||
|
||||
Prefers HF metadata ``blob_last_modified``, falls back to stat(); uses
|
||||
only mtimes (portable across Windows, macOS, Linux), never path parsing.
|
||||
"""
|
||||
ts = getattr(f, "blob_last_modified", None)
|
||||
if isinstance(ts, (int, float)) and ts > 0:
|
||||
return float(ts)
|
||||
blob_path = getattr(f, "blob_path", None)
|
||||
if blob_path:
|
||||
try:
|
||||
return float(Path(blob_path).stat().st_mtime)
|
||||
except OSError:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _repo_gguf_last_modified(repo_info) -> float:
|
||||
"""Newest mtime among a repo's primary (non-mmproj) GGUF blobs.
|
||||
|
||||
Drives the Downloaded list's "last downloaded" ordering and groups a
|
||||
multi-quant repo by its most recently downloaded quant.
|
||||
"""
|
||||
latest = 0.0
|
||||
for revision in repo_info.revisions:
|
||||
for f in revision.files:
|
||||
if _is_main_gguf_filename(f.file_name):
|
||||
latest = max(latest, _blob_mtime(f))
|
||||
return latest
|
||||
|
||||
|
||||
@router.get("/cached-gguf")
|
||||
async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
|
||||
"""List GGUF repos downloaded to HF cache, legacy Unsloth cache, and HF default cache."""
|
||||
|
|
@ -2399,17 +2477,30 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
|
|||
continue
|
||||
key = repo_id.lower()
|
||||
existing = seen_lower.get(key)
|
||||
last_modified = _repo_gguf_last_modified(repo_info)
|
||||
if existing is None or total_size > existing["size_bytes"]:
|
||||
seen_lower[key] = {
|
||||
row = {
|
||||
"repo_id": repo_id,
|
||||
"size_bytes": total_size,
|
||||
"cache_path": str(repo_info.repo_path),
|
||||
}
|
||||
# Keep the newest timestamp across duplicate caches;
|
||||
# attach only when known so absent rows sort as oldest.
|
||||
lm = max(last_modified, (existing or {}).get("last_modified", 0.0))
|
||||
if lm > 0:
|
||||
row["last_modified"] = lm
|
||||
seen_lower[key] = row
|
||||
elif last_modified > existing.get("last_modified", 0.0):
|
||||
existing["last_modified"] = last_modified
|
||||
except Exception as e:
|
||||
repo_label = getattr(repo_info, "repo_id", "<unknown>")
|
||||
logger.warning(f"Skipping cached GGUF repo {repo_label}: {e}")
|
||||
continue
|
||||
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
|
||||
# Newest download first; stable repo_id tie-break for equal/missing mtimes.
|
||||
cached = sorted(
|
||||
seen_lower.values(),
|
||||
key = lambda c: (-(c.get("last_modified") or 0.0), c["repo_id"].lower()),
|
||||
)
|
||||
return {"cached": cached}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing cached GGUF repos: {e}", exc_info = True)
|
||||
|
|
@ -2447,18 +2538,39 @@ async def list_cached_models(current_subject: str = Depends(get_current_subject)
|
|||
)
|
||||
if not has_weights:
|
||||
continue
|
||||
last_modified = max(
|
||||
(
|
||||
_blob_mtime(f)
|
||||
for rev in repo_info.revisions
|
||||
for f in rev.files
|
||||
if f.file_name.endswith(_WEIGHT_EXTENSIONS)
|
||||
),
|
||||
default = 0.0,
|
||||
)
|
||||
key = repo_id.lower()
|
||||
existing = seen_lower.get(key)
|
||||
if existing is None or total_size > existing["size_bytes"]:
|
||||
seen_lower[key] = {
|
||||
row = {
|
||||
"repo_id": repo_id,
|
||||
"size_bytes": total_size,
|
||||
}
|
||||
# Keep the newest timestamp across duplicate caches;
|
||||
# attach only when known so absent rows sort as oldest.
|
||||
lm = max(last_modified, (existing or {}).get("last_modified", 0.0))
|
||||
if lm > 0:
|
||||
row["last_modified"] = lm
|
||||
seen_lower[key] = row
|
||||
elif last_modified > existing.get("last_modified", 0.0):
|
||||
existing["last_modified"] = last_modified
|
||||
except Exception as e:
|
||||
repo_label = getattr(repo_info, "repo_id", "<unknown>")
|
||||
logger.warning(f"Skipping cached model repo {repo_label}: {e}")
|
||||
continue
|
||||
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
|
||||
# Newest download first; stable repo_id tie-break for equal/missing mtimes.
|
||||
cached = sorted(
|
||||
seen_lower.values(),
|
||||
key = lambda c: (-(c.get("last_modified") or 0.0), c["repo_id"].lower()),
|
||||
)
|
||||
return {"cached": cached}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing cached models: {e}", exc_info = True)
|
||||
|
|
|
|||
|
|
@ -371,3 +371,156 @@ def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeyp
|
|||
"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
|
||||
|
|
|
|||
|
|
@ -1371,12 +1371,11 @@ def _iter_hf_cache_snapshots(repo_id: str):
|
|||
return
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
if not cache_dir.is_dir():
|
||||
return
|
||||
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
repo_dir: Optional[Path] = None
|
||||
try:
|
||||
if not cache_dir.is_dir():
|
||||
return
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.is_dir() and entry.name.lower() == target:
|
||||
repo_dir = entry
|
||||
|
|
@ -1387,10 +1386,9 @@ def _iter_hf_cache_snapshots(repo_id: str):
|
|||
return
|
||||
|
||||
snapshots = repo_dir / "snapshots"
|
||||
if not snapshots.is_dir():
|
||||
return
|
||||
|
||||
try:
|
||||
if not snapshots.is_dir():
|
||||
return
|
||||
snap_dirs = [s for s in snapshots.iterdir() if s.is_dir()]
|
||||
except OSError:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ function dedupe(values: string[]): string[] {
|
|||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
/** Newest-first by `last_modified` (epoch s), repo_id tie-break. Copies the
|
||||
* input; treats a missing field as oldest for older-backend compatibility. */
|
||||
function sortByDownloadRecency<T extends { repo_id: string; last_modified?: number }>(
|
||||
rows: T[],
|
||||
): T[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const at = a.last_modified ?? -1;
|
||||
const bt = b.last_modified ?? -1;
|
||||
if (at !== bt) return bt - at;
|
||||
return a.repo_id.localeCompare(b.repo_id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lowercase and strip separators for fuzzy search. */
|
||||
function normalizeForSearch(s: string): string {
|
||||
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
|
||||
|
|
@ -735,6 +748,33 @@ export function HubModelPicker({
|
|||
|
||||
const showHfSection = debouncedQuery.trim().length > 0;
|
||||
|
||||
// Newest-first (also covers older backends without `last_modified`).
|
||||
const sortedCachedGguf = useMemo(
|
||||
() => sortByDownloadRecency(cachedGguf),
|
||||
[cachedGguf],
|
||||
);
|
||||
const sortedCachedModels = useMemo(
|
||||
() => sortByDownloadRecency(cachedModels),
|
||||
[cachedModels],
|
||||
);
|
||||
|
||||
// While searching, filter Downloaded by the query instead of hiding it, so a
|
||||
// downloaded model the user is searching for stays visible.
|
||||
const visibleCachedGguf = useMemo(() => {
|
||||
if (!showHfSection) return sortedCachedGguf;
|
||||
const q = normalizeForSearch(debouncedQuery.trim());
|
||||
return sortedCachedGguf.filter((c) => normalizeForSearch(c.repo_id).includes(q));
|
||||
}, [sortedCachedGguf, showHfSection, debouncedQuery]);
|
||||
const visibleCachedModels = useMemo(() => {
|
||||
if (!showHfSection) return sortedCachedModels;
|
||||
const q = normalizeForSearch(debouncedQuery.trim());
|
||||
return sortedCachedModels.filter((c) => normalizeForSearch(c.repo_id).includes(q));
|
||||
}, [sortedCachedModels, showHfSection, debouncedQuery]);
|
||||
|
||||
// Non-GGUF cached rows are not shown in chat-only mode, so the empty-state
|
||||
// logic must use this (not visibleCachedModels) or the picker can go blank.
|
||||
const visibleCachedModelRows = chatOnly ? [] : visibleCachedModels;
|
||||
|
||||
// Recommended models that match the current search query
|
||||
const filteredRecommendedIds = useMemo(() => {
|
||||
if (!showHfSection) return [];
|
||||
|
|
@ -765,9 +805,11 @@ export function HubModelPicker({
|
|||
return results
|
||||
.map((result) => result.id)
|
||||
.filter((id) => !recommendedSet.has(id))
|
||||
// Shown under Downloaded (kept visible while searching); no duplicate.
|
||||
.filter((id) => !downloadedSet.has(id.toLowerCase()))
|
||||
.filter((id) => !chatOnly || isKnownGgufRepo(id))
|
||||
.filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id));
|
||||
}, [recommendedSet, results, showHfSection, chatOnly, isKnownGgufRepo]);
|
||||
}, [recommendedSet, downloadedSet, results, showHfSection, chatOnly, isKnownGgufRepo]);
|
||||
|
||||
const metricsById = useMemo(
|
||||
() =>
|
||||
|
|
@ -897,23 +939,28 @@ export function HubModelPicker({
|
|||
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
|
||||
<div className="py-1">
|
||||
{!cachedReady && !showHfSection ? (
|
||||
{/* First-load spinner only when nothing cached is shown yet. */}
|
||||
{!cachedReady &&
|
||||
!showHfSection &&
|
||||
visibleCachedGguf.length === 0 &&
|
||||
visibleCachedModelRows.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-5 py-3">
|
||||
<Spinner className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Loading models…
|
||||
</span>
|
||||
</div>
|
||||
) : !showHfSection &&
|
||||
(cachedGguf.length > 0 ||
|
||||
(!chatOnly && cachedModels.length > 0)) ? (
|
||||
) : null}
|
||||
|
||||
{/* Downloaded stays visible (filtered) while searching. */}
|
||||
{visibleCachedGguf.length > 0 || visibleCachedModelRows.length > 0 ? (
|
||||
<>
|
||||
<ListLabel
|
||||
icon={<HugeiconsIcon icon={Download01Icon} className="size-3" />}
|
||||
collapsed={downloadedCollapsed}
|
||||
onToggle={() => setDownloadedCollapsed((v) => !v)}
|
||||
>Downloaded</ListLabel>
|
||||
{!downloadedCollapsed && cachedGguf.map((c) => (
|
||||
{!downloadedCollapsed && visibleCachedGguf.map((c) => (
|
||||
<div key={c.repo_id}>
|
||||
<ModelRow
|
||||
label={c.repo_id}
|
||||
|
|
@ -942,8 +989,8 @@ export function HubModelPicker({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{!downloadedCollapsed && !chatOnly &&
|
||||
cachedModels.map((c) => (
|
||||
{!downloadedCollapsed &&
|
||||
visibleCachedModelRows.map((c) => (
|
||||
<div key={c.repo_id} className="flex items-center gap-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ModelRow
|
||||
|
|
@ -1340,7 +1387,9 @@ export function HubModelPicker({
|
|||
<ListLabel>Hugging Face</ListLabel>
|
||||
)}
|
||||
{hfIds.length === 0 && !isLoading ? (
|
||||
filteredRecommendedIds.length === 0 ? (
|
||||
filteredRecommendedIds.length === 0 &&
|
||||
visibleCachedGguf.length === 0 &&
|
||||
visibleCachedModelRows.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No matching models.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ export interface CachedGgufRepo {
|
|||
repo_id: string;
|
||||
size_bytes: number;
|
||||
cache_path: string;
|
||||
/** Epoch seconds of the newest downloaded quant; sorts Downloaded
|
||||
* newest-first. Optional for older-backend compatibility. */
|
||||
last_modified?: number;
|
||||
}
|
||||
|
||||
export async function getGgufDownloadProgress(
|
||||
|
|
@ -241,6 +244,9 @@ export async function listCachedGguf(): Promise<CachedGgufRepo[]> {
|
|||
export interface CachedModelRepo {
|
||||
repo_id: string;
|
||||
size_bytes: number;
|
||||
/** Epoch seconds of the newest downloaded weight file; sorts Downloaded
|
||||
* newest-first. Optional for older-backend compatibility. */
|
||||
last_modified?: number;
|
||||
}
|
||||
|
||||
export async function listCachedModels(): Promise<CachedModelRepo[]> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue