mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-09 15:58:41 +00:00
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>
This commit is contained in:
parent
2246a6c9ae
commit
d0f8d40c36
24 changed files with 1797 additions and 296 deletions
|
|
@ -3697,12 +3697,22 @@ class LlamaCppBackend:
|
|||
hf_repo: str,
|
||||
hf_variant: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
force: bool = False,
|
||||
allow_smaller_fallback: bool = True,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
"""Download GGUF file(s) from HuggingFace. Returns local path.
|
||||
|
||||
Runs WITHOUT self._lock so unload_model() can set _cancel_event at
|
||||
any time; checks it between each shard download.
|
||||
|
||||
``force`` re-fetches even when a (possibly stale) blob is cached.
|
||||
``allow_smaller_fallback=False`` raises on low disk instead of silently
|
||||
switching to a smaller quant. ``cancel_event`` overrides
|
||||
``self._cancel_event`` so an update can use a private event without
|
||||
touching the shared one; defaults to the shared event.
|
||||
"""
|
||||
cancel_event = cancel_event if cancel_event is not None else self._cancel_event
|
||||
try:
|
||||
import huggingface_hub # noqa: F401 -- presence check only
|
||||
except ImportError:
|
||||
|
|
@ -3768,21 +3778,22 @@ class LlamaCppBackend:
|
|||
# cold whenever free disk is below the full weight footprint,
|
||||
# even though nothing needs downloading.
|
||||
already_cached_bytes = 0
|
||||
for p in path_infos:
|
||||
if not p.size:
|
||||
continue
|
||||
try:
|
||||
cached_path = try_to_load_from_cache(hf_repo, p.path)
|
||||
except Exception:
|
||||
cached_path = None
|
||||
if isinstance(cached_path, str) and os.path.exists(cached_path):
|
||||
if not force:
|
||||
for p in path_infos:
|
||||
if not p.size:
|
||||
continue
|
||||
try:
|
||||
on_disk = os.path.getsize(cached_path)
|
||||
except OSError:
|
||||
on_disk = 0
|
||||
# Satisfied only when the full blob is present.
|
||||
if on_disk >= p.size:
|
||||
already_cached_bytes += p.size
|
||||
cached_path = try_to_load_from_cache(hf_repo, p.path)
|
||||
except Exception:
|
||||
cached_path = None
|
||||
if isinstance(cached_path, str) and os.path.exists(cached_path):
|
||||
try:
|
||||
on_disk = os.path.getsize(cached_path)
|
||||
except OSError:
|
||||
on_disk = 0
|
||||
# Satisfied only when the full blob is present.
|
||||
if on_disk >= p.size:
|
||||
already_cached_bytes += p.size
|
||||
|
||||
total_download_bytes = max(0, total_bytes - already_cached_bytes)
|
||||
|
||||
|
|
@ -3805,6 +3816,13 @@ class LlamaCppBackend:
|
|||
)
|
||||
|
||||
if total_download_bytes > free_bytes:
|
||||
if not allow_smaller_fallback:
|
||||
# Update path: never silently switch to a smaller quant;
|
||||
# surface the disk shortfall for the requested variant.
|
||||
raise RuntimeError(
|
||||
f"Not enough disk space to download {gguf_filename}. "
|
||||
f"Only {free_gb:.1f} GB free in {cache_dir}"
|
||||
)
|
||||
smaller = self._find_smallest_fitting_variant(
|
||||
hf_repo,
|
||||
free_bytes,
|
||||
|
|
@ -3845,7 +3863,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
logger.info(f"Resolving GGUF: {gguf_label}")
|
||||
try:
|
||||
if self._cancel_event.is_set():
|
||||
if cancel_event.is_set():
|
||||
raise RuntimeError("Cancelled")
|
||||
dl_start = time.monotonic()
|
||||
# Xet primary, HTTP fallback on stall; per-file so finished shards stay cached.
|
||||
|
|
@ -3853,18 +3871,20 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
gguf_filename,
|
||||
hf_token,
|
||||
cancel_event = self._cancel_event,
|
||||
cancel_event = cancel_event,
|
||||
on_status = lambda m: logger.info(m),
|
||||
force_download = force,
|
||||
)
|
||||
for shard in gguf_extra_shards:
|
||||
if self._cancel_event.is_set():
|
||||
if cancel_event.is_set():
|
||||
raise RuntimeError("Cancelled")
|
||||
logger.info(f"Resolving GGUF shard: {shard}")
|
||||
hf_hub_download_with_xet_fallback(
|
||||
hf_repo,
|
||||
shard,
|
||||
hf_token,
|
||||
cancel_event = self._cancel_event,
|
||||
cancel_event = cancel_event,
|
||||
force_download = force,
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, RuntimeError) and "Cancelled" in str(e):
|
||||
|
|
@ -3887,6 +3907,7 @@ class LlamaCppBackend:
|
|||
hf_token: Optional[str],
|
||||
pick: Callable[[list[str]], Optional[str]],
|
||||
label: str,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve and fetch a companion GGUF (mmproj / MTP drafter) by name.
|
||||
|
||||
|
|
@ -3894,8 +3915,10 @@ class LlamaCppBackend:
|
|||
(offline, same fallback as _download_gguf), then hf_hub_download.
|
||||
Runs WITHOUT self._lock (like _download_gguf); honors _cancel_event so
|
||||
an /unload between the main download and here skips the fetch.
|
||||
``cancel_event`` overrides ``self._cancel_event`` (defaults to it).
|
||||
"""
|
||||
if self._cancel_event.is_set():
|
||||
cancel_event = cancel_event if cancel_event is not None else self._cancel_event
|
||||
if cancel_event.is_set():
|
||||
return None
|
||||
|
||||
target: Optional[str] = None
|
||||
|
|
@ -3904,7 +3927,7 @@ class LlamaCppBackend:
|
|||
# Retry a transient listing blip; permanent repo/auth errors and offline
|
||||
# mode are not retried (offline raises at once -> fall through to cache).
|
||||
for attempt in range(3):
|
||||
if self._cancel_event.is_set():
|
||||
if cancel_event.is_set():
|
||||
return None
|
||||
try:
|
||||
target = pick(list_repo_files(hf_repo, token = hf_token))
|
||||
|
|
@ -3923,7 +3946,7 @@ class LlamaCppBackend:
|
|||
f"Could not list repo files for {label} (attempt {attempt + 1}/3): {e}"
|
||||
)
|
||||
if attempt < 2:
|
||||
self._cancel_event.wait(2**attempt)
|
||||
cancel_event.wait(2**attempt)
|
||||
|
||||
if target is None:
|
||||
try:
|
||||
|
|
@ -3937,7 +3960,7 @@ class LlamaCppBackend:
|
|||
except Exception as e:
|
||||
logger.debug(f"Offline cache lookup for {label} failed: {e}")
|
||||
|
||||
if target is None or self._cancel_event.is_set():
|
||||
if target is None or cancel_event.is_set():
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -3947,7 +3970,7 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
target,
|
||||
hf_token,
|
||||
cancel_event = self._cancel_event,
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not download {label}: {e}")
|
||||
|
|
@ -3958,11 +3981,13 @@ class LlamaCppBackend:
|
|||
*,
|
||||
hf_repo: str,
|
||||
hf_token: Optional[str] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> Optional[str]:
|
||||
"""Download the mmproj (vision projection) file from a GGUF repo.
|
||||
|
||||
Prefers mmproj-F16.gguf, else any mmproj*.gguf. Returns the local
|
||||
path, or None if none exists.
|
||||
path, or None if none exists. ``cancel_event`` overrides
|
||||
``self._cancel_event`` (defaults to it).
|
||||
"""
|
||||
|
||||
def _pick_mmproj(candidates: list[str]) -> Optional[str]:
|
||||
|
|
@ -3983,6 +4008,7 @@ class LlamaCppBackend:
|
|||
hf_token = hf_token,
|
||||
pick = _pick_mmproj,
|
||||
label = "mmproj",
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
|
||||
def _download_mtp(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ class GgufVariantDetail(BaseModel):
|
|||
downloaded: bool = Field(
|
||||
False, description = "Whether this variant is already in the local HF cache"
|
||||
)
|
||||
update_available: bool = Field(
|
||||
False, description = "Whether a newer main GGUF blob is available on Hugging Face"
|
||||
)
|
||||
partial: bool = Field(
|
||||
False,
|
||||
description = "Whether this variant has an in-progress (.incomplete) blob in cache",
|
||||
|
|
|
|||
|
|
@ -39,8 +39,10 @@ from hub.services.models.common import (
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_repo_size_cache: "OrderedDict[tuple[str, str], tuple[int, frozenset[str], float]]" = OrderedDict()
|
||||
_repo_size_neg_cache: "OrderedDict[tuple[str, str], float]" = OrderedDict()
|
||||
_repo_size_cache: "OrderedDict[tuple[str, str, str], tuple[int, frozenset[str], float]]" = (
|
||||
OrderedDict()
|
||||
)
|
||||
_repo_size_neg_cache: "OrderedDict[tuple[str, str, str], float]" = OrderedDict()
|
||||
_REPO_SIZE_CACHE_MAX = 256
|
||||
_REPO_SIZE_POS_TTL = 60.0
|
||||
_REPO_SIZE_NEG_TTL = 60.0
|
||||
|
|
@ -52,7 +54,7 @@ def get_repo_snapshot_metadata_cached(
|
|||
repo_id: str, hf_token: Optional[str] = None
|
||||
) -> tuple[int, frozenset[str]]:
|
||||
token_fp = hf_cache_scan.token_fingerprint(hf_token)
|
||||
cache_key = (repo_id, token_fp)
|
||||
cache_key = (repo_id, token_fp, "snapshot")
|
||||
with _repo_size_cache_lock:
|
||||
cached = _repo_size_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
|
|
@ -119,6 +121,52 @@ def _repo_has_gguf_files(repo_info) -> bool:
|
|||
return _repo_gguf_size_bytes(repo_info) > 0
|
||||
|
||||
|
||||
def _cached_repo_file_name(file_obj) -> str:
|
||||
file_path = getattr(file_obj, "file_path", None)
|
||||
if file_path:
|
||||
try:
|
||||
path = Path(file_path)
|
||||
parts = path.parts
|
||||
snapshots_idx = max(i for i, part in enumerate(parts) if part == "snapshots")
|
||||
if len(parts) > snapshots_idx + 2:
|
||||
return Path(*parts[snapshots_idx + 2 :]).as_posix()
|
||||
except Exception:
|
||||
pass
|
||||
return str(getattr(file_obj, "file_name", "")).replace("\\", "/")
|
||||
|
||||
|
||||
def _repo_gguf_blob_map(repo_info, *, include_companions: bool = False) -> dict[str, set[str]]:
|
||||
"""Map each cached GGUF file's repo-relative name to the SET of its local
|
||||
blob hashes across all cached revisions.
|
||||
|
||||
HF names each local cache blob FILE by the file's etag (lfs.sha256 else
|
||||
blob_id), so a local file's blob hash == ``Path(blob_path).name``. An updated
|
||||
repo keeps BOTH the old and new revision snapshots until HF garbage-collects
|
||||
them, so the same file resolves to several blobs; collecting them ALL (not
|
||||
just the first one seen, since ``repo_info.revisions`` is a frozenset and
|
||||
yields them in arbitrary order) lets the remote-vs-local diff treat the file
|
||||
as current when the remote (``main``) blob is present in any cached revision.
|
||||
Mirrors the ``cached_blob_ids`` membership test in routes/models.py.
|
||||
|
||||
By default this keeps the historical MAIN-GGUF-only behavior. GGUF update
|
||||
checks opt into companions so a shared mmproj/MTP blob can be compared too.
|
||||
"""
|
||||
blob_map: dict[str, set[str]] = {}
|
||||
for revision in repo_info.revisions:
|
||||
for f in revision.files:
|
||||
if include_companions:
|
||||
if not _is_gguf_filename(f.file_name):
|
||||
continue
|
||||
elif not _is_main_gguf_filename(f.file_name):
|
||||
continue
|
||||
blob_path = getattr(f, "blob_path", None)
|
||||
if not blob_path:
|
||||
continue
|
||||
name = _cached_repo_file_name(f)
|
||||
blob_map.setdefault(name, set()).add(Path(blob_path).name)
|
||||
return blob_map
|
||||
|
||||
|
||||
def _prefer_cache_row(candidate: dict, existing: Optional[dict]) -> bool:
|
||||
if existing is None:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -153,6 +153,30 @@ def _remove_empty_variant_dirs(target_repos: list, variant: str) -> tuple[int, l
|
|||
return removed, failures
|
||||
|
||||
|
||||
def _remove_empty_snapshot_dirs(target_repos: list) -> tuple[int, list[str]]:
|
||||
removed = 0
|
||||
failures: list[str] = []
|
||||
for target_repo in target_repos:
|
||||
repo_path = getattr(target_repo, "repo_path", None)
|
||||
if not repo_path:
|
||||
continue
|
||||
snapshots = Path(repo_path) / "snapshots"
|
||||
if not snapshots.is_dir():
|
||||
continue
|
||||
try:
|
||||
snap_dirs = [s for s in snapshots.iterdir() if s.is_dir() and not s.is_symlink()]
|
||||
except OSError:
|
||||
continue
|
||||
for snap in snap_dirs:
|
||||
try:
|
||||
snap.rmdir()
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
if e.errno != errno.ENOTEMPTY:
|
||||
failures.append(f"{snap.name}: {e}")
|
||||
return removed, failures
|
||||
|
||||
|
||||
def _delete_gguf_variant_from_repos(
|
||||
repo_id: str,
|
||||
variant: str,
|
||||
|
|
@ -255,6 +279,9 @@ def _delete_gguf_variant_from_repos(
|
|||
state_purged = download_manifest.purge_state("model", repo_id, variant)
|
||||
# Reclaim the empty quant folder so it stops 404ing on delete.
|
||||
removed_dirs, dir_failures = _remove_empty_variant_dirs(target_repos, variant)
|
||||
removed_snap_dirs, snap_dir_failures = _remove_empty_snapshot_dirs(target_repos)
|
||||
removed_dirs += removed_snap_dirs
|
||||
dir_failures.extend(snap_dir_failures)
|
||||
if dir_failures:
|
||||
raise HTTPException(
|
||||
status_code = 409,
|
||||
|
|
@ -284,6 +311,181 @@ def _delete_gguf_variant_from_repos(
|
|||
return {"status": "deleted", "repo_id": repo_id, "variant": variant}
|
||||
|
||||
|
||||
def reclaim_replaced_gguf_variant(
|
||||
repo_id: str,
|
||||
variant: str,
|
||||
keep_main_hashes: frozenset[str],
|
||||
hf_token: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Prune stale main-GGUF files for a variant after a replacement verified.
|
||||
|
||||
This is intentionally narrower than user-driven delete: it removes only
|
||||
same-variant main files whose local blob hash is not in *keep_main_hashes*,
|
||||
then unlinks their blobs only if no remaining snapshot references them.
|
||||
Shared companions and sibling variants are left intact.
|
||||
"""
|
||||
if not keep_main_hashes:
|
||||
logger.info(
|
||||
"Skipping stale GGUF reclaim for %s [%s]: current main hashes unresolved",
|
||||
repo_id,
|
||||
variant,
|
||||
)
|
||||
return {
|
||||
"status": "skipped",
|
||||
"repo_id": repo_id,
|
||||
"variant": variant,
|
||||
"reason": "unresolved_hashes",
|
||||
}
|
||||
if not _is_valid_repo_id(repo_id) or not _is_valid_gguf_variant(variant):
|
||||
return {
|
||||
"status": "skipped",
|
||||
"repo_id": repo_id,
|
||||
"variant": variant,
|
||||
"reason": "invalid_target",
|
||||
}
|
||||
|
||||
failures: list[str] = []
|
||||
removed_snapshots = 0
|
||||
deleted_blobs = 0
|
||||
deleted_bytes = 0
|
||||
variant_key = variant.lower()
|
||||
|
||||
try:
|
||||
cache_scans = cache_inventory.all_hf_cache_scans()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Skipping stale GGUF reclaim for %s [%s]: cache scan failed: %s",
|
||||
repo_id,
|
||||
variant,
|
||||
download_registry.scrub_secrets(str(e), hf_token = hf_token),
|
||||
)
|
||||
return {
|
||||
"status": "skipped",
|
||||
"repo_id": repo_id,
|
||||
"variant": variant,
|
||||
"reason": "scan_failed",
|
||||
}
|
||||
|
||||
candidate_repos = [
|
||||
repo_info
|
||||
for hf_cache in cache_scans
|
||||
for repo_info in hf_cache.repos
|
||||
if str(getattr(repo_info, "repo_type", "")) == "model"
|
||||
and str(getattr(repo_info, "repo_id", "")).lower() == repo_id.lower()
|
||||
]
|
||||
try:
|
||||
matched_repo_ids = resolve_destructive_repo_ids(
|
||||
repo_id,
|
||||
[str(getattr(repo_info, "repo_id", "")) for repo_info in candidate_repos],
|
||||
noun = "models",
|
||||
)
|
||||
except HTTPException as e:
|
||||
detail = getattr(e, "detail", str(e))
|
||||
logger.warning(
|
||||
"Skipping stale GGUF reclaim for %s [%s]: %s",
|
||||
repo_id,
|
||||
variant,
|
||||
download_registry.scrub_secrets(str(detail), hf_token = hf_token),
|
||||
)
|
||||
return {
|
||||
"status": "skipped",
|
||||
"repo_id": repo_id,
|
||||
"variant": variant,
|
||||
"reason": "ambiguous_repo",
|
||||
}
|
||||
target_repos = [
|
||||
repo_info
|
||||
for repo_info in candidate_repos
|
||||
if str(getattr(repo_info, "repo_id", "")) in matched_repo_ids
|
||||
]
|
||||
|
||||
for target_repo in target_repos:
|
||||
repo_dir = Path(target_repo.repo_path) if getattr(target_repo, "repo_path", None) else None
|
||||
stale_matches: list[tuple[Path, Optional[Path], str]] = []
|
||||
matches = _repo_file_matches(
|
||||
target_repo,
|
||||
lambda name: _is_main_gguf_filename(name)
|
||||
and extract_quant_label(name).lower() == variant_key,
|
||||
)
|
||||
for snap, blob, name in matches:
|
||||
blob_hash = _blob_hash_from_path(blob) if blob is not None else None
|
||||
if blob_hash is None or blob_hash in keep_main_hashes:
|
||||
continue
|
||||
stale_matches.append((snap, blob, name))
|
||||
|
||||
if not stale_matches:
|
||||
continue
|
||||
|
||||
for snap, _blob, name in stale_matches:
|
||||
try:
|
||||
if _path_exists_or_symlink(snap):
|
||||
snap.unlink()
|
||||
removed_snapshots += 1
|
||||
except OSError as e:
|
||||
failures.append(f"{name}: {e}")
|
||||
|
||||
ref_counts = _snapshot_blob_reference_counts(repo_dir)
|
||||
seen_blobs: set[Path] = set()
|
||||
for _snap, blob, name in stale_matches:
|
||||
if blob is None:
|
||||
continue
|
||||
try:
|
||||
blob_key = blob.resolve()
|
||||
except OSError:
|
||||
blob_key = blob
|
||||
if blob_key in seen_blobs:
|
||||
continue
|
||||
seen_blobs.add(blob_key)
|
||||
if ref_counts.get(blob_key, 0) > 0:
|
||||
continue
|
||||
try:
|
||||
if blob.exists():
|
||||
deleted_bytes += blob.stat().st_size
|
||||
blob.unlink()
|
||||
deleted_blobs += 1
|
||||
except OSError as e:
|
||||
failures.append(f"{name}: {e}")
|
||||
|
||||
removed_dirs = 0
|
||||
dir_failures: list[str] = []
|
||||
if target_repos:
|
||||
removed_dirs, dir_failures = _remove_empty_variant_dirs(target_repos, variant)
|
||||
removed_snap_dirs, snap_dir_failures = _remove_empty_snapshot_dirs(target_repos)
|
||||
removed_dirs += removed_snap_dirs
|
||||
dir_failures.extend(snap_dir_failures)
|
||||
failures.extend(dir_failures)
|
||||
|
||||
if failures:
|
||||
logger.warning(
|
||||
"Stale GGUF reclaim for %s [%s] left %d failure(s): %s",
|
||||
repo_id,
|
||||
variant,
|
||||
len(failures),
|
||||
"; ".join(failures[:3]),
|
||||
)
|
||||
|
||||
if removed_snapshots or deleted_blobs or removed_dirs:
|
||||
cache_inventory.invalidate_hf_cache_scans()
|
||||
logger.info(
|
||||
"Reclaimed stale GGUF %s [%s]: snapshots=%d blobs=%d dirs=%d freed=%.1f MB",
|
||||
repo_id,
|
||||
variant,
|
||||
removed_snapshots,
|
||||
deleted_blobs,
|
||||
removed_dirs,
|
||||
deleted_bytes / (1024 * 1024),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "reclaimed",
|
||||
"repo_id": repo_id,
|
||||
"variant": variant,
|
||||
"removed_snapshots": removed_snapshots,
|
||||
"deleted_blobs": deleted_blobs,
|
||||
"removed_dirs": removed_dirs,
|
||||
}
|
||||
|
||||
|
||||
def _loaded_id_matches_repo(loaded_id: str, repo_id: str) -> bool:
|
||||
"""True when *loaded_id* is *repo_id* or a file within it; ``/``-boundary aware so ``org/model`` doesn't match sibling ``org/model-v2``."""
|
||||
rid = repo_id.lower()
|
||||
|
|
|
|||
|
|
@ -291,6 +291,75 @@ def _partial_transport_for_variant(repo_id: str, variant: str) -> Optional[str]:
|
|||
return hf_cache_scan.partial_transport_for("model", repo_id, variant)
|
||||
|
||||
|
||||
def _local_main_gguf_blobs_by_quant(repo_id: str) -> dict[str, dict[str, set[str]]]:
|
||||
"""Map quant -> repo-relative expected GGUF filename -> cached blob hashes.
|
||||
|
||||
Shared companions are copied into each main-quant bucket so update checks can
|
||||
detect mmproj/MTP-only upstream changes without a separate remote call.
|
||||
"""
|
||||
result: dict[str, dict[str, set[str]]] = {}
|
||||
companion_blobs: dict[str, set[str]] = {}
|
||||
try:
|
||||
from hub.services.models import cache_inventory
|
||||
scans = cache_inventory.all_hf_cache_scans()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to scan local GGUF blobs for %s: %s", repo_id, e)
|
||||
return result
|
||||
|
||||
target_lower = repo_id.lower()
|
||||
for hf_cache in scans:
|
||||
for repo_info in hf_cache.repos:
|
||||
if str(getattr(repo_info, "repo_type", "")) != "model":
|
||||
continue
|
||||
if str(getattr(repo_info, "repo_id", "")).lower() != target_lower:
|
||||
continue
|
||||
for path, hashes in cache_inventory._repo_gguf_blob_map(
|
||||
repo_info,
|
||||
include_companions = True,
|
||||
).items():
|
||||
normalized = str(path).replace("\\", "/")
|
||||
if not hashes:
|
||||
continue
|
||||
if _is_mmproj_filename(normalized) or _is_mtp_drafter_path(normalized):
|
||||
companion_blobs.setdefault(normalized, set()).update(
|
||||
str(blob) for blob in hashes if blob
|
||||
)
|
||||
continue
|
||||
quant = extract_quant_label(normalized).lower()
|
||||
if is_big_endian_gguf_path(normalized, quant):
|
||||
continue
|
||||
bucket = result.setdefault(quant, {}).setdefault(normalized, set())
|
||||
bucket.update(str(blob) for blob in hashes if blob)
|
||||
if companion_blobs:
|
||||
for local_blobs in result.values():
|
||||
for path, hashes in companion_blobs.items():
|
||||
local_blobs.setdefault(path, set()).update(hashes)
|
||||
return result
|
||||
|
||||
|
||||
def _variant_update_available_from_requirement(
|
||||
local_blobs: dict[str, set[str]], requirement: Optional[_GgufVariantRequirement], variant: str
|
||||
) -> bool:
|
||||
if requirement is None or not local_blobs:
|
||||
return False
|
||||
local_by_posix = {path.replace("\\", "/"): blobs for path, blobs in local_blobs.items()}
|
||||
for expected in requirement.expected_files:
|
||||
path = str(expected.path).replace("\\", "/")
|
||||
if not (
|
||||
is_main_gguf_variant_path(path, variant)
|
||||
or _is_mmproj_filename(path)
|
||||
or _is_mtp_drafter_path(path)
|
||||
):
|
||||
continue
|
||||
remote_blob = expected.sha256
|
||||
if not remote_blob:
|
||||
continue
|
||||
local_set = local_by_posix.get(path)
|
||||
if not local_set or remote_blob not in local_set:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def delete_variant_incomplete_blobs_result(
|
||||
repo_id: str,
|
||||
variant: str,
|
||||
|
|
@ -657,9 +726,12 @@ async def get_gguf_variants_response(
|
|||
_partial_transport_for_variant(repo_id, variant.quant),
|
||||
)
|
||||
|
||||
local_blobs_by_quant = _local_main_gguf_blobs_by_quant(repo_id)
|
||||
|
||||
def _variant_detail(v) -> GgufVariantDetail:
|
||||
is_partial = v.quant in partial_quants
|
||||
requirement = requirements_by_quant.get(v.quant.lower())
|
||||
downloaded = _is_fully_downloaded(v) and not is_partial
|
||||
return GgufVariantDetail(
|
||||
filename = v.filename,
|
||||
quant = v.quant,
|
||||
|
|
@ -668,7 +740,13 @@ async def get_gguf_variants_response(
|
|||
download_size_bytes = (
|
||||
requirement.download_size_bytes if requirement is not None else v.size_bytes
|
||||
),
|
||||
downloaded = _is_fully_downloaded(v) and not is_partial,
|
||||
downloaded = downloaded,
|
||||
update_available = downloaded
|
||||
and _variant_update_available_from_requirement(
|
||||
local_blobs_by_quant.get(v.quant.lower(), {}),
|
||||
requirement,
|
||||
v.quant,
|
||||
),
|
||||
partial = is_partial,
|
||||
partial_transport = (partial_quant_transports.get(v.quant) if is_partial else None),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1632,6 +1632,34 @@ def test_variant_partial_accepts_variant_filtered_legacy_hashes(monkeypatch, tmp
|
|||
)
|
||||
|
||||
|
||||
def test_variant_partial_accepts_completed_variant_in_non_latest_snapshot(monkeypatch, tmp_path):
|
||||
"""A verified GGUF update can prune an older snapshot and make that old
|
||||
directory the newest by mtime. The variant is still complete when another
|
||||
snapshot satisfies its manifest."""
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
repo_dir = tmp_path / "cache" / "models--Org--Repo"
|
||||
old_snapshot = repo_dir / "snapshots" / "old"
|
||||
new_snapshot = repo_dir / "snapshots" / "new"
|
||||
old_snapshot.mkdir(parents = True)
|
||||
new_snapshot.mkdir(parents = True)
|
||||
(old_snapshot / "model-Q8_0.gguf").write_bytes(b"sibling")
|
||||
(new_snapshot / "model-Q4_K_M.gguf").write_bytes(b"new")
|
||||
assert download_manifest.write_manifest(
|
||||
"model",
|
||||
"Org/Repo",
|
||||
"Q4_K_M",
|
||||
[download_manifest.ExpectedFile(path = "model-Q4_K_M.gguf", size = 3)],
|
||||
"http",
|
||||
)
|
||||
|
||||
assert not inventory_scan.is_variant_partial(
|
||||
"Org/Repo",
|
||||
"Q4_K_M",
|
||||
snapshot_dir = old_snapshot,
|
||||
repo_cache_dir = repo_dir,
|
||||
)
|
||||
|
||||
|
||||
def test_gguf_variants_partial_marker_overrides_size_only_downloaded(monkeypatch, tmp_path):
|
||||
async def _run_inline(fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ def sibling_sha256(sibling) -> Optional[str]:
|
|||
value = lfs.get("sha256")
|
||||
else:
|
||||
value = getattr(lfs, "sha256", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
blob_id = getattr(sibling, "blob_id", None)
|
||||
return blob_id if isinstance(blob_id, str) and blob_id else None
|
||||
|
||||
|
||||
def sibling_size(sibling) -> int:
|
||||
|
|
|
|||
|
|
@ -387,9 +387,55 @@ def _manifest_partial(
|
|||
)
|
||||
if resolved is None:
|
||||
return True
|
||||
if repo_type == "model" and variant is not None:
|
||||
if download_manifest.verify_against_disk(manifest, resolved).ok:
|
||||
return False
|
||||
for candidate in _manifest_snapshot_dirs(repo_type, repo_id, repo_cache_dir):
|
||||
if candidate == resolved:
|
||||
continue
|
||||
if download_manifest.verify_against_disk(manifest, candidate).ok:
|
||||
return False
|
||||
return True
|
||||
return not download_manifest.verify_against_disk(manifest, resolved).ok
|
||||
|
||||
|
||||
def _manifest_snapshot_dirs(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
repo_cache_dir: Optional[Path] = None,
|
||||
) -> list[Path]:
|
||||
repo_dirs = (
|
||||
[repo_cache_dir]
|
||||
if repo_cache_dir is not None
|
||||
else list(iter_repo_cache_dirs(repo_type, repo_id))
|
||||
)
|
||||
snapshots: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for repo_dir in repo_dirs:
|
||||
if repo_dir is None:
|
||||
continue
|
||||
snapshots_dir = repo_dir / "snapshots"
|
||||
try:
|
||||
if not snapshots_dir.is_dir():
|
||||
continue
|
||||
entries = list(snapshots_dir.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
try:
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
resolved = entry.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
key = str(resolved)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
snapshots.append(resolved)
|
||||
return snapshots
|
||||
|
||||
|
||||
def is_snapshot_partial(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
|
|
|
|||
|
|
@ -653,6 +653,21 @@ def _download_gguf_variant(repo_id: str, variant: str, hf_token: str | None, mod
|
|||
snapshot_path,
|
||||
metadata_unavailable = metadata_unavailable,
|
||||
)
|
||||
if plan is not None:
|
||||
try:
|
||||
from hub.services.models.deletion import reclaim_replaced_gguf_variant
|
||||
reclaim_replaced_gguf_variant(
|
||||
repo_id,
|
||||
variant,
|
||||
plan.main_hashes,
|
||||
hf_token,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Verified GGUF update for {repo_id} [{variant}], but stale-cache "
|
||||
f"reclaim failed ({type(e).__name__}: {e})",
|
||||
file = sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _download_dataset(repo_id: str, hf_token: str | None, mode: str) -> None:
|
||||
|
|
|
|||
|
|
@ -136,9 +136,13 @@ class GgufVariantDetail(BaseModel):
|
|||
filename: str = Field(..., description = "GGUF filename (e.g., 'gemma-3-4b-it-Q4_K_M.gguf')")
|
||||
quant: str = Field(..., description = "Quantization label (e.g., 'Q4_K_M')")
|
||||
size_bytes: int = Field(0, description = "File size in bytes")
|
||||
download_size_bytes: int = Field(0, description = "Total bytes needed to download this variant")
|
||||
downloaded: bool = Field(
|
||||
False, description = "Whether this variant is already in the local HF cache"
|
||||
)
|
||||
update_available: bool = Field(
|
||||
False, description = "Whether a newer version of this variant is available on HF"
|
||||
)
|
||||
|
||||
|
||||
class GgufVariantsResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import sys
|
|||
import uuid
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -22,10 +23,27 @@ import re as _re
|
|||
_VALID_REPO_ID = _re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
class CachedModelRepo(BaseModel):
|
||||
repo_id: str
|
||||
size_bytes: int
|
||||
last_modified: Optional[float] = None
|
||||
|
||||
|
||||
class CachedModelsResponse(BaseModel):
|
||||
cached: List[CachedModelRepo]
|
||||
|
||||
|
||||
def _is_valid_repo_id(repo_id: str) -> bool:
|
||||
return bool(_VALID_REPO_ID.fullmatch(repo_id))
|
||||
|
||||
|
||||
def _normalize_hf_token(hf_token) -> Optional[str]:
|
||||
if not isinstance(hf_token, str):
|
||||
return None
|
||||
token = hf_token.strip()
|
||||
return token or None
|
||||
|
||||
|
||||
def _safe_is_dir(path) -> bool:
|
||||
"""``Path.is_dir()`` returning ``False`` instead of raising.
|
||||
|
||||
|
|
@ -74,6 +92,7 @@ if str(backend_path) not in sys.path:
|
|||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from hub.dependencies import get_hf_token
|
||||
|
||||
try:
|
||||
from utils.models import (
|
||||
|
|
@ -2594,109 +2613,41 @@ async def get_gguf_variants(
|
|||
..., description = "HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')"
|
||||
),
|
||||
hf_token: Optional[str] = Query(None, description = "HuggingFace token for private repos"),
|
||||
hf_token_header: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""List GGUF quantization variants for a HF repo or local directory.
|
||||
|
||||
Returns all variants with file sizes, vision support, and the
|
||||
recommended default.
|
||||
"""
|
||||
"""List GGUF quantization variants for a HF repo or local directory."""
|
||||
try:
|
||||
from utils.models.model_config import is_local_path, list_local_gguf_variants
|
||||
hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token)
|
||||
from hub.services.models import gguf_variants as hub_gguf_variants
|
||||
|
||||
# Local directory path — scan filesystem.
|
||||
if is_local_path(repo_id):
|
||||
variants, has_vision = list_local_gguf_variants(repo_id)
|
||||
|
||||
filenames = [v.filename for v in variants]
|
||||
best = _pick_best_gguf(filenames)
|
||||
default_variant = _extract_quant_label(best) if best else None
|
||||
|
||||
return GgufVariantsResponse(
|
||||
repo_id = repo_id,
|
||||
variants = [
|
||||
GgufVariantDetail(
|
||||
filename = v.filename,
|
||||
quant = v.quant,
|
||||
size_bytes = v.size_bytes,
|
||||
downloaded = True, # all local variants are downloaded
|
||||
)
|
||||
for v in variants
|
||||
],
|
||||
has_vision = has_vision,
|
||||
default_variant = default_variant,
|
||||
context_length = _read_native_context_length(repo_id, is_local = True),
|
||||
)
|
||||
|
||||
# Remote HuggingFace repo — query HF API.
|
||||
variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token)
|
||||
|
||||
filenames = [v.filename for v in variants]
|
||||
best = _pick_best_gguf(filenames)
|
||||
default_variant = _extract_quant_label(best) if best else None
|
||||
|
||||
# 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:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
raise ValueError(f"Invalid repo_id format: {repo_id}")
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() == target:
|
||||
snapshots = entry / "snapshots"
|
||||
if snapshots.is_dir():
|
||||
for snap in snapshots.iterdir():
|
||||
by_quant: dict[str, int] = {}
|
||||
for f in _iter_gguf_paths(snap):
|
||||
if _is_mmproj_filename(f.name):
|
||||
continue
|
||||
try:
|
||||
size = f.stat().st_size
|
||||
except OSError:
|
||||
continue # broken symlink / unreadable: skip
|
||||
rel = f.relative_to(snap).as_posix()
|
||||
q = _extract_quant_label(rel)
|
||||
if _is_big_endian_gguf_path(rel, q):
|
||||
continue
|
||||
q = q.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:
|
||||
if variant.size_bytes == 0:
|
||||
return False
|
||||
# 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
|
||||
)
|
||||
response = await hub_gguf_variants.get_gguf_variants_response(
|
||||
repo_id,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
local = is_local_path(repo_id)
|
||||
|
||||
return GgufVariantsResponse(
|
||||
repo_id = repo_id,
|
||||
repo_id = response.repo_id,
|
||||
variants = [
|
||||
GgufVariantDetail(
|
||||
filename = v.filename,
|
||||
quant = v.quant,
|
||||
size_bytes = v.size_bytes,
|
||||
downloaded = _is_fully_downloaded(v),
|
||||
download_size_bytes = int(
|
||||
getattr(v, "download_size_bytes", v.size_bytes) or v.size_bytes
|
||||
),
|
||||
downloaded = bool(v.downloaded),
|
||||
update_available = bool(getattr(v, "update_available", False)),
|
||||
)
|
||||
for v in variants
|
||||
for v in response.variants
|
||||
],
|
||||
has_vision = has_vision,
|
||||
default_variant = default_variant,
|
||||
context_length = _read_native_context_length(repo_id, is_local = False),
|
||||
has_vision = response.has_vision,
|
||||
default_variant = response.default_variant,
|
||||
context_length = _read_native_context_length(repo_id, is_local = local),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing GGUF variants for '{repo_id}': {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
|
|
@ -3123,10 +3074,14 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
|
|||
return {"cached": []}
|
||||
|
||||
|
||||
@router.get("/cached-models")
|
||||
async def list_cached_models(current_subject: str = Depends(get_current_subject)):
|
||||
@router.get("/cached-models", response_model = CachedModelsResponse)
|
||||
async def list_cached_models(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
):
|
||||
"""List non-GGUF model repos downloaded to HF cache, legacy Unsloth cache, and HF default cache."""
|
||||
_WEIGHT_EXTENSIONS = (".safetensors", ".bin")
|
||||
hf_token = _normalize_hf_token(hf_token)
|
||||
|
||||
try:
|
||||
cache_scans = _all_hf_cache_scans()
|
||||
|
|
@ -3147,20 +3102,16 @@ async def list_cached_models(current_subject: str = Depends(get_current_subject)
|
|||
)
|
||||
if total_size == 0:
|
||||
continue
|
||||
has_weights = any(
|
||||
f.file_name.endswith(_WEIGHT_EXTENSIONS)
|
||||
weight_files = [
|
||||
f
|
||||
for rev in repo_info.revisions
|
||||
for f in rev.files
|
||||
)
|
||||
if not has_weights:
|
||||
if f.file_name.endswith(_WEIGHT_EXTENSIONS)
|
||||
]
|
||||
if not weight_files:
|
||||
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)
|
||||
),
|
||||
(_blob_mtime(f) for f in weight_files),
|
||||
default = 0.0,
|
||||
)
|
||||
key = repo_id.lower()
|
||||
|
|
@ -3182,9 +3133,12 @@ async def list_cached_models(current_subject: str = Depends(get_current_subject)
|
|||
repo_label = getattr(repo_info, "repo_id", "<unknown>")
|
||||
logger.warning(f"Skipping cached model repo {repo_label}: {e}")
|
||||
continue
|
||||
# Newest download first; stable repo_id tie-break for equal/missing mtimes.
|
||||
|
||||
rows = list(seen_lower.values())
|
||||
# Local-only list path: update checks are GGUF-only and happen lazily
|
||||
# when a repo's variants are viewed.
|
||||
cached = sorted(
|
||||
seen_lower.values(),
|
||||
rows,
|
||||
key = lambda c: (-(c.get("last_modified") or 0.0), c["repo_id"].lower()),
|
||||
)
|
||||
return {"cached": cached}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ if "structlog" not in sys.modules:
|
|||
)
|
||||
|
||||
import routes.models as models_route
|
||||
from hub.services.models import gguf_variants as GV
|
||||
|
||||
|
||||
def _repo(
|
||||
|
|
@ -527,21 +528,32 @@ def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_pa
|
|||
"""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),
|
||||
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(
|
||||
models_route, "list_gguf_variants", lambda repo_id, hf_token = None: (variants, True)
|
||||
GV,
|
||||
"list_gguf_variants",
|
||||
lambda repo_id, hf_token = None: (variants, True, []),
|
||||
)
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
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(
|
||||
|
|
@ -555,21 +567,32 @@ def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_pa
|
|||
|
||||
|
||||
def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
|
||||
import huggingface_hub.constants as hf_constants
|
||||
|
||||
siblings = [
|
||||
SimpleNamespace(rfilename = "model-Q4_K_M-be.gguf", size = 100),
|
||||
SimpleNamespace(rfilename = "model-Q4_K_M.gguf", size = 10),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"huggingface_hub.model_info",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(siblings = siblings),
|
||||
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(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
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(
|
||||
|
|
@ -583,19 +606,25 @@ def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
|
|||
|
||||
|
||||
def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, tmp_path):
|
||||
import huggingface_hub.constants as hf_constants
|
||||
|
||||
variants = [
|
||||
SimpleNamespace(filename = "model-Q4_K_M.gguf", quant = "Q4_K_M", size_bytes = 10),
|
||||
SimpleNamespace(
|
||||
filename = "model-Q4_K_M.gguf",
|
||||
quant = "Q4_K_M",
|
||||
display_label = None,
|
||||
size_bytes = 10,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
models_route, "list_gguf_variants", lambda repo_id, hf_token = None: (variants, False)
|
||||
GV,
|
||||
"list_gguf_variants",
|
||||
lambda repo_id, hf_token = None: (variants, False, []),
|
||||
)
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ class _FakeAttempt:
|
|||
interval,
|
||||
grace_period,
|
||||
on_status,
|
||||
force_download = False,
|
||||
):
|
||||
self.calls.append(
|
||||
_types.SimpleNamespace(
|
||||
|
|
|
|||
483
studio/backend/tests/test_model_update_robustness.py
Normal file
483
studio/backend/tests/test_model_update_robustness.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
# 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]
|
||||
|
|
@ -161,6 +161,7 @@ def _download_child_entry(
|
|||
repo_type: str,
|
||||
disable_xet: bool,
|
||||
result_queue: Any,
|
||||
force_download: bool = False,
|
||||
) -> None:
|
||||
"""Spawn-child entrypoint: download one file and report the result.
|
||||
|
||||
|
|
@ -211,6 +212,7 @@ def _download_child_entry(
|
|||
filename = filename,
|
||||
repo_type = repo_type,
|
||||
token = token,
|
||||
force_download = force_download,
|
||||
)
|
||||
result_queue.put({"ok": True, "path": path})
|
||||
except BaseException as e: # noqa: BLE001 - report every failure to the parent
|
||||
|
|
@ -264,6 +266,7 @@ def _run_download_attempt(
|
|||
interval: float,
|
||||
grace_period: float,
|
||||
on_status: Optional[Callable[[str], None]],
|
||||
force_download: bool = False,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""Run one download in a spawn child supervised by the no-progress watchdog.
|
||||
|
||||
|
|
@ -280,6 +283,7 @@ def _run_download_attempt(
|
|||
repo_type = repo_type,
|
||||
disable_xet = disable_xet,
|
||||
result_queue = result_queue,
|
||||
force_download = force_download,
|
||||
),
|
||||
daemon = True,
|
||||
)
|
||||
|
|
@ -345,21 +349,28 @@ def hf_hub_download_with_xet_fallback(
|
|||
interval: float = DEFAULT_HEARTBEAT_INTERVAL,
|
||||
grace_period: float = DEFAULT_GRACE_PERIOD,
|
||||
on_status: Optional[Callable[[str], None]] = None,
|
||||
force_download: bool = False,
|
||||
) -> str:
|
||||
"""Download a single file with Xet primary and HTTP as a stall-only fallback.
|
||||
|
||||
Returns the local cache path. Raises ``RuntimeError("Cancelled")`` if
|
||||
*cancel_event* is set, re-raises a deterministic child error unchanged (no
|
||||
fallback), and raises ``DownloadStallError`` only if BOTH transports stall.
|
||||
|
||||
When *force_download* is True the cache-first early-return is skipped and the
|
||||
flag is threaded to ``hf_hub_download`` so a newer remote blob is re-fetched
|
||||
even if an older blob is already cached.
|
||||
"""
|
||||
# Finalized blob already cached: return it with no child and no network.
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
cached = try_to_load_from_cache(repo_id, filename, repo_type = repo_type)
|
||||
if isinstance(cached, str) and os.path.exists(cached):
|
||||
return cached
|
||||
except Exception as e:
|
||||
logger.debug("Cached probe failed for %s/%s: %s", repo_id, filename, e)
|
||||
# Skipped when force_download is set so an update re-fetches a newer blob.
|
||||
if not force_download:
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
cached = try_to_load_from_cache(repo_id, filename, repo_type = repo_type)
|
||||
if isinstance(cached, str) and os.path.exists(cached):
|
||||
return cached
|
||||
except Exception as e:
|
||||
logger.debug("Cached probe failed for %s/%s: %s", repo_id, filename, e)
|
||||
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise RuntimeError("Cancelled")
|
||||
|
|
@ -386,6 +397,7 @@ def hf_hub_download_with_xet_fallback(
|
|||
interval = interval,
|
||||
grace_period = grace_period,
|
||||
on_status = on_status,
|
||||
force_download = force_download,
|
||||
)
|
||||
|
||||
if kind == "ok":
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
// 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { DeleteConfirmDialog } from "@/features/hub/catalog/download-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Delete02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -22,7 +13,6 @@ interface ModelDeleteActionProps {
|
|||
title: string;
|
||||
description: ReactNode;
|
||||
successMessage: string;
|
||||
loadingLabel?: string;
|
||||
buttonClassName?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -35,7 +25,6 @@ export function ModelDeleteAction({
|
|||
title,
|
||||
description,
|
||||
successMessage,
|
||||
loadingLabel = "Deleting...",
|
||||
buttonClassName,
|
||||
iconClassName,
|
||||
disabled = false,
|
||||
|
|
@ -85,33 +74,17 @@ export function ModelDeleteAction({
|
|||
/>
|
||||
</button>
|
||||
|
||||
<AlertDialog
|
||||
<DeleteConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && deleting) return;
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>No</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}}
|
||||
>
|
||||
{deleting ? loadingLabel : "Yes"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
title={title}
|
||||
description={description}
|
||||
deleting={deleting}
|
||||
onConfirm={() => void handleConfirm()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
// 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 { subscribeJobListeners } from "@/features/hub/download-manager";
|
||||
import { UpdateConfirmDialog } from "@/features/hub/catalog/download-card";
|
||||
import { ggufVariantsMatch } from "@/features/hub/lib/model-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ModelUpdateActionProps {
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
/** Repo + variant the update targets, so this action can refresh its caller
|
||||
* (clearing the "update available" cue) when the matching managed download
|
||||
* completes. `variant` is null for full-model (safetensors / MLX) rows. */
|
||||
repoId: string;
|
||||
variant?: string | null;
|
||||
buttonClassName?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
/** Starts the update — which now runs as a managed download. Resolves once the
|
||||
* download has been handed to the download manager, NOT when it finishes. */
|
||||
onConfirm: () => Promise<void> | void;
|
||||
/** Fired when THIS repo+variant's managed update actually completes. */
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
export function ModelUpdateAction({
|
||||
ariaLabel,
|
||||
title,
|
||||
description,
|
||||
repoId,
|
||||
variant = null,
|
||||
buttonClassName,
|
||||
iconClassName,
|
||||
disabled = false,
|
||||
onConfirm,
|
||||
onUpdated,
|
||||
}: ModelUpdateActionProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// The update is a managed download (it surfaces in the global Downloads panel
|
||||
// with progress + cancel). When this exact repo+variant finishes, refresh the
|
||||
// caller so the "update available" cue clears once the new revision is on
|
||||
// disk. A ref keeps the subscription stable across renders without resubscribing.
|
||||
const onUpdatedRef = useRef(onUpdated);
|
||||
onUpdatedRef.current = onUpdated;
|
||||
useEffect(() => {
|
||||
return subscribeJobListeners("model", repoId, {
|
||||
onComplete: (completedVariant) => {
|
||||
const matches = variant
|
||||
? ggufVariantsMatch(completedVariant, variant)
|
||||
: !completedVariant;
|
||||
if (matches) onUpdatedRef.current?.();
|
||||
},
|
||||
});
|
||||
}, [repoId, variant]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
// Start the background re-download and close the dialog immediately; the
|
||||
// Downloads panel owns progress + cancel from here. Only a failure to START
|
||||
// surfaces a toast — a failed download reports itself in the panel.
|
||||
void Promise.resolve()
|
||||
.then(onConfirm)
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to start update",
|
||||
);
|
||||
});
|
||||
setOpen(false);
|
||||
}, [onConfirm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-amber-500/10 hover:text-amber-700 dark:hover:bg-amber-500/15 dark:hover:text-amber-300",
|
||||
disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", iconClassName)} />
|
||||
</button>
|
||||
|
||||
<UpdateConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
description={description}
|
||||
updating={false}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,18 +23,19 @@ import {
|
|||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import type {
|
||||
CachedGgufRepo,
|
||||
CachedModelRepo,
|
||||
LocalModelInfo,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import type { GgufVariantDetail } from "@/features/chat/types/api";
|
||||
import { DotTag } from "@/features/hub/catalog/dot-tag";
|
||||
import {
|
||||
type HubOption,
|
||||
HubOptionMenu,
|
||||
} from "@/features/hub/catalog/hub-option-menu";
|
||||
import { TransportConflictDialog } from "@/features/hub/catalog/transport-conflict-dialog";
|
||||
import { TrainIcon } from "@/features/hub/components/train-icon";
|
||||
import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
|
||||
import {
|
||||
|
|
@ -46,6 +47,11 @@ import { useOnlineStatus } from "@/features/hub/hooks/use-online-status";
|
|||
import { isHiddenModelId } from "@/features/hub/lib/hidden-models";
|
||||
import { classifyUnslothSupport } from "@/features/hub/lib/unsloth-support";
|
||||
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
|
||||
import {
|
||||
downloadManager,
|
||||
jobKeyOf,
|
||||
useDownloadManagerStore,
|
||||
} from "@/features/hub/download-manager";
|
||||
import { useDebouncedValue, useGpuInfo } from "@/hooks";
|
||||
import { extractParamLabel } from "@/lib/model-size";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -85,6 +91,7 @@ import {
|
|||
hasAnyCapability,
|
||||
} from "./model-capabilities";
|
||||
import { ModelDeleteAction } from "./model-delete-action";
|
||||
import { ModelUpdateAction } from "./model-update-action";
|
||||
import { ModelLoadSettingsAction } from "./model-load-settings-action";
|
||||
import {
|
||||
type ModelLoadTimes,
|
||||
|
|
@ -616,20 +623,26 @@ function normalizeGgufVariantsResponse(res: {
|
|||
};
|
||||
}
|
||||
|
||||
function ggufVariantExpectedBytes(variant: GgufVariantDetail): number {
|
||||
const downloadBytes = variant.download_size_bytes;
|
||||
return typeof downloadBytes === "number" &&
|
||||
Number.isFinite(downloadBytes) &&
|
||||
downloadBytes > 0
|
||||
? downloadBytes
|
||||
: variant.size_bytes;
|
||||
}
|
||||
|
||||
function GgufVariantExpander({
|
||||
repoId,
|
||||
onSelect,
|
||||
gpuGb,
|
||||
systemRamGb,
|
||||
hfToken,
|
||||
parentOptionKey,
|
||||
onNavigatePastStart,
|
||||
onNavigatePastEnd,
|
||||
onDeleteVariant,
|
||||
sourceOverride,
|
||||
deleteVariantTitle = "Delete cached model?",
|
||||
renderDeleteVariantDescription,
|
||||
getDeleteVariantSuccessMessage,
|
||||
deleteDisabled = false,
|
||||
variantActions,
|
||||
onDevice = false,
|
||||
onHasVision,
|
||||
}: {
|
||||
|
|
@ -637,21 +650,42 @@ function GgufVariantExpander({
|
|||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
gpuGb?: number;
|
||||
systemRamGb?: number;
|
||||
/** HF token threaded into the variant fetch so private/gated repos resolve
|
||||
* their GGUF variants (and update badges). */
|
||||
hfToken?: string;
|
||||
parentOptionKey?: string;
|
||||
onNavigatePastStart?: () => void;
|
||||
onNavigatePastEnd?: () => void;
|
||||
onDeleteVariant?: (quant: string) => Promise<void> | void;
|
||||
sourceOverride?: ModelSelectorChangeMeta["source"];
|
||||
deleteVariantTitle?: string;
|
||||
renderDeleteVariantDescription?: (quant: string) => ReactNode;
|
||||
getDeleteVariantSuccessMessage?: (quant: string) => string;
|
||||
deleteDisabled?: boolean;
|
||||
/** Update/delete actions for cached variant rows. Omitted by browse-only
|
||||
* expanders (Recommended, etc.) that don't manage on-disk variants. */
|
||||
variantActions?: {
|
||||
onUpdate?: (quant: string, expectedBytes: number) => Promise<void> | void;
|
||||
updateTitle?: string;
|
||||
renderUpdateDescription?: (quant: string) => ReactNode;
|
||||
getUpdateSuccessMessage?: (quant: string) => string;
|
||||
updateDisabled?: boolean;
|
||||
onDelete?: (quant: string) => Promise<void> | void;
|
||||
deleteTitle?: string;
|
||||
renderDeleteDescription?: (quant: string) => ReactNode;
|
||||
getDeleteSuccessMessage?: (quant: string) => string;
|
||||
deleteDisabled?: boolean;
|
||||
};
|
||||
/** On Device rows honor the Show all quantizations setting; Recommended and
|
||||
* other browse lists always show every quant. */
|
||||
onDevice?: boolean;
|
||||
/** Report GGUF vision support up so the parent row can badge it. */
|
||||
onHasVision?: (hasVision: boolean) => void;
|
||||
}) {
|
||||
const onUpdateVariant = variantActions?.onUpdate;
|
||||
const updateVariantTitle = variantActions?.updateTitle ?? "Update cached model?";
|
||||
const renderUpdateVariantDescription = variantActions?.renderUpdateDescription;
|
||||
const updateDisabled = variantActions?.updateDisabled ?? false;
|
||||
const onDeleteVariant = variantActions?.onDelete;
|
||||
const deleteVariantTitle = variantActions?.deleteTitle ?? "Delete cached model?";
|
||||
const renderDeleteVariantDescription = variantActions?.renderDeleteDescription;
|
||||
const getDeleteVariantSuccessMessage = variantActions?.getDeleteSuccessMessage;
|
||||
const deleteDisabled = variantActions?.deleteDisabled ?? false;
|
||||
const [variants, setVariants] = useState<GgufVariantDetail[] | null>(null);
|
||||
const [defaultVariant, setDefaultVariant] = useState<string | null>(null);
|
||||
const [hasVision, setHasVision] = useState(false);
|
||||
|
|
@ -659,13 +693,14 @@ function GgufVariantExpander({
|
|||
const [nativeContext, setNativeContext] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
listGgufVariants(repoId)
|
||||
listGgufVariants(repoId, hfToken)
|
||||
.then((res) => {
|
||||
if (canceled) return;
|
||||
const normalized = normalizeGgufVariantsResponse(res);
|
||||
|
|
@ -688,7 +723,7 @@ function GgufVariantExpander({
|
|||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [repoId]);
|
||||
}, [repoId, refreshKey, hfToken]);
|
||||
|
||||
// Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/)
|
||||
const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(
|
||||
|
|
@ -870,6 +905,7 @@ function GgufVariantExpander({
|
|||
const fit = getGgufFit(v.size_bytes);
|
||||
const oom = fit === "oom";
|
||||
const tight = fit === "tight";
|
||||
const expectedBytes = ggufVariantExpectedBytes(v);
|
||||
const keyBase = `${repoId}:${v.filename}`;
|
||||
const variantOptionKey = makeModelOptionKey("gguf-variant", keyBase);
|
||||
return (
|
||||
|
|
@ -878,7 +914,7 @@ function GgufVariantExpander({
|
|||
type="button"
|
||||
{...variantList.getOptionProps(variantOptionKey, false)}
|
||||
onClick={() =>
|
||||
handleVariantClick(v.quant, v.downloaded, v.size_bytes)
|
||||
handleVariantClick(v.quant, v.downloaded, expectedBytes)
|
||||
}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-2 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
|
||||
|
|
@ -891,9 +927,16 @@ function GgufVariantExpander({
|
|||
{v.quant}
|
||||
</span>
|
||||
{v.downloaded ? (
|
||||
<span className="ml-1.5 text-[9px] font-sans font-medium text-green-400">
|
||||
downloaded
|
||||
</span>
|
||||
<>
|
||||
<span className="ml-1.5 text-[9px] font-sans font-medium text-green-400">
|
||||
downloaded
|
||||
</span>
|
||||
{v.update_available ? (
|
||||
<span className="ml-1.5 text-[9px] font-sans font-medium text-amber-700 dark:text-amber-300">
|
||||
update available
|
||||
</span>
|
||||
): null}
|
||||
</>
|
||||
) : v.quant === effectiveRecommended ? (
|
||||
<span className="ml-1.5 text-[9px] font-sans font-medium text-primary/70">
|
||||
recommended
|
||||
|
|
@ -916,6 +959,29 @@ function GgufVariantExpander({
|
|||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{v.downloaded && v.update_available && onUpdateVariant && (
|
||||
<ModelUpdateAction
|
||||
ariaLabel={`Update ${repoId} ${v.quant}`}
|
||||
title={updateVariantTitle}
|
||||
description={
|
||||
renderUpdateVariantDescription?.(v.quant) ?? (
|
||||
<>
|
||||
This will update{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{repoId} ({v.quant})
|
||||
</span>{"."}
|
||||
</>
|
||||
)
|
||||
}
|
||||
repoId={repoId}
|
||||
variant={v.quant}
|
||||
buttonClassName="p-1"
|
||||
iconClassName="size-3"
|
||||
disabled={updateDisabled}
|
||||
onConfirm={() => onUpdateVariant(v.quant, expectedBytes)}
|
||||
onUpdated={() => setRefreshKey((key) => key + 1)}
|
||||
/>
|
||||
)}
|
||||
{v.downloaded && (
|
||||
<ModelLoadSettingsAction
|
||||
ariaLabel={`Inference settings for ${repoId} ${v.quant}`}
|
||||
|
|
@ -1179,18 +1245,25 @@ export function HubModelPicker({
|
|||
onEject?: () => void;
|
||||
}) {
|
||||
const gpu = useGpuInfo();
|
||||
// The currently-loaded/running model id. We read params.checkpoint from the
|
||||
// runtime store (backend-mirrored from /api/inference/status.active_model, see
|
||||
// chat-runtime-store) rather than the dropdown `isSelected` highlight (which is
|
||||
// just `value === repo_id` and can reflect a staged, not-yet-loaded pick). Used
|
||||
// to disable the cached-row update action for the model that's live in memory.
|
||||
const loadedModelId = useChatRuntimeStore((s) => s.params.checkpoint);
|
||||
// Last-loaded timestamps power the "Recent" sort (vs "Downloaded" = file date).
|
||||
const loadTimes = useModelLoadTimes(value);
|
||||
// Fade the list's top edge once scrolled, and its bottom edge while more
|
||||
// rows sit below the fold.
|
||||
const [listScrolled, setListScrolled] = useState(false);
|
||||
const [listMoreBelow, setListMoreBelow] = useState(false);
|
||||
const hfToken = useHfTokenStore((s) => s.token);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
// Shared Hub search stack (the same hooks the Hub page uses) so the picker
|
||||
// and Hub run one implementation. Scoped to unsloth like the old listing.
|
||||
const online = useOnlineStatus();
|
||||
const accessToken = useHfTokenStore((s) => s.token) || undefined;
|
||||
const accessToken = hfToken || undefined;
|
||||
// Recommended section: a live unsloth listing sorted by the dropdown. The
|
||||
// same sort drives the search results so the dropdown works while searching.
|
||||
const [recommendedSort, setRecommendedSort] =
|
||||
|
|
@ -1358,6 +1431,28 @@ export function HubModelPicker({
|
|||
const alreadyCached =
|
||||
_cachedGgufCache.length > 0 || _cachedModelsCache.length > 0;
|
||||
const [cachedReady, setCachedReady] = useState(alreadyCached);
|
||||
const [updateConflictKey, setUpdateConflictKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const updateTransportConflict = useDownloadManagerStore((state) =>
|
||||
updateConflictKey
|
||||
? (state.conflicts[updateConflictKey]?.info ?? null)
|
||||
: null,
|
||||
);
|
||||
const cancelUpdateConflict = useCallback(() => {
|
||||
if (updateConflictKey) downloadManager.cancelConflict(updateConflictKey);
|
||||
setUpdateConflictKey(null);
|
||||
}, [updateConflictKey]);
|
||||
const resumeUpdateConflict = useCallback(() => {
|
||||
if (!updateConflictKey) return;
|
||||
downloadManager.resumeConflict(updateConflictKey);
|
||||
setUpdateConflictKey(null);
|
||||
}, [updateConflictKey]);
|
||||
const restartUpdateConflict = useCallback(() => {
|
||||
if (!updateConflictKey) return;
|
||||
downloadManager.restartConflict(updateConflictKey);
|
||||
setUpdateConflictKey(null);
|
||||
}, [updateConflictKey]);
|
||||
|
||||
// LM Studio local models -- module-level cache, same pattern as above.
|
||||
const [lmStudioModels, setLmStudioModels] =
|
||||
|
|
@ -1480,14 +1575,42 @@ export function HubModelPicker({
|
|||
setCachedGguf(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
listCachedModels()
|
||||
listCachedModels(hfToken || undefined)
|
||||
.then((v) => {
|
||||
_cachedModelsCache = v;
|
||||
setCachedModels(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
refreshLocalModelsList();
|
||||
}, [refreshLocalModelsList]);
|
||||
}, [hfToken, refreshLocalModelsList]);
|
||||
|
||||
// Updates run as MANAGED downloads (they show in the global Downloads panel
|
||||
// with manifest-based progress + a working Cancel), instead of a blocking
|
||||
// call. The worker re-resolves `main` and pulls only changed blobs, so the
|
||||
// cached copy stays usable until the new revision lands. The row's
|
||||
// ModelUpdateAction refreshes the list when this repo+variant completes.
|
||||
const startManagedUpdate = useCallback((repoId: string, variant: string, expectedBytes: number) => {
|
||||
return downloadManager
|
||||
.requestStart({
|
||||
kind: "model",
|
||||
repoId,
|
||||
variant,
|
||||
expectedBytes,
|
||||
})
|
||||
.then((outcome) => {
|
||||
if (outcome === "conflict") {
|
||||
setUpdateConflictKey(jobKeyOf("model", repoId, variant));
|
||||
} else if (outcome === "error") {
|
||||
throw new Error("Failed to start update");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateGgufVariant = useCallback(
|
||||
(repoId: string, quant: string, expectedBytes: number) =>
|
||||
startManagedUpdate(repoId, quant, expectedBytes),
|
||||
[startManagedUpdate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Always refresh LM Studio + custom folder models (not gated by alreadyCached).
|
||||
|
|
@ -1512,14 +1635,14 @@ export function HubModelPicker({
|
|||
})
|
||||
.catch(() => {})
|
||||
.finally(check);
|
||||
listCachedModels()
|
||||
listCachedModels(hfToken || undefined)
|
||||
.then((v) => {
|
||||
_cachedModelsCache = v;
|
||||
setCachedModels(v);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(check);
|
||||
}, [refreshLocalModelsList, refreshScanFolders]);
|
||||
}, [hfToken, refreshLocalModelsList, refreshScanFolders]);
|
||||
|
||||
// Hide downloaded models from the recommended list. Case-insensitive
|
||||
// since the HF cache lowercases repo IDs.
|
||||
|
|
@ -2317,14 +2440,21 @@ export function HubModelPicker({
|
|||
onDevice={true}
|
||||
onHasVision={(v) => reportVision(c.repo_id, v)}
|
||||
onSelect={onSelect}
|
||||
hfToken={hfToken || undefined}
|
||||
parentOptionKey={optionKey}
|
||||
onNavigatePastStart={() => hubModelList.focusOption(optionKey)}
|
||||
onNavigatePastEnd={() => hubModelList.moveFocus(optionKey, "next")}
|
||||
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
|
||||
systemRamGb={gpu.systemRamAvailableGb || undefined}
|
||||
onDeleteVariant={async (quant) => {
|
||||
await deleteCachedModel(c.repo_id, quant);
|
||||
refreshCachedLists();
|
||||
variantActions={{
|
||||
onUpdate: (quant, expectedBytes) =>
|
||||
updateGgufVariant(c.repo_id, quant, expectedBytes),
|
||||
// Can't update the model that's live in memory under itself.
|
||||
updateDisabled: loadedModelId === c.repo_id,
|
||||
onDelete: async (quant) => {
|
||||
await deleteCachedModel(c.repo_id, quant);
|
||||
refreshCachedLists();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -2383,7 +2513,8 @@ export function HubModelPicker({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<>
|
||||
<div className="relative space-y-2">
|
||||
{/* A small right inset shortens the search bar so Search Hub lands on the
|
||||
last dropdown's right edge (none on the wider Connected box). */}
|
||||
<div
|
||||
|
|
@ -3239,6 +3370,7 @@ export function HubModelPicker({
|
|||
<GgufVariantExpander
|
||||
repoId={id}
|
||||
onSelect={onSelect}
|
||||
hfToken={hfToken || undefined}
|
||||
parentOptionKey={optionKey}
|
||||
onNavigatePastStart={() =>
|
||||
hubModelList.focusOption(optionKey)
|
||||
|
|
@ -3250,9 +3382,11 @@ export function HubModelPicker({
|
|||
gpu.available ? gpu.memoryTotalGb : undefined
|
||||
}
|
||||
systemRamGb={gpu.systemRamAvailableGb || undefined}
|
||||
onDeleteVariant={async (quant) => {
|
||||
await deleteCachedModel(id, quant);
|
||||
refreshCachedLists();
|
||||
variantActions={{
|
||||
onDelete: async (quant) => {
|
||||
await deleteCachedModel(id, quant);
|
||||
refreshCachedLists();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -3324,6 +3458,7 @@ export function HubModelPicker({
|
|||
<GgufVariantExpander
|
||||
repoId={id}
|
||||
onSelect={onSelect}
|
||||
hfToken={hfToken || undefined}
|
||||
parentOptionKey={optionKey}
|
||||
onNavigatePastStart={() =>
|
||||
hubModelList.focusOption(optionKey)
|
||||
|
|
@ -3335,9 +3470,11 @@ export function HubModelPicker({
|
|||
gpu.available ? gpu.memoryTotalGb : undefined
|
||||
}
|
||||
systemRamGb={gpu.systemRamAvailableGb || undefined}
|
||||
onDeleteVariant={async (quant) => {
|
||||
await deleteCachedModel(id, quant);
|
||||
refreshCachedLists();
|
||||
variantActions={{
|
||||
onDelete: async (quant) => {
|
||||
await deleteCachedModel(id, quant);
|
||||
refreshCachedLists();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -3411,6 +3548,7 @@ export function HubModelPicker({
|
|||
<GgufVariantExpander
|
||||
repoId={id}
|
||||
onSelect={onSelect}
|
||||
hfToken={hfToken || undefined}
|
||||
parentOptionKey={optionKey}
|
||||
onNavigatePastStart={() =>
|
||||
hubModelList.focusOption(optionKey)
|
||||
|
|
@ -3422,9 +3560,11 @@ export function HubModelPicker({
|
|||
gpu.available ? gpu.memoryTotalGb : undefined
|
||||
}
|
||||
systemRamGb={gpu.systemRamAvailableGb || undefined}
|
||||
onDeleteVariant={async (quant) => {
|
||||
await deleteCachedModel(id, quant);
|
||||
refreshCachedLists();
|
||||
variantActions={{
|
||||
onDelete: async (quant) => {
|
||||
await deleteCachedModel(id, quant);
|
||||
refreshCachedLists();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -3459,7 +3599,14 @@ export function HubModelPicker({
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<TransportConflictDialog
|
||||
conflict={updateTransportConflict}
|
||||
onCancel={cancelUpdateConflict}
|
||||
onKeepTransport={resumeUpdateConflict}
|
||||
onSwitchTransport={restartUpdateConflict}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3611,22 +3758,21 @@ function FineTunedRows({
|
|||
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
|
||||
systemRamGb={gpu.systemRamAvailableGb || undefined}
|
||||
sourceOverride={isExportedGguf ? "exported" : undefined}
|
||||
deleteVariantTitle="Delete exported GGUF variant?"
|
||||
renderDeleteVariantDescription={(quant) => (
|
||||
<>
|
||||
This will remove{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{adapter.name} ({quant})
|
||||
</span>{" "}
|
||||
from disk. This cannot be undone.
|
||||
</>
|
||||
)}
|
||||
getDeleteVariantSuccessMessage={(quant) =>
|
||||
`Deleted ${adapter.name} ${quant}`
|
||||
}
|
||||
deleteDisabled={deleteDisabled}
|
||||
onDeleteVariant={
|
||||
isExportedGguf
|
||||
variantActions={{
|
||||
deleteTitle: "Delete exported GGUF variant?",
|
||||
renderDeleteDescription: (quant) => (
|
||||
<>
|
||||
This will remove{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{adapter.name} ({quant})
|
||||
</span>{" "}
|
||||
from disk. This cannot be undone.
|
||||
</>
|
||||
),
|
||||
getDeleteSuccessMessage: (quant) =>
|
||||
`Deleted ${adapter.name} ${quant}`,
|
||||
deleteDisabled: deleteDisabled,
|
||||
onDelete: isExportedGguf
|
||||
? async (quant) => {
|
||||
await deleteFineTunedModel({
|
||||
modelPath: adapter.id,
|
||||
|
|
@ -3639,8 +3785,8 @@ function FineTunedRows({
|
|||
ggufVariant: quant,
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { hubTokenHeader } from "@/features/hub/lib/hub-token-header";
|
||||
import { consumeNativePathToken } from "@/features/native-intents/api";
|
||||
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
|
||||
import type {
|
||||
|
|
@ -313,8 +314,12 @@ export interface CachedModelRepo {
|
|||
last_modified?: number;
|
||||
}
|
||||
|
||||
export async function listCachedModels(): Promise<CachedModelRepo[]> {
|
||||
const response = await authFetch("/api/models/cached-models");
|
||||
export async function listCachedModels(
|
||||
hfToken?: string | null,
|
||||
): Promise<CachedModelRepo[]> {
|
||||
const response = await authFetch("/api/models/cached-models", {
|
||||
headers: hubTokenHeader(hfToken),
|
||||
});
|
||||
const data = await parseJsonOrThrow<{ cached: CachedModelRepo[] }>(response);
|
||||
return data.cached;
|
||||
}
|
||||
|
|
@ -778,8 +783,9 @@ export async function listGgufVariants(
|
|||
hfToken?: string,
|
||||
): Promise<GgufVariantsResponse> {
|
||||
const params = new URLSearchParams({ repo_id: repoId });
|
||||
if (hfToken) params.set("hf_token", hfToken);
|
||||
const response = await authFetch(`/api/models/gguf-variants?${params}`);
|
||||
const response = await authFetch(`/api/models/gguf-variants?${params}`, {
|
||||
headers: hubTokenHeader(hfToken),
|
||||
});
|
||||
return parseJsonOrThrow<GgufVariantsResponse>(response);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ export interface GgufVariantDetail {
|
|||
filename: string;
|
||||
quant: string;
|
||||
size_bytes: number;
|
||||
download_size_bytes?: number;
|
||||
downloaded?: boolean;
|
||||
update_available?: boolean;
|
||||
}
|
||||
|
||||
export interface GgufVariantsResponse {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Delete02Icon, Download01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
ArrowReloadHorizontalIcon,
|
||||
Delete02Icon,
|
||||
Download01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
DownloadProgressBar,
|
||||
|
|
@ -111,6 +115,73 @@ export function CardDeleteButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function CardUpdateButton({
|
||||
label,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** When true (a newer revision is available) the control becomes a prominent
|
||||
* labeled amber pill instead of the quiet hover-revealed icon — the
|
||||
* "update available" cue. Amber is the established status tone in this surface
|
||||
* (the older-cache hint banner), kept tinted (never solid) per the design
|
||||
* system's feedback-color convention, so the one emerald accent stays scarce. */
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
if (emphasized) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-amber-500/[0.07] pl-2 pr-2.5 text-[12px] font-medium text-amber-800/90 transition-colors duration-150 hover:bg-amber-500/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/25 dark:bg-amber-400/[0.08] dark:text-amber-200/85 dark:hover:bg-amber-400/[0.16]"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowReloadHorizontalIcon}
|
||||
strokeWidth={2}
|
||||
className="size-3.5"
|
||||
/>
|
||||
Update
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
A newer version is available on Hugging Face
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground opacity-0 transition-[opacity,background-color,color] duration-150 hover:bg-amber-500/10 hover:text-amber-600 focus-visible:opacity-100 group-hover/dl:opacity-100 dark:hover:bg-amber-500/15 dark:hover:text-amber-400"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowReloadHorizontalIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Update from Hugging Face
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Download / Cancel / Resume button for the safetensors and dataset cards. */
|
||||
export function DownloadActionButton({
|
||||
downloading,
|
||||
|
|
@ -213,3 +284,44 @@ export function DeleteConfirmDialog({
|
|||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmation dialog shared by the model and quantization update flows. */
|
||||
export function UpdateConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
updating,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
updating: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={updating}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="default"
|
||||
disabled={updating}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{updating ? "Updating…" : "Update"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import {
|
|||
useDownloadManagerStore,
|
||||
useRepoDownload,
|
||||
} from "../download-manager";
|
||||
import { type GgufVariantDetail, deleteCachedModel } from "../inventory";
|
||||
import {
|
||||
type GgufVariantDetail,
|
||||
deleteCachedModel,
|
||||
} from "../inventory";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { type GgufFitClass, classifyGgufFit } from "../lib/gguf-fit";
|
||||
import { HUB_GGUF_RUN_ACTIONS_VISIBLE } from "../lib/hub-feature-flags";
|
||||
|
|
@ -27,7 +30,9 @@ import {
|
|||
} from "../lib/model-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useHfTokenStore } from "../stores/hf-token-store";
|
||||
import { useOnlineStatus } from "../hooks/use-online-status";
|
||||
import {
|
||||
ArrowReloadHorizontalIcon,
|
||||
Delete02Icon,
|
||||
Download01Icon,
|
||||
InformationCircleIcon,
|
||||
|
|
@ -56,6 +61,7 @@ import {
|
|||
CardDivider,
|
||||
DeleteConfirmDialog,
|
||||
DownloadCard,
|
||||
UpdateConfirmDialog,
|
||||
} from "./download-card";
|
||||
import {
|
||||
activeDownloadState,
|
||||
|
|
@ -160,9 +166,13 @@ function QuantBadge({
|
|||
<span className="min-w-0 truncate">{quant}</span>
|
||||
</span>
|
||||
) : (
|
||||
// Trigger quant label is the row's primary identity and is short
|
||||
// (e.g. "Q4_K_M"); keep it `shrink-0` + `whitespace-nowrap` so it never
|
||||
// collapses to "q…" when the Update/Run actions crowd the row. The info
|
||||
// group's `overflow-hidden` sacrifices the trailing status tags instead.
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-w-0 max-w-full shrink cursor-help items-center gap-1.5 text-[12.5px] font-medium tracking-tight tabular-nums",
|
||||
"inline-flex shrink-0 cursor-help items-center gap-1.5 whitespace-nowrap text-[12.5px] font-medium tracking-tight tabular-nums",
|
||||
active ? "text-emerald-600 dark:text-emerald-400" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
|
|
@ -173,7 +183,7 @@ function QuantBadge({
|
|||
className={cn("size-3.5 shrink-0", meta.iconClassName)}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{quant}</span>
|
||||
<span>{quant}</span>
|
||||
</span>
|
||||
);
|
||||
if (!showFit || tooltipMode === "none") return inner;
|
||||
|
|
@ -288,6 +298,11 @@ const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
|
|||
: "hover:bg-foreground/[0.05] dark:hover:bg-foreground/[0.06]",
|
||||
)}
|
||||
>
|
||||
{/* Status (On device / Loaded / Partial) sits beside the quant on the
|
||||
left so the model's identity reads as one unit; only the size pins
|
||||
right. No per-row "GGUF" tag: every row here is a GGUF quant and the
|
||||
trigger already labels it, so repeating it only stole the room the
|
||||
quant label needs (it would otherwise truncate to "q…"). */}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<QuantBadge
|
||||
quant={item.label}
|
||||
|
|
@ -297,8 +312,6 @@ const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
|
|||
variant="menu"
|
||||
tooltipMode="lazy"
|
||||
/>
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{item.downloaded && (
|
||||
<DotTag tone="success" label={loaded ? "Loaded" : "On device"} />
|
||||
)}
|
||||
|
|
@ -319,7 +332,8 @@ const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DotTag tone="gguf" label="GGUF" />
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<span className="relative">
|
||||
<span className={cn(CHIP_BASE, CHIP_DEFAULT)}>
|
||||
{item.downloadSizeLabel}
|
||||
|
|
@ -377,6 +391,7 @@ export function GgufDownloadCard({
|
|||
onChange?: () => void;
|
||||
}) {
|
||||
const hfToken = useHfTokenStore((s) => s.token);
|
||||
const online = useOnlineStatus();
|
||||
const localVariantPath = cachePath?.trim() || null;
|
||||
const { variants, loading, error, refreshError, refresh } =
|
||||
useGgufVariantFetchState({
|
||||
|
|
@ -394,6 +409,7 @@ export function GgufDownloadCard({
|
|||
selectedQuantState.repoId === repoId ? selectedQuantState.quant : null;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [updateTarget, setUpdateTarget] = useState<string | null>(null);
|
||||
const [completedVariantKeys, setCompletedVariantKeys] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set<string>());
|
||||
|
|
@ -416,7 +432,7 @@ export function GgufDownloadCard({
|
|||
if (completedVariantKeys.size === 0) return withLive;
|
||||
return withLive.map((v) =>
|
||||
completedVariantKeys.has(normalizeGgufVariantIdentity(v.quant))
|
||||
? { ...v, downloaded: true, partial: false }
|
||||
? { ...v, downloaded: true, partial: false, update_available: false }
|
||||
: v,
|
||||
);
|
||||
}, [completedVariantKeys, liveVariantStates, rawSortedVariants]);
|
||||
|
|
@ -444,6 +460,7 @@ export function GgufDownloadCard({
|
|||
setCompletedVariantKeys((prev) =>
|
||||
prev.has(key) ? prev : new Set(prev).add(key),
|
||||
);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
const progress = job.progress;
|
||||
|
|
@ -529,6 +546,8 @@ export function GgufDownloadCard({
|
|||
const selectedDownloadSizeLabel = selected
|
||||
? formatBytes(ggufVariantDownloadSizeBytes(selected))
|
||||
: null;
|
||||
const updateAvailable =
|
||||
selected?.downloaded === true && selected.update_available === true;
|
||||
const selectedVariantKey = selectedQuant
|
||||
? normalizeGgufVariantIdentity(selectedQuant)
|
||||
: null;
|
||||
|
|
@ -587,6 +606,35 @@ export function GgufDownloadCard({
|
|||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
const updateTargetVariant =
|
||||
updateTarget && sortedVariants
|
||||
? sortedVariants.find((v) => ggufVariantsMatch(v.quant, updateTarget))
|
||||
: null;
|
||||
const updateTargetLabel = updateTargetVariant
|
||||
? ggufVariantDisplayLabel(updateTargetVariant)
|
||||
: updateTarget;
|
||||
// Confirm → close the dialog and run the re-download as a MANAGED download, so
|
||||
// it surfaces in the "Downloading N items" panel with correct manifest-based
|
||||
// progress and a working Cancel — the same UX as any other download — instead
|
||||
// of a bespoke modal/toast. The worker re-resolves `main` and pulls only the
|
||||
// changed blobs, so the cached version stays intact (and runnable) until the
|
||||
// new revision lands. Completion refreshes the variant list, whose metadata
|
||||
// carries the "Update available" cue.
|
||||
const handleConfirmUpdate = useCallback(() => {
|
||||
if (!updateTarget) return;
|
||||
const variant = updateTarget;
|
||||
const expectedBytes =
|
||||
updateTargetVariant?.download_size_bytes ??
|
||||
updateTargetVariant?.size_bytes ??
|
||||
0;
|
||||
setUpdateTarget(null);
|
||||
void downloadManager.requestStart({
|
||||
kind: "model",
|
||||
repoId,
|
||||
variant,
|
||||
expectedBytes,
|
||||
});
|
||||
}, [updateTarget, updateTargetVariant, repoId]);
|
||||
const variantListUnavailable = !sortedVariants || sortedVariants.length === 0;
|
||||
const showVariantLoadingState = loading && variantListUnavailable;
|
||||
|
||||
|
|
@ -640,24 +688,44 @@ export function GgufDownloadCard({
|
|||
job={job}
|
||||
progress={downloadingThisVariant ? progress : null}
|
||||
dialogs={
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o && !deleting) setDeleteTarget(null);
|
||||
}}
|
||||
title="Delete quantization?"
|
||||
deleting={deleting}
|
||||
onConfirm={() => void runDelete()}
|
||||
description={
|
||||
<>
|
||||
This will remove{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{repoId} ({deleteTargetLabel})
|
||||
</span>{" "}
|
||||
from disk. You can re-download it later.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o && !deleting) setDeleteTarget(null);
|
||||
}}
|
||||
title="Delete quantization?"
|
||||
deleting={deleting}
|
||||
onConfirm={() => void runDelete()}
|
||||
description={
|
||||
<>
|
||||
This will remove{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{repoId} ({deleteTargetLabel})
|
||||
</span>{" "}
|
||||
from disk. You can re-download it later.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<UpdateConfirmDialog
|
||||
open={updateTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setUpdateTarget(null);
|
||||
}}
|
||||
title="Update quantization?"
|
||||
updating={false}
|
||||
onConfirm={handleConfirmUpdate}
|
||||
description={
|
||||
<>
|
||||
This will re-download the latest version of{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{repoId} ({updateTargetLabel})
|
||||
</span>{" "}
|
||||
from Hugging Face.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -668,21 +736,24 @@ export function GgufDownloadCard({
|
|||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.04] dark:data-[state=open]:bg-white/[0.06]"
|
||||
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.04] dark:data-[state=open]:bg-white/[0.06]"
|
||||
>
|
||||
{selected ? (
|
||||
<QuantBadge
|
||||
quant={selectedLabel ?? selected.quant}
|
||||
fit={selectedFit ?? "oom"}
|
||||
showFit={showFitInfo}
|
||||
active={Boolean(selectedIsActive)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-muted-foreground">
|
||||
Select quantization
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
{/* Quant label + status tags travel together as one left-aligned
|
||||
group so the fit-info icon never floats orphaned from its tags;
|
||||
only the chevron pins right, the standard select affordance. */}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-[12px] text-muted-foreground">
|
||||
{selected ? (
|
||||
<QuantBadge
|
||||
quant={selectedLabel ?? selected.quant}
|
||||
fit={selectedFit ?? "oom"}
|
||||
showFit={showFitInfo}
|
||||
active={Boolean(selectedIsActive)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-muted-foreground">
|
||||
Select quantization
|
||||
</span>
|
||||
)}
|
||||
{selected?.downloaded && (
|
||||
<DotTag
|
||||
tone="success"
|
||||
|
|
@ -710,15 +781,15 @@ export function GgufDownloadCard({
|
|||
{selected &&
|
||||
selectedDownloadSizeLabel &&
|
||||
!selected.downloaded && (
|
||||
<span className="tabular-nums">
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{selectedDownloadSizeLabel}
|
||||
</span>
|
||||
)}
|
||||
<HugeiconsIcon
|
||||
icon={ChevronDownStandardIcon}
|
||||
className="ml-0.5 size-3.5 shrink-0"
|
||||
/>
|
||||
</span>
|
||||
<HugeiconsIcon
|
||||
icon={ChevronDownStandardIcon}
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
|
@ -726,7 +797,7 @@ export function GgufDownloadCard({
|
|||
side="bottom"
|
||||
sideOffset={8}
|
||||
avoidCollisions={false}
|
||||
className="hub-menu-instant menu-soft-surface w-[var(--radix-popover-trigger-width)] min-w-[200px] gap-0 overflow-hidden p-0 py-2 ring-0"
|
||||
className="hub-menu-instant menu-soft-surface w-[var(--radix-popover-trigger-width)] min-w-[300px] gap-0 overflow-hidden p-0 py-2 ring-0"
|
||||
>
|
||||
<div className="max-h-[344px] overflow-y-auto [scrollbar-width:thin]">
|
||||
{variantMenuItems.map((item) => {
|
||||
|
|
@ -760,6 +831,25 @@ export function GgufDownloadCard({
|
|||
|
||||
{!isGgufRunCta && <CardDivider />}
|
||||
|
||||
{selected?.downloaded &&
|
||||
online &&
|
||||
updateAvailable &&
|
||||
!selectedIsActive &&
|
||||
!downloadingThisVariant && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selected && setUpdateTarget(selected.quant)}
|
||||
aria-label={`Update ${repoId}`}
|
||||
className="hub-action-btn ml-1 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowReloadHorizontalIcon}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={downloadAction.disabled}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ import {
|
|||
type ModelInventoryFormat,
|
||||
deleteCachedModel,
|
||||
} from "../inventory";
|
||||
import {
|
||||
downloadManager,
|
||||
jobKeyOf,
|
||||
selectActiveJob,
|
||||
useDownloadManagerStore,
|
||||
} from "../download-manager";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { ggufVariantsMatch } from "../lib/model-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -37,16 +43,23 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
ggufVariantDisplayLabel,
|
||||
sortLocalGgufVariants,
|
||||
} from "../lib/gguf-variant-sort";
|
||||
import { DotTag } from "./dot-tag";
|
||||
import { CardDeleteButton, DeleteConfirmDialog } from "./download-card";
|
||||
import {
|
||||
CardDeleteButton,
|
||||
CardUpdateButton,
|
||||
DeleteConfirmDialog,
|
||||
UpdateConfirmDialog,
|
||||
} from "./download-card";
|
||||
import { PathInfoButton } from "./path-info-button";
|
||||
import { TransportConflictDialog } from "./transport-conflict-dialog";
|
||||
import { useCardDelete } from "./use-card-delete";
|
||||
import { useGgufVariantFetchState } from "./use-gguf-variant-fetch-state";
|
||||
import { useOnlineStatus } from "../hooks/use-online-status";
|
||||
|
||||
type LocalLoadOptions = {
|
||||
ggufVariant?: string;
|
||||
|
|
@ -196,8 +209,34 @@ export function LocalOnDeviceCard({
|
|||
onChange,
|
||||
}: LocalOnDeviceCardProps) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [updateOpen, setUpdateOpen] = useState(false);
|
||||
const [variantOpen, setVariantOpen] = useState(false);
|
||||
const [updateConflictKey, setUpdateConflictKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const updateTransportConflict = useDownloadManagerStore((state) =>
|
||||
updateConflictKey
|
||||
? (state.conflicts[updateConflictKey]?.info ?? null)
|
||||
: null,
|
||||
);
|
||||
const cancelUpdateConflict = useCallback(() => {
|
||||
if (updateConflictKey) downloadManager.cancelConflict(updateConflictKey);
|
||||
setUpdateConflictKey(null);
|
||||
}, [updateConflictKey]);
|
||||
const resumeUpdateConflict = useCallback(() => {
|
||||
if (!updateConflictKey) return;
|
||||
downloadManager.resumeConflict(updateConflictKey);
|
||||
setUpdateConflictKey(null);
|
||||
}, [updateConflictKey]);
|
||||
const restartUpdateConflict = useCallback(() => {
|
||||
if (!updateConflictKey) return;
|
||||
downloadManager.restartConflict(updateConflictKey);
|
||||
setUpdateConflictKey(null);
|
||||
}, [updateConflictKey]);
|
||||
const hfToken = useHfTokenStore((s) => s.token);
|
||||
// Update availability is derived from the GGUF variant metadata; offline rows
|
||||
// keep the button hidden because there is no remote revision to fetch.
|
||||
const online = useOnlineStatus();
|
||||
const { deleting, runDelete } = useCardDelete({
|
||||
action: async () => {
|
||||
if (!repoId) return;
|
||||
|
|
@ -221,6 +260,13 @@ export function LocalOnDeviceCard({
|
|||
enabled: needsVariantSelection,
|
||||
errorFallback: "Failed to load quantizations",
|
||||
});
|
||||
const remoteVariantState = useGgufVariantFetchState({
|
||||
repoId: repoId ?? modelId,
|
||||
hfToken,
|
||||
enabled:
|
||||
online && source === "hf_cache" && needsVariantSelection && !!repoId,
|
||||
errorFallback: "Failed to check for updates",
|
||||
});
|
||||
const variantKey = currentVariantState.key;
|
||||
const [selectedVariantState, setSelectedVariantState] = useState<{
|
||||
key: string;
|
||||
|
|
@ -232,7 +278,23 @@ export function LocalOnDeviceCard({
|
|||
|
||||
const canDelete =
|
||||
source === "hf_cache" && !!repoId && !isActive && !isLoading;
|
||||
const variants = currentVariantState.variants;
|
||||
const variants = useMemo(() => {
|
||||
const localVariants = currentVariantState.variants;
|
||||
const remoteVariants = remoteVariantState.variants;
|
||||
if (!localVariants || !remoteVariants) return localVariants;
|
||||
return localVariants.map((variant) => {
|
||||
const remoteVariant = remoteVariants.find((remote) =>
|
||||
ggufVariantsMatch(remote.quant, variant.quant),
|
||||
);
|
||||
if (!remoteVariant) return variant;
|
||||
return {
|
||||
...variant,
|
||||
download_size_bytes:
|
||||
remoteVariant.download_size_bytes || variant.download_size_bytes,
|
||||
update_available: remoteVariant.update_available === true,
|
||||
};
|
||||
});
|
||||
}, [currentVariantState.variants, remoteVariantState.variants]);
|
||||
const sortedVariants = useMemo(
|
||||
() =>
|
||||
variants
|
||||
|
|
@ -273,6 +335,54 @@ export function LocalOnDeviceCard({
|
|||
sortedVariants?.find((variant) =>
|
||||
ggufVariantsMatch(variant.quant, selectedQuant),
|
||||
) ?? null;
|
||||
// True while a managed download/update for this repo+variant is in flight.
|
||||
const updateJobActive = useDownloadManagerStore((s) =>
|
||||
repoId
|
||||
? Boolean(
|
||||
selectActiveJob(
|
||||
s,
|
||||
"model",
|
||||
repoId,
|
||||
needsVariantSelection ? selectedQuant : null,
|
||||
),
|
||||
)
|
||||
: false,
|
||||
);
|
||||
const updateTargetVariant = needsVariantSelection ? selectedQuant : null;
|
||||
const updateExpectedBytes =
|
||||
selectedVariant?.download_size_bytes ?? selectedVariant?.size_bytes ?? 0;
|
||||
const updateAvailable =
|
||||
needsVariantSelection &&
|
||||
selectedVariant?.downloaded === true &&
|
||||
selectedVariant.update_available === true;
|
||||
const canUpdate =
|
||||
online &&
|
||||
source === "hf_cache" &&
|
||||
!!repoId &&
|
||||
!isActive &&
|
||||
!isLoading &&
|
||||
!updateJobActive &&
|
||||
updateAvailable;
|
||||
// Update runs as a MANAGED download (same path as a normal download) so it
|
||||
// shows in the Downloads panel with manifest-based progress and a working
|
||||
// Cancel. The worker re-resolves `main` and pulls changed blobs while the old
|
||||
// cached copy stays runnable until the new revision verifies.
|
||||
const handleConfirmUpdate = () => {
|
||||
if (!repoId || !updateTargetVariant) return;
|
||||
setUpdateOpen(false);
|
||||
void downloadManager.requestStart({
|
||||
kind: "model",
|
||||
repoId,
|
||||
variant: updateTargetVariant,
|
||||
expectedBytes: updateExpectedBytes,
|
||||
}).then((outcome) => {
|
||||
if (outcome === "conflict") {
|
||||
setUpdateConflictKey(jobKeyOf("model", repoId, updateTargetVariant));
|
||||
}
|
||||
void currentVariantState.refresh();
|
||||
void remoteVariantState.refresh();
|
||||
});
|
||||
};
|
||||
const selectedVariantIsActive =
|
||||
needsVariantSelection && selectedQuant
|
||||
? isActive && ggufVariantsMatch(activeGgufVariant, selectedQuant)
|
||||
|
|
@ -426,6 +536,13 @@ export function LocalOnDeviceCard({
|
|||
)}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{canUpdate && (
|
||||
<CardUpdateButton
|
||||
label={`Update ${repoId}`}
|
||||
emphasized
|
||||
onClick={() => setUpdateOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<CardDeleteButton
|
||||
label={`Delete ${repoId}`}
|
||||
|
|
@ -544,6 +661,22 @@ export function LocalOnDeviceCard({
|
|||
</>
|
||||
}
|
||||
/>
|
||||
<UpdateConfirmDialog
|
||||
open={updateOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setUpdateOpen(false);
|
||||
}}
|
||||
title={`Update ${repoId}?`}
|
||||
updating={false}
|
||||
onConfirm={handleConfirmUpdate}
|
||||
description="Re-download the latest version of this model from Hugging Face. Progress shows in the Downloads panel."
|
||||
/>
|
||||
<TransportConflictDialog
|
||||
conflict={updateTransportConflict}
|
||||
onCancel={cancelUpdateConflict}
|
||||
onKeepTransport={resumeUpdateConflict}
|
||||
onSwitchTransport={restartUpdateConflict}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export interface GgufVariantDetail {
|
|||
size_bytes: number;
|
||||
download_size_bytes?: number;
|
||||
downloaded?: boolean;
|
||||
update_available?: boolean;
|
||||
partial?: boolean;
|
||||
partial_transport?: string | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue