* Studio: fix the permanent GGUF "update available" on no-symlink caches Without the symlink privilege (the default on Windows with Developer Mode OFF), hf_hub_download MOVES the downloaded blob into snapshots/ instead of symlinking it out of blobs/, so blobs/ is left empty and scan_cache_dir reports blob_path = the snapshot file itself. Path(blob_path).name is then the GGUF FILENAME, not the file's etag. _repo_gguf_blob_map recorded that filename as the file's local blob hash, so _variant_update_available_from_requirement's `remote_sha256 in local_set` test could never match and every cached GGUF reported "update available" forever. Re-downloading could not clear it: the same file is rewritten, still with no blob. Only treat Path(blob_path).name as a hash when the file really lives in the cache's blobs/ dir; otherwise record a size identity so the file still appears in the map (dropping it would make the update check read it as absent and report the same phantom update). The comparison falls back to the remote ExpectedFile.size only when the cached file carries no blob hash, so the blob-hash path is unchanged wherever HF does produce blobs. A remote requant that keeps the byte size identical is not detected in that layout; re-hashing multi-GB GGUFs on the inventory hot path is the only stricter option. Fixes #7060 * Studio: match GGUF update checks by manifest sha256, closing the equal-size requant blind spot (#7060) On a no-symlink cache (Windows without Developer Mode) blobs/ is empty, so the update check falls back to comparing byte size. A Studio download records each file's sha256 in its manifest, so feed that into the local identity set: the check can then match by hash and detect an equal-size requant. The size fallback now applies only when no real hash is present, so a manifest hash that differs is still reported as a genuine update. Adds regression tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Revert the manifest-sha256 identity merge; keep the size-identity fallback (#7060) The download manifest is written before the transfer with the expected remote hashes, so it records download intent, not verified on-disk content, and completion is checked by size only. Merging those hashes into the local identity set could clear the update badge for an interrupted equal-size update that left the old bytes on disk. The accompanying all-size-identity gate also suppressed the size fallback whenever an older revision contributed a real blob hash, which re-showed a false update on mixed hash and size caches. Restoring the plain size-identity fallback keeps the fix without those regressions. * Studio: don't delete no-symlink GGUFs during stale-variant reclaim (#7060) On a no-symlink cache (Windows without Developer Mode) the downloaded file is moved into snapshots/ and scan_cache_dir reports its blob_path as that snapshot file, whose name is the filename, not an etag. reclaim_replaced_gguf_variant treated that name as a blob hash, which never matches the current hashes to keep, so it unlinked the freshly downloaded file. Only extract a deletable hash when the blob path is a real cache blob under the repo blobs directory, and keep any file we cannot identify; stale no-symlink revisions leak rather than risk removing the current file. Adds a regression test. * Studio: anchor GGUF blob-hash detection to the repo cache blobs dir (#7060) The inventory update check and the stale-variant reclaim both decide whether a scanned file is a real cache blob (name is the etag) or a moved no-symlink snapshot file (name is the filename). Both now share one _is_real_cache_blob helper that anchors to the repo cache blobs directory instead of matching any parent folder named blobs, so a repo that ships GGUFs under its own blobs subdir is no longer misread as the cache blob store. Threads repo_path through _repo_gguf_blob_map. Adds a regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten comments in the GGUF update-check helpers (#7060) Post-review comment pass: shorten the internal blob-identity and size-fallback docstrings. Comments only, no behavior change. --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
726 lines
26 KiB
Python
726 lines
26 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 deletion."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import errno
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException
|
|
from loggers import get_logger
|
|
|
|
from hub.utils import download_manifest
|
|
from hub.utils import download_registry
|
|
from hub.utils import inventory_scan as hf_cache_scan
|
|
from hub.utils.gguf import extract_quant_label, extract_quant_token
|
|
from hub.utils.hf_cache_state import (
|
|
INCOMPLETE_SUFFIX,
|
|
purge_partial_repo,
|
|
purge_repo_cache_dirs,
|
|
)
|
|
from hub.utils.paths import (
|
|
is_valid_gguf_variant as _is_valid_gguf_variant,
|
|
is_valid_repo_id as _is_valid_repo_id,
|
|
resolve_cached_repo_id_case,
|
|
)
|
|
from hub.services import resolve_destructive_repo_ids
|
|
from hub.services.models import cache_inventory, downloads, gguf_variants
|
|
from hub.services.models.common import (
|
|
_is_gguf_filename,
|
|
_is_main_gguf_filename,
|
|
_is_mmproj_filename,
|
|
_is_mtp_drafter_path,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _snapshot_blob_reference_counts(repo_dir: Optional[Path]) -> dict[Path, int]:
|
|
"""Map each blob's realpath to its live snapshot symlink count, so per-variant deletion never unlinks a blob another revision still references (call after the target variant's own symlinks are removed)."""
|
|
counts: dict[Path, int] = {}
|
|
if repo_dir is None:
|
|
return counts
|
|
snapshots = repo_dir / "snapshots"
|
|
if not snapshots.is_dir():
|
|
return counts
|
|
try:
|
|
entries = list(snapshots.rglob("*"))
|
|
except OSError:
|
|
return counts
|
|
for link in entries:
|
|
try:
|
|
if not link.is_symlink():
|
|
continue
|
|
target = link.resolve()
|
|
except OSError:
|
|
continue
|
|
counts[target] = counts.get(target, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _blob_hash_from_path(blob: Path) -> Optional[str]:
|
|
name = blob.name
|
|
if not name or name.endswith(INCOMPLETE_SUFFIX):
|
|
return None
|
|
return name
|
|
|
|
|
|
def _path_exists_or_symlink(path: Path) -> bool:
|
|
try:
|
|
return path.is_symlink() or path.exists()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _repo_file_matches(target_repo, predicate) -> list[tuple[Path, Optional[Path], str]]:
|
|
matches: list[tuple[Path, Optional[Path], str]] = []
|
|
for rev in getattr(target_repo, "revisions", ()):
|
|
for f in getattr(rev, "files", ()):
|
|
name = str(getattr(f, "file_name", ""))
|
|
if not predicate(name):
|
|
continue
|
|
file_path = getattr(f, "file_path", None)
|
|
if not file_path:
|
|
continue
|
|
blob_path = getattr(f, "blob_path", None)
|
|
matches.append(
|
|
(
|
|
Path(file_path),
|
|
Path(blob_path) if blob_path else None,
|
|
name,
|
|
)
|
|
)
|
|
return matches
|
|
|
|
|
|
def _has_remaining_main_gguf(target_repo) -> bool:
|
|
return any(
|
|
_path_exists_or_symlink(snap)
|
|
for snap, _blob, _name in _repo_file_matches(
|
|
target_repo,
|
|
_is_main_gguf_filename,
|
|
)
|
|
)
|
|
|
|
|
|
def _remove_empty_variant_dirs(target_repos: list, variant: str) -> tuple[int, list[str]]:
|
|
"""Remove now-empty ``snapshots/<rev>/<quant>/`` folders for *variant* (the
|
|
quant label names the folder); only empty dirs go, so siblings are safe.
|
|
Returns (count removed, removal failures other than a concurrent refill)."""
|
|
variant_key = (extract_quant_token(variant) or variant).lower()
|
|
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:
|
|
subs = list(snap.iterdir())
|
|
except OSError:
|
|
continue
|
|
for sub in subs:
|
|
try:
|
|
if sub.is_symlink() or not sub.is_dir():
|
|
continue
|
|
folder_quant = extract_quant_token(sub.name)
|
|
matches = (
|
|
folder_quant is not None and folder_quant.lower() == variant_key
|
|
) or sub.name.lower() == variant.lower()
|
|
if not matches or any(sub.iterdir()):
|
|
continue
|
|
except OSError:
|
|
continue
|
|
try:
|
|
sub.rmdir()
|
|
removed += 1
|
|
except OSError as e:
|
|
# A concurrent download refilling the dir (ENOTEMPTY) is not a
|
|
# failure; a read-only cache or locked dir is, so surface it.
|
|
if e.errno != errno.ENOTEMPTY:
|
|
failures.append(f"{sub.name}: {e}")
|
|
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,
|
|
target_repos: list,
|
|
hf_token: Optional[str],
|
|
*,
|
|
sibling_active: bool = False,
|
|
) -> dict:
|
|
failures: list[str] = []
|
|
removed_snapshots = 0
|
|
deleted_bytes = 0
|
|
deleted_blobs = 0
|
|
completed_hashes: set[str] = set()
|
|
|
|
for target_repo in target_repos:
|
|
repo_dir = Path(target_repo.repo_path) if getattr(target_repo, "repo_path", None) else None
|
|
matched = _repo_file_matches(
|
|
target_repo,
|
|
lambda name: _is_main_gguf_filename(name)
|
|
and extract_quant_label(name).lower() == variant.lower(),
|
|
)
|
|
|
|
for snap, _blob, name in matched:
|
|
try:
|
|
if _path_exists_or_symlink(snap):
|
|
snap.unlink()
|
|
removed_snapshots += 1
|
|
except OSError as e:
|
|
failures.append(f"{name}: {e}")
|
|
|
|
companion_matches: list[tuple[Path, Optional[Path], str]] = []
|
|
if matched and not sibling_active and not _has_remaining_main_gguf(target_repo):
|
|
companion_matches = _repo_file_matches(
|
|
target_repo,
|
|
# Companions: mmproj and the MTP drafter -- downloaded with
|
|
# every variant, so the last variant's delete reclaims them.
|
|
lambda name: _is_gguf_filename(name)
|
|
and (_is_mmproj_filename(name) or _is_mtp_drafter_path(name)),
|
|
)
|
|
for snap, _blob, name in companion_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 [*matched, *companion_matches]:
|
|
if blob is None:
|
|
continue
|
|
blob_hash = _blob_hash_from_path(blob)
|
|
if blob_hash:
|
|
completed_hashes.add(blob_hash)
|
|
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}")
|
|
|
|
if failures:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
f"Couldn't fully delete {variant} for {repo_id}: "
|
|
f"{len(failures)} file(s) are in use. "
|
|
"Unload the model and try again."
|
|
),
|
|
)
|
|
|
|
incomplete_result = gguf_variants.delete_variant_incomplete_blobs_result(
|
|
repo_id,
|
|
variant,
|
|
hf_token,
|
|
extra_hashes = frozenset(completed_hashes),
|
|
companions = not sibling_active,
|
|
)
|
|
if incomplete_result.unresolved:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
f"Couldn't fully delete {variant} for {repo_id}: partial "
|
|
"download bytes exist but this variant's blob hashes are unavailable. "
|
|
"Reconnect or provide access to the repo, then try again."
|
|
),
|
|
)
|
|
|
|
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,
|
|
detail = (
|
|
f"Couldn't fully delete {variant} for {repo_id}: "
|
|
f"{len(dir_failures)} folder(s) could not be removed "
|
|
"(read-only cache or in use). Try again."
|
|
),
|
|
)
|
|
if (
|
|
removed_snapshots == 0
|
|
and deleted_blobs == 0
|
|
and incomplete_result.deleted == 0
|
|
and not state_purged
|
|
and removed_dirs == 0
|
|
):
|
|
raise HTTPException(
|
|
status_code = 404,
|
|
detail = f"Variant {variant} not found in cache for {repo_id}",
|
|
)
|
|
|
|
freed_mb = deleted_bytes / (1024 * 1024)
|
|
logger.info(
|
|
f"Deleted {removed_snapshots} file(s) for {repo_id} variant {variant}: "
|
|
f"{freed_mb:.1f} MB freed"
|
|
)
|
|
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:
|
|
# Prune only a file we can identify as a real, stale cache blob. A
|
|
# no-symlink snapshot file has no identifiable blob hash, so keep it.
|
|
blob_hash = (
|
|
_blob_hash_from_path(blob)
|
|
if cache_inventory._is_real_cache_blob(blob, repo_dir)
|
|
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()
|
|
lid = loaded_id.lower()
|
|
return lid == rid or lid.startswith(f"{rid}/")
|
|
|
|
|
|
def _loaded_repo_variant_blocks_delete(
|
|
loaded_id: str, repo_id: str, delete_variant: Optional[str], loaded_variant: Optional[str]
|
|
) -> bool:
|
|
if not _loaded_id_matches_repo(loaded_id, repo_id):
|
|
return False
|
|
if not delete_variant:
|
|
return True
|
|
if not loaded_variant:
|
|
return True
|
|
return loaded_variant.lower() == delete_variant.lower()
|
|
|
|
|
|
_LOAD_STATE_UNVERIFIABLE_DETAIL = (
|
|
"Couldn't verify whether this model is still loaded for inference. "
|
|
"Unload it if it is active, then try deleting again."
|
|
)
|
|
|
|
|
|
def _llama_cpp_blocks_delete(repo_id: str, variant: Optional[str]) -> bool:
|
|
"""Whether the llama.cpp backend holds *repo_id* (/variant). Acquiring fails open (import error means nothing loaded); reading load state is unguarded so a raise propagates and the caller fails closed rather than delete a live model."""
|
|
try:
|
|
from routes.inference import get_llama_cpp_backend
|
|
backend = get_llama_cpp_backend()
|
|
except Exception as e:
|
|
logger.debug(f"llama.cpp backend unavailable during delete guard for {repo_id}: {e}")
|
|
return False
|
|
loaded_id = backend.model_identifier
|
|
loaded_variant = getattr(backend, "hf_variant", None)
|
|
if backend.is_active and not backend.is_loaded and loaded_id:
|
|
return _loaded_repo_variant_blocks_delete(
|
|
loaded_id,
|
|
repo_id,
|
|
variant,
|
|
loaded_variant,
|
|
)
|
|
if backend.is_loaded and loaded_id:
|
|
return _loaded_repo_variant_blocks_delete(
|
|
loaded_id,
|
|
repo_id,
|
|
variant,
|
|
loaded_variant,
|
|
)
|
|
return False
|
|
|
|
|
|
def _inference_backend_blocks_delete(repo_id: str) -> bool:
|
|
"""Whether the subprocess inference backend holds *repo_id*; same fail-open-on-acquire / surface-on-query contract as :func:`_llama_cpp_blocks_delete`."""
|
|
try:
|
|
from core.inference import get_inference_backend
|
|
backend = get_inference_backend()
|
|
except Exception as e:
|
|
logger.debug(f"Inference backend unavailable during delete guard for {repo_id}: {e}")
|
|
return False
|
|
active_name = backend.active_model_name
|
|
return bool(active_name) and _loaded_id_matches_repo(active_name, repo_id)
|
|
|
|
|
|
async def delete_cached_model_response(
|
|
repo_id: str,
|
|
variant: Optional[str] = None,
|
|
hf_token: Optional[str] = None,
|
|
):
|
|
"""Delete a cached model repo (or a specific GGUF variant) from the HF cache.
|
|
|
|
When *variant* is provided, only the GGUF files matching that quant label
|
|
are removed (e.g. ``UD-Q4_K_XL``). Otherwise the entire repo is deleted.
|
|
Refuses if the model is currently loaded for inference.
|
|
"""
|
|
if not _is_valid_repo_id(repo_id):
|
|
raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
|
|
variant = (variant or "").strip() or None
|
|
if variant is not None and not _is_valid_gguf_variant(variant):
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = f"Invalid gguf_variant: {variant!r}",
|
|
)
|
|
|
|
# Guard fails closed: if a live backend's load state can't be read, abort
|
|
# with 503 rather than risk unlinking weights under a running process.
|
|
try:
|
|
blocks_delete = _llama_cpp_blocks_delete(repo_id, variant) or (
|
|
_inference_backend_blocks_delete(repo_id)
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Load-state verification failed for {repo_id}; refusing delete: {e}")
|
|
raise HTTPException(
|
|
status_code = 503,
|
|
detail = _LOAD_STATE_UNVERIFIABLE_DETAIL,
|
|
)
|
|
if blocks_delete:
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = "Unload the model before deleting",
|
|
)
|
|
|
|
repo_key = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
|
|
if not downloads.registry.begin_delete(repo_key, variant):
|
|
detail = (
|
|
f"Cancel the {variant} download before deleting it."
|
|
if variant is not None
|
|
else "Cancel the active downloads before deleting."
|
|
)
|
|
raise HTTPException(status_code = 400, detail = detail)
|
|
try:
|
|
return await asyncio.to_thread(_delete_cached_model_blocking, repo_id, variant, hf_token)
|
|
finally:
|
|
downloads.registry.end_delete(repo_key, variant)
|
|
cache_inventory.invalidate_hf_cache_scans()
|
|
|
|
|
|
def _delete_cached_model_blocking(
|
|
repo_id: str, variant: Optional[str], hf_token: Optional[str]
|
|
) -> dict:
|
|
try:
|
|
# If a sibling quant is downloading concurrently, restrict this delete to
|
|
# the variant's own files and leave the shared mmproj companion for it.
|
|
sibling_active = bool(
|
|
variant and downloads.registry.has_active_peer_variant(repo_id, variant)
|
|
)
|
|
|
|
cache_scans = cache_inventory.all_hf_cache_scans()
|
|
|
|
candidate_entries = []
|
|
for hf_cache in cache_scans:
|
|
for repo_info in hf_cache.repos:
|
|
if str(repo_info.repo_type) != "model":
|
|
continue
|
|
if repo_info.repo_id.lower() == repo_id.lower():
|
|
candidate_entries.append((hf_cache, repo_info))
|
|
|
|
matched_repo_ids = resolve_destructive_repo_ids(
|
|
repo_id,
|
|
[str(repo_info.repo_id) for _hf_cache, repo_info in candidate_entries],
|
|
noun = "models",
|
|
)
|
|
target_entries = [
|
|
(hf_cache, repo_info)
|
|
for hf_cache, repo_info in candidate_entries
|
|
if str(repo_info.repo_id) in matched_repo_ids
|
|
]
|
|
|
|
if not target_entries:
|
|
if variant is None:
|
|
cache_purged = purge_repo_cache_dirs("model", repo_id) or purge_partial_repo(
|
|
"model", repo_id
|
|
)
|
|
state_purged = download_manifest.purge_all_state_for_repo("model", repo_id) > 0
|
|
if cache_purged or state_purged:
|
|
return {"status": "deleted", "repo_id": repo_id}
|
|
if variant:
|
|
incomplete_result = gguf_variants.delete_variant_incomplete_blobs_result(
|
|
repo_id,
|
|
variant,
|
|
hf_token,
|
|
companions = not sibling_active,
|
|
)
|
|
if incomplete_result.unresolved:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
f"Couldn't fully delete {variant} for {repo_id}: partial "
|
|
"download bytes exist but this variant's blob hashes are unavailable. "
|
|
"Reconnect or provide access to the repo, then try again."
|
|
),
|
|
)
|
|
state_purged = download_manifest.purge_state(
|
|
"model",
|
|
repo_id,
|
|
variant,
|
|
)
|
|
if incomplete_result.deleted > 0 or state_purged:
|
|
return {
|
|
"status": "deleted",
|
|
"repo_id": repo_id,
|
|
"variant": variant,
|
|
}
|
|
raise HTTPException(status_code = 404, detail = "Model not found in cache")
|
|
|
|
if variant:
|
|
return _delete_gguf_variant_from_repos(
|
|
repo_id,
|
|
variant,
|
|
[repo for _cache, repo in target_entries],
|
|
hf_token,
|
|
sibling_active = sibling_active,
|
|
)
|
|
|
|
deleted_revisions = False
|
|
for hf_cache, repo_info in target_entries:
|
|
revision_hashes = [
|
|
rev.commit_hash for rev in repo_info.revisions if getattr(rev, "commit_hash", None)
|
|
]
|
|
if not revision_hashes:
|
|
continue
|
|
delete_strategy = hf_cache.delete_revisions(*revision_hashes)
|
|
logger.info(
|
|
f"Deleting cached model {repo_id} from "
|
|
f"{getattr(hf_cache, 'cache_dir', '<unknown>')}: "
|
|
f"{delete_strategy.expected_freed_size_str} will be freed"
|
|
)
|
|
delete_strategy.execute()
|
|
deleted_revisions = True
|
|
|
|
cache_purged = purge_repo_cache_dirs("model", repo_id)
|
|
partial_purged = purge_partial_repo("model", repo_id)
|
|
state_purged = download_manifest.purge_all_state_for_repo("model", repo_id) > 0
|
|
|
|
if not (deleted_revisions or cache_purged or partial_purged or state_purged):
|
|
raise HTTPException(status_code = 404, detail = "No revisions found for model")
|
|
|
|
return {"status": "deleted", "repo_id": repo_id}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
"Error deleting cached model %s: %s",
|
|
repo_id,
|
|
download_registry.scrub_secrets(str(e), hf_token = hf_token),
|
|
)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to delete cached model: "
|
|
+ download_registry.scrub_secrets(str(e), hf_token = hf_token),
|
|
)
|