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

* add logic for identifying out of date hf models

* add endpoint for updating hf models

* add relevant field to GgufVariantDetail

* make exception handling better

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

* hook up /update endpoint on the frontend

* implement update scenarios for the model picker

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

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

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

* fix import and make hf calls async

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

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

* remove has_vision from UpdateRequest

* fix ci

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

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

* clear cancel event before updating gguf variant

* set _cancel_event back if it was set initially

* add hf_token to get_paths_info

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

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

* studio: harden model update endpoint and update checks

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

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

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

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

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

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

Adds regression tests for the multi-revision update check.

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

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

* Studio: accept force_download kwarg in hf_xet_fallback test double

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

* Fix Studio model update regressions

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

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

* Address Studio update review feedback

* Address Studio update edge cases

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

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

* Share GGUF update status helper

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

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

* Fix GGUF update detection and cache cleanup

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

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

* Fix cached GGUF update badges

---------

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

509 lines
18 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Cached model inventory."""
from __future__ import annotations
import json
import asyncio
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import NamedTuple, Optional
from fastapi import HTTPException
from loggers import get_logger
from hub.schemas.inventory import ModelFormat
from hub.utils import inventory_scan as hf_cache_scan
from hub.utils import download_registry
from hub.utils.snapshot_filters import (
snapshot_download_blob_hashes,
snapshot_download_size,
)
from hub.services.models.common import (
_capabilities_for_format,
_classify_non_gguf_model_format,
_gguf_variant_state_summary,
_is_adapter_weight_name,
_is_checkpoint_weight_name,
_is_gguf_filename,
_is_main_gguf_filename,
_is_transformers_safetensors_weight_name,
_local_inventory_id,
_prefer_complete_larger,
_runtime_for_format,
)
logger = get_logger(__name__)
_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
_MODEL_METADATA_TIMEOUT_SECONDS = 5.0
_repo_size_cache_lock = threading.Lock()
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, "snapshot")
with _repo_size_cache_lock:
cached = _repo_size_cache.get(cache_key)
if cached is not None:
total, blob_hashes, ts = cached
if (time.monotonic() - ts) < _REPO_SIZE_POS_TTL:
_repo_size_cache.move_to_end(cache_key)
return total, blob_hashes
del _repo_size_cache[cache_key]
neg_ts = _repo_size_neg_cache.get(cache_key)
if neg_ts is not None and (time.monotonic() - neg_ts) < _REPO_SIZE_NEG_TTL:
return 0, frozenset()
try:
from huggingface_hub import HfApi
info = HfApi(token = hf_token).model_info(
repo_id,
files_metadata = True,
timeout = _MODEL_METADATA_TIMEOUT_SECONDS,
)
total = snapshot_download_size(info.siblings)
blob_hashes = snapshot_download_blob_hashes(info.siblings)
except Exception as e:
logger.warning(
"Failed to get repo size for %s: %s",
repo_id,
download_registry.scrub_secrets(str(e), hf_token = hf_token),
)
with _repo_size_cache_lock:
_repo_size_neg_cache[cache_key] = time.monotonic()
_repo_size_neg_cache.move_to_end(cache_key)
while len(_repo_size_neg_cache) > _REPO_SIZE_CACHE_MAX:
_repo_size_neg_cache.popitem(last = False)
return 0, frozenset()
with _repo_size_cache_lock:
_repo_size_cache[cache_key] = (total, blob_hashes, time.monotonic())
_repo_size_cache.move_to_end(cache_key)
_repo_size_neg_cache.pop(cache_key, None)
while len(_repo_size_cache) > _REPO_SIZE_CACHE_MAX:
_repo_size_cache.popitem(last = False)
return total, blob_hashes
def all_hf_cache_scans():
return hf_cache_scan.all_hf_cache_scans()
def _repo_gguf_size_bytes(repo_info) -> int:
"""Sum primary GGUF blob sizes across revisions, deduped by blob path (HF hardlinks shared blobs); mmproj is excluded so a vision-adapter-only repo isn't classed as GGUF."""
unique_blobs: dict[str, int] = {}
for revision in repo_info.revisions:
rev_id = getattr(revision, "commit_hash", None) or str(id(revision))
for f in revision.files:
if _is_main_gguf_filename(f.file_name):
blob_path = getattr(f, "blob_path", None)
size = f.size_on_disk or 0
if blob_path:
unique_blobs[str(blob_path)] = size
else:
unique_blobs[f"{rev_id}:{f.file_name}"] = size
return sum(unique_blobs.values())
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
return _prefer_complete_larger(
bool(candidate.get("partial")),
int(candidate.get("size_bytes") or 0),
bool(existing.get("partial")),
int(existing.get("size_bytes") or 0),
)
def _cache_inventory_fields(
repo_id: str,
model_format: ModelFormat,
*,
partial: bool = False,
requires_variant: bool = False,
) -> dict:
return {
"inventory_id": _local_inventory_id("cache", model_format, repo_id),
"load_id": repo_id,
"model_format": model_format,
"runtime": _runtime_for_format(model_format),
"format_variant": None,
"capabilities": _capabilities_for_format(
model_format,
"hf_cache",
partial = partial,
requires_variant = requires_variant,
).model_dump(),
}
def invalidate_hf_cache_scans() -> None:
hf_cache_scan.invalidate_hf_cache_scans()
def _scan_cached_gguf() -> list[dict]:
"""Synchronous HF-cache disk walk for GGUF repos; runs in a worker thread."""
cache_scans = all_hf_cache_scans()
seen_lower: dict[str, dict] = {}
for hf_cache in cache_scans:
for repo_info in hf_cache.repos:
try:
if str(repo_info.repo_type) != "model":
continue
repo_id = repo_info.repo_id
total_size = _repo_gguf_size_bytes(repo_info)
has_variant_state, variant_state_size = _gguf_variant_state_summary(repo_id)
if total_size == 0 and not has_variant_state:
continue
partial = hf_cache_scan.is_gguf_repo_partial(
repo_id,
Path(repo_info.repo_path),
)
if total_size == 0 and not partial:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
row = {
"repo_id": repo_id,
"size_bytes": max(total_size, variant_state_size),
"cache_path": str(repo_info.repo_path),
"partial": partial,
# GGUF row-level transport is ambiguous (variants may differ);
# per-variant detail lives on GgufVariantDetail.
"partial_transport": None,
}
row.update(
_cache_inventory_fields(
repo_id,
"gguf",
partial = bool(row["partial"]),
requires_variant = True,
)
)
if _prefer_cache_row(row, existing):
seen_lower[key] = row
except Exception as e:
repo_label = getattr(repo_info, "repo_id", "<unknown>")
logger.warning(f"Skipping cached GGUF repo {repo_label}: {e}")
continue
return sorted(seen_lower.values(), key = lambda c: c["repo_id"])
async def list_cached_gguf_response(hf_token: Optional[str] = None):
"""List GGUF repos downloaded to HF cache, legacy Unsloth cache, and HF default cache."""
try:
cached = await asyncio.to_thread(_scan_cached_gguf)
return {"cached": cached}
except Exception as e:
logger.error(
"Error listing cached GGUF repos: %s",
download_registry.scrub_secrets(str(e), hf_token = hf_token),
)
raise HTTPException(
status_code = 500,
detail = "Failed to read the local model cache.",
) from e
class _CachedNonGgufPayload(NamedTuple):
size_bytes: int
has_runnable_weights: bool
model_format: ModelFormat
def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
all_weight_blobs: dict[str, int] = {}
adapter_blobs: dict[str, int] = {}
safetensors_blobs: dict[str, int] = {}
checkpoint_blobs: dict[str, int] = {}
has_config = False
has_adapter_config = False
has_adapter_weights = False
has_safetensors = False
has_transformers_safetensors = False
has_checkpoint = False
def _record_blob(target: dict[str, int], file_obj, rev_id: str, file_name: str) -> None:
blob_path = getattr(file_obj, "blob_path", None)
size = int(file_obj.size_on_disk or 0)
key = str(blob_path) if blob_path else f"{rev_id}:{file_name}"
target[key] = size
all_weight_blobs[key] = size
for revision in repo_info.revisions:
rev_id = getattr(revision, "commit_hash", None) or str(id(revision))
for f in revision.files:
file_name = str(f.file_name)
lower = file_name.lower()
name = lower.replace("\\", "/").rsplit("/", 1)[-1]
if _is_gguf_filename(lower):
continue
if name == "config.json":
has_config = True
continue
if name == "adapter_config.json":
has_adapter_config = True
continue
is_adapter = _is_adapter_weight_name(name)
is_safetensors = name.endswith(".safetensors") and not is_adapter
is_checkpoint = _is_checkpoint_weight_name(name)
if is_adapter:
has_adapter_weights = True
_record_blob(adapter_blobs, f, rev_id, file_name)
if is_safetensors:
has_safetensors = True
if _is_transformers_safetensors_weight_name(name):
has_transformers_safetensors = True
_record_blob(safetensors_blobs, f, rev_id, file_name)
if is_checkpoint:
has_checkpoint = True
_record_blob(checkpoint_blobs, f, rev_id, file_name)
model_format = (
_classify_non_gguf_model_format(
has_config = has_config,
has_adapter_config = has_adapter_config,
has_adapter_weights = has_adapter_weights,
has_safetensors = has_safetensors,
has_transformers_safetensors = has_transformers_safetensors,
has_checkpoint_weights = has_checkpoint,
trusted_hf_cache_repo = True,
)
or "unknown"
)
if model_format == "adapter":
size_bytes = sum(adapter_blobs.values())
elif model_format == "safetensors":
size_bytes = sum(safetensors_blobs.values())
elif model_format == "checkpoint":
size_bytes = sum(checkpoint_blobs.values())
else:
size_bytes = sum(all_weight_blobs.values())
return _CachedNonGgufPayload(
size_bytes = size_bytes,
has_runnable_weights = model_format != "unknown",
model_format = model_format,
)
def _cached_model_snapshot_path(repo_path: Path) -> Optional[Path]:
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_path)
if not resolved:
return None
path = Path(resolved)
return path if path.is_dir() else None
def _read_json_object(path: Path) -> dict:
try:
with open(path, "r", encoding = "utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _read_model_card_frontmatter(path: Path) -> dict:
try:
text = path.read_text(encoding = "utf-8")
except Exception:
return {}
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}
body: list[str] = []
for line in lines[1:]:
if line.strip() == "---":
break
body.append(line)
if not body:
return {}
try:
import yaml
data = yaml.safe_load("\n".join(body)) or {}
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _cached_model_local_metadata(repo_path: Path) -> dict:
snapshot = _cached_model_snapshot_path(repo_path)
if snapshot is None:
return {}
result: dict = {}
config = _read_json_object(snapshot / "config.json")
quant_method = (
config.get("quantization_config", {}).get("quant_method")
if isinstance(config.get("quantization_config"), dict)
else None
)
if isinstance(quant_method, str) and quant_method.strip():
result["quant_method"] = quant_method.strip()
card = _read_model_card_frontmatter(snapshot / "README.md")
pipeline_tag = card.get("pipeline_tag")
if isinstance(pipeline_tag, str) and pipeline_tag.strip():
result["pipeline_tag"] = pipeline_tag.strip()
library_name = card.get("library_name")
if isinstance(library_name, str) and library_name.strip():
result["library_name"] = library_name.strip()
tags = card.get("tags")
if isinstance(tags, list):
clean_tags = [tag.strip() for tag in tags if isinstance(tag, str) and tag.strip()]
if clean_tags:
result["tags"] = clean_tags
return result
def _scan_cached_models() -> list[dict]:
"""Synchronous HF-cache disk walk for non-GGUF model repos; runs in a worker thread."""
cache_scans = all_hf_cache_scans()
seen_lower: dict[str, dict] = {}
inspected = 0
skipped_gguf = 0
skipped_no_weights = 0
for hf_cache in cache_scans:
for repo_info in hf_cache.repos:
inspected += 1
try:
if str(repo_info.repo_type) != "model":
continue
repo_id = repo_info.repo_id
has_main_gguf = _repo_has_gguf_files(repo_info)
payload = _repo_non_gguf_model_payload(repo_info)
if payload.size_bytes == 0:
if has_main_gguf:
skipped_gguf += 1
continue
if not payload.has_runnable_weights:
skipped_no_weights += 1
continue
key = repo_id.lower()
existing = seen_lower.get(key)
repo_path = Path(repo_info.repo_path)
snapshot_partial = hf_cache_scan.is_snapshot_partial(
"model",
repo_id,
repo_path,
)
row = {
"repo_id": repo_id,
"size_bytes": payload.size_bytes,
"cache_path": str(repo_info.repo_path),
"partial": snapshot_partial,
"partial_transport": (
hf_cache_scan.partial_transport_for(
"model",
repo_id,
repo_cache_dir = repo_path,
)
if snapshot_partial
else None
),
**_cached_model_local_metadata(repo_path),
}
row.update(
_cache_inventory_fields(
repo_id,
payload.model_format,
partial = bool(row["partial"]),
)
)
if _prefer_cache_row(row, existing):
seen_lower[key] = row
except Exception as e:
repo_label = getattr(repo_info, "repo_id", "<unknown>")
logger.warning(f"Skipping cached model repo {repo_label}: {e}")
continue
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
logger.info(
"Cached model scan: inspected=%d skipped_gguf=%d skipped_no_weights=%d returned=%d",
inspected,
skipped_gguf,
skipped_no_weights,
len(cached),
)
return cached
async def list_cached_models_response(hf_token: Optional[str] = None):
"""List non-GGUF model repos downloaded to HF cache, legacy Unsloth cache, and HF default cache."""
try:
cached = await asyncio.to_thread(_scan_cached_models)
return {"cached": cached}
except Exception as e:
logger.error(
"Error listing cached models: %s",
download_registry.scrub_secrets(str(e), hf_token = hf_token),
)
raise HTTPException(
status_code = 500,
detail = "Failed to read the local model cache.",
) from e