Seven fixes from the latest review round on the Images page and the hub cache inventory. Images page: - The lost-POST settle path built its "already seen" gallery id set inside the catch, after the request failed. By then the earlier runs of the same batch had already prepended their records, so run 2 could accept run 1's image as proof that its own request reached the backend. The set is now captured once before the first POST and grows with every record the batch produces. - settleLostGeneration fell out of its SETTLE_MAX_MS loop and returned normally, so a wedged generation was counted as done and the next run started against a busy backend. It now throws on timeout. - Restoring a recipe cleared the ControlNet selection but left the workflow tab and the init / mask / reference images pointing at whatever was loaded, so the next Generate conditioned on an unrelated image. It now clears all of them and returns to Create. - The download plan omitted the adapter selection the load itself bakes in. A baked LoRA forces the dense build path, so the plan described a different file set than the load that followed and the rest was pulled inline, outside the download manager. Both now derive the list from one helper. Hub cache inventory: - A download for a repo an Images or Video load is staging was allowed to start: only the llama.cpp loader was consulted. Both diffusion backends already expose loading_repo_ids for the delete guard, and the download guard now reads them too. - A companion-only prefetch (pipeline manifest plus VAE and text encoder, no transformer) passed the snapshot-partial check, since every file its manifest expected did arrive, and was advertised as on-device although from_pretrained cannot load it. - The single-file flag never reached the picker through the hub inventory path, so a checkpoint-only diffusion repo read as a full pipeline and failed after the handoff. The two pipeline-shape helpers now live in hub/utils/inventory_scan.py so /api/models/cached and the hub inventory classify the same repos the same way.
736 lines
29 KiB
Python
736 lines
29 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_mmproj_filename,
|
|
_is_transformers_safetensors_weight_name,
|
|
_local_inventory_id,
|
|
_runtime_for_format,
|
|
)
|
|
|
|
# Imported at module scope (not inside the per-repo scan loop) so a broken
|
|
# import surfaces at startup instead of silently emptying the inventory: the
|
|
# scan loop swallows per-repo exceptions and would drop every repo. Lives under
|
|
# ``utils`` (not ``utils.models``) to avoid the eager model-config/checkpoint
|
|
# imports in ``utils/models/__init__.py``.
|
|
from utils.hidden_models import is_hidden_model
|
|
|
|
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()
|
|
|
|
# Identity for a cached file with no HF blob (Windows without Developer Mode: hf
|
|
# moves the blob into snapshots/ and leaves blobs/ empty).
|
|
_LOCAL_SIZE_IDENTITY_PREFIX = "size:"
|
|
|
|
|
|
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 _blob_mtime(file_obj) -> float:
|
|
ts = getattr(file_obj, "blob_last_modified", None)
|
|
if isinstance(ts, (int, float)) and ts > 0:
|
|
return float(ts)
|
|
blob_path = getattr(file_obj, "blob_path", None)
|
|
if blob_path:
|
|
try:
|
|
return float(Path(blob_path).stat().st_mtime)
|
|
except OSError:
|
|
pass
|
|
return 0.0
|
|
|
|
|
|
def _repo_gguf_last_modified(repo_info) -> float:
|
|
latest = 0.0
|
|
for revision in repo_info.revisions:
|
|
for f in revision.files:
|
|
if _is_main_gguf_filename(f.file_name):
|
|
latest = max(latest, _blob_mtime(f))
|
|
return latest
|
|
|
|
|
|
def _repo_has_mmproj(repo_info) -> bool:
|
|
# An mmproj file only makes a repo vision-capable when it is an actual GGUF
|
|
# projector; a non-GGUF sidecar (e.g. mmproj_config.json) does not, and the
|
|
# runtime's projector detection is GGUF-only.
|
|
return any(
|
|
_is_gguf_filename(f.file_name) and _is_mmproj_filename(f.file_name)
|
|
for revision in repo_info.revisions
|
|
for f in revision.files
|
|
)
|
|
|
|
|
|
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 _is_real_cache_blob(blob: Optional[Path], repo_dir: Optional[Path]) -> bool:
|
|
"""True only for a real cache blob at ``<repo_dir>/blobs/<etag>``.
|
|
|
|
A no-symlink ``snapshots/`` file (name is the filename, not an etag) or a
|
|
repo's own ``blobs/`` subdir is not the cache blob store.
|
|
"""
|
|
if blob is None or repo_dir is None:
|
|
return False
|
|
try:
|
|
return blob.parent.resolve(strict = False) == (repo_dir / "blobs").resolve(strict = False)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _cached_blob_hash(blob_path, repo_path = None) -> Optional[str]:
|
|
"""The cache blob hash (etag) for a cached file, or None when there is no blob.
|
|
|
|
Only a real blob under the repo's ``blobs/`` dir has name == hash; a moved
|
|
no-symlink ``snapshots/`` file is "no blob", so the caller uses a size identity.
|
|
"""
|
|
path = Path(blob_path)
|
|
repo_dir = Path(repo_path) if repo_path is not None else None
|
|
return path.name if _is_real_cache_blob(path, repo_dir) else None
|
|
|
|
|
|
def local_size_identity(size: int) -> str:
|
|
"""Identity for a cached file whose blob hash is unknowable: its size.
|
|
|
|
Re-hashing multi-GB GGUFs on the inventory hot path is not viable, and a
|
|
``size:`` token never collides with a hex hash.
|
|
"""
|
|
return f"{_LOCAL_SIZE_IDENTITY_PREFIX}{int(size)}"
|
|
|
|
|
|
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
|
|
identities across all revisions.
|
|
|
|
An identity is the file's blob hash, or a size identity when the cache holds no
|
|
blob (Windows without Developer Mode). BOTH old and new revision blobs are kept
|
|
(a set), so the diff treats the file as current when the remote ``main`` blob is
|
|
in any cached revision. Main GGUF only by default; update checks opt into
|
|
companions to compare a shared mmproj/MTP blob too.
|
|
"""
|
|
blob_map: dict[str, set[str]] = {}
|
|
repo_path = getattr(repo_info, "repo_path", None)
|
|
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)
|
|
identity = _cached_blob_hash(blob_path, repo_path)
|
|
if identity is None:
|
|
size = int(getattr(f, "size_on_disk", 0) or 0)
|
|
if size <= 0:
|
|
continue
|
|
identity = local_size_identity(size)
|
|
blob_map.setdefault(name, set()).add(identity)
|
|
return blob_map
|
|
|
|
|
|
def _prefer_cache_row(candidate: dict, existing: Optional[dict]) -> bool:
|
|
if existing is None:
|
|
return True
|
|
candidate_partial = bool(candidate.get("partial"))
|
|
existing_partial = bool(existing.get("partial"))
|
|
if candidate_partial != existing_partial:
|
|
return not candidate_partial
|
|
candidate_active = bool(candidate.get("active_cache"))
|
|
existing_active = bool(existing.get("active_cache"))
|
|
if candidate_active != existing_active:
|
|
return candidate_active
|
|
return int(candidate.get("size_bytes") or 0) > int(existing.get("size_bytes") or 0)
|
|
|
|
|
|
def _cache_inventory_fields(
|
|
repo_id: str,
|
|
model_format: ModelFormat,
|
|
*,
|
|
repo_path: Optional[Path] = None,
|
|
snapshot_path: Optional[Path] = None,
|
|
active_hub_cache: Optional[Path] = None,
|
|
partial: bool = False,
|
|
requires_variant: bool = False,
|
|
) -> dict:
|
|
load_id = repo_id
|
|
active_cache = True
|
|
if repo_path is not None:
|
|
try:
|
|
if active_hub_cache is None:
|
|
from utils.hf_cache_settings import get_hf_cache_paths
|
|
active_hub_cache = get_hf_cache_paths().hub_cache
|
|
active_root = active_hub_cache.resolve(strict = False)
|
|
cached_root = repo_path.parent.resolve(strict = False)
|
|
if cached_root != active_root:
|
|
active_cache = False
|
|
load_id = str(snapshot_path or repo_path.resolve(strict = False))
|
|
except (OSError, RuntimeError, ValueError):
|
|
active_cache = False
|
|
load_id = str(snapshot_path or repo_path)
|
|
return {
|
|
"inventory_id": _local_inventory_id("cache", model_format, repo_id),
|
|
"load_id": load_id,
|
|
"active_cache": active_cache,
|
|
"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 _is_hidden_infra_repo(*values: str | None) -> bool:
|
|
"""True for infra-only repos (the RAG embedder and the llama.cpp install
|
|
validation probe) that are cached as a side effect of Studio itself and are
|
|
not usable chat models."""
|
|
return is_hidden_model(*values)
|
|
|
|
|
|
def _cached_row_task(repo_info, *, gguf: bool) -> Optional[str]:
|
|
"""Pipeline task for a cached row, from the same classifiers the models API uses.
|
|
|
|
The Images/Video pickers filter On Device rows on this and the chat picker routes a diffusion
|
|
pick by it, so a row that arrives without one is dropped from those lists entirely. Imported
|
|
lazily (routes.models imports this package) and best-effort: an unreadable repo just has no
|
|
task, exactly as before.
|
|
"""
|
|
try:
|
|
from routes.models import _cached_repo_task, _repo_gguf_task
|
|
return _repo_gguf_task(repo_info) if gguf else _cached_repo_task(repo_info)
|
|
except Exception: # noqa: BLE001 -- a classification failure never hides a row
|
|
return None
|
|
|
|
|
|
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()
|
|
from utils.hf_cache_settings import get_hf_cache_paths
|
|
|
|
active_hub_cache = get_hf_cache_paths().hub_cache
|
|
|
|
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
|
|
repo_path = Path(repo_info.repo_path)
|
|
snapshot_path = _cached_model_snapshot_path(repo_path)
|
|
total_size = _repo_gguf_size_bytes(repo_info)
|
|
has_variant_state, variant_state_size = _gguf_variant_state_summary(
|
|
repo_id,
|
|
hub_cache = repo_path.parent,
|
|
)
|
|
is_hidden_infra = _is_hidden_infra_repo(
|
|
repo_id,
|
|
str(repo_path),
|
|
str(snapshot_path) if snapshot_path is not None else None,
|
|
)
|
|
# Hide infra repos unless the user downloaded a variant via
|
|
# the Hub; variant state only exists for user downloads.
|
|
if is_hidden_infra and not has_variant_state:
|
|
continue
|
|
if total_size == 0 and not has_variant_state:
|
|
continue
|
|
partial = hf_cache_scan.is_gguf_repo_partial(
|
|
repo_id,
|
|
repo_path,
|
|
)
|
|
if total_size == 0 and not partial:
|
|
continue
|
|
key = repo_id.lower()
|
|
existing = seen_lower.get(key)
|
|
last_modified = _repo_gguf_last_modified(repo_info)
|
|
row = {
|
|
"repo_id": repo_id,
|
|
"size_bytes": max(total_size, variant_state_size),
|
|
"cache_path": str(repo_info.repo_path),
|
|
"task": _cached_row_task(repo_info, gguf = True),
|
|
"partial": partial,
|
|
# GGUF row-level transport is ambiguous (variants may differ);
|
|
# per-variant detail lives on GgufVariantDetail.
|
|
"partial_transport": None,
|
|
}
|
|
last_modified = max(last_modified, (existing or {}).get("last_modified", 0.0))
|
|
if last_modified > 0:
|
|
row["last_modified"] = last_modified
|
|
row.update(
|
|
_cache_inventory_fields(
|
|
repo_id,
|
|
"gguf",
|
|
repo_path = repo_path,
|
|
snapshot_path = snapshot_path,
|
|
active_hub_cache = active_hub_cache,
|
|
partial = bool(row["partial"]),
|
|
requires_variant = True,
|
|
)
|
|
)
|
|
if _repo_has_mmproj(repo_info):
|
|
row["capabilities"]["supports_vision"] = True
|
|
# Visible infra variants remain management-only.
|
|
if is_hidden_infra:
|
|
row["capabilities"]["can_chat"] = False
|
|
if _prefer_cache_row(row, existing):
|
|
if existing and existing["capabilities"].get("supports_vision"):
|
|
row["capabilities"]["supports_vision"] = True
|
|
seen_lower[key] = row
|
|
else:
|
|
if last_modified > existing.get("last_modified", 0.0):
|
|
existing["last_modified"] = last_modified
|
|
if row["capabilities"].get("supports_vision"):
|
|
existing["capabilities"]["supports_vision"] = True
|
|
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
|
|
last_modified: float
|
|
|
|
|
|
def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|
all_weight_blobs: dict[str, tuple[int, float]] = {}
|
|
adapter_blobs: dict[str, tuple[int, float]] = {}
|
|
safetensors_blobs: dict[str, tuple[int, float]] = {}
|
|
checkpoint_blobs: dict[str, tuple[int, float]] = {}
|
|
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, tuple[int, float]], 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}"
|
|
value = (size, _blob_mtime(file_obj))
|
|
target[key] = value
|
|
all_weight_blobs[key] = value
|
|
|
|
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":
|
|
selected_blobs = adapter_blobs
|
|
elif model_format == "safetensors":
|
|
selected_blobs = safetensors_blobs
|
|
elif model_format == "checkpoint":
|
|
selected_blobs = checkpoint_blobs
|
|
else:
|
|
selected_blobs = all_weight_blobs
|
|
|
|
return _CachedNonGgufPayload(
|
|
size_bytes = sum(size for size, _mtime in selected_blobs.values()),
|
|
has_runnable_weights = model_format != "unknown",
|
|
model_format = model_format,
|
|
last_modified = max((mtime for _size, mtime in selected_blobs.values()), default = 0.0),
|
|
)
|
|
|
|
|
|
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 _is_whisper_model_config(config: object) -> bool:
|
|
if not isinstance(config, dict):
|
|
return False
|
|
model_type = config.get("model_type")
|
|
if isinstance(model_type, str) and model_type.strip().lower() == "whisper":
|
|
return True
|
|
architectures = config.get("architectures")
|
|
return isinstance(architectures, list) and any(
|
|
isinstance(name, str) and name == "WhisperForConditionalGeneration"
|
|
for name in architectures
|
|
)
|
|
|
|
|
|
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")
|
|
if _is_whisper_model_config(config):
|
|
result["_hidden_stt"] = True
|
|
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()
|
|
from utils.hf_cache_settings import get_hf_cache_paths
|
|
|
|
active_hub_cache = get_hf_cache_paths().hub_cache
|
|
|
|
seen_lower: dict[str, dict] = {}
|
|
inspected = 0
|
|
skipped_gguf = 0
|
|
skipped_no_weights = 0
|
|
skipped_stt = 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
|
|
repo_path = Path(repo_info.repo_path)
|
|
snapshot_path = _cached_model_snapshot_path(repo_path)
|
|
# The non-GGUF embedder has no variant downloads; always hide.
|
|
if _is_hidden_infra_repo(
|
|
repo_id,
|
|
str(repo_path),
|
|
str(snapshot_path) if snapshot_path is not None else None,
|
|
):
|
|
continue
|
|
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)
|
|
local_metadata = _cached_model_local_metadata(repo_path)
|
|
if local_metadata.pop("_hidden_stt", False):
|
|
skipped_stt += 1
|
|
continue
|
|
download_partial = hf_cache_scan.is_snapshot_partial(
|
|
"model",
|
|
repo_id,
|
|
repo_path,
|
|
)
|
|
# A companion-only prefetch (pipeline manifest + VAE / text-encoder but no transformer
|
|
# shards, left behind by a GGUF load) passes the download check -- every file its
|
|
# manifest expected did arrive -- yet from_pretrained cannot load it. Mark it partial
|
|
# so the pickers do not advertise it as on-device, matching /api/models/cached.
|
|
companion_only = hf_cache_scan.repo_pipeline_missing_denoiser(repo_info)
|
|
snapshot_partial = download_partial or companion_only
|
|
row_task = _cached_row_task(repo_info, gguf = False)
|
|
row = {
|
|
"repo_id": repo_id,
|
|
"size_bytes": payload.size_bytes,
|
|
"cache_path": str(repo_info.repo_path),
|
|
"task": row_task,
|
|
"partial": snapshot_partial,
|
|
"partial_transport": (
|
|
hf_cache_scan.partial_transport_for(
|
|
"model",
|
|
repo_id,
|
|
repo_cache_dir = repo_path,
|
|
)
|
|
# Only a genuine download partial has a transport; a companion-only
|
|
# snapshot arrived intact and has no Resume / Redownload story.
|
|
if download_partial
|
|
else None
|
|
),
|
|
# Diffusion repos with no pipeline index load only via from_single_file, so the
|
|
# task pickers must not offer them as pipeline loads. Same rule as
|
|
# /api/models/cached; without it here, the picker's single_file gate sees
|
|
# undefined on every hub-sourced row and lets a checkpoint-only repo through.
|
|
"single_file": bool(
|
|
row_task is not None
|
|
and not hf_cache_scan.repo_has_pipeline_index(repo_info)
|
|
),
|
|
**local_metadata,
|
|
}
|
|
last_modified = max(
|
|
payload.last_modified,
|
|
(existing or {}).get("last_modified", 0.0),
|
|
)
|
|
if last_modified > 0:
|
|
row["last_modified"] = last_modified
|
|
row.update(
|
|
_cache_inventory_fields(
|
|
repo_id,
|
|
payload.model_format,
|
|
repo_path = repo_path,
|
|
snapshot_path = snapshot_path,
|
|
active_hub_cache = active_hub_cache,
|
|
partial = bool(row["partial"]),
|
|
)
|
|
)
|
|
if _prefer_cache_row(row, existing):
|
|
seen_lower[key] = row
|
|
elif last_modified > existing.get("last_modified", 0.0):
|
|
existing["last_modified"] = last_modified
|
|
except Exception as e:
|
|
repo_label = getattr(repo_info, "repo_id", "<unknown>")
|
|
logger.warning(f"Skipping cached model repo {repo_label}: {e}")
|
|
continue
|
|
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
|
|
logger.info(
|
|
"Cached model scan: inspected=%d skipped_gguf=%d skipped_no_weights=%d "
|
|
"skipped_stt=%d returned=%d",
|
|
inspected,
|
|
skipped_gguf,
|
|
skipped_no_weights,
|
|
skipped_stt,
|
|
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
|