* 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>
509 lines
18 KiB
Python
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
|