Studio: add configurable model download location (#7274)
Adds a configurable Hugging Face model download cache location to Unsloth Studio, selectable from Settings, with per-cache download manifests, scoped deletion, and read-only inventory of previously selected caches.
This commit is contained in:
parent
88583dd2ec
commit
dbb06ff60e
96 changed files with 4051 additions and 1231 deletions
|
|
@ -27,7 +27,6 @@ from .constants import (
|
|||
)
|
||||
from .parse import apply_update, coerce_event, parse_log_message
|
||||
from .types import Job
|
||||
from .worker import run_job_process
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -169,12 +168,18 @@ class JobManager:
|
|||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
|
||||
|
||||
with native_path_secret_removed_for_child_start():
|
||||
cache_env = get_hf_cache_paths().child_env({})
|
||||
|
||||
with (
|
||||
child_environment_for_spawn(cache_env),
|
||||
native_path_secret_removed_for_child_start(),
|
||||
):
|
||||
mp_q = _CTX.Queue()
|
||||
proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_job_process,),
|
||||
args = ("core.data_recipe.jobs.worker", "run_job_process", cache_env),
|
||||
kwargs = {"event_queue": mp_q, "recipe": recipe, "run": run_payload},
|
||||
daemon = True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -230,16 +230,20 @@ class ExportOrchestrator:
|
|||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
|
||||
|
||||
from .worker import run_export_process
|
||||
cache_env = get_hf_cache_paths().child_env({})
|
||||
|
||||
with native_path_secret_removed_for_child_start():
|
||||
with (
|
||||
child_environment_for_spawn(cache_env),
|
||||
native_path_secret_removed_for_child_start(),
|
||||
):
|
||||
self._cmd_queue = _CTX.Queue()
|
||||
self._resp_queue = _CTX.Queue()
|
||||
|
||||
self._proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_export_process,),
|
||||
args = ("core.export.worker", "run_export_process", cache_env),
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
|
|
|
|||
|
|
@ -76,8 +76,14 @@ class AudioCodecManager:
|
|||
if self._snac_model is not None:
|
||||
return
|
||||
from snac import SNAC
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
self._snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
|
||||
# Route weights to the selected cache; this can run in the main process.
|
||||
self._snac_model = (
|
||||
SNAC.from_pretrained("hubertsiuzdak/snac_24khz", cache_dir = active_hf_hub_cache())
|
||||
.to(device)
|
||||
.eval()
|
||||
)
|
||||
logger.info("Loaded SNAC codec (24kHz)")
|
||||
|
||||
def _load_bicodec(
|
||||
|
|
|
|||
|
|
@ -579,7 +579,14 @@ def _swa_entry_from_layer_types(lt) -> Optional[object]:
|
|||
def _fetch_swa_entry_from_hf(repo_id: str) -> Optional[object]:
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
cfg_path = hf_hub_download(repo_id, "config.json", repo_type = "model")
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
cfg_path = hf_hub_download(
|
||||
repo_id,
|
||||
"config.json",
|
||||
repo_type = "model",
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
with open(cfg_path) as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
|
|
@ -981,6 +988,7 @@ def _cached_hf_snapshot_file(
|
|||
filename: str,
|
||||
*,
|
||||
expected_size: Optional[int] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a cached snapshot file even when HF's current-ref probe misses it."""
|
||||
if not filename:
|
||||
|
|
@ -989,8 +997,22 @@ def _cached_hf_snapshot_file(
|
|||
if not parts or any(part in (".", "..") for part in parts):
|
||||
return None
|
||||
try:
|
||||
from utils.models.model_config import _iter_hf_cache_snapshots
|
||||
for snap in _iter_hf_cache_snapshots(repo_id):
|
||||
if cache_dir is None:
|
||||
from utils.models.model_config import _iter_hf_cache_snapshots
|
||||
snapshots = _iter_hf_cache_snapshots(repo_id)
|
||||
else:
|
||||
from hub.utils.hf_cache_state import iter_active_repo_cache_dirs
|
||||
snapshots = (
|
||||
snapshot
|
||||
for repo_dir in iter_active_repo_cache_dirs(
|
||||
"model",
|
||||
repo_id,
|
||||
root = Path(cache_dir),
|
||||
)
|
||||
for snapshot in (repo_dir / "snapshots").glob("*")
|
||||
if snapshot.is_dir()
|
||||
)
|
||||
for snap in snapshots:
|
||||
candidate = snap.joinpath(*parts)
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
|
|
@ -1232,6 +1254,16 @@ def _snapshot_dir_of(path: str) -> Optional[Path]:
|
|||
return None
|
||||
|
||||
|
||||
def _hub_cache_dir_for_snapshot_path(path: Optional[str]) -> Optional[str]:
|
||||
"""Return the HF Hub cache root that owns a snapshot-contained path."""
|
||||
if not path:
|
||||
return None
|
||||
snapshot = _snapshot_dir_of(path)
|
||||
if snapshot is None or snapshot.parent.name != "snapshots":
|
||||
return None
|
||||
return str(snapshot.parent.parent.parent)
|
||||
|
||||
|
||||
def _companion_snapshot_sibling(
|
||||
near_path: str, pick: Callable[[list[str]], Optional[str]]
|
||||
) -> Optional[str]:
|
||||
|
|
@ -5070,6 +5102,9 @@ class LlamaCppBackend:
|
|||
touching the shared one; defaults to the shared event.
|
||||
"""
|
||||
cancel_event = cancel_event if cancel_event is not None else self._cancel_event
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
download_cache_dir = str(get_hf_cache_paths().hub_cache)
|
||||
try:
|
||||
import huggingface_hub # noqa: F401 -- presence check only
|
||||
except ImportError:
|
||||
|
|
@ -5165,7 +5200,11 @@ class LlamaCppBackend:
|
|||
if not p.size:
|
||||
continue
|
||||
try:
|
||||
cached_path = try_to_load_from_cache(hf_repo, p.path)
|
||||
cached_path = try_to_load_from_cache(
|
||||
hf_repo,
|
||||
p.path,
|
||||
cache_dir = download_cache_dir,
|
||||
)
|
||||
except Exception:
|
||||
cached_path = None
|
||||
if (
|
||||
|
|
@ -5176,6 +5215,7 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
p.path,
|
||||
expected_size = p.size,
|
||||
cache_dir = download_cache_dir,
|
||||
)
|
||||
if isinstance(cached_path, str) and os.path.exists(cached_path):
|
||||
try:
|
||||
|
|
@ -5189,12 +5229,8 @@ class LlamaCppBackend:
|
|||
total_download_bytes = max(0, total_bytes - already_cached_bytes)
|
||||
|
||||
if total_download_bytes > 0:
|
||||
cache_dir = os.environ.get(
|
||||
"HF_HUB_CACHE",
|
||||
str(Path.home() / ".cache" / "huggingface" / "hub"),
|
||||
)
|
||||
Path(cache_dir).mkdir(parents = True, exist_ok = True)
|
||||
free_bytes = shutil.disk_usage(cache_dir).free
|
||||
Path(download_cache_dir).mkdir(parents = True, exist_ok = True)
|
||||
free_bytes = shutil.disk_usage(download_cache_dir).free
|
||||
|
||||
total_gb = total_download_bytes / (1024**3)
|
||||
free_gb = free_bytes / (1024**3)
|
||||
|
|
@ -5212,7 +5248,7 @@ class LlamaCppBackend:
|
|||
# surface the disk shortfall for the requested variant.
|
||||
raise RuntimeError(
|
||||
f"Not enough disk space to download {gguf_filename}. "
|
||||
f"Only {free_gb:.1f} GB free in {cache_dir}"
|
||||
f"Only {free_gb:.1f} GB free in {download_cache_dir}"
|
||||
)
|
||||
smaller = self._find_smallest_fitting_variant(
|
||||
hf_repo,
|
||||
|
|
@ -5243,7 +5279,7 @@ class LlamaCppBackend:
|
|||
else:
|
||||
raise RuntimeError(
|
||||
f"Not enough disk space to download any variant. "
|
||||
f"Only {free_gb:.1f} GB free in {cache_dir}"
|
||||
f"Only {free_gb:.1f} GB free in {download_cache_dir}"
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
|
|
@ -5266,6 +5302,7 @@ class LlamaCppBackend:
|
|||
cancel_event = cancel_event,
|
||||
on_status = lambda m: logger.info(m),
|
||||
force_download = force,
|
||||
cache_dir = download_cache_dir,
|
||||
)
|
||||
for shard in gguf_extra_shards:
|
||||
if cancel_event.is_set():
|
||||
|
|
@ -5277,6 +5314,7 @@ class LlamaCppBackend:
|
|||
hf_token,
|
||||
cancel_event = cancel_event,
|
||||
force_download = force,
|
||||
cache_dir = download_cache_dir,
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, RuntimeError) and "Cancelled" in str(e):
|
||||
|
|
@ -5322,6 +5360,12 @@ class LlamaCppBackend:
|
|||
logger.info("Reusing cached %s: %s", label, cached)
|
||||
return cached
|
||||
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
companion_cache_dir = _hub_cache_dir_for_snapshot_path(near_path) or str(
|
||||
get_hf_cache_paths().hub_cache
|
||||
)
|
||||
|
||||
if _hub_download_in_flight(hf_repo):
|
||||
logger.info("Skipping %s download while a hub download is active", label)
|
||||
return None
|
||||
|
|
@ -5356,7 +5400,7 @@ class LlamaCppBackend:
|
|||
if target is None:
|
||||
try:
|
||||
from utils.models.model_config import _iter_hf_cache_snapshots
|
||||
for snap in _iter_hf_cache_snapshots(hf_repo):
|
||||
for snap in _iter_hf_cache_snapshots(hf_repo, companion_cache_dir):
|
||||
rel_files = _gguf_snapshot_files(snap)
|
||||
target = pick(rel_files)
|
||||
if target is not None:
|
||||
|
|
@ -5374,7 +5418,11 @@ class LlamaCppBackend:
|
|||
# hf_hub_download with hf_repo would miss the canonical file and silently
|
||||
# drop the companion. _cached_hf_snapshot_file scans every case variant.
|
||||
if _hf_env_offline():
|
||||
cached = _cached_hf_snapshot_file(hf_repo, target)
|
||||
cached = _cached_hf_snapshot_file(
|
||||
hf_repo,
|
||||
target,
|
||||
cache_dir = companion_cache_dir,
|
||||
)
|
||||
if cached:
|
||||
logger.info("Resolved %s from local HF cache: %s", label, cached)
|
||||
return cached
|
||||
|
|
@ -5387,6 +5435,7 @@ class LlamaCppBackend:
|
|||
target,
|
||||
hf_token,
|
||||
cancel_event = cancel_event,
|
||||
cache_dir = companion_cache_dir,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not download {label}: {e}")
|
||||
|
|
@ -5417,7 +5466,12 @@ class LlamaCppBackend:
|
|||
near_path = near_path,
|
||||
)
|
||||
|
||||
def _cached_repo_mtp_drafter(self, hf_repo: str) -> Optional[str]:
|
||||
def _cached_repo_mtp_drafter(
|
||||
self,
|
||||
hf_repo: str,
|
||||
*,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""A drafter already in this repo's local HF cache, reused offline when a
|
||||
fresh copy can't be fetched. Prefers a repo-root ``mtp-*.gguf`` across all
|
||||
cached snapshots; else an existing ``MTP/`` copy (any precision -- the
|
||||
|
|
@ -5427,7 +5481,12 @@ class LlamaCppBackend:
|
|||
|
||||
roots: list[Path] = []
|
||||
subdirs: list[Path] = []
|
||||
for snap in _iter_hf_cache_snapshots(hf_repo): # newest first
|
||||
snapshots = (
|
||||
_iter_hf_cache_snapshots(hf_repo)
|
||||
if cache_dir is None
|
||||
else _iter_hf_cache_snapshots(hf_repo, cache_dir)
|
||||
)
|
||||
for snap in snapshots: # newest first
|
||||
for f in sorted(_gguf_snapshot_files(snap)):
|
||||
if _is_companion_gguf_path(f) and "mmproj" not in f.lower():
|
||||
(roots if "/" not in f else subdirs).append(snap / f)
|
||||
|
|
@ -5480,7 +5539,10 @@ class LlamaCppBackend:
|
|||
# current cached file and refetch a changed one, so skip the probe here
|
||||
# rather than pair new weights with a stale draft.
|
||||
if _hf_env_offline():
|
||||
cached = self._cached_repo_mtp_drafter(hf_repo)
|
||||
cached = self._cached_repo_mtp_drafter(
|
||||
hf_repo,
|
||||
cache_dir = _hub_cache_dir_for_snapshot_path(near_path),
|
||||
)
|
||||
if cached:
|
||||
logger.info(f"Reusing cached MTP drafter (offline): {cached}")
|
||||
return cached
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
_is_hidden_model,
|
||||
)
|
||||
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir, lmstudio_model_dirs
|
||||
from utils.hf_cache_settings import known_hf_hub_caches
|
||||
|
||||
index: dict[str, _LocalGgufEntry] = {}
|
||||
seen_hf: set[str] = set()
|
||||
|
|
@ -174,7 +175,12 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
except Exception as exc:
|
||||
logger.debug("auto-switch: ./models scan failed: %s", exc)
|
||||
try:
|
||||
for hf_dir in (_resolve_hf_cache_dir(), legacy_hf_cache_dir(), hf_default_cache_dir()):
|
||||
for hf_dir in (
|
||||
*known_hf_hub_caches(),
|
||||
_resolve_hf_cache_dir(),
|
||||
legacy_hf_cache_dir(),
|
||||
hf_default_cache_dir(),
|
||||
):
|
||||
found += _scan_hf_once(hf_dir)
|
||||
except Exception as exc:
|
||||
logger.debug("auto-switch: HF cache scan failed: %s", exc)
|
||||
|
|
|
|||
|
|
@ -217,10 +217,14 @@ class InferenceOrchestrator:
|
|||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
|
||||
|
||||
from .worker import run_inference_process
|
||||
cache_env = get_hf_cache_paths().child_env({})
|
||||
|
||||
with native_path_secret_removed_for_child_start():
|
||||
with (
|
||||
child_environment_for_spawn(cache_env),
|
||||
native_path_secret_removed_for_child_start(),
|
||||
):
|
||||
self._cmd_queue = _CTX.Queue()
|
||||
self._resp_queue = _CTX.Queue()
|
||||
self._cancel_event = _CTX.Event()
|
||||
|
|
@ -228,7 +232,7 @@ class InferenceOrchestrator:
|
|||
|
||||
self._proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_inference_process,),
|
||||
args = ("core.inference.worker", "run_inference_process", cache_env),
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
|
|
|
|||
|
|
@ -188,7 +188,14 @@ class LlamaServerBackend:
|
|||
match = [f for f in files if variant in f.lower()] or files
|
||||
filename = sorted(match, key = len)[0]
|
||||
logger.info("resolving GGUF embedder %s/%s", repo, filename)
|
||||
self._model_path = hf_hub_download(repo_id = repo, filename = filename, token = token)
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
self._model_path = hf_hub_download(
|
||||
repo_id = repo,
|
||||
filename = filename,
|
||||
token = token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
self._model_repo = desired
|
||||
self._dim = None
|
||||
return self._model_path
|
||||
|
|
|
|||
|
|
@ -104,9 +104,15 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
|
|||
else:
|
||||
from huggingface_hub import hf_hub_download
|
||||
from huggingface_hub.utils import EntryNotFoundError
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
try:
|
||||
local = hf_hub_download(name, "modules.json", token = token or None)
|
||||
local = hf_hub_download(
|
||||
name,
|
||||
"modules.json",
|
||||
token = token or None,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except EntryNotFoundError:
|
||||
return ()
|
||||
data = json.loads(open(local).read())
|
||||
|
|
@ -183,11 +189,16 @@ def _get(model_name: str | None = None):
|
|||
if _model is None or _name != name:
|
||||
_install_torchao_stub_once()
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
device = _device()
|
||||
logger.info("loading embedding model %s on %s", name, device)
|
||||
_guard_model_security(name, local_only)
|
||||
st_kwargs = dict(device = device, model_kwargs = dtype_kwargs("float16"))
|
||||
st_kwargs = dict(
|
||||
device = device,
|
||||
cache_folder = active_hf_hub_cache(),
|
||||
model_kwargs = dtype_kwargs("float16"),
|
||||
)
|
||||
load_target = name
|
||||
if local_only:
|
||||
from utils.utils import hf_cache_snapshot_dir
|
||||
|
|
|
|||
|
|
@ -929,16 +929,21 @@ class TrainingBackend:
|
|||
config["resolved_gpu_ids"] = resolved_gpu_ids
|
||||
config["gpu_selection"] = gpu_selection
|
||||
|
||||
from .worker import run_training_process
|
||||
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
|
||||
|
||||
cache_env = get_hf_cache_paths().child_env({})
|
||||
|
||||
try:
|
||||
with native_path_secret_removed_for_child_start():
|
||||
with (
|
||||
child_environment_for_spawn(cache_env),
|
||||
native_path_secret_removed_for_child_start(),
|
||||
):
|
||||
event_queue = _CTX.Queue()
|
||||
stop_queue = _CTX.Queue()
|
||||
|
||||
proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_training_process,),
|
||||
args = ("core.training.worker", "run_training_process", cache_env),
|
||||
kwargs = {
|
||||
"event_queue": event_queue,
|
||||
"stop_queue": stop_queue,
|
||||
|
|
@ -991,6 +996,7 @@ class TrainingBackend:
|
|||
self._db_started_at = datetime.now(timezone.utc).isoformat()
|
||||
# Start each job Xet-first; keep config so a stall can respawn over HTTP.
|
||||
self._last_full_config = config
|
||||
self._last_hf_cache_env = cache_env
|
||||
self._in_model_load = False
|
||||
self._xet_fallback_used = False
|
||||
self._needs_xet_respawn = False
|
||||
|
|
@ -1400,7 +1406,11 @@ class TrainingBackend:
|
|||
self._last_full_config = config
|
||||
logger.warning("Respawning training worker with HF_HUB_DISABLE_XET=1 after Xet stall")
|
||||
|
||||
from .worker import run_training_process
|
||||
cache_env = getattr(self, "_last_hf_cache_env", None)
|
||||
if not cache_env:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
cache_env = get_hf_cache_paths().child_env({})
|
||||
from utils.hf_cache_settings import child_environment_for_spawn
|
||||
|
||||
# This run is active, so an install request 409s rather than proceeds: a reservation seen here
|
||||
# is transient (an aborting install or short lazy repair). Wait it out instead of stranding the
|
||||
|
|
@ -1432,12 +1442,15 @@ class TrainingBackend:
|
|||
# crashed respawn cannot wedge is_training_active until restart.
|
||||
try:
|
||||
try:
|
||||
with native_path_secret_removed_for_child_start():
|
||||
with (
|
||||
child_environment_for_spawn(cache_env),
|
||||
native_path_secret_removed_for_child_start(),
|
||||
):
|
||||
event_queue = _CTX.Queue()
|
||||
stop_queue = _CTX.Queue()
|
||||
new_proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_training_process,),
|
||||
args = ("core.training.worker", "run_training_process", cache_env),
|
||||
kwargs = {
|
||||
"event_queue": event_queue,
|
||||
"stop_queue": stop_queue,
|
||||
|
|
|
|||
|
|
@ -61,9 +61,11 @@ async def list_cached_datasets(current_subject: str = Depends(get_current_subjec
|
|||
|
||||
@router.delete("/cached", response_model = DeleteCachedDatasetResponse)
|
||||
async def delete_cached_dataset(
|
||||
repo_id: str = Body(..., embed = True), current_subject: str = Depends(get_current_subject)
|
||||
repo_id: str = Body(..., embed = True),
|
||||
cache_path: Optional[str] = Body(None, embed = True),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
return await cache_inventory.delete_cached_dataset_response(repo_id)
|
||||
return await cache_inventory.delete_cached_dataset_response(repo_id, cache_path)
|
||||
|
||||
|
||||
@router.get("/download-progress", response_model = DownloadProgressResponse)
|
||||
|
|
|
|||
|
|
@ -233,7 +233,8 @@ async def list_hidden_models(current_subject: str = Depends(get_current_subject)
|
|||
async def delete_cached_model(
|
||||
repo_id: str = Body(...),
|
||||
variant: Optional[str] = Body(None),
|
||||
cache_path: Optional[str] = Body(None),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
return await deletion.delete_cached_model_response(repo_id, variant, hf_token)
|
||||
return await deletion.delete_cached_model_response(repo_id, variant, hf_token, cache_path)
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ class LocalModelInfo(BaseModel):
|
|||
None,
|
||||
description = "HF repo id for cached models, e.g. org/model",
|
||||
)
|
||||
active_cache: Optional[bool] = Field(
|
||||
None,
|
||||
description = "Whether this HF entry belongs to the current download cache.",
|
||||
)
|
||||
base_model: Optional[str] = Field(
|
||||
None,
|
||||
description = "Base model from adapter_config.json when this is an adapter",
|
||||
|
|
|
|||
|
|
@ -20,12 +20,11 @@ from hub.utils import inventory_scan as hf_cache_scan
|
|||
from hub.utils.hf_cache_state import (
|
||||
purge_partial_repo,
|
||||
purge_repo_cache_dirs,
|
||||
resolve_delete_target_root,
|
||||
resolve_destructive_case_matches,
|
||||
)
|
||||
from hub.utils.paths import (
|
||||
hf_default_cache_dir,
|
||||
is_valid_repo_id as _is_valid_repo_id,
|
||||
legacy_hf_cache_dir,
|
||||
resolve_cached_repo_id_case,
|
||||
)
|
||||
|
||||
|
|
@ -43,38 +42,8 @@ def _collect_hf_cache_scans() -> tuple[list, set[str]]:
|
|||
|
||||
|
||||
def _hf_hub_cache_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(path: Optional[Path]) -> None:
|
||||
if path is None or not path.is_dir():
|
||||
return
|
||||
try:
|
||||
resolved = str(path.resolve())
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen:
|
||||
return
|
||||
seen.add(resolved)
|
||||
roots.append(path)
|
||||
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
_add(Path(HF_HUB_CACHE))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
hf_hub_cache = os.environ.get("HF_HUB_CACHE")
|
||||
if hf_hub_cache:
|
||||
_add(Path(hf_hub_cache).expanduser())
|
||||
|
||||
hf_home = os.environ.get("HF_HOME")
|
||||
if hf_home:
|
||||
_add(Path(hf_home).expanduser() / "hub")
|
||||
|
||||
_add(legacy_hf_cache_dir())
|
||||
_add(hf_default_cache_dir())
|
||||
return roots
|
||||
from hub.utils.hf_cache_state import hf_cache_roots
|
||||
return hf_cache_roots()
|
||||
|
||||
|
||||
def _repo_id_from_hub_dataset_dir(name: str) -> str | None:
|
||||
|
|
@ -207,6 +176,21 @@ def _repo_id_from_datasets_cache_dir(name: str) -> str | None:
|
|||
return repo_id if _is_valid_repo_id(repo_id) else None
|
||||
|
||||
|
||||
def _is_processed_dataset_cache_path(repo_id: str, cache_path: str) -> bool:
|
||||
"""True when *cache_path* is this repo's processed Arrow cache dir
|
||||
(``<owner>___<repo>`` directly under an HF_DATASETS_CACHE root). Such rows
|
||||
have no Hub ``datasets--`` layout, so they are deleted via the processed
|
||||
path and must not be rejected as an invalid cache_path."""
|
||||
try:
|
||||
resolved = Path(cache_path).expanduser().resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
if resolved.name.lower() != repo_id.replace("/", "___").lower():
|
||||
return False
|
||||
roots = {r.resolve(strict = False) for r in _hf_datasets_cache_roots()}
|
||||
return resolved.parent.resolve(strict = False) in roots
|
||||
|
||||
|
||||
def _processed_dataset_cache_size(path: Path) -> int:
|
||||
total = 0
|
||||
try:
|
||||
|
|
@ -361,7 +345,7 @@ async def list_cached_datasets_response() -> dict:
|
|||
) from exc
|
||||
|
||||
|
||||
async def delete_cached_dataset_response(repo_id: str) -> dict:
|
||||
async def delete_cached_dataset_response(repo_id: str, cache_path: Optional[str] = None) -> dict:
|
||||
"""Remove a cached dataset repo from the HF cache."""
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
|
||||
|
|
@ -373,22 +357,40 @@ async def delete_cached_dataset_response(repo_id: str) -> dict:
|
|||
detail = "Cancel the active download before deleting.",
|
||||
)
|
||||
try:
|
||||
return await asyncio.to_thread(_delete_cached_dataset_blocking, repo_key)
|
||||
return await asyncio.to_thread(_delete_cached_dataset_blocking, repo_key, cache_path)
|
||||
finally:
|
||||
downloads.registry.end_delete(repo_key)
|
||||
hf_cache_scan.invalidate_hf_cache_scans()
|
||||
|
||||
|
||||
def _delete_cached_dataset_blocking(repo_id: str) -> dict:
|
||||
def _delete_cached_dataset_blocking(repo_id: str, cache_path: Optional[str] = None) -> dict:
|
||||
scans, _seen_roots = _collect_hf_cache_scans()
|
||||
|
||||
candidate_entries = []
|
||||
# Group this dataset's copies by owning cache root, then target exactly one
|
||||
# cache so a delete never removes copies in other, previously selected caches.
|
||||
owners: dict = {}
|
||||
for hf_cache in scans:
|
||||
for repo_info in hf_cache.repos:
|
||||
if str(repo_info.repo_type) != "dataset":
|
||||
continue
|
||||
if repo_info.repo_id.lower() == repo_id.lower():
|
||||
candidate_entries.append((hf_cache, repo_info))
|
||||
if repo_info.repo_id.lower() != repo_id.lower():
|
||||
continue
|
||||
try:
|
||||
owner = Path(repo_info.repo_path).parent.resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
owners.setdefault(owner, []).append((hf_cache, repo_info))
|
||||
|
||||
target_root = resolve_delete_target_root("dataset", repo_id, cache_path, owners.keys())
|
||||
# A processed-only dataset row sends its Arrow cache path (<owner>___<repo>
|
||||
# under HF_DATASETS_CACHE), which is not a Hub datasets-- dir, so
|
||||
# resolve_delete_target_root returns None. Accept it and fall through to the
|
||||
# processed-cache delete rather than rejecting a legitimate row.
|
||||
if target_root is None and not (
|
||||
cache_path and _is_processed_dataset_cache_path(repo_id, cache_path)
|
||||
):
|
||||
raise HTTPException(status_code = 400, detail = "Invalid cache_path")
|
||||
candidate_entries = owners.get(target_root, []) if target_root is not None else []
|
||||
matched_repo_ids = resolve_destructive_repo_ids(
|
||||
repo_id,
|
||||
[str(repo_info.repo_id) for _hf_cache, repo_info in candidate_entries],
|
||||
|
|
@ -414,7 +416,26 @@ def _delete_cached_dataset_blocking(repo_id: str) -> dict:
|
|||
exc_info = True,
|
||||
)
|
||||
|
||||
processed_deleted, processed_failures = _delete_processed_dataset_cache(repo_id)
|
||||
# Restrict the processed Arrow-cache delete to the selected cache's datasets
|
||||
# root so it never removes copies under other cache homes. A processed
|
||||
# cache_path scopes to its own root; a Hub target scopes to the datasets root
|
||||
# sharing its cache home; an unspecified cache_path stays global (legacy).
|
||||
processed_roots: Optional[set[Path]]
|
||||
if not cache_path:
|
||||
processed_roots = None
|
||||
elif _is_processed_dataset_cache_path(repo_id, cache_path):
|
||||
processed_roots = {Path(cache_path).expanduser().resolve(strict = False).parent}
|
||||
else:
|
||||
home = target_root.parent if target_root is not None else None
|
||||
processed_roots = {
|
||||
root.resolve(strict = False)
|
||||
for root in _hf_datasets_cache_roots()
|
||||
if home is not None and root.resolve(strict = False).parent == home
|
||||
}
|
||||
|
||||
processed_deleted, processed_failures = _delete_processed_dataset_cache(
|
||||
repo_id, only_roots = processed_roots
|
||||
)
|
||||
failures.extend(processed_failures)
|
||||
if failures:
|
||||
raise HTTPException(
|
||||
|
|
@ -427,15 +448,23 @@ def _delete_cached_dataset_blocking(repo_id: str) -> dict:
|
|||
|
||||
# ``scan_cache_dir()`` skips blob-only/corrupt repos the revision delete
|
||||
# can't touch, yet the fallback scanner shows them; purge the whole dir.
|
||||
cache_purged = purge_repo_cache_dirs("dataset", repo_id)
|
||||
partial_purged = purge_partial_repo("dataset", repo_id)
|
||||
state_purged = download_manifest.purge_all_state_for_repo("dataset", repo_id) > 0
|
||||
# Only for a Hub cache target; a processed-only path has no Hub dir/state.
|
||||
cache_purged = partial_purged = state_purged = False
|
||||
if target_root is not None:
|
||||
cache_purged = purge_repo_cache_dirs("dataset", repo_id, root = target_root)
|
||||
partial_purged = purge_partial_repo("dataset", repo_id, root = target_root)
|
||||
state_purged = (
|
||||
download_manifest.purge_all_state_for_repo("dataset", repo_id, hub_cache = target_root)
|
||||
> 0
|
||||
)
|
||||
if not (deleted or processed_deleted or cache_purged or partial_purged or state_purged):
|
||||
raise HTTPException(status_code = 404, detail = "Dataset not found in cache")
|
||||
return {"status": "deleted", "repo_id": repo_id}
|
||||
|
||||
|
||||
def _delete_processed_dataset_cache(repo_id: str) -> tuple[bool, list[str]]:
|
||||
def _delete_processed_dataset_cache(
|
||||
repo_id: str, only_roots: Optional[set[Path]] = None
|
||||
) -> tuple[bool, list[str]]:
|
||||
import shutil
|
||||
|
||||
target = repo_id.replace("/", "___")
|
||||
|
|
@ -443,6 +472,10 @@ def _delete_processed_dataset_cache(repo_id: str) -> tuple[bool, list[str]]:
|
|||
deleted = False
|
||||
failures: list[str] = []
|
||||
for root in _hf_datasets_cache_roots():
|
||||
# Scope to the selected cache's datasets root(s): a delete must not remove
|
||||
# processed copies living under other, previously selected cache homes.
|
||||
if only_roots is not None and root.resolve(strict = False) not in only_roots:
|
||||
continue
|
||||
try:
|
||||
entries = [
|
||||
entry
|
||||
|
|
|
|||
|
|
@ -159,12 +159,18 @@ async def download_dataset_response(
|
|||
|
||||
use_xet = download_lifecycle.resolve_effective_use_xet(body.use_xet)
|
||||
transport = download_lifecycle.resolve_transport(use_xet)
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
cache_paths = get_hf_cache_paths()
|
||||
cache_env = cache_paths.child_env({})
|
||||
|
||||
claimed, claim_state = _registry.claim(
|
||||
key,
|
||||
transport,
|
||||
repo_type = "dataset",
|
||||
repo_id = repo_id,
|
||||
hub_cache = str(cache_paths.hub_cache),
|
||||
xet_cache = str(cache_paths.xet_cache),
|
||||
)
|
||||
generation = _registry.current_generation(key)
|
||||
if not claimed:
|
||||
|
|
@ -176,7 +182,12 @@ async def download_dataset_response(
|
|||
"accepted": _registry.adoptable(key),
|
||||
"generation": generation,
|
||||
}
|
||||
download_manifest.clear_cancel_marker("dataset", repo_id, None)
|
||||
download_manifest.clear_cancel_marker(
|
||||
"dataset",
|
||||
repo_id,
|
||||
None,
|
||||
hub_cache = cache_paths.hub_cache,
|
||||
)
|
||||
|
||||
state = download_lifecycle.launch_worker(
|
||||
_registry,
|
||||
|
|
@ -185,6 +196,7 @@ async def download_dataset_response(
|
|||
["--repo-id", repo_id, "--dataset"],
|
||||
hf_token,
|
||||
use_xet = use_xet,
|
||||
cache_env = cache_env,
|
||||
),
|
||||
hf_token = hf_token,
|
||||
label = repo_id,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import sys
|
|||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, Mapping, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ def spawn_worker(
|
|||
*,
|
||||
use_xet: bool,
|
||||
protected_blob_hashes: Optional[frozenset[str]] = None,
|
||||
cache_env: Optional[Mapping[str, str]] = None,
|
||||
) -> subprocess.Popen:
|
||||
"""Spawn the download worker.
|
||||
|
||||
|
|
@ -68,7 +69,11 @@ def spawn_worker(
|
|||
"""
|
||||
cwd = backend_dir()
|
||||
mode = download_registry.TRANSPORT_XET if use_xet else download_registry.TRANSPORT_HTTP
|
||||
env = os.environ.copy()
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
env = get_hf_cache_paths().child_env()
|
||||
if cache_env is not None:
|
||||
env.update(cache_env)
|
||||
if protected_blob_hashes:
|
||||
env["UNSLOTH_PROTECTED_BLOB_HASHES"] = ",".join(sorted(protected_blob_hashes))
|
||||
else:
|
||||
|
|
@ -230,6 +235,7 @@ def finalize_worker_exit(
|
|||
(stderr_data or b"").decode("utf-8", "replace").strip(),
|
||||
hf_token = hf_token,
|
||||
)
|
||||
metadata = registry.get_job_metadata(key)
|
||||
state = classify_exit(rc, cancel_requested = cancel_requested)
|
||||
if state == "complete":
|
||||
registry.set_job(key, "complete")
|
||||
|
|
@ -252,13 +258,13 @@ def finalize_worker_exit(
|
|||
repo_type,
|
||||
repo_id,
|
||||
download_registry.variant_from_key(key),
|
||||
hub_cache = metadata.hub_cache if metadata is not None else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"clear_cancel_marker failed for {repo_id} (rc=0): {exc}")
|
||||
elif state == "cancelled":
|
||||
# Read metadata before the terminal set_job so a concurrent eviction
|
||||
# can't drop it; the job key is the fallback variant label.
|
||||
metadata = registry.get_job_metadata(key)
|
||||
registry.set_job(key, "cancelled")
|
||||
logger.info(f"{log_prefix} cancelled: {label} (rc={rc})")
|
||||
download_registry.persist_cancel_marker(
|
||||
|
|
@ -268,6 +274,7 @@ def finalize_worker_exit(
|
|||
if metadata is not None and metadata.variant
|
||||
else download_registry.variant_from_key(key),
|
||||
cancel_marker_transport or transport,
|
||||
hub_cache = metadata.hub_cache if metadata is not None else None,
|
||||
logger = logger,
|
||||
)
|
||||
else:
|
||||
|
|
@ -303,6 +310,7 @@ def _set_retry_failure_state(
|
|||
metadata.transport
|
||||
if metadata is not None and metadata.transport
|
||||
else fallback_transport,
|
||||
hub_cache = metadata.hub_cache if metadata is not None else None,
|
||||
logger = logger,
|
||||
)
|
||||
return state
|
||||
|
|
@ -371,6 +379,7 @@ def _try_http_retry(
|
|||
repo_type,
|
||||
repo_id,
|
||||
progress_blob_hashes,
|
||||
root = Path(original_metadata.hub_cache) if original_metadata.hub_cache else None,
|
||||
)
|
||||
if progress_blob_hashes
|
||||
else 0
|
||||
|
|
@ -403,6 +412,8 @@ def _try_http_retry(
|
|||
generation = generation,
|
||||
replace_active = True,
|
||||
cancel_marker_transport = original_metadata.transport,
|
||||
hub_cache = original_metadata.hub_cache,
|
||||
xet_cache = original_metadata.xet_cache,
|
||||
)
|
||||
if claimed:
|
||||
break
|
||||
|
|
@ -446,11 +457,24 @@ def _try_http_retry(
|
|||
label,
|
||||
)
|
||||
try:
|
||||
cache_env = (
|
||||
{
|
||||
"HF_HUB_CACHE": original_metadata.hub_cache,
|
||||
"HF_XET_CACHE": original_metadata.xet_cache,
|
||||
}
|
||||
if original_metadata.hub_cache and original_metadata.xet_cache
|
||||
else None
|
||||
)
|
||||
spawn_kwargs = {
|
||||
"use_xet": False,
|
||||
"protected_blob_hashes": peer_hashes or None,
|
||||
}
|
||||
if cache_env is not None:
|
||||
spawn_kwargs["cache_env"] = cache_env
|
||||
proc = spawn_worker(
|
||||
args,
|
||||
hf_token,
|
||||
use_xet = False,
|
||||
protected_blob_hashes = peer_hashes or None,
|
||||
**spawn_kwargs,
|
||||
)
|
||||
except Exception as exc:
|
||||
scrubbed = download_registry.scrub_secrets(str(exc), hf_token = hf_token)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ from hub.services.models.common import (
|
|||
_is_mmproj_filename,
|
||||
_is_transformers_safetensors_weight_name,
|
||||
_local_inventory_id,
|
||||
_prefer_complete_larger,
|
||||
_runtime_for_format,
|
||||
)
|
||||
|
||||
|
|
@ -250,24 +249,46 @@ def _repo_gguf_blob_map(repo_info, *, include_companions: bool = False) -> dict[
|
|||
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),
|
||||
)
|
||||
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": repo_id,
|
||||
"load_id": load_id,
|
||||
"active_cache": active_cache,
|
||||
"model_format": model_format,
|
||||
"runtime": _runtime_for_format(model_format),
|
||||
"format_variant": None,
|
||||
|
|
@ -294,6 +315,9 @@ def _is_hidden_infra_repo(*values: str | None) -> bool:
|
|||
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:
|
||||
|
|
@ -305,7 +329,10 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
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)
|
||||
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),
|
||||
|
|
@ -342,6 +369,9 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
_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,
|
||||
)
|
||||
|
|
@ -543,6 +573,9 @@ def _cached_model_local_metadata(repo_path: Path) -> dict:
|
|||
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
|
||||
|
|
@ -606,6 +639,9 @@ def _scan_cached_models() -> list[dict]:
|
|||
_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"]),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -150,7 +150,9 @@ def _prefer_complete_larger(
|
|||
return candidate_size_bytes > existing_size_bytes
|
||||
|
||||
|
||||
def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
|
||||
def _gguf_variant_state_summary(
|
||||
repo_id: str, *, hub_cache: Optional[str | Path] = None
|
||||
) -> tuple[bool, int]:
|
||||
"""Whether GGUF variant-scoped state exists and its expected size; a cancelled/in-progress variant may have only manifests/markers/`.incomplete` blobs, which inventory needs to avoid a generic fallback row."""
|
||||
from hub.utils import download_manifest
|
||||
|
||||
|
|
@ -159,10 +161,16 @@ def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
|
|||
for variant, _path in download_manifest.iter_variant_manifests(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
key = variant.lower()
|
||||
variant_keys.add(key)
|
||||
manifest = download_manifest.read_manifest("model", repo_id, variant)
|
||||
manifest = download_manifest.read_manifest(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
if manifest is None:
|
||||
continue
|
||||
size_by_variant[key] = max(
|
||||
|
|
@ -172,6 +180,7 @@ def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
|
|||
for variant, _path in download_manifest.iter_variant_markers(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
variant_keys.add(variant.lower())
|
||||
return bool(variant_keys), sum(size_by_variant.values())
|
||||
|
|
@ -432,8 +441,13 @@ def _local_model_info(
|
|||
base_model_source: Optional[str] = None,
|
||||
adapter_type: Optional[str] = None,
|
||||
training_method: Optional[str] = None,
|
||||
active_cache: Optional[bool] = None,
|
||||
) -> LocalModelInfo:
|
||||
load_id = model_id if source == "hf_cache" and model_id else str(load_path)
|
||||
load_id = (
|
||||
model_id
|
||||
if source == "hf_cache" and model_id and active_cache is not False
|
||||
else str(load_path)
|
||||
)
|
||||
semantic_id = model_id or str(load_path)
|
||||
return LocalModelInfo(
|
||||
id = load_id,
|
||||
|
|
@ -445,6 +459,7 @@ def _local_model_info(
|
|||
),
|
||||
load_id = load_id,
|
||||
model_id = model_id,
|
||||
active_cache = active_cache if source == "hf_cache" else None,
|
||||
display_name = display_name or (scan_path.stem if scan_path.is_file() else scan_path.name),
|
||||
path = str(load_path),
|
||||
size_bytes = max(0, int(size_bytes or 0)),
|
||||
|
|
@ -476,6 +491,7 @@ def _classify_local_path(
|
|||
model_id: Optional[str] = None,
|
||||
updated_at: Optional[float] = None,
|
||||
partial: bool = False,
|
||||
active_cache: Optional[bool] = None,
|
||||
) -> list[LocalModelInfo]:
|
||||
load_path = load_path or scan_path
|
||||
files = (
|
||||
|
|
@ -512,6 +528,7 @@ def _classify_local_path(
|
|||
requires_variant = scan_path.is_dir(),
|
||||
format_variant = variant,
|
||||
size_bytes = gguf_size_bytes,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -574,6 +591,7 @@ def _classify_local_path(
|
|||
),
|
||||
adapter_type = adapter_type if model_format == "adapter" else None,
|
||||
training_method = training_method if model_format == "adapter" else None,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
)
|
||||
elif not rows:
|
||||
|
|
@ -592,6 +610,7 @@ def _classify_local_path(
|
|||
updated_at = updated_at,
|
||||
partial = partial or trusted_hf_cache_repo,
|
||||
size_bytes = size_bytes,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ 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,
|
||||
iter_repo_cache_dirs,
|
||||
purge_partial_repo,
|
||||
purge_repo_cache_dirs,
|
||||
resolve_delete_target_root,
|
||||
)
|
||||
from hub.utils.paths import (
|
||||
is_valid_gguf_variant as _is_valid_gguf_variant,
|
||||
|
|
@ -184,6 +186,7 @@ def _delete_gguf_variant_from_repos(
|
|||
hf_token: Optional[str],
|
||||
*,
|
||||
sibling_active: bool = False,
|
||||
root: Optional[Path] = None,
|
||||
) -> dict:
|
||||
failures: list[str] = []
|
||||
removed_snapshots = 0
|
||||
|
|
@ -265,6 +268,7 @@ def _delete_gguf_variant_from_repos(
|
|||
hf_token,
|
||||
extra_hashes = frozenset(completed_hashes),
|
||||
companions = not sibling_active,
|
||||
root = root,
|
||||
)
|
||||
if incomplete_result.unresolved:
|
||||
raise HTTPException(
|
||||
|
|
@ -276,7 +280,7 @@ def _delete_gguf_variant_from_repos(
|
|||
),
|
||||
)
|
||||
|
||||
state_purged = download_manifest.purge_state("model", repo_id, variant)
|
||||
state_purged = download_manifest.purge_state("model", repo_id, variant, hub_cache = root)
|
||||
# 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)
|
||||
|
|
@ -316,6 +320,8 @@ def reclaim_replaced_gguf_variant(
|
|||
variant: str,
|
||||
keep_main_hashes: frozenset[str],
|
||||
hf_token: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> dict:
|
||||
"""Prune stale main-GGUF files for a variant after a replacement verified.
|
||||
|
||||
|
|
@ -366,12 +372,22 @@ def reclaim_replaced_gguf_variant(
|
|||
"reason": "scan_failed",
|
||||
}
|
||||
|
||||
if hub_cache is None:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
hub_cache = get_hf_cache_paths().hub_cache
|
||||
try:
|
||||
target_hub_cache = Path(hub_cache).expanduser().resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
target_hub_cache = Path(hub_cache).expanduser()
|
||||
|
||||
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()
|
||||
and getattr(repo_info, "repo_path", None)
|
||||
and Path(repo_info.repo_path).parent.resolve(strict = False) == target_hub_cache
|
||||
]
|
||||
try:
|
||||
matched_repo_ids = resolve_destructive_repo_ids(
|
||||
|
|
@ -493,10 +509,24 @@ def reclaim_replaced_gguf_variant(
|
|||
|
||||
|
||||
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``."""
|
||||
"""Match a loaded repo ID or an on-disk path inside any copy of the repo."""
|
||||
rid = repo_id.lower()
|
||||
lid = loaded_id.lower()
|
||||
return lid == rid or lid.startswith(f"{rid}/")
|
||||
if lid == rid or lid.startswith(f"{rid}/"):
|
||||
return True
|
||||
|
||||
try:
|
||||
loaded_path = Path(loaded_id).expanduser().resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
for repo_dir in iter_repo_cache_dirs("model", repo_id):
|
||||
try:
|
||||
resolved_repo = repo_dir.resolve(strict = False)
|
||||
if loaded_path == resolved_repo or loaded_path.is_relative_to(resolved_repo):
|
||||
return True
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _loaded_repo_variant_blocks_delete(
|
||||
|
|
@ -560,6 +590,7 @@ async def delete_cached_model_response(
|
|||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
cache_path: Optional[str] = None,
|
||||
):
|
||||
"""Delete a cached model repo (or a specific GGUF variant) from the HF cache.
|
||||
|
||||
|
|
@ -603,14 +634,19 @@ async def delete_cached_model_response(
|
|||
)
|
||||
raise HTTPException(status_code = 400, detail = detail)
|
||||
try:
|
||||
return await asyncio.to_thread(_delete_cached_model_blocking, repo_id, variant, hf_token)
|
||||
return await asyncio.to_thread(
|
||||
_delete_cached_model_blocking, repo_id, variant, hf_token, cache_path
|
||||
)
|
||||
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]
|
||||
repo_id: str,
|
||||
variant: Optional[str],
|
||||
hf_token: Optional[str],
|
||||
cache_path: Optional[str] = None,
|
||||
) -> dict:
|
||||
try:
|
||||
# If a sibling quant is downloading concurrently, restrict this delete to
|
||||
|
|
@ -621,13 +657,26 @@ def _delete_cached_model_blocking(
|
|||
|
||||
cache_scans = cache_inventory.all_hf_cache_scans()
|
||||
|
||||
candidate_entries = []
|
||||
# A repo can live in several remembered caches. Group its copies by the
|
||||
# cache root that owns each, then target exactly one cache so a delete
|
||||
# never removes copies in other, previously selected caches.
|
||||
owners: dict = {}
|
||||
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))
|
||||
if repo_info.repo_id.lower() != repo_id.lower():
|
||||
continue
|
||||
try:
|
||||
owner = Path(repo_info.repo_path).parent.resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
owners.setdefault(owner, []).append((hf_cache, repo_info))
|
||||
|
||||
target_root = resolve_delete_target_root("model", repo_id, cache_path, owners.keys())
|
||||
if target_root is None:
|
||||
raise HTTPException(status_code = 400, detail = "Invalid cache_path")
|
||||
candidate_entries = owners.get(target_root, [])
|
||||
|
||||
matched_repo_ids = resolve_destructive_repo_ids(
|
||||
repo_id,
|
||||
|
|
@ -642,10 +691,15 @@ def _delete_cached_model_blocking(
|
|||
|
||||
if not target_entries:
|
||||
if variant is None:
|
||||
cache_purged = purge_repo_cache_dirs("model", repo_id) or purge_partial_repo(
|
||||
"model", repo_id
|
||||
cache_purged = purge_repo_cache_dirs(
|
||||
"model", repo_id, root = target_root
|
||||
) or purge_partial_repo("model", repo_id, root = target_root)
|
||||
state_purged = (
|
||||
download_manifest.purge_all_state_for_repo(
|
||||
"model", repo_id, hub_cache = target_root
|
||||
)
|
||||
> 0
|
||||
)
|
||||
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:
|
||||
|
|
@ -654,6 +708,7 @@ def _delete_cached_model_blocking(
|
|||
variant,
|
||||
hf_token,
|
||||
companions = not sibling_active,
|
||||
root = target_root,
|
||||
)
|
||||
if incomplete_result.unresolved:
|
||||
raise HTTPException(
|
||||
|
|
@ -668,6 +723,7 @@ def _delete_cached_model_blocking(
|
|||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = target_root,
|
||||
)
|
||||
if incomplete_result.deleted > 0 or state_purged:
|
||||
return {
|
||||
|
|
@ -684,6 +740,7 @@ def _delete_cached_model_blocking(
|
|||
[repo for _cache, repo in target_entries],
|
||||
hf_token,
|
||||
sibling_active = sibling_active,
|
||||
root = target_root,
|
||||
)
|
||||
|
||||
deleted_revisions = False
|
||||
|
|
@ -702,9 +759,11 @@ def _delete_cached_model_blocking(
|
|||
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
|
||||
cache_purged = purge_repo_cache_dirs("model", repo_id, root = target_root)
|
||||
partial_purged = purge_partial_repo("model", repo_id, root = target_root)
|
||||
state_purged = (
|
||||
download_manifest.purge_all_state_for_repo("model", repo_id, hub_cache = target_root) > 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")
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ def _spawn_download_worker(
|
|||
hf_token: Optional[str],
|
||||
use_xet: bool = True,
|
||||
protected_blob_hashes: Optional[frozenset[str]] = None,
|
||||
cache_env: Optional[dict[str, str]] = None,
|
||||
) -> subprocess.Popen:
|
||||
args = ["--repo-id", repo_id]
|
||||
if variant:
|
||||
|
|
@ -99,6 +100,7 @@ def _spawn_download_worker(
|
|||
hf_token,
|
||||
use_xet = use_xet,
|
||||
protected_blob_hashes = protected_blob_hashes,
|
||||
cache_env = cache_env,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -125,6 +127,10 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
key = _download_job_key(repo_id, variant)
|
||||
use_xet = download_lifecycle.resolve_effective_use_xet(body.use_xet)
|
||||
transport = download_lifecycle.resolve_transport(use_xet)
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
cache_paths = get_hf_cache_paths()
|
||||
cache_env = cache_paths.child_env({})
|
||||
variant_blob_hashes = frozenset()
|
||||
variant_progress_blob_hashes = frozenset()
|
||||
completed_baseline_bytes = 0
|
||||
|
|
@ -175,6 +181,8 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
progress_blob_hashes = variant_progress_blob_hashes,
|
||||
completed_baseline_bytes = completed_baseline_bytes,
|
||||
admission_check = lambda: not _load_in_flight(repo_id),
|
||||
hub_cache = str(cache_paths.hub_cache),
|
||||
xet_cache = str(cache_paths.xet_cache),
|
||||
)
|
||||
generation = _registry.current_generation(key)
|
||||
if not claimed:
|
||||
|
|
@ -189,7 +197,12 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
"accepted": _registry.adoptable(key),
|
||||
"generation": generation,
|
||||
}
|
||||
download_manifest.clear_cancel_marker("model", repo_id, variant)
|
||||
download_manifest.clear_cancel_marker(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = cache_paths.hub_cache,
|
||||
)
|
||||
# Blobs a concurrent same-repo variant is already writing (e.g. a shared
|
||||
# mmproj). The worker must not purge these during cache preparation.
|
||||
protected_blob_hashes = _registry.peer_blob_hashes(key) if variant else frozenset()
|
||||
|
|
@ -204,6 +217,7 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
hf_token,
|
||||
use_xet = use_xet,
|
||||
protected_blob_hashes = protected_blob_hashes,
|
||||
cache_env = cache_env,
|
||||
),
|
||||
hf_token = hf_token,
|
||||
label = label,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from hub.utils.paths import (
|
|||
)
|
||||
from utils.paths.external_media import (
|
||||
linux_run_media_mount_roots,
|
||||
macos_volume_roots,
|
||||
windows_drive_roots,
|
||||
)
|
||||
from hub.services.models.common import _safe_is_dir
|
||||
|
|
@ -187,7 +188,7 @@ def _build_browse_allowlist(
|
|||
|
||||
_add(Path.home())
|
||||
if media_roots is None:
|
||||
media_roots = linux_run_media_mount_roots()
|
||||
media_roots = [*linux_run_media_mount_roots(), *macos_volume_roots()]
|
||||
if drive_roots is None:
|
||||
drive_roots = windows_drive_roots()
|
||||
for p in media_roots:
|
||||
|
|
@ -195,6 +196,12 @@ def _build_browse_allowlist(
|
|||
for p in drive_roots:
|
||||
_add(p)
|
||||
_add(_resolve_hf_cache_dir())
|
||||
try:
|
||||
from utils.hf_cache_settings import known_hf_cache_homes
|
||||
for cache_home in known_hf_cache_homes():
|
||||
_add(cache_home)
|
||||
except Exception: # noqa: BLE001 -- best-effort
|
||||
pass
|
||||
try:
|
||||
_add(hf_default_cache_dir())
|
||||
except Exception: # noqa: BLE001 -- best-effort
|
||||
|
|
@ -431,7 +438,7 @@ def browse_folders_response(
|
|||
|
||||
# Probe removable-media and Windows drive roots once; the allowlist and
|
||||
# chips reuse the result so a disconnected mapped drive isn't scanned twice.
|
||||
media_roots = linux_run_media_mount_roots()
|
||||
media_roots = [*linux_run_media_mount_roots(), *macos_volume_roots()]
|
||||
drive_roots = windows_drive_roots()
|
||||
# Build the allowlist once -- the sandbox check and suggestion chips share
|
||||
# it so chips are always navigable.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import asyncio
|
|||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -22,6 +23,7 @@ from hub.utils.hf_errors import hf_error_status
|
|||
from hub.utils.hf_cache_state import (
|
||||
INCOMPLETE_SUFFIX,
|
||||
iter_destructive_repo_cache_dirs,
|
||||
repo_cache_dir_name,
|
||||
)
|
||||
from hub.utils.gguf import (
|
||||
extract_quant_label,
|
||||
|
|
@ -233,8 +235,14 @@ def _manifest_variant_blob_hashes(
|
|||
variant: str,
|
||||
*,
|
||||
include_companions: bool = True,
|
||||
repo_cache_dir: Optional[Path] = None,
|
||||
) -> frozenset[str]:
|
||||
manifest = download_manifest.read_manifest("model", repo_id, variant)
|
||||
manifest = download_manifest.read_manifest(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = repo_cache_dir.parent if repo_cache_dir is not None else None,
|
||||
)
|
||||
if manifest is None:
|
||||
return frozenset()
|
||||
variant_key = variant.lower()
|
||||
|
|
@ -257,6 +265,7 @@ def gguf_variant_blob_hashes(
|
|||
*,
|
||||
include_companions: bool = True,
|
||||
allow_remote: bool = True,
|
||||
repo_cache_dir: Optional[Path] = None,
|
||||
) -> frozenset[str]:
|
||||
key = _variant_blob_hash_cache_key(
|
||||
repo_id,
|
||||
|
|
@ -271,9 +280,9 @@ def gguf_variant_blob_hashes(
|
|||
repo_id,
|
||||
variant,
|
||||
include_companions = include_companions,
|
||||
repo_cache_dir = repo_cache_dir,
|
||||
)
|
||||
if hashes:
|
||||
_variant_hash_cache_set(key, hashes)
|
||||
return hashes
|
||||
requirement_key = _variant_hash_cache_key(repo_id, variant, hf_token)
|
||||
requirement = _variant_requirement_cache_get(requirement_key)
|
||||
|
|
@ -287,11 +296,22 @@ def gguf_variant_blob_hashes(
|
|||
return frozenset()
|
||||
|
||||
|
||||
def _partial_transport_for_variant(repo_id: str, variant: str) -> Optional[str]:
|
||||
return hf_cache_scan.partial_transport_for("model", repo_id, variant)
|
||||
def _partial_transport_for_variant(
|
||||
repo_id: str,
|
||||
variant: str,
|
||||
repo_cache_dir: Optional[Path] = None,
|
||||
) -> Optional[str]:
|
||||
return hf_cache_scan.partial_transport_for(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
repo_cache_dir,
|
||||
)
|
||||
|
||||
|
||||
def _local_main_gguf_blobs_by_quant(repo_id: str) -> dict[str, dict[str, set[str]]]:
|
||||
def _local_main_gguf_blobs_by_quant(
|
||||
repo_id: str, repo_cache_dir: Optional[Path] = None
|
||||
) -> dict[str, dict[str, set[str]]]:
|
||||
"""Map quant -> repo-relative expected GGUF filename -> cached blob hashes.
|
||||
|
||||
Shared companions are copied into each main-quant bucket so update checks can
|
||||
|
|
@ -313,6 +333,14 @@ def _local_main_gguf_blobs_by_quant(repo_id: str) -> dict[str, dict[str, set[str
|
|||
continue
|
||||
if str(getattr(repo_info, "repo_id", "")).lower() != target_lower:
|
||||
continue
|
||||
if repo_cache_dir is not None:
|
||||
try:
|
||||
if Path(repo_info.repo_path).resolve(strict = False) != repo_cache_dir.resolve(
|
||||
strict = False
|
||||
):
|
||||
continue
|
||||
except (AttributeError, OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
for path, hashes in cache_inventory._repo_gguf_blob_map(
|
||||
repo_info,
|
||||
include_companions = True,
|
||||
|
|
@ -388,6 +416,7 @@ def delete_variant_incomplete_blobs_result(
|
|||
*,
|
||||
extra_hashes: frozenset[str] = frozenset(),
|
||||
companions: bool = True,
|
||||
root: Optional[Path] = None,
|
||||
) -> VariantIncompleteDeleteResult:
|
||||
# With a sibling still downloading, ``companions=False`` keeps a shared mmproj
|
||||
# from being unlinked out from under it; the repo's last delete reclaims it.
|
||||
|
|
@ -409,8 +438,9 @@ def delete_variant_incomplete_blobs_result(
|
|||
)
|
||||
deleted = 0
|
||||
# Destructive iterator: only the exact-case match (or abort if ambiguous),
|
||||
# so a case-variant sibling repo's partials are never unlinked.
|
||||
for entry in iter_destructive_repo_cache_dirs("model", repo_id):
|
||||
# so a case-variant sibling repo's partials are never unlinked. ``root`` scopes
|
||||
# the purge to one cache so a delete never touches another cache's partials.
|
||||
for entry in iter_destructive_repo_cache_dirs("model", repo_id, root = root):
|
||||
blobs_dir = entry / "blobs"
|
||||
if not blobs_dir.is_dir():
|
||||
continue
|
||||
|
|
@ -425,15 +455,37 @@ def delete_variant_incomplete_blobs_result(
|
|||
return VariantIncompleteDeleteResult(deleted = deleted, unresolved = False)
|
||||
|
||||
|
||||
def _repo_cache_dir_for_request(repo_id: str, local_path: Optional[str]) -> Path:
|
||||
"""Resolve the one Hub repo cache represented by this variant request."""
|
||||
expected_name = repo_cache_dir_name("model", repo_id).lower()
|
||||
if local_path:
|
||||
try:
|
||||
local = Path(local_path).expanduser().resolve(strict = False)
|
||||
for candidate in (local, *local.parents):
|
||||
if candidate.name.lower() == expected_name:
|
||||
return candidate
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pass
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
return get_hf_cache_paths().hub_cache / repo_cache_dir_name("model", repo_id)
|
||||
|
||||
|
||||
def _mark_empty_dir_cleanables(
|
||||
repo_id: str, response: GgufVariantsResponse
|
||||
repo_id: str,
|
||||
response: GgufVariantsResponse,
|
||||
repo_cache_dir: Optional[Path] = None,
|
||||
) -> GgufVariantsResponse:
|
||||
"""Surface empty leftover ``<quant>/`` folders (interrupted downloads) as
|
||||
partial so the UI can delete them -- on local/offline paths too, not just a
|
||||
remote listing. A listed quant is flipped to partial; an unlisted one is
|
||||
appended as a zero-byte cleanable entry."""
|
||||
try:
|
||||
empty_labels = list_empty_gguf_variant_dirs(repo_id)
|
||||
empty_labels = (
|
||||
list_empty_gguf_variant_dirs(repo_id, root = repo_cache_dir.parent)
|
||||
if repo_cache_dir is not None
|
||||
else list_empty_gguf_variant_dirs(repo_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to scan empty GGUF variant folders for {repo_id}: {e}")
|
||||
return response
|
||||
|
|
@ -468,6 +520,11 @@ async def get_gguf_variants_response(
|
|||
"""
|
||||
|
||||
def _compute() -> GgufVariantsResponse:
|
||||
repo_cache_dir = (
|
||||
None if is_local_path(repo_id) else _repo_cache_dir_for_request(repo_id, local_path)
|
||||
)
|
||||
hub_cache = repo_cache_dir.parent if repo_cache_dir is not None else None
|
||||
|
||||
def _local_response(
|
||||
response_repo_id: str, variants, has_vision: bool
|
||||
) -> GgufVariantsResponse:
|
||||
|
|
@ -511,6 +568,7 @@ async def get_gguf_variants_response(
|
|||
partial_transport = _partial_transport_for_variant(
|
||||
response_repo_id,
|
||||
v.quant,
|
||||
repo_cache_dir,
|
||||
),
|
||||
)
|
||||
for v in variants
|
||||
|
|
@ -532,7 +590,7 @@ async def get_gguf_variants_response(
|
|||
|
||||
local_only = prefer_local_cache or offline
|
||||
if local_only:
|
||||
cached = list_gguf_variants_from_hf_cache(repo_id)
|
||||
cached = list_gguf_variants_from_hf_cache(repo_id, root = hub_cache)
|
||||
if cached is not None:
|
||||
variants, has_vision = cached
|
||||
return _local_response(repo_id, variants, has_vision)
|
||||
|
|
@ -540,7 +598,7 @@ async def get_gguf_variants_response(
|
|||
variants, has_vision = list_local_gguf_variants(local_path)
|
||||
if variants or has_vision:
|
||||
return _local_response(repo_id, variants, has_vision)
|
||||
partial = list_partial_gguf_variants_from_state(repo_id)
|
||||
partial = list_partial_gguf_variants_from_state(repo_id, hub_cache = hub_cache)
|
||||
if partial is not None:
|
||||
variants, has_vision = partial
|
||||
return _partial_local_response(repo_id, variants, has_vision)
|
||||
|
|
@ -560,11 +618,11 @@ async def get_gguf_variants_response(
|
|||
try:
|
||||
variants, has_vision, siblings = list_gguf_variants(repo_id, hf_token = hf_token)
|
||||
except Exception:
|
||||
cached = list_gguf_variants_from_hf_cache(repo_id)
|
||||
cached = list_gguf_variants_from_hf_cache(repo_id, root = hub_cache)
|
||||
if cached is not None:
|
||||
variants, has_vision = cached
|
||||
return _local_response(repo_id, variants, has_vision)
|
||||
partial = list_partial_gguf_variants_from_state(repo_id)
|
||||
partial = list_partial_gguf_variants_from_state(repo_id, hub_cache = hub_cache)
|
||||
if partial is not None:
|
||||
variants, has_vision = partial
|
||||
return _partial_local_response(repo_id, variants, has_vision)
|
||||
|
|
@ -581,7 +639,7 @@ async def get_gguf_variants_response(
|
|||
cached_filenames_by_snapshot: list[dict[str, int]] = []
|
||||
cached_quant_bytes_by_snapshot: list[dict[str, int]] = []
|
||||
if _is_valid_repo_id(repo_id):
|
||||
for snap in iter_hf_cache_snapshots(repo_id):
|
||||
for snap in iter_hf_cache_snapshots(repo_id, root = hub_cache):
|
||||
try:
|
||||
gguf_paths = list(_iter_gguf_paths(snap))
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
|
|
@ -694,11 +752,20 @@ async def get_gguf_variants_response(
|
|||
partial_quants: set[str] = set()
|
||||
partial_quant_transports: dict[str, Optional[str]] = {}
|
||||
try:
|
||||
incomplete_hashes = download_registry.incomplete_blob_hashes("model", repo_id)
|
||||
incomplete_hashes = download_registry.incomplete_blob_hashes(
|
||||
"model",
|
||||
repo_id,
|
||||
active_only = True,
|
||||
root = hub_cache,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute partial GGUF variants for {repo_id}: {e}")
|
||||
incomplete_hashes = set()
|
||||
scan_snapshot_dir = hf_cache_scan.resolve_snapshot_dir_for_scan("model", repo_id)
|
||||
scan_snapshot_dir = hf_cache_scan.resolve_snapshot_dir_for_scan(
|
||||
"model",
|
||||
repo_id,
|
||||
repo_cache_dir,
|
||||
)
|
||||
# Manifest + marker + main incomplete-blob check: catches variants whose
|
||||
# download was cancelled or whose expected shards are missing/undersized.
|
||||
for variant in variants:
|
||||
|
|
@ -711,6 +778,7 @@ async def get_gguf_variants_response(
|
|||
variant.quant,
|
||||
hf_token,
|
||||
include_companions = False,
|
||||
repo_cache_dir = repo_cache_dir,
|
||||
)
|
||||
if hf_cache_scan.is_variant_partial(
|
||||
repo_id,
|
||||
|
|
@ -718,11 +786,13 @@ async def get_gguf_variants_response(
|
|||
scan_snapshot_dir,
|
||||
incomplete_blob_hashes = incomplete_hashes,
|
||||
variant_blob_hashes = variant_hashes,
|
||||
repo_cache_dir = repo_cache_dir,
|
||||
):
|
||||
partial_quants.add(variant.quant)
|
||||
partial_quant_transports[variant.quant] = _partial_transport_for_variant(
|
||||
repo_id,
|
||||
variant.quant,
|
||||
repo_cache_dir,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
|
|
@ -744,10 +814,14 @@ async def get_gguf_variants_response(
|
|||
partial_quants.add(variant.quant)
|
||||
partial_quant_transports.setdefault(
|
||||
variant.quant,
|
||||
_partial_transport_for_variant(repo_id, variant.quant),
|
||||
_partial_transport_for_variant(
|
||||
repo_id,
|
||||
variant.quant,
|
||||
repo_cache_dir,
|
||||
),
|
||||
)
|
||||
|
||||
local_blobs_by_quant = _local_main_gguf_blobs_by_quant(repo_id)
|
||||
local_blobs_by_quant = _local_main_gguf_blobs_by_quant(repo_id, repo_cache_dir)
|
||||
|
||||
def _variant_detail(v) -> GgufVariantDetail:
|
||||
is_partial = v.quant in partial_quants
|
||||
|
|
@ -790,14 +864,20 @@ async def get_gguf_variants_response(
|
|||
if skip:
|
||||
raise
|
||||
enriched = _mark_empty_dir_cleanables(
|
||||
repo_id, GgufVariantsResponse(repo_id = repo_id, variants = [])
|
||||
repo_id,
|
||||
GgufVariantsResponse(repo_id = repo_id, variants = []),
|
||||
_repo_cache_dir_for_request(repo_id, local_path),
|
||||
)
|
||||
if enriched.variants:
|
||||
return enriched
|
||||
raise
|
||||
if skip:
|
||||
return response
|
||||
return _mark_empty_dir_cleanables(repo_id, response)
|
||||
return _mark_empty_dir_cleanables(
|
||||
repo_id,
|
||||
response,
|
||||
_repo_cache_dir_for_request(repo_id, local_path),
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_compute_with_cleanables)
|
||||
|
|
|
|||
|
|
@ -106,11 +106,8 @@ def _is_model_directory_for_scan(path: Path, *, entry_limit: int | None) -> bool
|
|||
|
||||
|
||||
def _resolve_hf_cache_dir() -> Path:
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
return Path(HF_HUB_CACHE)
|
||||
except Exception:
|
||||
return Path.home() / ".cache" / "huggingface" / "hub"
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
return get_hf_cache_paths().hub_cache
|
||||
|
||||
|
||||
def _scan_models_dir(
|
||||
|
|
@ -202,7 +199,12 @@ def _hf_repo_dir_has_content(repo_dir: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[LocalModelInfo]:
|
||||
def _scan_hf_cache(
|
||||
cache_dir: Path,
|
||||
*,
|
||||
entry_limit: int | None = None,
|
||||
active_cache: bool = True,
|
||||
) -> List[LocalModelInfo]:
|
||||
if not _safe_is_dir(cache_dir):
|
||||
return []
|
||||
|
||||
|
|
@ -240,7 +242,10 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
|
|||
repo_dir,
|
||||
)
|
||||
gguf_partial = hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
|
||||
has_gguf_variant_state, gguf_variant_state_size = _gguf_variant_state_summary(model_id)
|
||||
has_gguf_variant_state, gguf_variant_state_size = _gguf_variant_state_summary(
|
||||
model_id,
|
||||
hub_cache = cache_dir,
|
||||
)
|
||||
snapshot_partial_transport = (
|
||||
hf_cache_scan.partial_transport_for(
|
||||
"model",
|
||||
|
|
@ -252,23 +257,25 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
|
|||
)
|
||||
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_dir)
|
||||
scan_path = Path(resolved) if resolved else repo_dir
|
||||
load_path = repo_dir if active_cache else scan_path
|
||||
# partial=False here; _apply_format_aware_partial below rewrites per-row
|
||||
# so a hybrid repo's gguf row doesn't taint its safetensors row.
|
||||
rows = _classify_local_path(
|
||||
scan_path,
|
||||
"hf_cache",
|
||||
load_path = repo_dir,
|
||||
load_path = load_path,
|
||||
display_name = model_id.split("/")[-1],
|
||||
model_id = model_id,
|
||||
updated_at = updated_at,
|
||||
partial = False,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
if not rows:
|
||||
if has_gguf_variant_state and gguf_partial:
|
||||
rows = [
|
||||
_local_model_info(
|
||||
scan_path = repo_dir,
|
||||
load_path = repo_dir,
|
||||
load_path = load_path,
|
||||
source = "hf_cache",
|
||||
model_format = "gguf",
|
||||
display_name = model_id.split("/")[-1],
|
||||
|
|
@ -277,6 +284,7 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
|
|||
partial = True,
|
||||
requires_variant = True,
|
||||
size_bytes = gguf_variant_state_size,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
]
|
||||
else:
|
||||
|
|
@ -285,13 +293,14 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
|
|||
rows = [
|
||||
_local_model_info(
|
||||
scan_path = repo_dir,
|
||||
load_path = repo_dir,
|
||||
load_path = load_path,
|
||||
source = "hf_cache",
|
||||
model_format = "unknown",
|
||||
display_name = model_id.split("/")[-1],
|
||||
model_id = model_id,
|
||||
updated_at = updated_at,
|
||||
partial = snapshot_partial or gguf_partial,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
]
|
||||
elif (
|
||||
|
|
@ -302,7 +311,7 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
|
|||
rows.append(
|
||||
_local_model_info(
|
||||
scan_path = repo_dir,
|
||||
load_path = repo_dir,
|
||||
load_path = load_path,
|
||||
source = "hf_cache",
|
||||
model_format = "gguf",
|
||||
display_name = model_id.split("/")[-1],
|
||||
|
|
@ -311,6 +320,7 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
|
|||
partial = True,
|
||||
requires_variant = True,
|
||||
size_bytes = gguf_variant_state_size,
|
||||
active_cache = active_cache,
|
||||
)
|
||||
)
|
||||
rows = _apply_format_aware_partial(
|
||||
|
|
@ -515,14 +525,39 @@ async def _collect_models_from_default_sources(
|
|||
local_models += await _scan_source("HF cache", _scan_hf_cache, hf_cache_dir)
|
||||
|
||||
if _safe_is_dir(legacy_hf) and legacy_hf.resolve() != hf_cache_dir.resolve():
|
||||
local_models += await _scan_source("legacy HF cache", _scan_hf_cache, legacy_hf)
|
||||
local_models += await _scan_source(
|
||||
"legacy HF cache",
|
||||
lambda path: _scan_hf_cache(path, active_cache = False),
|
||||
legacy_hf,
|
||||
)
|
||||
|
||||
if (
|
||||
_safe_is_dir(hf_default)
|
||||
and hf_default.resolve() != hf_cache_dir.resolve()
|
||||
and hf_default.resolve() != legacy_hf.resolve()
|
||||
):
|
||||
local_models += await _scan_source("default HF cache", _scan_hf_cache, hf_default)
|
||||
local_models += await _scan_source(
|
||||
"default HF cache",
|
||||
lambda path: _scan_hf_cache(path, active_cache = False),
|
||||
hf_default,
|
||||
)
|
||||
|
||||
from utils.hf_cache_settings import known_hf_hub_caches
|
||||
|
||||
seen_hf = {
|
||||
os.path.normcase(str(path.resolve(strict = False)))
|
||||
for path in (hf_cache_dir, legacy_hf, hf_default)
|
||||
}
|
||||
for previous_cache in known_hf_hub_caches():
|
||||
key = os.path.normcase(str(previous_cache.resolve(strict = False)))
|
||||
if key in seen_hf:
|
||||
continue
|
||||
seen_hf.add(key)
|
||||
local_models += await _scan_source(
|
||||
"previous HF cache",
|
||||
lambda path: _scan_hf_cache(path, active_cache = False),
|
||||
previous_cache,
|
||||
)
|
||||
|
||||
for lm_dir in lm_dirs:
|
||||
local_models += await _scan_source("LM Studio", _scan_lmstudio_dir, lm_dir)
|
||||
|
|
@ -543,7 +578,11 @@ def _scan_custom_folder(folder_path: Path) -> List[LocalModelInfo]:
|
|||
limit = _MAX_MODELS_PER_CUSTOM_FOLDER,
|
||||
entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES,
|
||||
)
|
||||
+ _scan_hf_cache(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
|
||||
+ _scan_hf_cache(
|
||||
folder_path,
|
||||
entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES,
|
||||
active_cache = False,
|
||||
)
|
||||
+ _scan_lmstudio_dir(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
|
||||
)
|
||||
if m.model_format in supported_formats
|
||||
|
|
@ -610,12 +649,20 @@ def _dedupe_local_models(local_models: List[LocalModelInfo]) -> list[LocalModelI
|
|||
row_key = model.inventory_id or model.id
|
||||
key = f"{row_key}\x00custom" if model.source == "custom" else row_key
|
||||
existing = deduped.get(key)
|
||||
if existing is None or _prefer_complete_larger(
|
||||
model.partial,
|
||||
model.size_bytes,
|
||||
existing.partial,
|
||||
existing.size_bytes,
|
||||
):
|
||||
prefer_candidate = existing is None
|
||||
if existing is not None:
|
||||
if model.partial != existing.partial:
|
||||
prefer_candidate = not model.partial
|
||||
elif (model.active_cache is True) != (existing.active_cache is True):
|
||||
prefer_candidate = model.active_cache is True
|
||||
else:
|
||||
prefer_candidate = _prefer_complete_larger(
|
||||
model.partial,
|
||||
model.size_bytes,
|
||||
existing.partial,
|
||||
existing.size_bytes,
|
||||
)
|
||||
if prefer_candidate:
|
||||
deduped[key] = model
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
|
|
|
|||
|
|
@ -86,9 +86,20 @@ def _snapshot_complete_on_disk(
|
|||
return False
|
||||
if variant is None and hf_cache_scan.repo_cache_dir_has_incomplete_blobs(entry):
|
||||
return False
|
||||
if download_manifest.has_cancel_marker(repo_type, repo_id, variant):
|
||||
hub_cache = entry.parent
|
||||
if download_manifest.has_cancel_marker(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
return False
|
||||
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
|
||||
manifest = download_manifest.read_manifest(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
if manifest is None:
|
||||
return False
|
||||
return download_manifest.verify_against_disk(manifest, snapshot_dir).ok
|
||||
|
|
@ -118,6 +129,8 @@ def compute_snapshot_progress(
|
|||
0,
|
||||
int(getattr(metadata, "completed_baseline_bytes", 0) or 0),
|
||||
)
|
||||
metadata_hub_cache = getattr(metadata, "hub_cache", None)
|
||||
active_root = Path(metadata_hub_cache) if metadata_hub_cache else None
|
||||
|
||||
expected_total = max(expected_bytes, 0)
|
||||
# Always resolve the revision's blob hashes so stale blobs from a superseded
|
||||
|
|
@ -134,11 +147,17 @@ def compute_snapshot_progress(
|
|||
count_finalized_unscoped = variant is None
|
||||
|
||||
readings: list[tuple[int, int, Optional[str], bool]] = []
|
||||
for entry in preferred_repo_cache_dirs(
|
||||
repo_type,
|
||||
repo_id,
|
||||
force_active = force_active,
|
||||
):
|
||||
cache_dirs = (
|
||||
preferred_repo_cache_dirs(
|
||||
repo_type,
|
||||
repo_id,
|
||||
force_active = force_active,
|
||||
active_root = active_root,
|
||||
)
|
||||
if active_root is not None
|
||||
else preferred_repo_cache_dirs(repo_type, repo_id, force_active = force_active)
|
||||
)
|
||||
for entry in cache_dirs:
|
||||
completed_bytes = 0
|
||||
in_progress_bytes = 0
|
||||
cache_path = hf_cache_scan.resolve_hf_cache_realpath(entry)
|
||||
|
|
|
|||
|
|
@ -72,57 +72,115 @@ def test_dataset_cache_scan_merges_raw_and_processed_rows(monkeypatch):
|
|||
assert rows[0]["partial"] is False
|
||||
|
||||
|
||||
def test_delete_cached_dataset_attempts_all_roots_before_raising(monkeypatch):
|
||||
def test_delete_cached_dataset_scopes_delete_to_selected_root(monkeypatch, tmp_path):
|
||||
"""A dataset present in the active cache and a previously selected cache is
|
||||
deleted only from the selected root, so the other cache's copy survives."""
|
||||
calls = []
|
||||
purged_state = []
|
||||
target_hub = tmp_path / "active" / "hub"
|
||||
other_hub = tmp_path / "previous" / "hub"
|
||||
for hub in (target_hub, other_hub):
|
||||
(hub / "datasets--Org--Data").mkdir(parents = True)
|
||||
|
||||
class _DeleteStrategy:
|
||||
def __init__(self, label: str, fail: bool):
|
||||
def __init__(self, label: str):
|
||||
self.label = label
|
||||
self.fail = fail
|
||||
|
||||
def execute(self):
|
||||
calls.append(self.label)
|
||||
if self.fail:
|
||||
raise RuntimeError(f"{self.label} failed")
|
||||
|
||||
class _Cache:
|
||||
def __init__(self, label: str, fail: bool):
|
||||
self.cache_dir = label
|
||||
self.repos = [
|
||||
def _cache(label: str, hub):
|
||||
return SimpleNamespace(
|
||||
cache_dir = label,
|
||||
repos = [
|
||||
SimpleNamespace(
|
||||
repo_type = "dataset",
|
||||
repo_id = "Org/Data",
|
||||
repo_path = str(hub / "datasets--Org--Data"),
|
||||
revisions = [SimpleNamespace(commit_hash = f"{label}-rev")],
|
||||
)
|
||||
]
|
||||
self.fail = fail
|
||||
|
||||
def delete_revisions(self, *_revisions):
|
||||
return _DeleteStrategy(self.cache_dir, self.fail)
|
||||
],
|
||||
delete_revisions = lambda *_revs, _label = label: _DeleteStrategy(_label),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"_collect_hf_cache_scans",
|
||||
lambda: ([_Cache("first", True), _Cache("second", False)], set()),
|
||||
lambda: ([_cache("active", target_hub), _cache("previous", other_hub)], set()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"_delete_processed_dataset_cache",
|
||||
lambda _repo_id: (True, []),
|
||||
lambda _repo_id, **_kwargs: (False, []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.download_manifest,
|
||||
"purge_all_state_for_repo",
|
||||
lambda *_args: purged_state.append(True) or 1,
|
||||
lambda *_args, **_kwargs: 0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = target_hub),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hub.utils.hf_cache_state.hf_cache_roots",
|
||||
lambda: [target_hub, other_hub],
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
cache_inventory._delete_cached_dataset_blocking("Org/Data")
|
||||
result = cache_inventory._delete_cached_dataset_blocking("Org/Data")
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert calls == ["first", "second"]
|
||||
assert purged_state == []
|
||||
assert result == {"status": "deleted", "repo_id": "Org/Data"}
|
||||
# Only the selected (active) cache's revision is deleted; the previous
|
||||
# cache's copy is never touched.
|
||||
assert calls == ["active"]
|
||||
assert not (target_hub / "datasets--Org--Data").exists()
|
||||
assert (other_hub / "datasets--Org--Data").exists()
|
||||
|
||||
|
||||
def test_delete_processed_only_dataset_accepts_processed_cache_path(monkeypatch, tmp_path):
|
||||
"""A processed-only dataset row sends its Arrow cache path (<owner>___<repo>
|
||||
under HF_DATASETS_CACHE), which is not a Hub datasets-- dir. The delete must
|
||||
accept it and run the processed-cache delete instead of raising 400."""
|
||||
datasets_root = tmp_path / "datasets"
|
||||
processed_dir = datasets_root / "Org___Data"
|
||||
processed_dir.mkdir(parents = True)
|
||||
|
||||
# No Hub-cache copy exists; only the processed Arrow cache holds this repo.
|
||||
monkeypatch.setattr(cache_inventory, "_collect_hf_cache_scans", lambda: ([], set()))
|
||||
monkeypatch.setattr(cache_inventory, "_hf_datasets_cache_roots", lambda: [datasets_root])
|
||||
processed_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"_delete_processed_dataset_cache",
|
||||
lambda repo_id, **_kwargs: (processed_calls.append(repo_id) or True, []),
|
||||
)
|
||||
|
||||
result = cache_inventory._delete_cached_dataset_blocking("Org/Data", str(processed_dir))
|
||||
|
||||
assert result == {"status": "deleted", "repo_id": "Org/Data"}
|
||||
assert processed_calls == ["Org/Data"]
|
||||
|
||||
|
||||
def test_delete_processed_dataset_scopes_to_selected_root(monkeypatch, tmp_path):
|
||||
"""A dataset processed under two HF_DATASETS_CACHE roots is deleted only from
|
||||
the selected root; the copy under the other cache home survives (real delete,
|
||||
not stubbed)."""
|
||||
selected_root = tmp_path / "selected" / "datasets"
|
||||
other_root = tmp_path / "other" / "datasets"
|
||||
for root in (selected_root, other_root):
|
||||
(root / "Org___Data").mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(cache_inventory, "_collect_hf_cache_scans", lambda: ([], set()))
|
||||
monkeypatch.setattr(
|
||||
cache_inventory, "_hf_datasets_cache_roots", lambda: [selected_root, other_root]
|
||||
)
|
||||
|
||||
result = cache_inventory._delete_cached_dataset_blocking(
|
||||
"Org/Data", str(selected_root / "Org___Data")
|
||||
)
|
||||
|
||||
assert result == {"status": "deleted", "repo_id": "Org/Data"}
|
||||
assert not (selected_root / "Org___Data").exists() # the selected copy is deleted
|
||||
assert (other_root / "Org___Data").exists() # the other cache home is untouched
|
||||
|
||||
|
||||
def test_delete_cached_dataset_purges_blob_only_repo_dir(monkeypatch):
|
||||
|
|
@ -139,22 +197,22 @@ def test_delete_cached_dataset_purges_blob_only_repo_dir(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"_delete_processed_dataset_cache",
|
||||
lambda _repo_id: (False, []),
|
||||
lambda _repo_id, **_kwargs: (False, []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"purge_repo_cache_dirs",
|
||||
lambda _repo_type, repo_id: purged_dirs.append(repo_id) or True,
|
||||
lambda _repo_type, repo_id, **_kwargs: purged_dirs.append(repo_id) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"purge_partial_repo",
|
||||
lambda *_args: False,
|
||||
lambda *_args, **_kwargs: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.download_manifest,
|
||||
"purge_all_state_for_repo",
|
||||
lambda *_args: 0,
|
||||
lambda *_args, **_kwargs: 0,
|
||||
)
|
||||
|
||||
result = cache_inventory._delete_cached_dataset_blocking("Org/Data")
|
||||
|
|
@ -172,22 +230,22 @@ def test_delete_cached_dataset_absent_everywhere_raises_404(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"_delete_processed_dataset_cache",
|
||||
lambda _repo_id: (False, []),
|
||||
lambda _repo_id, **_kwargs: (False, []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"purge_repo_cache_dirs",
|
||||
lambda *_args: False,
|
||||
lambda *_args, **_kwargs: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"purge_partial_repo",
|
||||
lambda *_args: False,
|
||||
lambda *_args, **_kwargs: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.download_manifest,
|
||||
"purge_all_state_for_repo",
|
||||
lambda *_args: 0,
|
||||
lambda *_args, **_kwargs: 0,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
|
|||
62
studio/backend/hub/tests/test_download_manifest_scoping.py
Normal file
62
studio/backend/hub/tests/test_download_manifest_scoping.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from hub.utils import download_manifest, state_dir
|
||||
|
||||
|
||||
def _write_manifest(path, payload):
|
||||
path.parent.mkdir(parents = True, exist_ok = True)
|
||||
path.write_text(json.dumps(payload), encoding = "utf-8")
|
||||
|
||||
|
||||
def test_purge_state_preserves_active_legacy_when_deleting_inactive_cache(monkeypatch, tmp_path):
|
||||
"""A scoped delete of an inactive cache must not erase the unscoped legacy
|
||||
state, which _legacy_state_applies attributes to the active cache."""
|
||||
active = tmp_path / "active" / "hub"
|
||||
previous = tmp_path / "previous" / "hub"
|
||||
for path in (active, previous):
|
||||
path.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = str(active)),
|
||||
)
|
||||
|
||||
# Unowned legacy manifest -> belongs to the active cache.
|
||||
legacy = state_dir.manifest_path("model", "Org/Model")
|
||||
_write_manifest(legacy, {"version": 1})
|
||||
# The inactive cache's own scoped copy is the one being deleted.
|
||||
scoped = state_dir.manifest_path("model", "Org/Model", hub_cache = str(previous))
|
||||
_write_manifest(scoped, {"version": 1, "hub_cache": str(previous)})
|
||||
|
||||
removed = download_manifest.purge_state("model", "Org/Model", hub_cache = str(previous))
|
||||
|
||||
assert removed is True
|
||||
assert not scoped.is_file() # the inactive cache's copy is gone
|
||||
assert legacy.is_file() # the active cache's legacy state survives
|
||||
|
||||
|
||||
def test_purge_state_removes_legacy_owned_by_the_deleted_cache(monkeypatch, tmp_path):
|
||||
"""A legacy file that recorded the deleted cache as its owner is purged."""
|
||||
active = tmp_path / "active" / "hub"
|
||||
previous = tmp_path / "previous" / "hub"
|
||||
for path in (active, previous):
|
||||
path.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = str(active)),
|
||||
)
|
||||
|
||||
legacy = state_dir.manifest_path("model", "Org/Model")
|
||||
_write_manifest(legacy, {"version": 1, "hub_cache": str(previous)})
|
||||
|
||||
removed = download_manifest.purge_state("model", "Org/Model", hub_cache = str(previous))
|
||||
|
||||
assert removed is True
|
||||
assert not legacy.is_file() # owned by the deleted cache -> purged
|
||||
|
|
@ -120,10 +120,16 @@ def _force_compute_to_raise(monkeypatch):
|
|||
|
||||
monkeypatch.setattr(gguf_variants, "list_gguf_variants", _boom, raising = False)
|
||||
monkeypatch.setattr(
|
||||
gguf_variants, "list_gguf_variants_from_hf_cache", lambda repo_id: None, raising = False
|
||||
gguf_variants,
|
||||
"list_gguf_variants_from_hf_cache",
|
||||
lambda repo_id, root = None: None,
|
||||
raising = False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gguf_variants, "list_partial_gguf_variants_from_state", lambda repo_id: None, raising = False
|
||||
gguf_variants,
|
||||
"list_partial_gguf_variants_from_state",
|
||||
lambda repo_id, hub_cache = None: None,
|
||||
raising = False,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -133,7 +139,11 @@ def test_get_variants_surfaces_cleanable_when_metadata_fails(monkeypatch):
|
|||
import asyncio
|
||||
|
||||
_force_compute_to_raise(monkeypatch)
|
||||
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
|
||||
monkeypatch.setattr(
|
||||
gguf_variants,
|
||||
"list_empty_gguf_variant_dirs",
|
||||
lambda repo_id, root = None: {"UD-IQ1_S"},
|
||||
)
|
||||
|
||||
resp = asyncio.run(
|
||||
gguf_variants.get_gguf_variants_response(
|
||||
|
|
@ -152,7 +162,11 @@ def test_get_variants_reraises_when_no_cleanable(monkeypatch):
|
|||
from fastapi import HTTPException
|
||||
|
||||
_force_compute_to_raise(monkeypatch)
|
||||
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: set())
|
||||
monkeypatch.setattr(
|
||||
gguf_variants,
|
||||
"list_empty_gguf_variant_dirs",
|
||||
lambda repo_id, root = None: set(),
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -102,6 +103,236 @@ def test_big_endian_detection_ignores_model_name_be_token():
|
|||
)
|
||||
|
||||
|
||||
def _cached_model_row(tmp_path: Path, *, partial: bool, active_cache: bool | None, size_bytes: int):
|
||||
path = tmp_path / f"cache-{active_cache}-{partial}-{size_bytes}"
|
||||
return model_common._local_model_info(
|
||||
scan_path = path,
|
||||
load_path = path,
|
||||
source = "hf_cache",
|
||||
model_format = "safetensors",
|
||||
model_id = "Org/Model",
|
||||
partial = partial,
|
||||
active_cache = active_cache,
|
||||
size_bytes = size_bytes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
def test_local_inventory_prefers_complete_previous_cache_copy(tmp_path, reverse):
|
||||
active_partial = _cached_model_row(
|
||||
tmp_path,
|
||||
partial = True,
|
||||
active_cache = True,
|
||||
size_bytes = 20,
|
||||
)
|
||||
previous_complete = _cached_model_row(
|
||||
tmp_path,
|
||||
partial = False,
|
||||
active_cache = False,
|
||||
size_bytes = 10,
|
||||
)
|
||||
rows = [active_partial, previous_complete]
|
||||
if reverse:
|
||||
rows.reverse()
|
||||
|
||||
result = local_inventory._dedupe_local_models(rows)
|
||||
|
||||
assert result == [previous_complete]
|
||||
|
||||
|
||||
def test_local_inventory_compares_all_non_active_cache_copies(tmp_path):
|
||||
inactive_partial = _cached_model_row(
|
||||
tmp_path,
|
||||
partial = True,
|
||||
active_cache = False,
|
||||
size_bytes = 20,
|
||||
)
|
||||
custom_complete = _cached_model_row(
|
||||
tmp_path,
|
||||
partial = False,
|
||||
active_cache = None,
|
||||
size_bytes = 10,
|
||||
)
|
||||
|
||||
assert local_inventory._dedupe_local_models([inactive_partial, custom_complete]) == [
|
||||
custom_complete
|
||||
]
|
||||
|
||||
|
||||
def test_local_inventory_prefers_active_cache_when_copies_are_equally_complete(tmp_path):
|
||||
previous = _cached_model_row(
|
||||
tmp_path,
|
||||
partial = False,
|
||||
active_cache = False,
|
||||
size_bytes = 20,
|
||||
)
|
||||
active = _cached_model_row(
|
||||
tmp_path,
|
||||
partial = False,
|
||||
active_cache = True,
|
||||
size_bytes = 10,
|
||||
)
|
||||
|
||||
assert local_inventory._dedupe_local_models([previous, active]) == [active]
|
||||
|
||||
|
||||
def test_loaded_repo_match_accepts_previous_cache_snapshot_path(monkeypatch, tmp_path):
|
||||
repo_dir = tmp_path / "old-hub" / "models--Org--Model"
|
||||
snapshot = repo_dir / "snapshots" / "revision"
|
||||
snapshot.mkdir(parents = True)
|
||||
monkeypatch.setattr(deletion, "iter_repo_cache_dirs", lambda *_args: iter([repo_dir]))
|
||||
|
||||
assert deletion._loaded_id_matches_repo(str(snapshot), "Org/Model") is True
|
||||
assert deletion._loaded_id_matches_repo(str(snapshot / "model.gguf"), "Org/Model") is True
|
||||
assert deletion._loaded_id_matches_repo(str(tmp_path / "other"), "Org/Model") is False
|
||||
|
||||
|
||||
def test_cached_inventory_loads_previous_cache_copy_by_snapshot(monkeypatch, tmp_path):
|
||||
active_hub = tmp_path / "active-hub"
|
||||
previous_repo = tmp_path / "previous-hub" / "models--Org--Model"
|
||||
snapshot = previous_repo / "snapshots" / "revision"
|
||||
snapshot.mkdir(parents = True)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = active_hub),
|
||||
)
|
||||
|
||||
fields = cache_inventory._cache_inventory_fields(
|
||||
"Org/Model",
|
||||
"safetensors",
|
||||
repo_path = previous_repo,
|
||||
snapshot_path = snapshot,
|
||||
)
|
||||
|
||||
assert fields["load_id"] == str(snapshot)
|
||||
|
||||
|
||||
def test_cached_inventory_keeps_repo_id_for_active_cache(monkeypatch, tmp_path):
|
||||
active_hub = tmp_path / "active-hub"
|
||||
active_repo = active_hub / "models--Org--Model"
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = active_hub),
|
||||
)
|
||||
|
||||
fields = cache_inventory._cache_inventory_fields(
|
||||
"Org/Model",
|
||||
"safetensors",
|
||||
repo_path = active_repo,
|
||||
)
|
||||
|
||||
assert fields["load_id"] == "Org/Model"
|
||||
|
||||
|
||||
def test_cached_inventory_prefers_active_copy_when_completeness_matches():
|
||||
previous = {"partial": False, "active_cache": False, "size_bytes": 200}
|
||||
active = {"partial": False, "active_cache": True, "size_bytes": 100}
|
||||
|
||||
assert cache_inventory._prefer_cache_row(active, previous) is True
|
||||
assert cache_inventory._prefer_cache_row(previous, active) is False
|
||||
|
||||
|
||||
def test_cached_inventory_prefers_complete_copy_before_active_cache():
|
||||
previous = {"partial": False, "active_cache": False, "size_bytes": 100}
|
||||
active_partial = {"partial": True, "active_cache": True, "size_bytes": 200}
|
||||
|
||||
assert cache_inventory._prefer_cache_row(previous, active_partial) is True
|
||||
assert cache_inventory._prefer_cache_row(active_partial, previous) is False
|
||||
|
||||
|
||||
def test_inventory_scans_every_dynamic_cache_root(monkeypatch, tmp_path):
|
||||
first = tmp_path / "first-hub"
|
||||
second = tmp_path / "second-hub"
|
||||
unreadable = tmp_path / "unreadable-hub"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
unreadable.mkdir()
|
||||
scanned = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
inventory_scan,
|
||||
"hf_cache_roots",
|
||||
lambda: [first, unreadable, second],
|
||||
)
|
||||
|
||||
def scan_cache(cache_dir):
|
||||
path = Path(cache_dir)
|
||||
scanned.append(path)
|
||||
if path == unreadable:
|
||||
raise PermissionError("unreadable")
|
||||
return SimpleNamespace(cache_dir = cache_dir)
|
||||
|
||||
monkeypatch.setattr("huggingface_hub.scan_cache_dir", scan_cache)
|
||||
|
||||
result = inventory_scan._compute_all_hf_cache_scans()
|
||||
|
||||
assert scanned == [first, unreadable, second]
|
||||
assert [Path(scan.cache_dir) for scan in result] == [first, second]
|
||||
|
||||
|
||||
def test_inventory_applies_download_state_to_its_owning_cache(monkeypatch, tmp_path):
|
||||
state_root = tmp_path / "state"
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
repo_id = "Org/Model"
|
||||
repo_name = "models--Org--Model"
|
||||
repo_a = cache_a / repo_name
|
||||
repo_b = cache_b / repo_name
|
||||
snapshot_a = repo_a / "snapshots" / "revision"
|
||||
snapshot_b = repo_b / "snapshots" / "revision"
|
||||
snapshot_a.mkdir(parents = True)
|
||||
snapshot_b.mkdir(parents = True)
|
||||
(snapshot_a / "config.json").write_bytes(b"x")
|
||||
(snapshot_b / "config.json").write_bytes(b"xx")
|
||||
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: state_root)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = cache_b),
|
||||
)
|
||||
assert download_manifest.write_manifest(
|
||||
"model",
|
||||
repo_id,
|
||||
None,
|
||||
[download_manifest.ExpectedFile(path = "config.json", size = 2)],
|
||||
"http",
|
||||
hub_cache = cache_a,
|
||||
)
|
||||
|
||||
assert inventory_scan.is_snapshot_partial("model", repo_id, repo_a) is True
|
||||
assert inventory_scan.is_snapshot_partial("model", repo_id, repo_b) is False
|
||||
assert inventory_scan.partial_transport_for("model", repo_id, None, repo_a) == "http"
|
||||
assert inventory_scan.partial_transport_for("model", repo_id, None, repo_b) is None
|
||||
|
||||
|
||||
def test_inventory_scopes_cancel_markers_to_their_owning_cache(monkeypatch, tmp_path):
|
||||
state_root = tmp_path / "state"
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
repo_id = "Org/Model"
|
||||
repo_name = "models--Org--Model"
|
||||
repo_a = cache_a / repo_name
|
||||
repo_b = cache_b / repo_name
|
||||
repo_a.mkdir(parents = True)
|
||||
repo_b.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: state_root)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = cache_b),
|
||||
)
|
||||
assert download_manifest.write_cancel_marker(
|
||||
"model",
|
||||
repo_id,
|
||||
"Q4_K_M",
|
||||
"xet",
|
||||
hub_cache = cache_a,
|
||||
)
|
||||
|
||||
assert inventory_scan.is_variant_partial(repo_id, "Q4_K_M", repo_cache_dir = repo_a) is True
|
||||
assert inventory_scan.is_variant_partial(repo_id, "Q4_K_M", repo_cache_dir = repo_b) is False
|
||||
|
||||
|
||||
def test_list_local_gguf_variants_skips_big_endian_sibling(tmp_path):
|
||||
(tmp_path / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 100)
|
||||
(tmp_path / "model-Q4_K_M.gguf").write_bytes(b"y" * 10)
|
||||
|
|
@ -163,8 +394,19 @@ def test_download_state_bounds_long_repo_variant_filenames(monkeypatch, tmp_path
|
|||
"http",
|
||||
)
|
||||
|
||||
marker_path = state_dir.marker_path("model", repo_id, variant)
|
||||
manifest_path = state_dir.manifest_path("model", repo_id, variant)
|
||||
hub_cache = download_manifest._canonical_hub_cache()
|
||||
marker_path = state_dir.marker_path(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
manifest_path = state_dir.manifest_path(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
|
||||
assert marker_path is not None
|
||||
assert manifest_path is not None
|
||||
|
|
@ -181,6 +423,97 @@ def test_download_state_bounds_long_repo_variant_filenames(monkeypatch, tmp_path
|
|||
]
|
||||
|
||||
|
||||
def test_download_state_isolated_across_hub_cache_switches(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
selected = SimpleNamespace(hub_cache = cache_a)
|
||||
|
||||
from utils import hf_cache_settings
|
||||
|
||||
monkeypatch.setattr(hf_cache_settings, "get_hf_cache_paths", lambda: selected)
|
||||
expected_a = [download_manifest.ExpectedFile(path = "a.gguf", size = 1)]
|
||||
expected_b = [download_manifest.ExpectedFile(path = "b.gguf", size = 2)]
|
||||
|
||||
assert download_manifest.write_manifest("model", "Owner/Repo", "Q4_K_M", expected_a)
|
||||
assert download_manifest.write_cancel_marker("model", "Owner/Repo", "Q4_K_M", "http")
|
||||
|
||||
selected.hub_cache = cache_b
|
||||
assert download_manifest.write_manifest("model", "Owner/Repo", "Q4_K_M", expected_b)
|
||||
|
||||
manifest_b = download_manifest.read_manifest("model", "Owner/Repo", "Q4_K_M")
|
||||
manifest_a = download_manifest.read_manifest(
|
||||
"model",
|
||||
"Owner/Repo",
|
||||
"Q4_K_M",
|
||||
hub_cache = cache_a,
|
||||
)
|
||||
|
||||
assert manifest_b is not None and manifest_b.expected_files == tuple(expected_b)
|
||||
assert manifest_a is not None and manifest_a.expected_files == tuple(expected_a)
|
||||
assert not download_manifest.has_cancel_marker("model", "Owner/Repo", "Q4_K_M")
|
||||
assert download_manifest.has_cancel_marker(
|
||||
"model",
|
||||
"Owner/Repo",
|
||||
"Q4_K_M",
|
||||
hub_cache = cache_a,
|
||||
)
|
||||
assert len(list((tmp_path / "hub-state" / "manifests").rglob("*.json"))) == 2
|
||||
|
||||
|
||||
def test_legacy_unscoped_download_state_falls_back_only_for_selected_cache(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = cache_a),
|
||||
)
|
||||
manifest = state_dir.manifest_path("model", "Owner/Repo", "Q4_K_M")
|
||||
marker = state_dir.marker_path("model", "Owner/Repo", "Q4_K_M")
|
||||
assert manifest is not None and marker is not None
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"repo_id": "Owner/Repo",
|
||||
"variant": "Q4_K_M",
|
||||
"expected_files": [{"path": "model.gguf", "size": 10}],
|
||||
"transport": "http",
|
||||
}
|
||||
),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
marker.write_text(
|
||||
json.dumps({"version": 1, "repo_id": "Owner/Repo", "variant": "Q4_K_M"}),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
|
||||
assert download_manifest.read_manifest("model", "Owner/Repo", "Q4_K_M") is not None
|
||||
assert download_manifest.has_cancel_marker("model", "Owner/Repo", "Q4_K_M")
|
||||
assert list(download_manifest.iter_variant_manifests("model", "Owner/Repo")) == [
|
||||
("Q4_K_M", manifest)
|
||||
]
|
||||
assert list(download_manifest.iter_variant_markers("model", "Owner/Repo")) == [
|
||||
("Q4_K_M", marker)
|
||||
]
|
||||
assert (
|
||||
download_manifest.read_manifest(
|
||||
"model",
|
||||
"Owner/Repo",
|
||||
"Q4_K_M",
|
||||
hub_cache = cache_b,
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert not download_manifest.has_cancel_marker(
|
||||
"model",
|
||||
"Owner/Repo",
|
||||
"Q4_K_M",
|
||||
hub_cache = cache_b,
|
||||
)
|
||||
|
||||
|
||||
class _RecordingLogger:
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
|
|
@ -416,8 +749,15 @@ def test_cached_gguf_scan_includes_variant_state_without_completed_gguf(monkeypa
|
|||
"Q4_K_M",
|
||||
[download_manifest.ExpectedFile(path = "model-Q4_K_M.gguf", size = 4096)],
|
||||
"http",
|
||||
hub_cache = repo_path.parent,
|
||||
)
|
||||
assert download_manifest.write_cancel_marker(
|
||||
"model",
|
||||
"Org/PartialGguf",
|
||||
"Q4_K_M",
|
||||
"http",
|
||||
hub_cache = repo_path.parent,
|
||||
)
|
||||
assert download_manifest.write_cancel_marker("model", "Org/PartialGguf", "Q4_K_M", "http")
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
|
|
@ -484,6 +824,7 @@ def test_cached_gguf_scan_keeps_infra_repo_with_user_downloaded_variant(monkeypa
|
|||
"Q8_0",
|
||||
[download_manifest.ExpectedFile(path = "bge-small-en-v1.5-Q8_0.gguf", size = 35_000_000)],
|
||||
"http",
|
||||
hub_cache = Path(embedder.repo_path).parent,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
|
|
@ -1206,6 +1547,7 @@ def test_gguf_progress_counts_completed_mmproj_with_expected_bytes(monkeypatch,
|
|||
),
|
||||
],
|
||||
"http",
|
||||
hub_cache = entry.parent,
|
||||
)
|
||||
|
||||
requirement = gguf_variants._GgufVariantRequirement(
|
||||
|
|
@ -1292,6 +1634,7 @@ def test_gguf_progress_subtracts_new_job_completed_baseline(monkeypatch, tmp_pat
|
|||
),
|
||||
],
|
||||
"http",
|
||||
hub_cache = entry.parent,
|
||||
)
|
||||
|
||||
requirement = gguf_variants._GgufVariantRequirement(
|
||||
|
|
@ -1462,6 +1805,7 @@ def test_gguf_progress_complete_on_disk_ignores_full_baseline(monkeypatch, tmp_p
|
|||
),
|
||||
],
|
||||
"http",
|
||||
hub_cache = entry.parent,
|
||||
)
|
||||
|
||||
requirement = gguf_variants._GgufVariantRequirement(
|
||||
|
|
@ -1861,8 +2205,15 @@ def test_hf_cache_scan_uses_gguf_partial_row_for_variant_state(monkeypatch, tmp_
|
|||
"Q4_K_M",
|
||||
[download_manifest.ExpectedFile(path = "model-Q4_K_M.gguf", size = 8192)],
|
||||
"http",
|
||||
hub_cache = cache_dir,
|
||||
)
|
||||
assert download_manifest.write_cancel_marker(
|
||||
"model",
|
||||
"Org/PartialGguf",
|
||||
"Q4_K_M",
|
||||
"http",
|
||||
hub_cache = cache_dir,
|
||||
)
|
||||
assert download_manifest.write_cancel_marker("model", "Org/PartialGguf", "Q4_K_M", "http")
|
||||
monkeypatch.setattr(local_inventory, "_classify_local_path", lambda *_args, **_kwargs: [])
|
||||
monkeypatch.setattr(
|
||||
local_inventory.hf_cache_scan,
|
||||
|
|
@ -2117,7 +2468,7 @@ def test_gguf_variants_partial_marker_overrides_size_only_downloaded(monkeypatch
|
|||
monkeypatch.setattr(
|
||||
gguf_variants,
|
||||
"iter_hf_cache_snapshots",
|
||||
lambda _repo_id: [snapshot],
|
||||
lambda _repo_id, root = None: [snapshot],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gguf_variants,
|
||||
|
|
@ -2136,6 +2487,70 @@ def test_gguf_variants_partial_marker_overrides_size_only_downloaded(monkeypatch
|
|||
assert result.variants[0].partial is True
|
||||
|
||||
|
||||
def test_gguf_variants_scopes_partial_state_to_requested_cache(monkeypatch, tmp_path):
|
||||
async def _run_inline(fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
repo_id = "Org/SharedRepo"
|
||||
repo_name = "models--Org--SharedRepo"
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
repo_a = cache_a / repo_name
|
||||
snapshot_a = repo_a / "snapshots" / "revision"
|
||||
snapshot_a.mkdir(parents = True)
|
||||
(snapshot_a / "model-Q8_0.gguf").write_bytes(b"complete")
|
||||
blobs_b = cache_b / repo_name / "blobs"
|
||||
blobs_b.mkdir(parents = True)
|
||||
(blobs_b / "q8-hash.incomplete").write_bytes(b"partial")
|
||||
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(gguf_variants.asyncio, "to_thread", _run_inline)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = cache_b),
|
||||
)
|
||||
assert download_manifest.write_cancel_marker(
|
||||
"model",
|
||||
repo_id,
|
||||
"Q8_0",
|
||||
"http",
|
||||
hub_cache = cache_b,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gguf_variants,
|
||||
"list_gguf_variants",
|
||||
lambda *_args, **_kwargs: (
|
||||
[
|
||||
SimpleNamespace(
|
||||
filename = "model-Q8_0.gguf",
|
||||
quant = "Q8_0",
|
||||
display_label = None,
|
||||
size_bytes = 8,
|
||||
)
|
||||
],
|
||||
False,
|
||||
[
|
||||
SimpleNamespace(
|
||||
rfilename = "model-Q8_0.gguf",
|
||||
size = 8,
|
||||
lfs = SimpleNamespace(sha256 = "q8-hash"),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(cache_inventory, "all_hf_cache_scans", lambda: [])
|
||||
|
||||
result = asyncio.run(
|
||||
gguf_variants.get_gguf_variants_response(
|
||||
repo_id,
|
||||
local_path = str(repo_a),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.variants[0].downloaded is True
|
||||
assert result.variants[0].partial is False
|
||||
|
||||
|
||||
def test_download_registry_repo_keys_are_case_insensitive():
|
||||
registry = download_registry.DownloadRegistry()
|
||||
|
||||
|
|
@ -2444,6 +2859,34 @@ def test_prepare_cache_for_transport_purges_only_requested_hashes(monkeypatch, t
|
|||
assert (blobs / "shared-mmproj.incomplete").exists()
|
||||
|
||||
|
||||
def test_prepare_cache_for_transport_uses_captured_root(monkeypatch, tmp_path):
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
repo_name = "models--Org--Repo"
|
||||
partial_a = cache_a / repo_name / "blobs" / "blob.incomplete"
|
||||
partial_b = cache_b / repo_name / "blobs" / "blob.incomplete"
|
||||
partial_a.parent.mkdir(parents = True)
|
||||
partial_b.parent.mkdir(parents = True)
|
||||
partial_a.write_bytes(b"a")
|
||||
partial_b.write_bytes(b"b")
|
||||
monkeypatch.setattr(
|
||||
download_registry,
|
||||
"hf_cache_root",
|
||||
lambda create = False, root = None: root or cache_b,
|
||||
)
|
||||
|
||||
purged = download_registry.prepare_cache_for_transport(
|
||||
"model",
|
||||
"Org/Repo",
|
||||
download_registry.TRANSPORT_HTTP,
|
||||
root = cache_a,
|
||||
)
|
||||
|
||||
assert purged == 1
|
||||
assert not partial_a.exists()
|
||||
assert partial_b.exists()
|
||||
|
||||
|
||||
def _vision_cache_root(monkeypatch, tmp_path):
|
||||
root = tmp_path / "hub"
|
||||
blobs = root / "models--Org--Vision" / "blobs"
|
||||
|
|
@ -2802,6 +3245,47 @@ def test_shutdown_skips_marker_for_worker_that_exits_cleanly(monkeypatch):
|
|||
assert markers == ["Org/Cut"]
|
||||
|
||||
|
||||
def test_orphan_reaper_uses_worker_cache_root_after_setting_changes(monkeypatch, tmp_path):
|
||||
workers = tmp_path / "workers"
|
||||
workers.mkdir()
|
||||
cache_a = tmp_path / "cache-a" / "hub"
|
||||
cache_b = tmp_path / "cache-b" / "hub"
|
||||
partial = cache_a / "models--Org--Model" / "blobs" / "abc.incomplete"
|
||||
partial.parent.mkdir(parents = True)
|
||||
partial.write_bytes(b"partial")
|
||||
cache_b.mkdir(parents = True)
|
||||
monkeypatch.setattr(state_dir, "workers_dir", lambda: workers)
|
||||
monkeypatch.setattr(download_registry, "_process_alive", lambda _pid: False)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = cache_b),
|
||||
)
|
||||
markers = []
|
||||
monkeypatch.setattr(
|
||||
download_registry,
|
||||
"persist_cancel_marker",
|
||||
lambda *args, **kwargs: markers.append(args),
|
||||
)
|
||||
metadata = download_registry.DownloadMetadata(
|
||||
repo_type = "model",
|
||||
repo_id = "Org/Model",
|
||||
variant = None,
|
||||
transport = download_registry.TRANSPORT_HTTP,
|
||||
hub_cache = str(cache_a),
|
||||
xet_cache = str(tmp_path / "cache-a" / "xet"),
|
||||
)
|
||||
download_registry.write_worker_breadcrumb("org/model", 1234, metadata)
|
||||
[breadcrumb] = list(workers.iterdir())
|
||||
payload = json.loads(breadcrumb.read_text(encoding = "utf-8"))
|
||||
assert payload["hub_cache"] == str(cache_a)
|
||||
assert payload["xet_cache"] == str(tmp_path / "cache-a" / "xet")
|
||||
|
||||
download_registry.reap_orphan_workers()
|
||||
|
||||
assert markers == [("model", "Org/Model", None, "http")]
|
||||
assert list(workers.iterdir()) == []
|
||||
|
||||
|
||||
def test_model_claim_register_cancel_uses_registry_marker_owner(monkeypatch):
|
||||
killed = []
|
||||
|
||||
|
|
@ -3125,12 +3609,19 @@ def _build_variant_cache_repo(repo_dir, blob_specs, snapshot_links):
|
|||
return repo
|
||||
|
||||
|
||||
def _patch_variant_delete_side_effects(monkeypatch):
|
||||
def _patch_variant_delete_side_effects(monkeypatch, hub_cache = None):
|
||||
monkeypatch.setattr(
|
||||
deletion.download_manifest,
|
||||
"purge_state",
|
||||
lambda *_args, **_kwargs: False,
|
||||
)
|
||||
# The repo under test lives in this cache; make it the active one so the
|
||||
# delete scopes to it (default target root is the active hub cache).
|
||||
if hub_cache is not None:
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = hub_cache),
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_progress_filters_stale_blobs(monkeypatch, tmp_path):
|
||||
|
|
@ -3308,7 +3799,7 @@ def test_delete_variant_keeps_blob_shared_with_other_snapshot(monkeypatch, tmp_p
|
|||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [repo])],
|
||||
)
|
||||
_patch_variant_delete_side_effects(monkeypatch)
|
||||
_patch_variant_delete_side_effects(monkeypatch, tmp_path)
|
||||
|
||||
result = deletion._delete_cached_model_blocking("Org/Repo-GGUF", "Q4_K_M", None)
|
||||
|
||||
|
|
@ -3335,7 +3826,7 @@ def test_delete_variant_unlinks_unshared_blob(monkeypatch, tmp_path):
|
|||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [repo])],
|
||||
)
|
||||
_patch_variant_delete_side_effects(monkeypatch)
|
||||
_patch_variant_delete_side_effects(monkeypatch, tmp_path)
|
||||
|
||||
result = deletion._delete_cached_model_blocking("Org/Repo-GGUF", "Q4_K_M", None)
|
||||
|
||||
|
|
@ -3361,7 +3852,7 @@ def test_delete_variant_surfaces_locked_file_as_conflict(monkeypatch, tmp_path):
|
|||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [repo])],
|
||||
)
|
||||
_patch_variant_delete_side_effects(monkeypatch)
|
||||
_patch_variant_delete_side_effects(monkeypatch, tmp_path)
|
||||
|
||||
real_unlink = Path.unlink
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ class Manifest:
|
|||
started_at: str
|
||||
expected_files: tuple[ExpectedFile, ...]
|
||||
transport: Optional[str] = None
|
||||
hub_cache: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
|
|
@ -86,6 +87,78 @@ class VerifyResult:
|
|||
size_mismatched: tuple[str, ...]
|
||||
|
||||
|
||||
def _canonical_hub_cache(hub_cache: Optional[str | Path] = None) -> Optional[str]:
|
||||
if hub_cache is None:
|
||||
try:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
hub_cache = get_hf_cache_paths().hub_cache
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return str(Path(hub_cache).expanduser().resolve(strict = False))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return str(hub_cache)
|
||||
|
||||
|
||||
def _read_state_payload(path: Path) -> Optional[dict]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding = "utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("Could not read Hub state %s: %s", path, exc)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _legacy_state_applies(
|
||||
path: Path,
|
||||
requested_hub_cache: Optional[str],
|
||||
*,
|
||||
fail_closed: bool = False,
|
||||
) -> bool:
|
||||
"""Whether an old unscoped state file belongs to the requested cache.
|
||||
|
||||
Transitional files that recorded their cache keep that ownership. Older
|
||||
files with no ownership can only be attributed to the currently selected
|
||||
cache, which matches the single-cache behavior under which they were
|
||||
written without leaking them into remembered inactive caches.
|
||||
"""
|
||||
data = _read_state_payload(path)
|
||||
if data is not None:
|
||||
recorded = data.get("hub_cache")
|
||||
if isinstance(recorded, str) and recorded:
|
||||
return _canonical_hub_cache(recorded) == requested_hub_cache
|
||||
elif not fail_closed:
|
||||
return False
|
||||
return requested_hub_cache == _canonical_hub_cache()
|
||||
|
||||
|
||||
def _state_read_path(
|
||||
path_factory,
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str],
|
||||
hub_cache: Optional[str | Path],
|
||||
*,
|
||||
fail_closed: bool = False,
|
||||
) -> Optional[Path]:
|
||||
requested = _canonical_hub_cache(hub_cache)
|
||||
scoped = path_factory(repo_type, repo_id, variant, hub_cache = requested)
|
||||
try:
|
||||
if scoped is not None and scoped.is_file():
|
||||
return scoped
|
||||
except OSError:
|
||||
pass
|
||||
legacy = path_factory(repo_type, repo_id, variant)
|
||||
if legacy is None or legacy == scoped:
|
||||
return None
|
||||
try:
|
||||
if not legacy.is_file():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return legacy if _legacy_state_applies(legacy, requested, fail_closed = fail_closed) else None
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, payload: dict) -> bool:
|
||||
# Per-write uuid suffix so a concurrent caller or a stale tmp from a
|
||||
# previous crash cannot collide with the in-flight write.
|
||||
|
|
@ -124,6 +197,8 @@ def write_manifest(
|
|||
variant: Optional[str],
|
||||
expected_files: Sequence[ExpectedFile],
|
||||
transport: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> bool:
|
||||
"""Write/overwrite the manifest for this triple. Best-effort.
|
||||
|
||||
|
|
@ -131,7 +206,13 @@ def write_manifest(
|
|||
worst-case fallback is the pre-fix scanner behavior (one missed
|
||||
partial detection), which is no regression.
|
||||
"""
|
||||
path = manifest_path(repo_type, repo_id, variant)
|
||||
recorded_hub_cache = _canonical_hub_cache(hub_cache)
|
||||
path = manifest_path(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = recorded_hub_cache,
|
||||
)
|
||||
if path is None:
|
||||
return False
|
||||
payload = {
|
||||
|
|
@ -149,6 +230,7 @@ def write_manifest(
|
|||
for f in expected_files
|
||||
],
|
||||
"transport": transport,
|
||||
"hub_cache": recorded_hub_cache,
|
||||
}
|
||||
return _atomic_write_json(path, payload)
|
||||
|
||||
|
|
@ -157,6 +239,8 @@ def read_manifest(
|
|||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> Optional[Manifest]:
|
||||
"""Return the manifest if present and parseable; ``None`` otherwise.
|
||||
|
||||
|
|
@ -171,15 +255,17 @@ def read_manifest(
|
|||
``_MANIFEST_VERSION`` and widen this check) or live under a different
|
||||
filename, so an incompatible payload can never mis-classify rows.
|
||||
"""
|
||||
path = manifest_path(repo_type, repo_id, variant)
|
||||
path = _state_read_path(
|
||||
manifest_path,
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache,
|
||||
)
|
||||
if path is None or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding = "utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("Could not read manifest %s: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
data = _read_state_payload(path)
|
||||
if data is None:
|
||||
return None
|
||||
if data.get("version") != _MANIFEST_VERSION:
|
||||
logger.debug(
|
||||
|
|
@ -216,6 +302,7 @@ def read_manifest(
|
|||
started_at = str(data.get("started_at", "")),
|
||||
expected_files = tuple(expected),
|
||||
transport = transport if transport in ("http", "xet") else None,
|
||||
hub_cache = data.get("hub_cache") if isinstance(data.get("hub_cache"), str) else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -289,6 +376,8 @@ def write_cancel_marker(
|
|||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
transport: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> bool:
|
||||
"""Record that this triple was cancelled. Idempotent across repeated cancels.
|
||||
|
||||
|
|
@ -296,7 +385,13 @@ def write_cancel_marker(
|
|||
inventory rows so the UI labels HTTP retries as continuable and XET
|
||||
retries as full redownloads. None is accepted for forward-compat.
|
||||
"""
|
||||
path = marker_path(repo_type, repo_id, variant)
|
||||
recorded_hub_cache = _canonical_hub_cache(hub_cache)
|
||||
path = marker_path(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = recorded_hub_cache,
|
||||
)
|
||||
if path is None:
|
||||
return False
|
||||
payload = {
|
||||
|
|
@ -306,6 +401,7 @@ def write_cancel_marker(
|
|||
"variant": variant,
|
||||
"transport": transport,
|
||||
"cancelled_at": datetime.now(timezone.utc).isoformat(),
|
||||
"hub_cache": recorded_hub_cache,
|
||||
}
|
||||
return _atomic_write_json(path, payload)
|
||||
|
||||
|
|
@ -314,6 +410,8 @@ def read_cancel_marker_transport(
|
|||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return the transport recorded in the cancel marker, or ``None`` if no
|
||||
marker exists or it is unreadable.
|
||||
|
|
@ -330,15 +428,17 @@ def read_cancel_marker_transport(
|
|||
``None`` keeps the neutral "Retry" label.
|
||||
* Unknown future versions → ``None`` (unknown layout, unknown transport).
|
||||
"""
|
||||
path = marker_path(repo_type, repo_id, variant)
|
||||
path = _state_read_path(
|
||||
marker_path,
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache,
|
||||
)
|
||||
if path is None or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding = "utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("Could not read cancel marker %s: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
data = _read_state_payload(path)
|
||||
if data is None:
|
||||
return None
|
||||
version = data.get("version")
|
||||
if version == _LEGACY_MARKER_VERSION:
|
||||
|
|
@ -351,10 +451,30 @@ def read_cancel_marker_transport(
|
|||
return None
|
||||
|
||||
|
||||
def _all_matching_state_paths(
|
||||
parent: Optional[Path], repo_type: RepoType, repo_id: str, variant: Optional[str]
|
||||
) -> tuple[Path, ...]:
|
||||
if parent is None:
|
||||
return ()
|
||||
legacy_path = (
|
||||
manifest_path(repo_type, repo_id, variant)
|
||||
if parent.name == "manifests"
|
||||
else marker_path(repo_type, repo_id, variant)
|
||||
)
|
||||
if legacy_path is None:
|
||||
return ()
|
||||
try:
|
||||
return tuple(path for path in parent.rglob(legacy_path.name) if path.is_file())
|
||||
except OSError:
|
||||
return ()
|
||||
|
||||
|
||||
def clear_cancel_marker(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> None:
|
||||
"""Remove the cancel marker for this triple if present.
|
||||
|
||||
|
|
@ -362,31 +482,48 @@ def clear_cancel_marker(
|
|||
download-start (a fresh attempt supersedes prior cancel state) and
|
||||
again at successful completion (cleans up if the start clear failed).
|
||||
"""
|
||||
path = marker_path(repo_type, repo_id, variant)
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
path.unlink(missing_ok = True)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not clear cancel marker %s: %s", path, exc)
|
||||
requested = _canonical_hub_cache(hub_cache)
|
||||
path = marker_path(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = requested,
|
||||
)
|
||||
legacy = marker_path(repo_type, repo_id, variant)
|
||||
paths = [path]
|
||||
if (
|
||||
legacy is not None
|
||||
and legacy != path
|
||||
and _legacy_state_applies(legacy, requested, fail_closed = True)
|
||||
):
|
||||
paths.append(legacy)
|
||||
for target in paths:
|
||||
if target is None:
|
||||
continue
|
||||
try:
|
||||
target.unlink(missing_ok = True)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not clear cancel marker %s: %s", target, exc)
|
||||
|
||||
|
||||
def has_cancel_marker(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> bool:
|
||||
"""File-existence check only. Body is never read.
|
||||
|
||||
Fail-closed: a corrupt marker still returns ``True`` because the
|
||||
file's existence is the signal (the user once cancelled this
|
||||
triple, even if the body is unreadable).
|
||||
"""
|
||||
path = marker_path(repo_type, repo_id, variant)
|
||||
if path is None:
|
||||
return False
|
||||
"""Return whether a cancel marker applies to the selected cache."""
|
||||
path = _state_read_path(
|
||||
marker_path,
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache,
|
||||
fail_closed = True,
|
||||
)
|
||||
try:
|
||||
return path.is_file()
|
||||
return path is not None and path.is_file()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
|
@ -395,48 +532,124 @@ def delete_manifest(
|
|||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> bool:
|
||||
path = manifest_path(repo_type, repo_id, variant)
|
||||
if path is None:
|
||||
return False
|
||||
try:
|
||||
if not path.is_file():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.debug("Could not delete manifest %s: %s", path, exc)
|
||||
return False
|
||||
requested = _canonical_hub_cache(hub_cache)
|
||||
path = manifest_path(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = requested,
|
||||
)
|
||||
legacy = manifest_path(repo_type, repo_id, variant)
|
||||
paths = [path]
|
||||
if legacy is not None and legacy != path and _legacy_state_applies(legacy, requested):
|
||||
paths.append(legacy)
|
||||
removed = False
|
||||
for target in paths:
|
||||
if target is None:
|
||||
continue
|
||||
try:
|
||||
if target.is_file():
|
||||
target.unlink()
|
||||
removed = True
|
||||
except OSError as exc:
|
||||
logger.debug("Could not delete manifest %s: %s", target, exc)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_state(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> bool:
|
||||
"""Remove manifest + cancel marker for this triple. Returns ``True``
|
||||
when anything was present on disk before the call. Idempotent."""
|
||||
marker_existed = has_cancel_marker(repo_type, repo_id, variant)
|
||||
manifest_removed = delete_manifest(repo_type, repo_id, variant)
|
||||
clear_cancel_marker(repo_type, repo_id, variant)
|
||||
return marker_existed or manifest_removed
|
||||
when anything was present on disk before the call. Idempotent.
|
||||
|
||||
With ``hub_cache`` set, only that cache's scoped state (plus any legacy
|
||||
unscoped file that belongs to it) is removed, so purging one cache's copy
|
||||
never clears another cache's resumable/cancel state."""
|
||||
if hub_cache is None:
|
||||
paths = (
|
||||
*_all_matching_state_paths(manifests_dir(), repo_type, repo_id, variant),
|
||||
*_all_matching_state_paths(cancelled_dir(), repo_type, repo_id, variant),
|
||||
)
|
||||
else:
|
||||
requested = _canonical_hub_cache(hub_cache)
|
||||
candidates = [
|
||||
manifest_path(repo_type, repo_id, variant, hub_cache = hub_cache),
|
||||
marker_path(repo_type, repo_id, variant, hub_cache = hub_cache),
|
||||
]
|
||||
# Legacy unscoped state is shared: an unowned file belongs to the active
|
||||
# cache (per _legacy_state_applies), so only purge it when it belongs to
|
||||
# the cache being deleted -- else deleting an inactive cache would erase
|
||||
# the active cache's resume/cancel state.
|
||||
for path_factory in (manifest_path, marker_path):
|
||||
legacy = path_factory(repo_type, repo_id, variant)
|
||||
if legacy is not None and _legacy_state_applies(legacy, requested):
|
||||
candidates.append(legacy)
|
||||
paths = tuple(p for p in candidates if p is not None)
|
||||
removed = False
|
||||
for path in paths:
|
||||
try:
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
removed = True
|
||||
except OSError as exc:
|
||||
logger.debug("Could not purge Hub state %s: %s", path, exc)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_all_state_for_repo(repo_type: RepoType, repo_id: str) -> int:
|
||||
def purge_all_state_for_repo(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> int:
|
||||
"""Remove the snapshot-level manifest + marker AND every variant-keyed
|
||||
manifest + marker for this repo. Used by the route delete handlers so
|
||||
scanner state never outlives the cache it described. Returns the count
|
||||
of (repo, variant) triples that had any state on disk."""
|
||||
of (repo, variant) triples that had any state on disk.
|
||||
|
||||
With ``hub_cache`` set, only that cache's scoped state (plus any legacy
|
||||
unscoped file) is enumerated and removed, so deleting one cache's copy does
|
||||
not clear another cache's resumable/cancel state."""
|
||||
removed = 0
|
||||
if purge_state(repo_type, repo_id, None):
|
||||
if purge_state(repo_type, repo_id, None, hub_cache = hub_cache):
|
||||
removed += 1
|
||||
variants: set[str] = set()
|
||||
for variant, _ in iter_variant_manifests(repo_type, repo_id):
|
||||
variants.add(variant)
|
||||
for variant, _ in iter_variant_markers(repo_type, repo_id):
|
||||
variants.add(variant)
|
||||
prefix = variant_filename_prefix(repo_type, repo_id)
|
||||
if hub_cache is None:
|
||||
search = [(p, True) for p in (manifests_dir(), cancelled_dir()) if p is not None]
|
||||
else:
|
||||
# This cache's scoped dir (parent of its scoped path) plus the legacy
|
||||
# unscoped base; glob (not rglob) so other caches' dirs are not swept.
|
||||
search = []
|
||||
for scoped, base in (
|
||||
(manifest_path(repo_type, repo_id, None, hub_cache = hub_cache), manifests_dir()),
|
||||
(marker_path(repo_type, repo_id, None, hub_cache = hub_cache), cancelled_dir()),
|
||||
):
|
||||
if scoped is not None:
|
||||
search.append((scoped.parent, False))
|
||||
if base is not None:
|
||||
search.append((base, False))
|
||||
for parent, recursive in search:
|
||||
try:
|
||||
entries = tuple(
|
||||
parent.rglob(f"{prefix}*.json") if recursive else parent.glob(f"{prefix}*.json")
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
if not entry.is_file():
|
||||
continue
|
||||
fallback = entry.stem[len(prefix) :]
|
||||
variants.add(_variant_from_state_file(entry, fallback))
|
||||
for variant in variants:
|
||||
if purge_state(repo_type, repo_id, variant):
|
||||
if purge_state(repo_type, repo_id, variant, hub_cache = hub_cache):
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
|
@ -453,35 +666,83 @@ def _variant_from_state_file(path: Path, fallback: str) -> str:
|
|||
|
||||
|
||||
def _iter_variant_state_files(
|
||||
parent: Optional[Path], repo_type: RepoType, repo_id: str
|
||||
parent: Optional[Path],
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
hub_cache: Optional[str | Path],
|
||||
*,
|
||||
cancel_markers: bool,
|
||||
) -> Iterator[tuple[str, Path]]:
|
||||
if parent is None:
|
||||
return
|
||||
prefix = variant_filename_prefix(repo_type, repo_id)
|
||||
try:
|
||||
entries = list(parent.iterdir())
|
||||
except OSError:
|
||||
path_factory = marker_path if cancel_markers else manifest_path
|
||||
requested = _canonical_hub_cache(hub_cache)
|
||||
scoped_probe = path_factory(
|
||||
repo_type,
|
||||
repo_id,
|
||||
None,
|
||||
hub_cache = requested,
|
||||
)
|
||||
if scoped_probe is None:
|
||||
return
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json"):
|
||||
prefix = variant_filename_prefix(repo_type, repo_id)
|
||||
seen: set[str] = set()
|
||||
for directory, legacy in ((scoped_probe.parent, False), (parent, True)):
|
||||
if legacy and directory == scoped_probe.parent:
|
||||
continue
|
||||
stem = entry.name[: -len(".json")]
|
||||
if not stem.lower().startswith(prefix):
|
||||
try:
|
||||
entries = list(directory.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
variant = stem[len(prefix) :]
|
||||
if variant:
|
||||
yield _variant_from_state_file(entry, variant), entry
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json"):
|
||||
continue
|
||||
stem = entry.name[: -len(".json")]
|
||||
if not stem.lower().startswith(prefix) or entry.name in seen:
|
||||
continue
|
||||
if legacy and not _legacy_state_applies(
|
||||
entry,
|
||||
requested,
|
||||
fail_closed = cancel_markers,
|
||||
):
|
||||
continue
|
||||
fallback = stem[len(prefix) :]
|
||||
if fallback:
|
||||
seen.add(entry.name)
|
||||
yield _variant_from_state_file(entry, fallback), entry
|
||||
|
||||
|
||||
def iter_variant_manifests(repo_type: RepoType, repo_id: str) -> Iterator[tuple[str, Path]]:
|
||||
def iter_variant_manifests(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> Iterator[tuple[str, Path]]:
|
||||
"""Yield (variant, manifest_path) for every variant-keyed manifest
|
||||
written for this repo. Used by is_gguf_repo_partial to enumerate all
|
||||
variants present on disk so the all-variants-broken gate can run."""
|
||||
yield from _iter_variant_state_files(manifests_dir(), repo_type, repo_id)
|
||||
yield from _iter_variant_state_files(
|
||||
manifests_dir(),
|
||||
repo_type,
|
||||
repo_id,
|
||||
hub_cache,
|
||||
cancel_markers = False,
|
||||
)
|
||||
|
||||
|
||||
def iter_variant_markers(repo_type: RepoType, repo_id: str) -> Iterator[tuple[str, Path]]:
|
||||
def iter_variant_markers(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> Iterator[tuple[str, Path]]:
|
||||
"""Yield (variant, marker_path) for every variant-keyed cancel marker.
|
||||
Companion to iter_variant_manifests: catches variants cancelled
|
||||
before download-start ever wrote a manifest (very early failures)."""
|
||||
yield from _iter_variant_state_files(cancelled_dir(), repo_type, repo_id)
|
||||
yield from _iter_variant_state_files(
|
||||
cancelled_dir(),
|
||||
repo_type,
|
||||
repo_id,
|
||||
hub_cache,
|
||||
cancel_markers = True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ def write_worker_breadcrumb(key: str, pid: int, metadata: Optional["DownloadMeta
|
|||
"cancel_marker_transport": metadata.cancel_marker_transport
|
||||
if metadata is not None
|
||||
else None,
|
||||
"hub_cache": metadata.hub_cache if metadata is not None else None,
|
||||
"xet_cache": metadata.xet_cache if metadata is not None else None,
|
||||
}
|
||||
tmp = path.with_name(f".{path.name}.tmp-{pid}")
|
||||
try:
|
||||
|
|
@ -236,6 +238,7 @@ def _settle_orphaned_download(
|
|||
repo_id: Optional[str],
|
||||
variant: Optional[str],
|
||||
transport: Optional[str],
|
||||
hub_cache: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Persist a cancel marker for a reaped orphan still mid-download so the next
|
||||
launch settles it to a resumable "cancelled" state instead of a phantom-running
|
||||
|
|
@ -251,18 +254,42 @@ def _settle_orphaned_download(
|
|||
return
|
||||
from hub.utils import download_manifest
|
||||
|
||||
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
|
||||
cache_root = Path(hub_cache) if isinstance(hub_cache, str) and hub_cache else None
|
||||
|
||||
manifest = download_manifest.read_manifest(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = cache_root,
|
||||
)
|
||||
if repo_type == "model" and variant and manifest is None:
|
||||
return
|
||||
if manifest is None:
|
||||
if not has_active_incomplete_blobs(repo_type, repo_id):
|
||||
if not has_active_incomplete_blobs(repo_type, repo_id, root = cache_root):
|
||||
return
|
||||
else:
|
||||
if _manifest_verifies_against_active_cache(repo_type, repo_id, manifest):
|
||||
if _manifest_verifies_against_active_cache(
|
||||
repo_type,
|
||||
repo_id,
|
||||
manifest,
|
||||
root = cache_root,
|
||||
):
|
||||
return
|
||||
if not _manifest_has_active_incomplete_blobs(repo_type, repo_id, manifest):
|
||||
if not _manifest_has_active_incomplete_blobs(
|
||||
repo_type,
|
||||
repo_id,
|
||||
manifest,
|
||||
root = cache_root,
|
||||
):
|
||||
return
|
||||
persist_cancel_marker(repo_type, repo_id, variant, transport, logger = logger)
|
||||
persist_cancel_marker(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
transport,
|
||||
hub_cache = hub_cache,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
|
||||
def reap_orphan_workers() -> None:
|
||||
|
|
@ -309,6 +336,7 @@ def reap_orphan_workers() -> None:
|
|||
repo_id,
|
||||
data.get("variant"),
|
||||
data.get("cancel_marker_transport") or data.get("transport"),
|
||||
data.get("hub_cache"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Reaper failed for breadcrumb %s: %s", entry, exc)
|
||||
|
|
@ -355,8 +383,13 @@ def _purge_incomplete_blobs(
|
|||
return removed
|
||||
|
||||
|
||||
def _iter_active_snapshot_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
|
||||
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
|
||||
def _iter_active_snapshot_dirs(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> Iterator[Path]:
|
||||
for entry in iter_active_repo_cache_dirs(repo_type, repo_id, root = root):
|
||||
snapshots_dir = entry / "snapshots"
|
||||
if not snapshots_dir.is_dir():
|
||||
continue
|
||||
|
|
@ -369,24 +402,41 @@ def _iter_active_snapshot_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
|
|||
yield snapshot
|
||||
|
||||
|
||||
def _manifest_verifies_against_active_cache(repo_type: str, repo_id: str, manifest) -> bool:
|
||||
def _manifest_verifies_against_active_cache(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
manifest,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> bool:
|
||||
from hub.utils import download_manifest
|
||||
for snapshot_dir in _iter_active_snapshot_dirs(repo_type, repo_id):
|
||||
for snapshot_dir in _iter_active_snapshot_dirs(repo_type, repo_id, root = root):
|
||||
if download_manifest.verify_against_disk(manifest, snapshot_dir).ok:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _manifest_has_active_incomplete_blobs(repo_type: str, repo_id: str, manifest) -> bool:
|
||||
def _manifest_has_active_incomplete_blobs(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
manifest,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> bool:
|
||||
if not getattr(manifest, "variant", None):
|
||||
return has_active_incomplete_blobs(repo_type, repo_id)
|
||||
return has_active_incomplete_blobs(repo_type, repo_id, root = root)
|
||||
expected_hashes = frozenset(
|
||||
expected.sha256 for expected in manifest.expected_files if expected.sha256
|
||||
)
|
||||
if not expected_hashes:
|
||||
return has_active_incomplete_blobs(repo_type, repo_id)
|
||||
return has_active_incomplete_blobs(repo_type, repo_id, root = root)
|
||||
return bool(
|
||||
incomplete_blob_hashes(repo_type, repo_id, active_only = True).intersection(expected_hashes)
|
||||
incomplete_blob_hashes(
|
||||
repo_type,
|
||||
repo_id,
|
||||
active_only = True,
|
||||
root = root,
|
||||
).intersection(expected_hashes)
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -459,6 +509,7 @@ def prepare_cache_for_transport(
|
|||
only_blob_hashes: Optional[frozenset[str]] = None,
|
||||
companion_blob_hashes: Optional[frozenset[str]] = None,
|
||||
protected_blob_hashes: Optional[frozenset[str]] = None,
|
||||
root: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Guarantee any pre-existing ``.incomplete`` blobs are SAFE to resume under
|
||||
*mode*. Returns the number of partial blobs purged for untrusted provenance.
|
||||
|
|
@ -485,14 +536,13 @@ def prepare_cache_for_transport(
|
|||
they are excluded from every purge so a shared companion is never deleted
|
||||
mid-write.
|
||||
|
||||
Scope: only the active ``HF_HUB_CACHE`` root is inspected. That suffices for
|
||||
resume safety because ``snapshot_download`` runs without a ``cache_dir``
|
||||
override and so can only read or resume a ``.incomplete`` under this same
|
||||
active root. Markers are written for the new mode before returning.
|
||||
Scope: ``root`` selects the cache captured by the caller. It defaults to the
|
||||
active ``HF_HUB_CACHE`` root for workers that inherit their cache through
|
||||
the environment. Markers are written for the new mode before returning.
|
||||
"""
|
||||
if mode not in VALID_TRANSPORTS:
|
||||
raise ValueError(f"Invalid transport mode: {mode!r}")
|
||||
root = hf_cache_root(create = True)
|
||||
root = hf_cache_root(create = True) if root is None else hf_cache_root(create = True, root = root)
|
||||
if root is None:
|
||||
return 0
|
||||
target = target_dir_name(repo_type, repo_id)
|
||||
|
|
@ -618,10 +668,11 @@ def incomplete_blob_hashes(
|
|||
repo_id: str,
|
||||
*,
|
||||
active_only: bool = False,
|
||||
root: Optional[Path] = None,
|
||||
) -> set[str]:
|
||||
out: set[str] = set()
|
||||
entries = (
|
||||
iter_active_repo_cache_dirs(repo_type, repo_id)
|
||||
iter_active_repo_cache_dirs(repo_type, repo_id, root = root)
|
||||
if active_only
|
||||
else iter_repo_cache_dirs(repo_type, repo_id)
|
||||
)
|
||||
|
|
@ -638,16 +689,24 @@ def incomplete_blob_hashes(
|
|||
return out
|
||||
|
||||
|
||||
def completed_blob_bytes(repo_type: str, repo_id: str, blob_hashes: frozenset[str]) -> int:
|
||||
"""Sum finalized blob bytes for *blob_hashes* in the active HF cache root.
|
||||
def completed_blob_bytes(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
blob_hashes: frozenset[str],
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Sum finalized blob bytes for *blob_hashes* in a single HF cache root.
|
||||
|
||||
A worker only writes to the active ``HF_HUB_CACHE`` root, so a baseline must
|
||||
ignore legacy/default roots that ``snapshot_download`` won't reuse this run.
|
||||
A worker only writes to its captured ``HF_HUB_CACHE`` root, so a baseline
|
||||
must be scoped to that root (``root``), not re-resolved to whatever cache is
|
||||
active now; otherwise a runtime cache switch makes the retry baseline count
|
||||
bytes from the wrong disk.
|
||||
"""
|
||||
if not blob_hashes:
|
||||
return 0
|
||||
total = 0
|
||||
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
|
||||
for entry in iter_active_repo_cache_dirs(repo_type, repo_id, root = root):
|
||||
blobs_dir = entry / "blobs"
|
||||
if not blobs_dir.is_dir():
|
||||
continue
|
||||
|
|
@ -712,6 +771,8 @@ class DownloadMetadata:
|
|||
# Bytes already complete before this job started; not counted as this run's
|
||||
# progress.
|
||||
completed_baseline_bytes: int = 0
|
||||
hub_cache: Optional[str] = None
|
||||
xet_cache: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
|
|
@ -752,6 +813,7 @@ def persist_cancel_marker(
|
|||
variant: Optional[str],
|
||||
transport: Optional[str],
|
||||
*,
|
||||
hub_cache: Optional[str] = None,
|
||||
logger = logger,
|
||||
) -> None:
|
||||
if not repo_type or not repo_id:
|
||||
|
|
@ -763,6 +825,7 @@ def persist_cancel_marker(
|
|||
repo_id,
|
||||
variant,
|
||||
transport = transport,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
logger.debug("write_cancel_marker returned False for %s", repo_id)
|
||||
except Exception as exc:
|
||||
|
|
@ -971,6 +1034,7 @@ class DownloadRegistry:
|
|||
metadata_to_persist.repo_id,
|
||||
metadata_to_persist.variant,
|
||||
metadata_to_persist.transport,
|
||||
hub_cache = metadata_to_persist.hub_cache,
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
@ -1033,6 +1097,8 @@ class DownloadRegistry:
|
|||
replace_active: bool = False,
|
||||
metadata_transport: Optional[str] = None,
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
hub_cache: Optional[str] = None,
|
||||
xet_cache: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
key = normalize_job_key(key)
|
||||
repo = _repo_of_key(key)
|
||||
|
|
@ -1106,6 +1172,8 @@ class DownloadRegistry:
|
|||
0,
|
||||
int(completed_baseline_bytes or 0),
|
||||
),
|
||||
hub_cache = hub_cache,
|
||||
xet_cache = xet_cache,
|
||||
)
|
||||
if cancel_marker_transport is not None:
|
||||
self._cancel_marker_transports[key] = cancel_marker_transport
|
||||
|
|
@ -1386,6 +1454,7 @@ class DownloadRegistry:
|
|||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
hub_cache = metadata.hub_cache,
|
||||
)
|
||||
reaped: list[tuple[str, subprocess.Popen, Optional[DownloadMetadata]]] = []
|
||||
for key, proc, metadata in live:
|
||||
|
|
@ -1401,6 +1470,7 @@ class DownloadRegistry:
|
|||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
hub_cache = metadata.hub_cache,
|
||||
)
|
||||
continue
|
||||
reaped.append((key, proc, metadata))
|
||||
|
|
@ -1421,6 +1491,7 @@ class DownloadRegistry:
|
|||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
hub_cache = metadata.hub_cache,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -253,11 +253,16 @@ def _env_offline() -> bool:
|
|||
) or os.environ.get("TRANSFORMERS_OFFLINE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def iter_hf_cache_snapshots(repo_id: str):
|
||||
from hub.utils.hf_cache_state import iter_repo_cache_dirs
|
||||
def iter_hf_cache_snapshots(repo_id: str, root: Optional[Path] = None):
|
||||
from hub.utils.hf_cache_state import iter_active_repo_cache_dirs, iter_repo_cache_dirs
|
||||
|
||||
snapshots: list[Path] = []
|
||||
for repo_dir in iter_repo_cache_dirs("model", repo_id):
|
||||
repo_dirs = (
|
||||
iter_active_repo_cache_dirs("model", repo_id, root = root)
|
||||
if root is not None
|
||||
else iter_repo_cache_dirs("model", repo_id)
|
||||
)
|
||||
for repo_dir in repo_dirs:
|
||||
snapshots_dir = repo_dir / "snapshots"
|
||||
if not snapshots_dir.is_dir():
|
||||
continue
|
||||
|
|
@ -276,12 +281,17 @@ def iter_hf_cache_snapshots(repo_id: str):
|
|||
yield from snapshots
|
||||
|
||||
|
||||
def list_empty_gguf_variant_dirs(repo_id: str) -> set[str]:
|
||||
def list_empty_gguf_variant_dirs(repo_id: str, root: Optional[Path] = None) -> set[str]:
|
||||
"""Quant labels present only as an EMPTY snapshot ``<quant>/`` folder (an
|
||||
interrupted split download); a quant with shards in any snapshot is excluded."""
|
||||
empty: dict[str, str] = {}
|
||||
nonempty: set[str] = set()
|
||||
for snapshot in iter_hf_cache_snapshots(repo_id):
|
||||
snapshots = (
|
||||
iter_hf_cache_snapshots(repo_id, root = root)
|
||||
if root is not None
|
||||
else iter_hf_cache_snapshots(repo_id)
|
||||
)
|
||||
for snapshot in snapshots:
|
||||
try:
|
||||
entries = list(snapshot.iterdir())
|
||||
except OSError:
|
||||
|
|
@ -303,8 +313,15 @@ def list_empty_gguf_variant_dirs(repo_id: str) -> set[str]:
|
|||
return {label for key, label in empty.items() if key not in nonempty}
|
||||
|
||||
|
||||
def list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVariantInfo], bool]]:
|
||||
for snapshot in iter_hf_cache_snapshots(repo_id):
|
||||
def list_gguf_variants_from_hf_cache(
|
||||
repo_id: str, root: Optional[Path] = None
|
||||
) -> Optional[tuple[list[GgufVariantInfo], bool]]:
|
||||
snapshots = (
|
||||
iter_hf_cache_snapshots(repo_id, root = root)
|
||||
if root is not None
|
||||
else iter_hf_cache_snapshots(repo_id)
|
||||
)
|
||||
for snapshot in snapshots:
|
||||
variants, has_vision = list_local_gguf_variants(str(snapshot))
|
||||
if variants or has_vision:
|
||||
return variants, has_vision
|
||||
|
|
@ -312,7 +329,7 @@ def list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVa
|
|||
|
||||
|
||||
def list_partial_gguf_variants_from_state(
|
||||
repo_id: str,
|
||||
repo_id: str, hub_cache: Optional[Path] = None
|
||||
) -> Optional[tuple[list[GgufVariantInfo], bool]]:
|
||||
"""Reconstruct GGUF variants from download manifests/markers alone.
|
||||
|
||||
|
|
@ -328,10 +345,26 @@ def list_partial_gguf_variants_from_state(
|
|||
# original-casing label over a lowercased cancel marker for the same variant.
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for source in (
|
||||
download_manifest.iter_variant_manifests("model", repo_id),
|
||||
download_manifest.iter_variant_markers("model", repo_id),
|
||||
):
|
||||
sources = (
|
||||
(
|
||||
download_manifest.iter_variant_manifests("model", repo_id),
|
||||
download_manifest.iter_variant_markers("model", repo_id),
|
||||
)
|
||||
if hub_cache is None
|
||||
else (
|
||||
download_manifest.iter_variant_manifests(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
),
|
||||
download_manifest.iter_variant_markers(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
),
|
||||
)
|
||||
)
|
||||
for source in sources:
|
||||
for variant, _path in source:
|
||||
key = variant.lower()
|
||||
if key not in seen:
|
||||
|
|
@ -343,7 +376,16 @@ def list_partial_gguf_variants_from_state(
|
|||
variants: list[GgufVariantInfo] = []
|
||||
has_vision = False
|
||||
for variant in ordered:
|
||||
manifest = download_manifest.read_manifest("model", repo_id, variant)
|
||||
manifest = (
|
||||
download_manifest.read_manifest("model", repo_id, variant)
|
||||
if hub_cache is None
|
||||
else download_manifest.read_manifest(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
)
|
||||
main_filename: Optional[str] = None
|
||||
size_bytes = 0
|
||||
companion_bytes = 0
|
||||
|
|
|
|||
|
|
@ -29,12 +29,10 @@ def _safe_is_dir(path: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def hf_cache_root(*, create: bool = False) -> Optional[Path]:
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
except ImportError:
|
||||
return None
|
||||
root = Path(hf_constants.HF_HUB_CACHE)
|
||||
def hf_cache_root(*, create: bool = False, root: Optional[Path] = None) -> Optional[Path]:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
root = root or get_hf_cache_paths().hub_cache
|
||||
if create:
|
||||
try:
|
||||
root.mkdir(parents = True, exist_ok = True)
|
||||
|
|
@ -46,6 +44,7 @@ def hf_cache_root(*, create: bool = False) -> Optional[Path]:
|
|||
|
||||
def hf_cache_roots() -> list[Path]:
|
||||
from hub.utils.paths import hf_default_cache_dir, legacy_hf_cache_dir
|
||||
from utils.hf_cache_settings import known_hf_hub_caches
|
||||
|
||||
roots: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
|
|
@ -62,7 +61,8 @@ def hf_cache_roots() -> list[Path]:
|
|||
seen.add(key)
|
||||
roots.append(path)
|
||||
|
||||
_add(hf_cache_root())
|
||||
for configured in known_hf_hub_caches():
|
||||
_add(configured)
|
||||
_add(legacy_hf_cache_dir())
|
||||
_add(hf_default_cache_dir())
|
||||
return roots
|
||||
|
|
@ -181,12 +181,22 @@ def iter_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
|
|||
continue
|
||||
|
||||
|
||||
def iter_destructive_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
|
||||
def iter_destructive_repo_cache_dirs(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> Iterator[Path]:
|
||||
target = repo_cache_dir_name(repo_type, repo_id)
|
||||
folded_target = target.lower()
|
||||
for root in hf_cache_roots():
|
||||
if root is not None:
|
||||
scoped = hf_cache_root(root = root)
|
||||
bases = [scoped] if scoped is not None else []
|
||||
else:
|
||||
bases = hf_cache_roots()
|
||||
for base in bases:
|
||||
try:
|
||||
entries = [entry for entry in root.iterdir() if entry.name.lower() == folded_target]
|
||||
entries = [entry for entry in base.iterdir() if entry.name.lower() == folded_target]
|
||||
except OSError:
|
||||
continue
|
||||
matched_names = resolve_destructive_case_matches(
|
||||
|
|
@ -200,8 +210,13 @@ def iter_destructive_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[P
|
|||
yield entry
|
||||
|
||||
|
||||
def iter_active_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
|
||||
root = hf_cache_root()
|
||||
def iter_active_repo_cache_dirs(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> Iterator[Path]:
|
||||
root = hf_cache_root(root = root)
|
||||
if root is None:
|
||||
return
|
||||
target = target_dir_name(repo_type, repo_id)
|
||||
|
|
@ -218,12 +233,13 @@ def preferred_repo_cache_dirs(
|
|||
repo_id: str,
|
||||
*,
|
||||
force_active: bool = False,
|
||||
active_root: Optional[Path] = None,
|
||||
) -> list[Path]:
|
||||
active_entries = list(iter_active_repo_cache_dirs(repo_type, repo_id))
|
||||
active_entries = list(iter_active_repo_cache_dirs(repo_type, repo_id, root = active_root))
|
||||
if active_entries:
|
||||
return active_entries
|
||||
if force_active:
|
||||
root = hf_cache_root()
|
||||
root = hf_cache_root(root = active_root)
|
||||
if root is not None:
|
||||
canonical = repo_cache_dir_name(repo_type, repo_id)
|
||||
return [root / canonical]
|
||||
|
|
@ -237,8 +253,13 @@ def has_incomplete_blobs(repo_type: str, repo_id: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def has_active_incomplete_blobs(repo_type: str, repo_id: str) -> bool:
|
||||
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
|
||||
def has_active_incomplete_blobs(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> bool:
|
||||
for entry in iter_active_repo_cache_dirs(repo_type, repo_id, root = root):
|
||||
if repo_cache_dir_has_incomplete_blobs(entry):
|
||||
return True
|
||||
return False
|
||||
|
|
@ -273,9 +294,14 @@ def _prune_empty_dirs(root: Path) -> bool:
|
|||
return removed
|
||||
|
||||
|
||||
def purge_partial_repo(repo_type: str, repo_id: str) -> bool:
|
||||
def purge_partial_repo(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> bool:
|
||||
removed = False
|
||||
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id):
|
||||
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id, root = root):
|
||||
blobs_dir = entry / "blobs"
|
||||
if blobs_dir.is_dir():
|
||||
for blob in blobs_dir.iterdir():
|
||||
|
|
@ -290,9 +316,14 @@ def purge_partial_repo(repo_type: str, repo_id: str) -> bool:
|
|||
return removed
|
||||
|
||||
|
||||
def purge_repo_cache_dirs(repo_type: str, repo_id: str) -> bool:
|
||||
def purge_repo_cache_dirs(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
) -> bool:
|
||||
removed = False
|
||||
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id):
|
||||
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id, root = root):
|
||||
try:
|
||||
if entry.is_symlink() or not entry.is_dir():
|
||||
continue
|
||||
|
|
@ -301,3 +332,59 @@ def purge_repo_cache_dirs(repo_type: str, repo_id: str) -> bool:
|
|||
except FileNotFoundError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def scoped_delete_root(repo_type: str, repo_id: str, cache_path: Optional[str]) -> Optional[Path]:
|
||||
"""Resolve the single cache root a delete of this repo may touch.
|
||||
|
||||
Returns the active hub cache when *cache_path* is falsy, the owning cache
|
||||
root when *cache_path* points inside a known cache, or ``None`` when
|
||||
*cache_path* is set but not inside any known cache (caller should reject).
|
||||
This keeps a delete of one inventory row from removing copies in other,
|
||||
previously selected caches.
|
||||
"""
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
if not cache_path:
|
||||
return Path(get_hf_cache_paths().hub_cache).resolve(strict = False)
|
||||
try:
|
||||
resolved = Path(cache_path).expanduser().resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
expected = repo_cache_dir_name(repo_type, repo_id).lower()
|
||||
repo_dir = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in (resolved, *resolved.parents)
|
||||
if candidate.name.lower() == expected
|
||||
),
|
||||
None,
|
||||
)
|
||||
if repo_dir is None:
|
||||
return None
|
||||
allowed = {r.resolve(strict = False) for r in hf_cache_roots()}
|
||||
root = repo_dir.parent.resolve(strict = False)
|
||||
return root if root in allowed else None
|
||||
|
||||
|
||||
def resolve_delete_target_root(
|
||||
repo_type: str, repo_id: str, cache_path: Optional[str], owner_roots
|
||||
) -> Optional[Path]:
|
||||
"""Pick the single cache root a delete of this repo should target.
|
||||
|
||||
An explicit *cache_path* wins (``None`` when it is not a known cache, so the
|
||||
caller can reject it). Otherwise prefer the active cache when it holds a
|
||||
copy, else the sole cache that does -- so a model that lives only in a
|
||||
previously selected cache stays deletable while other caches are untouched.
|
||||
"""
|
||||
if cache_path:
|
||||
return scoped_delete_root(repo_type, repo_id, cache_path)
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
active = Path(get_hf_cache_paths().hub_cache).resolve(strict = False)
|
||||
roots = list(owner_roots)
|
||||
if active in roots:
|
||||
return active
|
||||
if len(roots) == 1:
|
||||
return roots[0]
|
||||
return active
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from hub.utils.state_dir import RepoType
|
|||
from hub.utils.hf_cache_state import (
|
||||
INCOMPLETE_SUFFIX,
|
||||
has_incomplete_blobs,
|
||||
hf_cache_root,
|
||||
hf_cache_roots,
|
||||
iter_repo_cache_dirs,
|
||||
latest_snapshot_dir,
|
||||
repo_cache_dir_has_incomplete_blobs,
|
||||
|
|
@ -127,33 +127,13 @@ def all_hf_cache_scans() -> list:
|
|||
|
||||
def _compute_all_hf_cache_scans() -> list:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
from hub.utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
|
||||
|
||||
scans: list = []
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
||||
active = Path(HF_HUB_CACHE).resolve()
|
||||
seen.add(str(active))
|
||||
if active.is_dir():
|
||||
scans.append(scan_cache_dir())
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan active HF cache: %s", exc)
|
||||
|
||||
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
|
||||
for cache_root in hf_cache_roots():
|
||||
try:
|
||||
extra = extra_fn()
|
||||
# is_dir()/resolve() can raise on an inaccessible path; skip it.
|
||||
if not extra.is_dir():
|
||||
continue
|
||||
resolved = str(extra.resolve())
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
scans.append(scan_cache_dir(cache_dir = str(extra)))
|
||||
scans.append(scan_cache_dir(cache_dir = str(cache_root)))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
|
||||
logger.warning("Could not scan HF cache %s: %s", cache_root, exc)
|
||||
return scans
|
||||
|
||||
|
||||
|
|
@ -224,16 +204,8 @@ def _compose_partial(*signals: Callable[[], bool]) -> bool:
|
|||
return any(signal() for signal in signals)
|
||||
|
||||
|
||||
def _state_applies_to_repo_cache_dir(repo_cache_dir: Optional[Path]) -> bool:
|
||||
if repo_cache_dir is None:
|
||||
return True
|
||||
root = hf_cache_root()
|
||||
if root is None:
|
||||
return False
|
||||
try:
|
||||
return repo_cache_dir.resolve().parent == root.resolve()
|
||||
except OSError:
|
||||
return False
|
||||
def _hub_cache_for_repo_dir(repo_cache_dir: Optional[Path]) -> Optional[Path]:
|
||||
return repo_cache_dir.parent if repo_cache_dir is not None else None
|
||||
|
||||
|
||||
def _legacy_partial(
|
||||
|
|
@ -285,12 +257,24 @@ def _repo_cache_dir_has_non_gguf_broken_snapshot_symlinks(repo_cache_dir: Path)
|
|||
return False
|
||||
|
||||
|
||||
def _gguf_variant_manifest_blob_hashes(repo_id: str) -> frozenset[str]:
|
||||
def _gguf_variant_manifest_blob_hashes(
|
||||
repo_id: str, repo_cache_dir: Optional[Path] = None
|
||||
) -> frozenset[str]:
|
||||
from hub.utils import download_manifest
|
||||
|
||||
hashes: set[str] = set()
|
||||
for variant, _path in download_manifest.iter_variant_manifests("model", repo_id):
|
||||
manifest = download_manifest.read_manifest("model", repo_id, variant)
|
||||
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir)
|
||||
for variant, _path in download_manifest.iter_variant_manifests(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
manifest = download_manifest.read_manifest(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
if manifest is None:
|
||||
continue
|
||||
for expected in manifest.expected_files:
|
||||
|
|
@ -315,7 +299,7 @@ def _snapshot_legacy_partial(
|
|||
) -> bool:
|
||||
if repo_type != "model":
|
||||
return _legacy_partial(repo_type, repo_id, repo_cache_dir)
|
||||
ignored_hashes = _gguf_variant_manifest_blob_hashes(repo_id)
|
||||
ignored_hashes = _gguf_variant_manifest_blob_hashes(repo_id, repo_cache_dir)
|
||||
if repo_cache_dir is not None:
|
||||
return _repo_cache_dir_has_snapshot_legacy_partial(
|
||||
repo_cache_dir,
|
||||
|
|
@ -375,9 +359,12 @@ def _manifest_partial(
|
|||
) -> bool:
|
||||
from hub.utils import download_manifest
|
||||
|
||||
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
|
||||
return False
|
||||
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
|
||||
manifest = download_manifest.read_manifest(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir),
|
||||
)
|
||||
if manifest is None:
|
||||
return False
|
||||
resolved = (
|
||||
|
|
@ -452,10 +439,13 @@ def is_snapshot_partial(
|
|||
A manifest without a resolvable snapshot is partial: the worker got
|
||||
far enough to record expectations but did not leave a usable snapshot."""
|
||||
from hub.utils import download_manifest
|
||||
|
||||
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
|
||||
return _compose_partial(
|
||||
lambda: state_applies and download_manifest.has_cancel_marker(repo_type, repo_id, None),
|
||||
lambda: download_manifest.has_cancel_marker(
|
||||
repo_type,
|
||||
repo_id,
|
||||
None,
|
||||
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir),
|
||||
),
|
||||
lambda: _snapshot_legacy_partial(repo_type, repo_id, repo_cache_dir),
|
||||
lambda: _manifest_partial(
|
||||
repo_type,
|
||||
|
|
@ -484,10 +474,13 @@ def is_variant_partial(
|
|||
caller is checking many variants of the same repo (see
|
||||
is_gguf_repo_partial for that usage)."""
|
||||
from hub.utils import download_manifest
|
||||
|
||||
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
|
||||
return _compose_partial(
|
||||
lambda: state_applies and download_manifest.has_cancel_marker("model", repo_id, variant),
|
||||
lambda: download_manifest.has_cancel_marker(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir),
|
||||
),
|
||||
lambda: bool(
|
||||
incomplete_blob_hashes
|
||||
and variant_blob_hashes
|
||||
|
|
@ -526,22 +519,38 @@ def is_gguf_repo_partial(repo_id: str, repo_cache_dir: Optional[Path] = None) ->
|
|||
from hub.utils import download_manifest
|
||||
|
||||
has_legacy_partial = _legacy_partial("model", repo_id, repo_cache_dir)
|
||||
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
|
||||
snapshot_dir = resolve_snapshot_dir_for_scan(
|
||||
"model",
|
||||
repo_id,
|
||||
repo_cache_dir,
|
||||
)
|
||||
variants: set[str] = set(_completed_gguf_variants(snapshot_dir))
|
||||
if state_applies:
|
||||
for variant, _path in download_manifest.iter_variant_manifests(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir)
|
||||
for variant, _path in download_manifest.iter_variant_manifests(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
if (
|
||||
download_manifest.read_manifest(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
is not None
|
||||
):
|
||||
variants.add(variant)
|
||||
for variant, _path in download_manifest.iter_variant_markers(
|
||||
for variant, _path in download_manifest.iter_variant_markers(
|
||||
"model",
|
||||
repo_id,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
if download_manifest.has_cancel_marker(
|
||||
"model",
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
):
|
||||
variants.add(variant)
|
||||
if not variants:
|
||||
|
|
@ -576,14 +585,19 @@ def partial_transport_for(
|
|||
available."""
|
||||
from hub.utils import download_manifest
|
||||
|
||||
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
|
||||
return None
|
||||
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir)
|
||||
marker_transport = download_manifest.read_cancel_marker_transport(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
if marker_transport is not None:
|
||||
return marker_transport
|
||||
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
|
||||
manifest = download_manifest.read_manifest(
|
||||
repo_type,
|
||||
repo_id,
|
||||
variant,
|
||||
hub_cache = hub_cache,
|
||||
)
|
||||
return manifest.transport if manifest is not None else None
|
||||
|
|
|
|||
|
|
@ -277,12 +277,8 @@ def _memo_drop(memo_key: tuple[str, str]) -> None:
|
|||
|
||||
|
||||
def _hf_hub_cache_dir() -> Path:
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
return Path(HF_HUB_CACHE)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read huggingface_hub HF_HUB_CACHE, using default: %s", exc)
|
||||
return Path.home() / ".cache" / "huggingface" / "hub"
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
return get_hf_cache_paths().hub_cache
|
||||
|
||||
|
||||
def _hf_hub_cache_dirs() -> list[Path]:
|
||||
|
|
@ -300,7 +296,10 @@ def _hf_hub_cache_dirs() -> list[Path]:
|
|||
seen.add(key)
|
||||
roots.append(resolved)
|
||||
|
||||
_add(_hf_hub_cache_dir())
|
||||
from utils.hf_cache_settings import known_hf_hub_caches
|
||||
|
||||
for configured in known_hf_hub_caches():
|
||||
_add(configured)
|
||||
try:
|
||||
_add(legacy_hf_cache_dir())
|
||||
_add(hf_default_cache_dir())
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ so it survives ``huggingface-cli delete-cache`` and any other HF-side
|
|||
cache lifecycle. Two subdirectories:
|
||||
|
||||
<studio cache>/hub-state/
|
||||
manifests/ <key>.json per-download expected-files manifest
|
||||
cancelled/ <key>.json per-download cancel marker
|
||||
manifests/cache-<digest>/<key>.json expected-files manifest
|
||||
cancelled/cache-<digest>/<key>.json cancel marker
|
||||
|
||||
The cache digest isolates state for the same repo across selectable Hub caches.
|
||||
The ``<key>`` mirrors HF's cache dir naming while the resulting manifest,
|
||||
cancel-marker, and atomic-write temp filenames fit common filesystem basename
|
||||
limits. Very long repo IDs use a stable hash in the state key:
|
||||
|
|
@ -29,6 +30,7 @@ configuration failure.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional, get_args
|
||||
|
|
@ -55,6 +57,7 @@ _STATE_EXTENSION = ".json"
|
|||
# _atomic_write_json writes ".<target>.tmp-<8hex>" beside the final file.
|
||||
_ATOMIC_WRITE_TMP_OVERHEAD = len(".") + len(".tmp-") + 8
|
||||
_MAX_VARIANT_FRAGMENT_LENGTH = 64
|
||||
_CACHE_SCOPE_DIGEST_LENGTH = 32
|
||||
|
||||
|
||||
def state_root() -> Optional[Path]:
|
||||
|
|
@ -130,13 +133,32 @@ def _entry_key(repo_type: RepoType, repo_id: str, variant: Optional[str]) -> str
|
|||
return f"{variant_filename_prefix(repo_type, repo_id)}{variant_fragment}"
|
||||
|
||||
|
||||
def _cache_scope(parent: Path, hub_cache: Optional[str | Path]) -> Optional[Path]:
|
||||
if hub_cache is None:
|
||||
return parent
|
||||
normalized = os.path.normcase(str(Path(hub_cache).expanduser()))
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:_CACHE_SCOPE_DIGEST_LENGTH]
|
||||
scoped = parent / f"cache-{digest}"
|
||||
try:
|
||||
scoped.mkdir(parents = True, exist_ok = True)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not create cache-scoped Hub state dir %s: %s", scoped, exc)
|
||||
return None
|
||||
return scoped
|
||||
|
||||
|
||||
def manifest_path(
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> Optional[Path]:
|
||||
"""Path to the manifest file for this triple. May or may not exist."""
|
||||
parent = _subdir(_MANIFESTS_SUBDIR)
|
||||
if parent is None:
|
||||
return None
|
||||
parent = _cache_scope(parent, hub_cache)
|
||||
if parent is None:
|
||||
return None
|
||||
return parent / f"{_entry_key(repo_type, repo_id, variant)}.json"
|
||||
|
|
@ -146,9 +168,14 @@ def marker_path(
|
|||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
variant: Optional[str] = None,
|
||||
*,
|
||||
hub_cache: Optional[str | Path] = None,
|
||||
) -> Optional[Path]:
|
||||
"""Path to the cancel-marker file for this triple. May or may not exist."""
|
||||
parent = _subdir(_CANCELLED_SUBDIR)
|
||||
if parent is None:
|
||||
return None
|
||||
parent = _cache_scope(parent, hub_cache)
|
||||
if parent is None:
|
||||
return None
|
||||
return parent / f"{_entry_key(repo_type, repo_id, variant)}.json"
|
||||
|
|
|
|||
|
|
@ -661,6 +661,7 @@ def _download_gguf_variant(repo_id: str, variant: str, hf_token: str | None, mod
|
|||
variant,
|
||||
plan.main_hashes,
|
||||
hf_token,
|
||||
hub_cache = Path(snapshot_path).parents[2],
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -178,6 +178,14 @@ class LocalModelInfo(BaseModel):
|
|||
None,
|
||||
description = "HF repo id for cached models, e.g. org/model",
|
||||
)
|
||||
active_cache: Optional[bool] = Field(
|
||||
None,
|
||||
description = "Whether an HF model belongs to the current download cache.",
|
||||
)
|
||||
partial: bool = Field(
|
||||
False,
|
||||
description = "Whether the cached model has an incomplete download.",
|
||||
)
|
||||
model_format: Optional[str] = Field(
|
||||
None,
|
||||
description = "Detected weights format ('gguf' when known). Lets the UI "
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from utils.models.model_config import (
|
|||
_is_mmproj,
|
||||
_is_mtp_drafter,
|
||||
)
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
from utils.paths.path_utils import (
|
||||
is_local_path,
|
||||
normalize_path,
|
||||
|
|
@ -378,7 +379,12 @@ def read_default_chat_template(
|
|||
if _remote_exceeds_cap(rel):
|
||||
return None
|
||||
try:
|
||||
path = hf_hub_download(resolved, rel, token = hf_token)
|
||||
path = hf_hub_download(
|
||||
resolved,
|
||||
rel,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
return _read_bounded_text(Path(path), MAX_TEMPLATE_METADATA_BYTES)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -23,40 +23,6 @@ def _is_valid_repo_id(repo_id: str) -> bool:
|
|||
return bool(_VALID_REPO_ID.fullmatch(repo_id))
|
||||
|
||||
|
||||
_dataset_size_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def _get_dataset_size_cached(repo_id: str) -> int:
|
||||
if repo_id in _dataset_size_cache:
|
||||
return _dataset_size_cache[repo_id]
|
||||
try:
|
||||
from huggingface_hub import dataset_info as hf_dataset_info
|
||||
|
||||
info = hf_dataset_info(repo_id, token = None, files_metadata = True)
|
||||
total = sum(s.size for s in info.siblings if getattr(s, "size", None))
|
||||
_dataset_size_cache[repo_id] = total
|
||||
return total
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
||||
"""Resolved realpath for a HF cache repo dir: most-recent snapshot, else cache root.
|
||||
|
||||
Mirrors routes/models.py; duplicated here to keep this module self-contained.
|
||||
"""
|
||||
try:
|
||||
snapshots_dir = repo_dir / "snapshots"
|
||||
if snapshots_dir.is_dir():
|
||||
snaps = [s for s in snapshots_dir.iterdir() if s.is_dir()]
|
||||
if snaps:
|
||||
latest = max(snaps, key = lambda s: s.stat().st_mtime)
|
||||
return str(latest.resolve())
|
||||
return str(repo_dir.resolve())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
if str(backend_path) not in sys.path:
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
|
@ -64,6 +30,7 @@ if str(backend_path) not in sys.path:
|
|||
from utils.datasets import check_dataset_format
|
||||
from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label
|
||||
from auth.authentication import get_current_subject
|
||||
from hub.dependencies import get_hf_token
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -292,11 +259,13 @@ def _download_hf_metadata(*, repo_id: str, repo_files: list[str], token: str | N
|
|||
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
local_path = hf_hub_download(
|
||||
repo_id = repo_id,
|
||||
filename = metadata_file,
|
||||
repo_type = "dataset",
|
||||
token = token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not read HF dataset metadata for {repo_id}: {exc}")
|
||||
|
|
@ -525,77 +494,15 @@ def list_local_datasets(
|
|||
@router.get("/download-progress")
|
||||
async def get_dataset_download_progress(
|
||||
repo_id: str = Query(..., description = "HuggingFace dataset repo ID, e.g. 'unsloth/LaTeX_OCR'"),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return download progress for a HuggingFace dataset repo.
|
||||
|
||||
Mirrors ``GET /api/models/download-progress`` but scans the
|
||||
``datasets--owner--name`` cache dir under HF_HUB_CACHE, where in-progress
|
||||
download bytes are visible. Returns ``cache_path`` so the UI can show it.
|
||||
"""
|
||||
_empty = {
|
||||
"downloaded_bytes": 0,
|
||||
"expected_bytes": 0,
|
||||
"progress": 0,
|
||||
"cache_path": None,
|
||||
}
|
||||
try:
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
return _empty
|
||||
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"datasets--{repo_id.replace('/', '--')}".lower()
|
||||
completed_bytes = 0
|
||||
in_progress_bytes = 0
|
||||
cache_path: Optional[str] = None
|
||||
|
||||
if cache_dir.is_dir():
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() != target:
|
||||
continue
|
||||
cache_path = _resolve_hf_cache_realpath(entry)
|
||||
blobs_dir = entry / "blobs"
|
||||
if not blobs_dir.is_dir():
|
||||
break
|
||||
for f in blobs_dir.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.name.endswith(".incomplete"):
|
||||
in_progress_bytes += f.stat().st_size
|
||||
else:
|
||||
completed_bytes += f.stat().st_size
|
||||
break
|
||||
|
||||
downloaded_bytes = completed_bytes + in_progress_bytes
|
||||
if downloaded_bytes == 0:
|
||||
return {**_empty, "cache_path": cache_path}
|
||||
|
||||
expected_bytes = _get_dataset_size_cached(repo_id)
|
||||
if expected_bytes <= 0:
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": 0,
|
||||
"progress": 0,
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
|
||||
# 95% threshold (as in the model endpoint): HF blob dedup makes
|
||||
# completed_bytes drift under expected_bytes; inter-file gaps look "done".
|
||||
if completed_bytes >= expected_bytes * 0.95:
|
||||
progress = 1.0
|
||||
else:
|
||||
progress = min(downloaded_bytes / expected_bytes, 0.99)
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": expected_bytes,
|
||||
"progress": round(progress, 3),
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking dataset download progress for {repo_id}: {e}")
|
||||
return _empty
|
||||
"""Compatibility route backed by the shared multi-cache progress service."""
|
||||
from hub.services.datasets import downloads
|
||||
return await downloads.get_dataset_download_progress_response(
|
||||
repo_id,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check-format", response_model = CheckFormatResponse)
|
||||
|
|
|
|||
|
|
@ -224,11 +224,8 @@ def derive_model_type(
|
|||
|
||||
def _resolve_hf_cache_dir() -> Path:
|
||||
"""Resolve local HF cache root used by hub downloads."""
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
return Path(HF_HUB_CACHE)
|
||||
except Exception:
|
||||
return Path.home() / ".cache" / "huggingface" / "hub"
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
return get_hf_cache_paths().hub_cache
|
||||
|
||||
|
||||
def _is_model_directory(d: Path) -> bool:
|
||||
|
|
@ -370,10 +367,12 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
return found
|
||||
|
||||
|
||||
def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
|
||||
def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalModelInfo]:
|
||||
if not cache_dir.exists() or not cache_dir.is_dir():
|
||||
return []
|
||||
|
||||
from hub.utils import inventory_scan as hf_cache_scan
|
||||
|
||||
found: List[LocalModelInfo] = []
|
||||
for repo_dir in cache_dir.glob("models--*"):
|
||||
if not repo_dir.is_dir():
|
||||
|
|
@ -389,13 +388,21 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
|
|||
except OSError:
|
||||
updated_at = None
|
||||
|
||||
partial = hf_cache_scan.is_snapshot_partial("model", model_id, repo_dir)
|
||||
partial = partial or hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
|
||||
|
||||
load_id = model_id
|
||||
if not active_cache:
|
||||
load_id = _resolve_hf_cache_realpath(repo_dir) or str(repo_dir.resolve())
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id = model_id,
|
||||
id = load_id,
|
||||
model_id = model_id,
|
||||
display_name = model_id.split("/")[-1],
|
||||
path = str(repo_dir),
|
||||
path = load_id if not active_cache else str(repo_dir),
|
||||
source = "hf_cache",
|
||||
active_cache = active_cache,
|
||||
partial = partial,
|
||||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
|
|
@ -776,26 +783,34 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
|
|||
legacy_hf_cache_dir,
|
||||
lmstudio_model_dirs,
|
||||
)
|
||||
from utils.hf_cache_settings import known_hf_hub_caches
|
||||
|
||||
hf_cache_dir = _resolve_hf_cache_dir()
|
||||
legacy_hf = legacy_hf_cache_dir()
|
||||
hf_default = hf_default_cache_dir()
|
||||
lm_dirs = lmstudio_model_dirs()
|
||||
|
||||
local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
|
||||
|
||||
# Resolve once; an inaccessible aux cache must skip that scan, not 500.
|
||||
hf_cache_real = _safe_resolve(hf_cache_dir)
|
||||
legacy_real = _safe_resolve(legacy_hf)
|
||||
default_real = _safe_resolve(hf_default)
|
||||
|
||||
# Scan legacy Unsloth HF cache for backward compatibility.
|
||||
if _safe_is_dir(legacy_hf) and legacy_real != hf_cache_real:
|
||||
local_models += _scan_hf_cache(legacy_hf)
|
||||
|
||||
# Scan HF system default cache (may differ under env overrides).
|
||||
if _safe_is_dir(hf_default) and default_real != hf_cache_real and default_real != legacy_real:
|
||||
local_models += _scan_hf_cache(hf_default)
|
||||
local_models = _scan_models_dir(models_root)
|
||||
active_cache_real = _safe_resolve(hf_cache_dir)
|
||||
active_cache_key = os.path.normcase(active_cache_real) if active_cache_real else None
|
||||
seen_hf: set[str] = set()
|
||||
for cache_dir in (
|
||||
hf_cache_dir,
|
||||
*known_hf_hub_caches(),
|
||||
legacy_hf,
|
||||
hf_default,
|
||||
):
|
||||
cache_real = _safe_resolve(cache_dir)
|
||||
if cache_real is None:
|
||||
continue
|
||||
cache_key = os.path.normcase(str(cache_real))
|
||||
if cache_key in seen_hf:
|
||||
continue
|
||||
seen_hf.add(cache_key)
|
||||
local_models += _scan_hf_cache(
|
||||
cache_dir,
|
||||
active_cache = cache_key == active_cache_key,
|
||||
)
|
||||
|
||||
# Scan LM Studio directories.
|
||||
for lm_dir in lm_dirs:
|
||||
|
|
@ -817,7 +832,7 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
|
|||
m
|
||||
for m in (
|
||||
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
|
||||
+ _scan_hf_cache(folder_path)
|
||||
+ _scan_hf_cache(folder_path, active_cache = False)
|
||||
+ _scan_lmstudio_dir(folder_path)
|
||||
)
|
||||
if not any(p in (".studio_links", "ollama_links") for p in Path(m.path).parts)
|
||||
|
|
@ -838,8 +853,18 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
|
|||
# even when the model is also in the HF cache.
|
||||
deduped: dict[str, LocalModelInfo] = {}
|
||||
for model in local_models:
|
||||
key = f"{model.id}\x00custom" if model.source == "custom" else model.id
|
||||
if key not in deduped:
|
||||
semantic_id = model.model_id if model.source == "hf_cache" and model.model_id else model.id
|
||||
key = f"{semantic_id}\x00custom" if model.source == "custom" else semantic_id
|
||||
existing = deduped.get(key)
|
||||
prefer_model = existing is None
|
||||
if existing is not None and model.source == existing.source == "hf_cache":
|
||||
if model.partial != existing.partial:
|
||||
prefer_model = not model.partial
|
||||
elif bool(model.active_cache) != bool(existing.active_cache):
|
||||
prefer_model = bool(model.active_cache)
|
||||
else:
|
||||
prefer_model = (model.updated_at or 0) > (existing.updated_at or 0)
|
||||
if prefer_model:
|
||||
deduped[key] = model
|
||||
|
||||
models = sorted(
|
||||
|
|
@ -1202,10 +1227,7 @@ def _build_browse_allowlist(
|
|||
legacy_hf_cache_dir,
|
||||
well_known_model_dirs,
|
||||
)
|
||||
from utils.paths.external_media import (
|
||||
linux_run_media_mount_roots,
|
||||
windows_drive_roots,
|
||||
)
|
||||
from utils.paths import external_media
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
candidates: list[Path] = []
|
||||
|
|
@ -1222,9 +1244,12 @@ def _build_browse_allowlist(
|
|||
|
||||
_add(Path.home())
|
||||
if media_roots is None:
|
||||
media_roots = linux_run_media_mount_roots()
|
||||
media_roots = [
|
||||
*external_media.linux_run_media_mount_roots(),
|
||||
*external_media.macos_volume_roots(),
|
||||
]
|
||||
if drive_roots is None:
|
||||
drive_roots = windows_drive_roots()
|
||||
drive_roots = external_media.windows_drive_roots()
|
||||
for p in media_roots:
|
||||
_add(p)
|
||||
for p in drive_roots:
|
||||
|
|
@ -1502,10 +1527,7 @@ def browse_folders(
|
|||
then hidden (if ``show_hidden=true``).
|
||||
"""
|
||||
from utils.paths import hf_default_cache_dir, well_known_model_dirs
|
||||
from utils.paths.external_media import (
|
||||
linux_run_media_mount_roots,
|
||||
windows_drive_roots,
|
||||
)
|
||||
from utils.paths import external_media
|
||||
from storage.studio_db import (
|
||||
contains_sensitive_path_component,
|
||||
is_denied_system_path,
|
||||
|
|
@ -1514,8 +1536,11 @@ def browse_folders(
|
|||
|
||||
# Probe removable-media and Windows drive roots once; the allowlist and
|
||||
# chips reuse the result so a disconnected mapped drive isn't scanned twice.
|
||||
media_roots = linux_run_media_mount_roots()
|
||||
drive_roots = windows_drive_roots()
|
||||
media_roots = [
|
||||
*external_media.linux_run_media_mount_roots(),
|
||||
*external_media.macos_volume_roots(),
|
||||
]
|
||||
drive_roots = external_media.windows_drive_roots()
|
||||
# Build once; the sandbox check and suggestion chips share it.
|
||||
allowed_roots = _build_browse_allowlist(media_roots, drive_roots)
|
||||
|
||||
|
|
@ -2034,19 +2059,19 @@ async def discard_remote_code_download(
|
|||
|
||||
# Never delete a model that is loaded for inference.
|
||||
try:
|
||||
from hub.services.models.deletion import _loaded_id_matches_repo
|
||||
from routes.inference import get_llama_cpp_backend
|
||||
|
||||
llama_backend = get_llama_cpp_backend()
|
||||
if llama_backend.is_loaded and llama_backend.model_identifier:
|
||||
loaded = llama_backend.model_identifier.lower()
|
||||
if loaded == model_name.lower() or loaded.startswith(model_name.lower()):
|
||||
if _loaded_id_matches_repo(llama_backend.model_identifier, model_name):
|
||||
return {"deleted": False, "reason": "loaded"}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
inference_backend = get_inference_backend()
|
||||
if inference_backend.active_model_name:
|
||||
active = inference_backend.active_model_name.lower()
|
||||
if active == model_name.lower() or active.startswith(model_name.lower()):
|
||||
if _loaded_id_matches_repo(inference_backend.active_model_name, model_name):
|
||||
return {"deleted": False, "reason": "loaded"}
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2588,13 +2613,10 @@ def _read_native_context_length(repo_id: str, is_local: bool) -> Optional[int]:
|
|||
if is_local:
|
||||
roots = [Path(repo_id)]
|
||||
else:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
from hub.utils.hf_cache_state import iter_repo_cache_dirs
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
return None
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
roots = [e for e in cache_dir.iterdir() if e.name.lower() == target]
|
||||
roots = list(iter_repo_cache_dirs("model", repo_id))
|
||||
|
||||
for root in roots:
|
||||
for f in _iter_gguf_paths(root):
|
||||
|
|
@ -2623,18 +2645,15 @@ def _resolve_quant_gguf(repo_id: str, quant: str, is_local: bool) -> tuple[Optio
|
|||
if is_local:
|
||||
roots = [Path(repo_id)]
|
||||
else:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from hub.utils.hf_cache_state import iter_repo_cache_dirs
|
||||
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
return None, 0
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
roots = []
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() == target:
|
||||
snaps = entry / "snapshots"
|
||||
if snaps.is_dir():
|
||||
roots.extend(s for s in snaps.iterdir() if s.is_dir())
|
||||
for entry in iter_repo_cache_dirs("model", repo_id):
|
||||
snaps = entry / "snapshots"
|
||||
if snaps.is_dir():
|
||||
roots.extend(s for s in snaps.iterdir() if s.is_dir())
|
||||
|
||||
want = _normalized_quant_label(quant)
|
||||
best_total = 0
|
||||
|
|
@ -2734,6 +2753,8 @@ async def get_gguf_variants(
|
|||
repo_id: str = Query(
|
||||
..., description = "HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')"
|
||||
),
|
||||
prefer_local_cache: bool = False,
|
||||
local_path: Optional[str] = None,
|
||||
hf_token: Optional[str] = Query(None, description = "HuggingFace token for private repos"),
|
||||
hf_token_header: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -2745,9 +2766,16 @@ async def get_gguf_variants(
|
|||
|
||||
response = await hub_gguf_variants.get_gguf_variants_response(
|
||||
repo_id,
|
||||
prefer_local_cache = prefer_local_cache,
|
||||
local_path = local_path,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
local = is_local_path(repo_id)
|
||||
context_model = (
|
||||
local_path
|
||||
if prefer_local_cache and local_path and is_local_path(local_path)
|
||||
else repo_id
|
||||
)
|
||||
local = is_local_path(context_model)
|
||||
|
||||
return GgufVariantsResponse(
|
||||
repo_id = response.repo_id,
|
||||
|
|
@ -2769,7 +2797,7 @@ async def get_gguf_variants(
|
|||
# The header walk reads tokenizer arrays on dense models (tens of
|
||||
# ms per uncached file); keep it off the event loop.
|
||||
context_length = await asyncio.to_thread(
|
||||
_read_native_context_length, repo_id, is_local = local
|
||||
_read_native_context_length, context_model, is_local = local
|
||||
),
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
@ -2787,69 +2815,17 @@ async def get_gguf_download_progress(
|
|||
repo_id: str = Query(..., description = "HuggingFace repo ID"),
|
||||
variant: str = Query("", description = "Quantization variant (e.g. UD-TQ1_0)"),
|
||||
expected_bytes: int = Query(0, description = "Expected total download size in bytes"),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Download progress from cached GGUF files for a specific variant.
|
||||
|
||||
Tracks completed shards in snapshots and in-progress (.incomplete)
|
||||
downloads in the blobs directory.
|
||||
"""
|
||||
try:
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
return {
|
||||
"downloaded_bytes": 0,
|
||||
"expected_bytes": expected_bytes,
|
||||
"progress": 0,
|
||||
}
|
||||
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
variant_lower = variant.lower().replace("-", "").replace("_", "")
|
||||
downloaded_bytes = 0
|
||||
in_progress_bytes = 0
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() == target:
|
||||
# Completed .gguf files for this variant in snapshots.
|
||||
# Exclude mmproj so a vision adapter can't satisfy a same-label
|
||||
# main variant (e.g. mmproj-F16 vs an F16 weight).
|
||||
for f in _iter_gguf_paths(entry):
|
||||
if _is_mmproj_filename(f.name):
|
||||
continue
|
||||
rel = f.relative_to(entry).as_posix()
|
||||
quant = _extract_quant_label(rel)
|
||||
if _is_big_endian_gguf_path(rel, quant):
|
||||
continue
|
||||
rel_key = rel.lower().replace("-", "").replace("_", "")
|
||||
if not variant_lower or variant_lower in rel_key:
|
||||
try:
|
||||
downloaded_bytes += f.stat().st_size
|
||||
except OSError:
|
||||
continue # broken symlink / unreadable: skip
|
||||
# In-progress (.incomplete) downloads in blobs.
|
||||
blobs_dir = entry / "blobs"
|
||||
if blobs_dir.is_dir():
|
||||
for f in blobs_dir.iterdir():
|
||||
if f.is_file() and f.name.endswith(".incomplete"):
|
||||
try:
|
||||
in_progress_bytes += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
break
|
||||
|
||||
total_progress_bytes = downloaded_bytes + in_progress_bytes
|
||||
progress = min(total_progress_bytes / expected_bytes, 0.99) if expected_bytes > 0 else 0
|
||||
# Report 1.0 only when all bytes are in completed files.
|
||||
if expected_bytes > 0 and downloaded_bytes >= expected_bytes:
|
||||
progress = 1.0
|
||||
return {
|
||||
"downloaded_bytes": total_progress_bytes,
|
||||
"expected_bytes": expected_bytes,
|
||||
"progress": round(progress, 3),
|
||||
}
|
||||
except Exception:
|
||||
return {"downloaded_bytes": 0, "expected_bytes": expected_bytes, "progress": 0}
|
||||
"""Compatibility route backed by the shared multi-cache progress service."""
|
||||
from hub.services.models import downloads
|
||||
return await downloads.get_gguf_download_progress_response(
|
||||
repo_id,
|
||||
variant = variant,
|
||||
expected_bytes = expected_bytes,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
||||
|
|
@ -2874,98 +2850,12 @@ def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
|||
@router.get("/download-progress")
|
||||
async def get_download_progress(
|
||||
repo_id: str = Query(..., description = "HuggingFace repo ID"),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return download progress for any HuggingFace model repo.
|
||||
|
||||
Checks the local HF cache for completed blobs and in-progress
|
||||
(.incomplete) downloads. Gets the expected total size from the HF API
|
||||
on the first call, then caches it for later polls. Also returns
|
||||
``cache_path``: the realpath of the snapshot dir (or cache repo root
|
||||
if no snapshot yet) so the UI can show where weights live on disk.
|
||||
"""
|
||||
_empty = {
|
||||
"downloaded_bytes": 0,
|
||||
"expected_bytes": 0,
|
||||
"progress": 0,
|
||||
"cache_path": None,
|
||||
}
|
||||
try:
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
return _empty
|
||||
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
completed_bytes = 0
|
||||
in_progress_bytes = 0
|
||||
cache_path: Optional[str] = None
|
||||
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() != target:
|
||||
continue
|
||||
cache_path = _resolve_hf_cache_realpath(entry)
|
||||
blobs_dir = entry / "blobs"
|
||||
if not blobs_dir.is_dir():
|
||||
break
|
||||
for f in blobs_dir.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.name.endswith(".incomplete"):
|
||||
in_progress_bytes += f.stat().st_size
|
||||
else:
|
||||
completed_bytes += f.stat().st_size
|
||||
break
|
||||
|
||||
downloaded_bytes = completed_bytes + in_progress_bytes
|
||||
if downloaded_bytes == 0:
|
||||
return {**_empty, "cache_path": cache_path}
|
||||
|
||||
expected_bytes = _get_repo_size_cached(repo_id)
|
||||
if expected_bytes <= 0:
|
||||
# Total unknown; report bytes only, no percentage.
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": 0,
|
||||
"progress": 0,
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
|
||||
# 95% threshold (blob dedup can skew completed_bytes). Do NOT
|
||||
# treat "no .incomplete files" as done: HF downloads sequentially,
|
||||
# so none exist between files even when far from finished.
|
||||
if completed_bytes >= expected_bytes * 0.95:
|
||||
progress = 1.0
|
||||
else:
|
||||
progress = min(downloaded_bytes / expected_bytes, 0.99)
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": expected_bytes,
|
||||
"progress": round(progress, 3),
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking download progress for {repo_id}: {e}")
|
||||
return _empty
|
||||
|
||||
|
||||
_repo_size_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def _get_repo_size_cached(repo_id: str) -> int:
|
||||
if repo_id in _repo_size_cache:
|
||||
return _repo_size_cache[repo_id]
|
||||
try:
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
|
||||
info = hf_model_info(repo_id, token = None, files_metadata = True)
|
||||
total = sum(s.size for s in info.siblings if s.size)
|
||||
_repo_size_cache[repo_id] = total
|
||||
return total
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get repo size for {repo_id}: {e}")
|
||||
return 0
|
||||
"""Compatibility route backed by the shared multi-cache progress service."""
|
||||
from hub.services.models import downloads
|
||||
return await downloads.get_download_progress_response(repo_id, hf_token = hf_token)
|
||||
|
||||
|
||||
def _repo_in_any_hf_cache(model_name: str) -> bool:
|
||||
|
|
@ -2978,25 +2868,13 @@ def _repo_in_any_hf_cache(model_name: str) -> bool:
|
|||
would delete a model they did not download via the scan. Mirrors the cache set in
|
||||
``_all_hf_cache_scans`` but only probes for the one repo dir (cheap, no full scan).
|
||||
"""
|
||||
from utils.paths import (
|
||||
hf_default_cache_dir,
|
||||
legacy_hf_cache_dir,
|
||||
resolve_cached_repo_id_case,
|
||||
)
|
||||
from utils.paths import resolve_cached_repo_id_case
|
||||
|
||||
dirname = f"models--{resolve_cached_repo_id_case(model_name).replace('/', '--')}"
|
||||
dirname_lower = dirname.lower()
|
||||
candidates = []
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
candidates.append(Path(HF_HUB_CACHE))
|
||||
except Exception:
|
||||
pass
|
||||
for fn in (legacy_hf_cache_dir, hf_default_cache_dir):
|
||||
try:
|
||||
candidates.append(fn())
|
||||
except Exception:
|
||||
continue
|
||||
from hub.utils.hf_cache_state import hf_cache_roots
|
||||
|
||||
candidates = hf_cache_roots()
|
||||
# resolve_cached_repo_id_case only normalizes the ACTIVE cache, but discard deletes
|
||||
# case-insensitively across all caches, so detect case-insensitively too -- else a
|
||||
# pre-existing case-variant repo is misreported as scan-created and deleted on decline.
|
||||
|
|
@ -3020,38 +2898,8 @@ def _all_hf_cache_scans():
|
|||
broken symlink, OS-redirected ~/.cache) is skipped, not fatal, so the
|
||||
Downloaded list never blanks out and downloads never leak into Recommended.
|
||||
"""
|
||||
from huggingface_hub import scan_cache_dir
|
||||
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
|
||||
|
||||
scans = []
|
||||
# Guard the active cache too: degrade to "no downloads" instead of raising.
|
||||
try:
|
||||
scans.append(scan_cache_dir())
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan active HF cache: %s", exc)
|
||||
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
# Resolve the active cache dir for dedup.
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
seen.add(str(Path(HF_HUB_CACHE).resolve()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
|
||||
try:
|
||||
extra = extra_fn()
|
||||
# is_dir()/resolve() can raise on an inaccessible path; skip it.
|
||||
if not extra.is_dir():
|
||||
continue
|
||||
resolved = str(extra.resolve())
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
scans.append(scan_cache_dir(cache_dir = str(extra)))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
|
||||
return scans
|
||||
from hub.utils.inventory_scan import all_hf_cache_scans
|
||||
return all_hf_cache_scans()
|
||||
|
||||
|
||||
def _is_gguf_filename(name: str) -> bool:
|
||||
|
|
@ -3293,124 +3141,13 @@ async def list_cached_models(
|
|||
async def delete_cached_model(
|
||||
repo_id: str = Body(...),
|
||||
variant: Optional[str] = Body(None),
|
||||
cache_path: Optional[str] = Body(None),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Delete a cached model repo (or a specific GGUF variant) from the HF cache.
|
||||
|
||||
With *variant*, only GGUF files matching that quant label are removed
|
||||
(e.g. ``UD-Q4_K_XL``); otherwise the whole 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")
|
||||
|
||||
# Refuse if the model is currently loaded.
|
||||
try:
|
||||
from routes.inference import get_llama_cpp_backend
|
||||
llama_backend = get_llama_cpp_backend()
|
||||
if llama_backend.is_loaded and llama_backend.model_identifier:
|
||||
loaded_id = llama_backend.model_identifier.lower()
|
||||
if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()):
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Unload the model before deleting",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
inference_backend = get_inference_backend()
|
||||
if inference_backend.active_model_name:
|
||||
active = inference_backend.active_model_name.lower()
|
||||
if active == repo_id.lower() or active.startswith(repo_id.lower()):
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Unload the model before deleting",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
cache_scans = _all_hf_cache_scans()
|
||||
|
||||
target_repo = None
|
||||
for hf_cache in cache_scans:
|
||||
for repo_info in hf_cache.repos:
|
||||
if repo_info.repo_type != "model":
|
||||
continue
|
||||
if repo_info.repo_id.lower() == repo_id.lower():
|
||||
target_repo = repo_info
|
||||
break
|
||||
if target_repo is not None:
|
||||
break
|
||||
|
||||
if target_repo is None:
|
||||
raise HTTPException(status_code = 404, detail = "Model not found in cache")
|
||||
|
||||
# ── Per-variant GGUF deletion ────────────────────────────
|
||||
if variant:
|
||||
deleted_bytes = 0
|
||||
deleted_count = 0
|
||||
for rev in target_repo.revisions:
|
||||
for f in rev.files:
|
||||
if not _is_gguf_filename(f.file_name):
|
||||
continue
|
||||
quant = _extract_quant_label(f.file_name)
|
||||
if quant.lower() != variant.lower():
|
||||
continue
|
||||
# Delete the blob (data) and the snapshot symlink.
|
||||
try:
|
||||
blob = Path(f.blob_path)
|
||||
snap = Path(f.file_path)
|
||||
size = blob.stat().st_size if blob.exists() else 0
|
||||
if snap.exists() or snap.is_symlink():
|
||||
snap.unlink()
|
||||
if blob.exists():
|
||||
blob.unlink()
|
||||
deleted_bytes += size
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete {f.file_name}: {e}")
|
||||
|
||||
if deleted_count == 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 {deleted_count} file(s) for {repo_id} variant {variant}: "
|
||||
f"{freed_mb:.1f} MB freed"
|
||||
)
|
||||
return {"status": "deleted", "repo_id": repo_id, "variant": variant}
|
||||
|
||||
# ── Full repo deletion ───────────────────────────────────
|
||||
revision_hashes = [rev.commit_hash for rev in target_repo.revisions]
|
||||
if not revision_hashes:
|
||||
raise HTTPException(status_code = 404, detail = "No revisions found for model")
|
||||
|
||||
delete_strategy = hf_cache.delete_revisions(*revision_hashes)
|
||||
logger.info(
|
||||
f"Deleting cached model {repo_id}: "
|
||||
f"{delete_strategy.expected_freed_size_str} will be freed"
|
||||
)
|
||||
delete_strategy.execute()
|
||||
|
||||
return {"status": "deleted", "repo_id": repo_id}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cached model {repo_id}: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = "Failed to delete cached model",
|
||||
)
|
||||
"""Compatibility route backed by the shared multi-cache deletion service."""
|
||||
from hub.services.models import deletion
|
||||
return await deletion.delete_cached_model_response(repo_id, variant, hf_token, cache_path)
|
||||
|
||||
|
||||
def _resolve_cached_model_path(repo_id: str, variant: Optional[str]) -> Path:
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from utils.embedding_model_settings import (
|
|||
set_rag_embedding_model,
|
||||
validate_embedding_model,
|
||||
)
|
||||
from utils.hf_cache_settings import cache_status, get_hf_cache_paths, set_hf_cache_home
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -89,6 +90,23 @@ class HelperPrecacheResponse(BaseModel):
|
|||
disabled_by_env: bool
|
||||
|
||||
|
||||
class HuggingFaceCachePayload(BaseModel):
|
||||
cache_home: Optional[str] = Field(default = None, max_length = 4096)
|
||||
|
||||
|
||||
class HuggingFaceCacheResponse(BaseModel):
|
||||
cache_home: str
|
||||
hub_cache: str
|
||||
xet_cache: str
|
||||
source: Literal["default", "studio", "environment"]
|
||||
editable: bool
|
||||
is_custom: bool
|
||||
available: bool
|
||||
writable: bool
|
||||
free_bytes: Optional[int] = None
|
||||
environment_variable: Optional[str] = None
|
||||
|
||||
|
||||
class OpenAIAutoSwitchPayload(BaseModel):
|
||||
enabled: bool
|
||||
# None leaves the stored value untouched (partial updates can't clobber it).
|
||||
|
|
@ -135,6 +153,30 @@ def _helper_precache_response(enabled: bool | None = None) -> HelperPrecacheResp
|
|||
)
|
||||
|
||||
|
||||
def _hugging_face_cache_response() -> HuggingFaceCacheResponse:
|
||||
return HuggingFaceCacheResponse(**cache_status(get_hf_cache_paths()))
|
||||
|
||||
|
||||
@router.get("/hugging-face-cache", response_model = HuggingFaceCacheResponse)
|
||||
def get_hugging_face_cache(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> HuggingFaceCacheResponse:
|
||||
return _hugging_face_cache_response()
|
||||
|
||||
|
||||
@router.put("/hugging-face-cache", response_model = HuggingFaceCacheResponse)
|
||||
def update_hugging_face_cache(
|
||||
payload: HuggingFaceCachePayload, current_subject: str = Depends(get_current_subject)
|
||||
) -> HuggingFaceCacheResponse:
|
||||
try:
|
||||
set_hf_cache_home(payload.cache_home)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code = 409, detail = str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
||||
return _hugging_face_cache_response()
|
||||
|
||||
|
||||
@router.get("/upload-limit", response_model = UploadLimitResponse)
|
||||
def get_upload_limit(current_subject: str = Depends(get_current_subject)) -> UploadLimitResponse:
|
||||
return _upload_limit_response(get_upload_limit_mb())
|
||||
|
|
|
|||
|
|
@ -66,6 +66,66 @@ def test_iter_gguf_paths_matches_extension_case_insensitively(tmp_path):
|
|||
assert result == ["Q4_K_M.gguf", "Q8_0.GGUF"]
|
||||
|
||||
|
||||
def test_legacy_hf_scan_uses_snapshot_path_for_inactive_cache(tmp_path):
|
||||
repo = tmp_path / "models--Org--Model"
|
||||
snapshot = repo / "snapshots" / "revision"
|
||||
snapshot.mkdir(parents = True)
|
||||
|
||||
[row] = models_route._scan_hf_cache(tmp_path, active_cache = False)
|
||||
|
||||
assert row.model_id == "Org/Model"
|
||||
assert row.id == str(snapshot.resolve())
|
||||
assert row.path == str(snapshot.resolve())
|
||||
|
||||
|
||||
def test_collect_local_models_scans_previous_cache(monkeypatch, tmp_path):
|
||||
active = tmp_path / "active"
|
||||
previous = tmp_path / "previous"
|
||||
active.mkdir()
|
||||
snapshot = previous / "models--Org--Previous" / "snapshots" / "revision"
|
||||
snapshot.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
|
||||
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
|
||||
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
|
||||
monkeypatch.setattr("utils.hf_cache_settings.known_hf_hub_caches", lambda: [active, previous])
|
||||
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
|
||||
|
||||
rows = models_route.collect_local_models(tmp_path / "models")
|
||||
|
||||
previous_row = next(row for row in rows if row.model_id == "Org/Previous")
|
||||
assert previous_row.id == str(snapshot.resolve())
|
||||
|
||||
|
||||
def test_collect_local_models_prefers_complete_previous_copy(monkeypatch, tmp_path):
|
||||
active = tmp_path / "active"
|
||||
previous = tmp_path / "previous"
|
||||
active_partial = active / "models--Org--Model" / "blobs" / "abc.incomplete"
|
||||
active_partial.parent.mkdir(parents = True)
|
||||
active_partial.write_bytes(b"partial")
|
||||
snapshot = previous / "models--Org--Model" / "snapshots" / "revision"
|
||||
snapshot.mkdir(parents = True)
|
||||
(snapshot / "model.safetensors").write_bytes(b"complete")
|
||||
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
|
||||
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
|
||||
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.known_hf_hub_caches",
|
||||
lambda: [active, previous],
|
||||
)
|
||||
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
|
||||
|
||||
rows = models_route.collect_local_models(tmp_path / "models")
|
||||
|
||||
[row] = [row for row in rows if row.model_id == "Org/Model"]
|
||||
assert row.id == str(snapshot.resolve())
|
||||
assert row.partial is False
|
||||
assert row.active_cache is False
|
||||
|
||||
|
||||
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monkeypatch, tmp_path):
|
||||
repo = _repo(
|
||||
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
|
|
@ -573,33 +633,14 @@ def _gfile(name: str, size: int, mtime: float) -> SimpleNamespace:
|
|||
)
|
||||
|
||||
|
||||
def test_all_hf_cache_scans_survives_inaccessible_aux_cache(monkeypatch, tmp_path):
|
||||
"""An unreadable auxiliary cache (e.g. an inaccessible
|
||||
``~/.cache/huggingface/hub``) must be skipped, not abort the scan.
|
||||
Regression guard for ``extra.is_dir()`` raising and wiping the response.
|
||||
"""
|
||||
import huggingface_hub
|
||||
import utils.paths as paths_mod
|
||||
def test_all_hf_cache_scans_uses_shared_inventory(monkeypatch, tmp_path):
|
||||
from hub.utils import inventory_scan
|
||||
|
||||
active = SimpleNamespace(
|
||||
repos = [_repo("Org/Active", [_file("Q4_K_M.gguf", 5_000)], tmp_path / "active")]
|
||||
)
|
||||
|
||||
def _fake_scan(cache_dir = None):
|
||||
if cache_dir is None:
|
||||
return active
|
||||
raise AssertionError("auxiliary scan should have been skipped")
|
||||
|
||||
class _Boom:
|
||||
def is_dir(self):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
def resolve(self):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "scan_cache_dir", _fake_scan)
|
||||
monkeypatch.setattr(paths_mod, "legacy_hf_cache_dir", lambda: _Boom())
|
||||
monkeypatch.setattr(paths_mod, "hf_default_cache_dir", lambda: _Boom())
|
||||
monkeypatch.setattr(inventory_scan, "all_hf_cache_scans", lambda: [active])
|
||||
|
||||
scans = models_route._all_hf_cache_scans()
|
||||
assert scans == [active]
|
||||
|
|
@ -686,13 +727,17 @@ def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_pa
|
|||
"list_gguf_variants",
|
||||
lambda repo_id, hf_token = None: (variants, True, []),
|
||||
)
|
||||
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
|
||||
monkeypatch.setattr(
|
||||
GV,
|
||||
"_local_main_gguf_blobs_by_quant",
|
||||
lambda _repo_id, repo_cache_dir = None: {},
|
||||
)
|
||||
|
||||
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
||||
snap.mkdir(parents = True)
|
||||
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10_000) # real weight, fully present
|
||||
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # mmproj adapter, label "F16"
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_variants(
|
||||
|
|
@ -705,6 +750,52 @@ def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_pa
|
|||
assert flags["F16"] is False
|
||||
|
||||
|
||||
def test_gguf_variants_route_scopes_local_probe_to_selected_cache(monkeypatch, tmp_path):
|
||||
snapshot = tmp_path / "inactive" / "models--org--repo" / "snapshots" / "rev"
|
||||
snapshot.mkdir(parents = True)
|
||||
calls = []
|
||||
|
||||
async def scoped_variants(repo_id, **kwargs):
|
||||
calls.append((repo_id, kwargs))
|
||||
return SimpleNamespace(
|
||||
repo_id = repo_id,
|
||||
variants = [],
|
||||
has_vision = False,
|
||||
default_variant = None,
|
||||
)
|
||||
|
||||
context_calls = []
|
||||
monkeypatch.setattr(GV, "get_gguf_variants_response", scoped_variants)
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_read_native_context_length",
|
||||
lambda model, *, is_local: context_calls.append((model, is_local)) or 8192,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_variants(
|
||||
repo_id = "org/repo",
|
||||
prefer_local_cache = True,
|
||||
local_path = str(snapshot),
|
||||
hf_token = None,
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
"org/repo",
|
||||
{
|
||||
"prefer_local_cache": True,
|
||||
"local_path": str(snapshot),
|
||||
"hf_token": None,
|
||||
},
|
||||
)
|
||||
]
|
||||
assert context_calls == [(str(snapshot), True)]
|
||||
assert result.context_length == 8192
|
||||
|
||||
|
||||
def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
|
||||
siblings = [
|
||||
SimpleNamespace(rfilename = "model-Q4_K_M-be.gguf", size = 100),
|
||||
|
|
@ -726,12 +817,16 @@ def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
|
|||
siblings,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
|
||||
monkeypatch.setattr(
|
||||
GV,
|
||||
"_local_main_gguf_blobs_by_quant",
|
||||
lambda _repo_id, repo_cache_dir = None: {},
|
||||
)
|
||||
|
||||
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
||||
snap.mkdir(parents = True)
|
||||
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10)
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_variants(
|
||||
|
|
@ -758,12 +853,16 @@ def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, t
|
|||
"list_gguf_variants",
|
||||
lambda repo_id, hf_token = None: (variants, False, []),
|
||||
)
|
||||
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
|
||||
monkeypatch.setattr(
|
||||
GV,
|
||||
"_local_main_gguf_blobs_by_quant",
|
||||
lambda _repo_id, repo_cache_dir = None: {},
|
||||
)
|
||||
|
||||
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
||||
snap.mkdir(parents = True)
|
||||
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 10)
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_variants(
|
||||
|
|
@ -774,66 +873,82 @@ def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, t
|
|||
assert result.variants[0].downloaded is False
|
||||
|
||||
|
||||
def test_gguf_download_progress_excludes_mmproj(monkeypatch, tmp_path):
|
||||
"""A cached mmproj adapter must not count toward a same-label main
|
||||
variant's download progress (mmproj-F16 vs an F16 weight)."""
|
||||
import huggingface_hub.constants as hf_constants
|
||||
def test_legacy_gguf_progress_delegates_to_shared_service(monkeypatch):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
||||
snap.mkdir(parents = True)
|
||||
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # only the adapter on disk
|
||||
async def shared(repo_id, *, variant, expected_bytes, hf_token):
|
||||
calls.append((repo_id, variant, expected_bytes, hf_token))
|
||||
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_download_progress(
|
||||
repo_id = "org/repo",
|
||||
variant = "F16",
|
||||
expected_bytes = 20_000,
|
||||
current_subject = "test-user",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hub.services.models.downloads.get_gguf_download_progress_response",
|
||||
shared,
|
||||
)
|
||||
|
||||
assert result["downloaded_bytes"] == 0
|
||||
assert result["progress"] == 0
|
||||
|
||||
|
||||
def test_gguf_download_progress_excludes_big_endian_sibling(monkeypatch, tmp_path):
|
||||
import huggingface_hub.constants as hf_constants
|
||||
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
|
||||
snap.mkdir(parents = True)
|
||||
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"y" * 20_000)
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_download_progress(
|
||||
repo_id = "org/repo",
|
||||
variant = "Q4_K_M",
|
||||
expected_bytes = 20_000,
|
||||
expected_bytes = 20,
|
||||
hf_token = "token",
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["downloaded_bytes"] == 0
|
||||
assert result["progress"] == 0
|
||||
assert result["progress"] == 0.5
|
||||
assert calls == [("org/repo", "Q4_K_M", 20, "token")]
|
||||
|
||||
|
||||
def test_gguf_download_progress_counts_quant_subdir(monkeypatch, tmp_path):
|
||||
import huggingface_hub.constants as hf_constants
|
||||
def test_legacy_model_progress_delegates_to_shared_service(monkeypatch):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
snap = tmp_path / "models--org--repo" / "snapshots" / "rev" / "Q4_K_M"
|
||||
snap.mkdir(parents = True)
|
||||
(snap / "foo.gguf").write_bytes(b"x" * 20_000)
|
||||
async def shared(repo_id, *, hf_token):
|
||||
calls.append((repo_id, hf_token))
|
||||
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hub.services.models.downloads.get_download_progress_response",
|
||||
shared,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.get_gguf_download_progress(
|
||||
models_route.get_download_progress(
|
||||
repo_id = "org/repo",
|
||||
variant = "Q4_K_M",
|
||||
expected_bytes = 20_000,
|
||||
hf_token = "token",
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["downloaded_bytes"] == 20_000
|
||||
assert result["progress"] == 1.0
|
||||
assert result["progress"] == 0.5
|
||||
assert calls == [("org/repo", "token")]
|
||||
|
||||
|
||||
def test_legacy_delete_delegates_to_shared_service(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def shared(
|
||||
repo_id,
|
||||
variant,
|
||||
hf_token,
|
||||
cache_path = None,
|
||||
):
|
||||
calls.append((repo_id, variant, hf_token, cache_path))
|
||||
return {"status": "deleted", "repo_id": repo_id}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hub.services.models.deletion.delete_cached_model_response",
|
||||
shared,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
models_route.delete_cached_model(
|
||||
repo_id = "org/repo",
|
||||
variant = None,
|
||||
cache_path = "/data/hf/hub",
|
||||
hf_token = "token",
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"status": "deleted", "repo_id": "org/repo"}
|
||||
assert calls == [("org/repo", None, "token", "/data/hf/hub")]
|
||||
|
|
|
|||
|
|
@ -873,6 +873,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
if fn == "config.json":
|
||||
import json
|
||||
|
|
@ -899,6 +900,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -932,6 +934,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -972,6 +975,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -1008,6 +1012,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -1037,6 +1042,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -1079,6 +1085,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -1120,6 +1127,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
@ -1182,6 +1190,7 @@ class TestScannerCoversAllExecutableCode:
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
import json
|
||||
import tempfile
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ def _build_cache(
|
|||
@pytest.fixture
|
||||
def hf_cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
|
||||
)
|
||||
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
||||
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
|
||||
return tmp_path
|
||||
|
|
@ -117,6 +121,61 @@ def _fail_get_paths_info(*_args, **_kwargs):
|
|||
|
||||
|
||||
class TestLoadReusesCachedCopy:
|
||||
def test_download_uses_selected_cache_for_lookup_preflight_and_write(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
backend = LlamaCppBackend()
|
||||
selected = tmp_path / "selected" / "hub"
|
||||
startup = tmp_path / "startup" / "hub"
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(startup))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(hub_cache = selected),
|
||||
)
|
||||
seen = {"lookups": [], "disk": [], "downloads": []}
|
||||
|
||||
def cached_lookup(
|
||||
repo_id,
|
||||
filename,
|
||||
*,
|
||||
cache_dir = None,
|
||||
**_kwargs,
|
||||
):
|
||||
seen["lookups"].append((repo_id, filename, cache_dir))
|
||||
return None
|
||||
|
||||
def disk_usage(path):
|
||||
seen["disk"].append(str(path))
|
||||
return _types.SimpleNamespace(free = 1024)
|
||||
|
||||
def download(repo_id, filename, _token, **kwargs):
|
||||
seen["downloads"].append((repo_id, filename, kwargs.get("cache_dir")))
|
||||
return str(selected / filename)
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch(
|
||||
"huggingface_hub.get_paths_info",
|
||||
lambda _repo, paths, **_kwargs: [
|
||||
_types.SimpleNamespace(path = path, size = 4) for path in paths
|
||||
],
|
||||
),
|
||||
patch("huggingface_hub.try_to_load_from_cache", cached_lookup),
|
||||
patch("core.inference.llama_cpp.shutil.disk_usage", disk_usage),
|
||||
patch(
|
||||
"core.inference.llama_cpp.hf_hub_download_with_xet_fallback",
|
||||
download,
|
||||
),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(selected / MAIN)
|
||||
assert seen == {
|
||||
"lookups": [(REPO, MAIN, str(selected))],
|
||||
"disk": [str(selected)],
|
||||
"downloads": [(REPO, MAIN, str(selected))],
|
||||
}
|
||||
|
||||
def test_online_reuse_after_revision_bump(self, hf_cache):
|
||||
"""A new repo revision does not replace a complete cached model."""
|
||||
backend = LlamaCppBackend()
|
||||
|
|
|
|||
290
studio/backend/tests/test_hf_cache_settings.py
Normal file
290
studio/backend/tests/test_hf_cache_settings.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
from hub.services.models.common import _local_model_info
|
||||
from utils import hf_cache_settings
|
||||
from utils import native_path_leases
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_store(monkeypatch, tmp_path):
|
||||
store = {}
|
||||
monkeypatch.setattr(hf_cache_settings, "_EXPLICIT_CACHE_ENV", {})
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
||||
monkeypatch.setattr(
|
||||
"storage.studio_db.get_app_setting",
|
||||
lambda key, fallback = None: store.get(key, fallback),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"storage.studio_db.upsert_app_settings",
|
||||
lambda values: store.update(values) or values,
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def test_studio_cache_switch_is_live_and_keeps_history(settings_store, tmp_path):
|
||||
first = tmp_path / "external-a" / "huggingface"
|
||||
second = tmp_path / "external-b" / "huggingface"
|
||||
first.parent.mkdir()
|
||||
second.parent.mkdir()
|
||||
|
||||
selected = hf_cache_settings.set_hf_cache_home(str(first))
|
||||
assert selected.hub_cache == first / "hub"
|
||||
assert selected.xet_cache == first / "xet"
|
||||
assert selected.child_env({}) == {
|
||||
"HF_HUB_CACHE": str(first / "hub"),
|
||||
"HF_XET_CACHE": str(first / "xet"),
|
||||
}
|
||||
|
||||
hf_cache_settings.set_hf_cache_home(str(second))
|
||||
assert settings_store[hf_cache_settings.CACHE_HISTORY_SETTING_KEY] == [str(first)]
|
||||
assert first / "hub" in hf_cache_settings.known_hf_hub_caches()
|
||||
|
||||
reset = hf_cache_settings.set_hf_cache_home(None)
|
||||
assert reset.source == "default"
|
||||
assert second in hf_cache_settings.known_hf_cache_homes()
|
||||
|
||||
|
||||
def test_environment_cache_is_read_only(monkeypatch, tmp_path):
|
||||
custom = tmp_path / "managed"
|
||||
monkeypatch.setattr(
|
||||
hf_cache_settings,
|
||||
"_EXPLICIT_CACHE_ENV",
|
||||
{"HF_HOME": str(custom)},
|
||||
)
|
||||
paths = hf_cache_settings.get_hf_cache_paths()
|
||||
assert paths.source == "environment"
|
||||
assert paths.editable is False
|
||||
assert paths.hub_cache == custom / "hub"
|
||||
with pytest.raises(RuntimeError, match = "environment variable"):
|
||||
hf_cache_settings.set_hf_cache_home(str(tmp_path / "other"))
|
||||
|
||||
|
||||
def test_explicit_hub_cache_is_the_displayed_location(monkeypatch, tmp_path):
|
||||
custom_hub = tmp_path / "models-cache"
|
||||
custom_hub.mkdir()
|
||||
monkeypatch.setattr(
|
||||
hf_cache_settings,
|
||||
"_EXPLICIT_CACHE_ENV",
|
||||
{"HF_HUB_CACHE": str(custom_hub)},
|
||||
)
|
||||
|
||||
paths = hf_cache_settings.get_hf_cache_paths()
|
||||
status = hf_cache_settings.cache_status(paths)
|
||||
|
||||
assert paths.cache_home == custom_hub
|
||||
assert paths.hub_cache == custom_hub
|
||||
assert status["cache_home"] == str(custom_hub)
|
||||
assert status["available"] is True
|
||||
assert custom_hub / "hub" not in hf_cache_settings.known_hf_hub_caches()
|
||||
|
||||
|
||||
def test_explicit_hub_cache_display_wins_over_hf_home(monkeypatch, tmp_path):
|
||||
hf_home = tmp_path / "hf-home"
|
||||
custom_hub = tmp_path / "other-disk" / "models-cache"
|
||||
hf_home.mkdir()
|
||||
custom_hub.mkdir(parents = True)
|
||||
monkeypatch.setattr(
|
||||
hf_cache_settings,
|
||||
"_EXPLICIT_CACHE_ENV",
|
||||
{"HF_HOME": str(hf_home), "HF_HUB_CACHE": str(custom_hub)},
|
||||
)
|
||||
|
||||
paths = hf_cache_settings.get_hf_cache_paths()
|
||||
|
||||
assert paths.cache_home == custom_hub
|
||||
assert paths.hub_cache == custom_hub
|
||||
assert paths.xet_cache == hf_home / "xet"
|
||||
assert custom_hub / "hub" not in hf_cache_settings.known_hf_hub_caches()
|
||||
assert hf_home / "hub" in hf_cache_settings.known_hf_hub_caches()
|
||||
|
||||
|
||||
def test_xet_only_override_keeps_model_cache_editable(settings_store, monkeypatch, tmp_path):
|
||||
xet_cache = tmp_path / "chunks"
|
||||
stored = tmp_path / "stored-cache"
|
||||
settings_store[hf_cache_settings.CACHE_HOME_SETTING_KEY] = str(stored)
|
||||
monkeypatch.setattr(
|
||||
hf_cache_settings,
|
||||
"_EXPLICIT_CACHE_ENV",
|
||||
{"HF_XET_CACHE": str(xet_cache)},
|
||||
)
|
||||
|
||||
paths = hf_cache_settings.get_hf_cache_paths()
|
||||
|
||||
assert paths.cache_home == stored
|
||||
assert paths.hub_cache == stored / "hub"
|
||||
assert paths.xet_cache == xet_cache
|
||||
assert paths.editable is True
|
||||
|
||||
selected = tmp_path / "selected-cache"
|
||||
selected.parent.mkdir(exist_ok = True)
|
||||
updated = hf_cache_settings.set_hf_cache_home(str(selected))
|
||||
assert updated.hub_cache == selected / "hub"
|
||||
assert updated.xet_cache == xet_cache
|
||||
|
||||
|
||||
def test_worker_environment_is_applied_before_import(monkeypatch, tmp_path):
|
||||
hub = str(tmp_path / "hub")
|
||||
xet = str(tmp_path / "xet")
|
||||
observed = {}
|
||||
|
||||
class Module:
|
||||
@staticmethod
|
||||
def run():
|
||||
import os
|
||||
return os.environ["HF_HUB_CACHE"], os.environ["HF_XET_CACHE"]
|
||||
|
||||
def fake_import(name):
|
||||
import os
|
||||
|
||||
observed["name"] = name
|
||||
observed["hub"] = os.environ.get("HF_HUB_CACHE")
|
||||
return Module
|
||||
|
||||
monkeypatch.setattr(native_path_leases.importlib, "import_module", fake_import)
|
||||
result = native_path_leases.run_without_native_path_secret(
|
||||
"fake.worker",
|
||||
"run",
|
||||
{"HF_HUB_CACHE": hub, "HF_XET_CACHE": xet},
|
||||
)
|
||||
assert observed == {"name": "fake.worker", "hub": hub}
|
||||
assert result == (hub, xet)
|
||||
|
||||
|
||||
def test_spawn_environment_is_applied_then_restored(monkeypatch, tmp_path):
|
||||
hub = str(tmp_path / "hub")
|
||||
xet = str(tmp_path / "xet")
|
||||
monkeypatch.setenv("HF_HUB_CACHE", "parent-hub")
|
||||
monkeypatch.delenv("HF_XET_CACHE", raising = False)
|
||||
|
||||
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": hub, "HF_XET_CACHE": xet}):
|
||||
import os
|
||||
assert os.environ["HF_HUB_CACHE"] == hub
|
||||
assert os.environ["HF_XET_CACHE"] == xet
|
||||
|
||||
assert os.environ["HF_HUB_CACHE"] == "parent-hub"
|
||||
assert "HF_XET_CACHE" not in os.environ
|
||||
|
||||
|
||||
def test_spawn_environment_supports_nested_contexts(monkeypatch):
|
||||
monkeypatch.setenv("HF_HUB_CACHE", "parent")
|
||||
|
||||
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "outer"}):
|
||||
assert os.environ["HF_HUB_CACHE"] == "outer"
|
||||
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "inner"}):
|
||||
assert os.environ["HF_HUB_CACHE"] == "inner"
|
||||
assert os.environ["HF_HUB_CACHE"] == "outer"
|
||||
|
||||
assert os.environ["HF_HUB_CACHE"] == "parent"
|
||||
|
||||
|
||||
def test_spawn_environment_serializes_threads(monkeypatch):
|
||||
monkeypatch.setenv("HF_HUB_CACHE", "parent")
|
||||
first_entered = threading.Event()
|
||||
release_first = threading.Event()
|
||||
observations: list[tuple[str, str]] = []
|
||||
|
||||
def first():
|
||||
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "first"}):
|
||||
observations.append(("first", os.environ["HF_HUB_CACHE"]))
|
||||
first_entered.set()
|
||||
assert release_first.wait(timeout = 2)
|
||||
|
||||
def second():
|
||||
assert first_entered.wait(timeout = 2)
|
||||
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "second"}):
|
||||
observations.append(("second", os.environ["HF_HUB_CACHE"]))
|
||||
|
||||
first_thread = threading.Thread(target = first)
|
||||
second_thread = threading.Thread(target = second)
|
||||
first_thread.start()
|
||||
second_thread.start()
|
||||
assert first_entered.wait(timeout = 2)
|
||||
time.sleep(0.02)
|
||||
assert observations == [("first", "first")]
|
||||
release_first.set()
|
||||
first_thread.join(timeout = 2)
|
||||
second_thread.join(timeout = 2)
|
||||
|
||||
assert observations == [("first", "first"), ("second", "second")]
|
||||
assert os.environ["HF_HUB_CACHE"] == "parent"
|
||||
|
||||
|
||||
def test_cache_switch_invalidates_inventory(settings_store, tmp_path, monkeypatch):
|
||||
invalidations = []
|
||||
monkeypatch.setattr(
|
||||
"hub.utils.inventory_scan.invalidate_hf_cache_scans",
|
||||
lambda: invalidations.append(True),
|
||||
)
|
||||
selected = tmp_path / "external" / "huggingface"
|
||||
selected.parent.mkdir()
|
||||
|
||||
hf_cache_settings.set_hf_cache_home(str(selected))
|
||||
|
||||
assert invalidations == [True]
|
||||
|
||||
|
||||
def test_cache_validation_write_tests_hub_and_xet(settings_store, tmp_path, monkeypatch):
|
||||
selected = tmp_path / "external" / "huggingface"
|
||||
selected.parent.mkdir()
|
||||
tested = []
|
||||
real_named_temporary_file = hf_cache_settings.tempfile.NamedTemporaryFile
|
||||
|
||||
def recording_write_test(*args, **kwargs):
|
||||
tested.append(Path(kwargs["dir"]))
|
||||
return real_named_temporary_file(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hf_cache_settings.tempfile,
|
||||
"NamedTemporaryFile",
|
||||
recording_write_test,
|
||||
)
|
||||
|
||||
hf_cache_settings.set_hf_cache_home(str(selected))
|
||||
|
||||
assert tested == [selected / "hub", selected / "xet"]
|
||||
|
||||
|
||||
def test_cache_validation_rejects_unwritable_child(settings_store, tmp_path, monkeypatch):
|
||||
selected = tmp_path / "external" / "huggingface"
|
||||
selected.parent.mkdir()
|
||||
|
||||
def reject_hub(*args, **kwargs):
|
||||
if Path(kwargs["dir"]).name == "hub":
|
||||
raise PermissionError("read-only")
|
||||
raise AssertionError("xet should not be tested after hub fails")
|
||||
|
||||
monkeypatch.setattr(hf_cache_settings.tempfile, "NamedTemporaryFile", reject_hub)
|
||||
|
||||
with pytest.raises(ValueError, match = "permission"):
|
||||
hf_cache_settings.set_hf_cache_home(str(selected))
|
||||
|
||||
|
||||
def test_inactive_cache_model_loads_from_snapshot_path(tmp_path):
|
||||
snapshot = tmp_path / "snapshots" / "revision"
|
||||
snapshot.mkdir(parents = True)
|
||||
row = _local_model_info(
|
||||
scan_path = snapshot,
|
||||
load_path = snapshot,
|
||||
source = "hf_cache",
|
||||
model_format = "safetensors",
|
||||
model_id = "org/model",
|
||||
active_cache = False,
|
||||
)
|
||||
assert row.model_id == "org/model"
|
||||
assert row.active_cache is False
|
||||
assert row.load_id == str(snapshot)
|
||||
|
|
@ -101,13 +101,23 @@ def test_shim_injects_studio_prepare_on_http_retry(monkeypatch):
|
|||
prepared = []
|
||||
monkeypatch.setattr(
|
||||
"hub.utils.download_registry.prepare_cache_for_transport",
|
||||
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
|
||||
lambda repo_type, repo_id, mode, *a, **k: prepared.append(
|
||||
(repo_type, repo_id, mode, k.get("root"))
|
||||
),
|
||||
)
|
||||
|
||||
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
||||
selected_cache = "/captured/hub"
|
||||
out = xf.hf_hub_download_with_xet_fallback(
|
||||
DL_REPO,
|
||||
FILE,
|
||||
None,
|
||||
cache_dir = selected_cache,
|
||||
)
|
||||
assert out == "/cache/model.gguf"
|
||||
assert seen_disable_xet == [False, True] # Xet first, then HTTP
|
||||
assert prepared == [("model", DL_REPO, "http")], "shim must run Unsloth's marker-aware prep"
|
||||
assert prepared == [
|
||||
("model", DL_REPO, "http", Path(selected_cache))
|
||||
], "shim must prepare the cache captured by the download"
|
||||
|
||||
|
||||
def test_shim_snapshot_injects_studio_prepare(monkeypatch):
|
||||
|
|
@ -120,10 +130,22 @@ def test_shim_snapshot_injects_studio_prepare(monkeypatch):
|
|||
return "/tmp/snap-dir"
|
||||
|
||||
monkeypatch.setattr(xf, "_shared_snapshot_download_with_xet_fallback", fake_snapshot)
|
||||
out = xf.snapshot_download_with_xet_fallback("org/model")
|
||||
selected_cache = "/captured/hub"
|
||||
out = xf.snapshot_download_with_xet_fallback(
|
||||
"org/model",
|
||||
cache_dir = selected_cache,
|
||||
)
|
||||
assert out == "/tmp/snap-dir"
|
||||
assert captured["repo_id"] == "org/model"
|
||||
assert captured["prepare_for_http_fn"] is xf._studio_prepare_for_http
|
||||
prepared = []
|
||||
monkeypatch.setattr(
|
||||
"hub.utils.download_registry.prepare_cache_for_transport",
|
||||
lambda repo_type, repo_id, mode, *a, **k: prepared.append(
|
||||
(repo_type, repo_id, mode, k.get("root"))
|
||||
),
|
||||
)
|
||||
captured["prepare_for_http_fn"]("model", "org/model")
|
||||
assert prepared == [("model", "org/model", "http", Path(selected_cache))]
|
||||
|
||||
|
||||
def test_degrades_gracefully_without_shared_helper(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -254,8 +254,10 @@ def test_legacy_browse_allowlist_includes_linux_run_media_mounts(monkeypatch, tm
|
|||
)
|
||||
fake_external_media = SimpleNamespace(
|
||||
linux_run_media_mount_roots = lambda: [media_root],
|
||||
macos_volume_roots = lambda: [],
|
||||
windows_drive_roots = lambda: [],
|
||||
)
|
||||
fake_paths.external_media = fake_external_media
|
||||
fake_studio_db = SimpleNamespace(
|
||||
list_scan_folders = lambda: [],
|
||||
contains_sensitive_path_component = studio_db.contains_sensitive_path_component,
|
||||
|
|
|
|||
|
|
@ -112,13 +112,21 @@ def patch_hub_gguf(monkeypatch):
|
|||
blob_ids = [local_blob],
|
||||
gguf_files = {"model-Q4_K_M.gguf": 1000},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = tmp_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
GV,
|
||||
"list_gguf_variants",
|
||||
lambda r, hf_token = None: (_variants(), False, [remote_sibling]),
|
||||
raising = True,
|
||||
)
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
||||
monkeypatch.setattr(
|
||||
GV,
|
||||
"iter_hf_cache_snapshots",
|
||||
lambda _repo_id, root = None: [snap],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
CI,
|
||||
"all_hf_cache_scans",
|
||||
|
|
@ -217,6 +225,10 @@ def test_variant_update_check_detects_companion_only_update(
|
|||
companion_path: 100,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = tmp_path),
|
||||
)
|
||||
siblings = [
|
||||
patch_hub_gguf.sibling("model-Q4_K_M.gguf", 1000, "mainsha"),
|
||||
patch_hub_gguf.sibling(companion_path, 100, "new-companion"),
|
||||
|
|
@ -227,7 +239,11 @@ def test_variant_update_check_detects_companion_only_update(
|
|||
lambda r, hf_token = None: (_variants(), has_vision, siblings),
|
||||
raising = True,
|
||||
)
|
||||
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
|
||||
monkeypatch.setattr(
|
||||
GV,
|
||||
"iter_hf_cache_snapshots",
|
||||
lambda _repo_id, root = None: [snap],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
CI,
|
||||
"all_hf_cache_scans",
|
||||
|
|
@ -372,7 +388,7 @@ def test_cached_gguf_scan_keeps_download_timestamp(monkeypatch, tmp_path):
|
|||
monkeypatch.setattr(
|
||||
CI,
|
||||
"_gguf_variant_state_summary",
|
||||
lambda _repo_id: (False, 0),
|
||||
lambda _repo_id, **_kwargs: (False, 0),
|
||||
)
|
||||
|
||||
rows = CI._scan_cached_gguf()
|
||||
|
|
@ -630,7 +646,12 @@ def test_reclaim_replaced_gguf_variant_prunes_old_revision_only(monkeypatch, tmp
|
|||
invalidated = []
|
||||
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: invalidated.append(True))
|
||||
|
||||
result = D.reclaim_replaced_gguf_variant(repo_id, "Q4_K_M", frozenset({"NEWsha"}))
|
||||
result = D.reclaim_replaced_gguf_variant(
|
||||
repo_id,
|
||||
"Q4_K_M",
|
||||
frozenset({"NEWsha"}),
|
||||
hub_cache = tmp_path,
|
||||
)
|
||||
|
||||
assert result["removed_snapshots"] == 1
|
||||
assert result["deleted_blobs"] == 1
|
||||
|
|
@ -677,13 +698,75 @@ def test_reclaim_replaced_gguf_variant_keeps_no_symlink_current_file(monkeypatch
|
|||
monkeypatch.setattr(CI, "all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo_info])])
|
||||
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: None)
|
||||
|
||||
result = D.reclaim_replaced_gguf_variant(repo_id, "Q4_K_M", frozenset({"REMOTEsha256"}))
|
||||
result = D.reclaim_replaced_gguf_variant(
|
||||
repo_id,
|
||||
"Q4_K_M",
|
||||
frozenset({"REMOTEsha256"}),
|
||||
hub_cache = tmp_path,
|
||||
)
|
||||
|
||||
assert snap.exists() is True # the current file must survive
|
||||
assert result["removed_snapshots"] == 0
|
||||
assert result["deleted_blobs"] == 0
|
||||
|
||||
|
||||
def test_reclaim_replaced_gguf_variant_only_mutates_worker_cache(monkeypatch, tmp_path):
|
||||
repo_id = "org/repo-GGUF"
|
||||
cache_a = tmp_path / "cache-a"
|
||||
cache_b = tmp_path / "cache-b"
|
||||
|
||||
def cached_repo(cache_dir, revision):
|
||||
repo_path = cache_dir / "models--org--repo-GGUF"
|
||||
snap = repo_path / "snapshots" / revision / "model-Q4_K_M.gguf"
|
||||
blob = repo_path / "blobs" / "OLDsha"
|
||||
snap.parent.mkdir(parents = True, exist_ok = True)
|
||||
blob.parent.mkdir(parents = True, exist_ok = True)
|
||||
blob.write_bytes(b"old")
|
||||
snap.symlink_to(blob)
|
||||
return (
|
||||
SimpleNamespace(
|
||||
repo_id = repo_id,
|
||||
repo_type = "model",
|
||||
repo_path = repo_path,
|
||||
revisions = [
|
||||
SimpleNamespace(
|
||||
files = [
|
||||
SimpleNamespace(
|
||||
file_name = snap.name,
|
||||
file_path = str(snap),
|
||||
blob_path = str(blob),
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
),
|
||||
snap,
|
||||
blob,
|
||||
)
|
||||
|
||||
repo_a, snap_a, blob_a = cached_repo(cache_a, "a" * 40)
|
||||
repo_b, snap_b, blob_b = cached_repo(cache_b, "b" * 40)
|
||||
monkeypatch.setattr(
|
||||
CI,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [repo_a]), SimpleNamespace(repos = [repo_b])],
|
||||
)
|
||||
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: None)
|
||||
|
||||
result = D.reclaim_replaced_gguf_variant(
|
||||
repo_id,
|
||||
"Q4_K_M",
|
||||
frozenset({"NEWsha"}),
|
||||
hub_cache = cache_b,
|
||||
)
|
||||
|
||||
assert result["removed_snapshots"] == 1
|
||||
assert snap_b.exists() is False
|
||||
assert blob_b.exists() is False
|
||||
assert snap_a.exists() is True
|
||||
assert blob_a.exists() is True
|
||||
|
||||
|
||||
def _mmproj_repo(*file_names: str):
|
||||
return SimpleNamespace(
|
||||
revisions = [SimpleNamespace(files = [SimpleNamespace(file_name = n) for n in file_names])]
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ def test_repo_in_any_hf_cache_matches_case_variant_in_legacy_cache(tmp_path, mon
|
|||
# covers the active cache; discard deletes case-insensitively, so detection must too,
|
||||
# else a decline deletes a pre-existing user repo).
|
||||
import utils.paths as paths_pkg
|
||||
import huggingface_hub.constants as hf_constants
|
||||
import hub.utils.paths as hub_paths
|
||||
|
||||
active = tmp_path / "active"
|
||||
legacy = tmp_path / "legacy"
|
||||
|
|
@ -96,9 +96,12 @@ def test_repo_in_any_hf_cache_matches_case_variant_in_legacy_cache(tmp_path, mon
|
|||
|
||||
# No active-cache variant; case resolution is a no-op here.
|
||||
monkeypatch.setattr(paths_pkg, "resolve_cached_repo_id_case", lambda name: name)
|
||||
monkeypatch.setattr(paths_pkg, "legacy_hf_cache_dir", lambda: legacy)
|
||||
monkeypatch.setattr(paths_pkg, "hf_default_cache_dir", lambda: default)
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(active))
|
||||
monkeypatch.setattr(hub_paths, "legacy_hf_cache_dir", lambda: legacy)
|
||||
monkeypatch.setattr(hub_paths, "hf_default_cache_dir", lambda: default)
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.known_hf_hub_caches",
|
||||
lambda: [active],
|
||||
)
|
||||
|
||||
assert models_route._repo_in_any_hf_cache("unsloth/foo") is True
|
||||
# Absent from every cache -> reported absent.
|
||||
|
|
|
|||
|
|
@ -85,11 +85,19 @@ def _is_embedding_model(*args, **kwargs):
|
|||
|
||||
@pytest.fixture
|
||||
def hf_cache(tmp_path, monkeypatch):
|
||||
"""Point the HF cache at a fresh temp dir."""
|
||||
"""Point the HF cache at a fresh temp dir.
|
||||
|
||||
get_hf_cache_paths() reads an import-time env snapshot, not live os.environ,
|
||||
so point it (and thus active_hf_hub_cache + the snapshot lookup's selected
|
||||
root) at this temp cache too."""
|
||||
root = tmp_path / "hub"
|
||||
root.mkdir()
|
||||
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(root))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = root),
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
|
|
@ -198,18 +206,25 @@ def test_snapshot_dir_uses_sentence_transformers_home(tmp_path, monkeypatch):
|
|||
assert hf_cache_snapshot_dir("org/emb") == snapshot
|
||||
|
||||
|
||||
def test_snapshot_dir_st_home_is_exclusive(tmp_path, monkeypatch):
|
||||
# With SENTENCE_TRANSFORMERS_HOME set, ST loads only from it, so a model living only under
|
||||
# HF_HUB_CACHE must not be reported.
|
||||
def test_snapshot_dir_prefers_selected_cache_over_st_home(tmp_path, monkeypatch):
|
||||
# The RAG loader passes cache_folder=active_hf_hub_cache(), which overrides
|
||||
# SENTENCE_TRANSFORMERS_HOME, so the snapshot + offline security lookup must
|
||||
# search the selected cache even when ST_HOME points elsewhere. Otherwise the
|
||||
# gate scans a cache the model never loads from and a pickle weight in the
|
||||
# selected cache slips through.
|
||||
st_home = tmp_path / "st_home"
|
||||
st_home.mkdir()
|
||||
hub = tmp_path / "hub"
|
||||
hub.mkdir()
|
||||
selected = tmp_path / "hub"
|
||||
selected.mkdir()
|
||||
monkeypatch.setenv("SENTENCE_TRANSFORMERS_HOME", str(st_home))
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(hub))
|
||||
monkeypatch.delenv("HF_HUB_CACHE", raising = False)
|
||||
monkeypatch.delenv("HF_HOME", raising = False)
|
||||
_make_cache(hub, "org/emb", {"modules.json": MODULES_JSON}) # only in the HF hub cache
|
||||
assert hf_cache_snapshot_dir("org/emb") is None
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: SimpleNamespace(hub_cache = selected),
|
||||
)
|
||||
snapshot = _make_cache(selected, "org/emb", {"modules.json": MODULES_JSON}) # only in selected
|
||||
assert hf_cache_snapshot_dir("org/emb") == snapshot
|
||||
|
||||
|
||||
def test_snapshot_is_loadable_with_config_and_weights(hf_cache):
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ def _symlink_or_skip(link: Path, target: Path) -> None:
|
|||
def hf_cache(tmp_path, monkeypatch):
|
||||
"""Point ``huggingface_hub.constants.HF_HUB_CACHE`` at a temp dir."""
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
|
|
@ -227,6 +231,10 @@ class TestGgufVariantFileResolution:
|
|||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"huggingface_hub.list_repo_files",
|
||||
|
|
@ -434,6 +442,40 @@ class TestGgufVariantFileResolution:
|
|||
|
||||
assert out == str(snap / "mmproj-F16.gguf")
|
||||
|
||||
def test_download_companion_uses_selected_cache_not_import_time_default(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
||||
import_time_cache = tmp_path / "import-time-cache"
|
||||
selected_cache = tmp_path / "selected-cache"
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(import_time_cache))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(hub_cache = selected_cache),
|
||||
)
|
||||
repo = "unsloth/vision-GGUF"
|
||||
snap = _build_cache(selected_cache, repo, {"mmproj-F16.gguf": 4})
|
||||
backend = LlamaCppBackend()
|
||||
|
||||
offline_error = type("OfflineModeIsEnabled", (Exception,), {})
|
||||
|
||||
def fail_list(*_args, **_kwargs):
|
||||
raise offline_error("offline")
|
||||
|
||||
def fail_download(*_args, **_kwargs):
|
||||
raise AssertionError("selected-cache companion must not download")
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", fail_list),
|
||||
patch(
|
||||
"core.inference.llama_cpp.hf_hub_download_with_xet_fallback",
|
||||
fail_download,
|
||||
),
|
||||
):
|
||||
out = backend._download_mmproj(hf_repo = repo)
|
||||
|
||||
assert out == str(snap / "mmproj-F16.gguf")
|
||||
|
||||
def test_download_includes_uppercase_split_gguf_shards(self, monkeypatch, tmp_path):
|
||||
backend = LlamaCppBackend()
|
||||
downloaded: list[str] = []
|
||||
|
|
@ -460,6 +502,10 @@ class TestGgufVariantFileResolution:
|
|||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
|
||||
)
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
|
|
|
|||
|
|
@ -1096,6 +1096,7 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
|
|||
from pathlib import Path
|
||||
import routes.models as models_route
|
||||
from utils import paths as upaths
|
||||
from utils import hf_cache_settings
|
||||
import storage.studio_db as studio_db
|
||||
|
||||
scanned = []
|
||||
|
|
@ -1116,13 +1117,18 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
|
|||
)
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: tmp_path / "active")
|
||||
monkeypatch.setattr(models_route, "_is_hidden_model", lambda *a, **k: False)
|
||||
monkeypatch.setattr(
|
||||
hf_cache_settings,
|
||||
"known_hf_hub_caches",
|
||||
lambda: [tmp_path / "active", tmp_path / "previous"],
|
||||
)
|
||||
monkeypatch.setattr(upaths, "legacy_hf_cache_dir", lambda: tmp_path / "legacy")
|
||||
monkeypatch.setattr(upaths, "hf_default_cache_dir", lambda: tmp_path / "default")
|
||||
monkeypatch.setattr(upaths, "lmstudio_model_dirs", lambda: [tmp_path / "lmstudio"])
|
||||
monkeypatch.setattr(
|
||||
studio_db, "list_scan_folders", lambda: [{"path": str(tmp_path / "custom")}]
|
||||
)
|
||||
for sub in ("active", "legacy", "default", "lmstudio", "custom"):
|
||||
for sub in ("active", "previous", "legacy", "default", "lmstudio", "custom"):
|
||||
(tmp_path / sub).mkdir()
|
||||
|
||||
resolver._build_index()
|
||||
|
|
@ -1131,6 +1137,7 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
|
|||
lm = {p for k, p in scanned if k == "lm"}
|
||||
assert str((tmp_path / "legacy").resolve()) in hf
|
||||
assert str((tmp_path / "default").resolve()) in hf
|
||||
assert str((tmp_path / "previous").resolve()) in hf
|
||||
assert str((tmp_path / "custom").resolve()) in hf
|
||||
assert str((tmp_path / "lmstudio").resolve()) in lm
|
||||
|
||||
|
|
|
|||
|
|
@ -241,11 +241,15 @@ def test_remote_oversized_jinja_falls_through_to_tokenizer_template(tmp_path, mo
|
|||
"chat_template.jinja": big_jinja,
|
||||
"tokenizer_config.json": tokenizer_config,
|
||||
}
|
||||
selected_cache = tmp_path / "selected-cache" / "hub"
|
||||
observed_cache_dirs = []
|
||||
|
||||
monkeypatch.setattr("picker.service.resolve_cached_repo_id_case", lambda name: name)
|
||||
monkeypatch.setattr("picker.service.iter_hf_cache_snapshots", lambda resolved: [])
|
||||
monkeypatch.setattr("picker.service.active_hf_hub_cache", lambda: str(selected_cache))
|
||||
|
||||
def _fake_download(repo_id, rel, **kwargs):
|
||||
observed_cache_dirs.append(kwargs.get("cache_dir"))
|
||||
target = files.get(rel)
|
||||
if target is None:
|
||||
raise FileNotFoundError(rel)
|
||||
|
|
@ -264,3 +268,5 @@ def test_remote_oversized_jinja_falls_through_to_tokenizer_template(tmp_path, mo
|
|||
monkeypatch.setattr(huggingface_hub.HfApi, "get_paths_info", _fake_get_paths_info)
|
||||
|
||||
assert read_default_chat_template("org/big-jinja-model") == "SMALL_TEMPLATE"
|
||||
assert observed_cache_dirs
|
||||
assert set(observed_cache_dirs) == {str(selected_cache)}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
and token counting must be serialized (else threads panic "Already borrowed")."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
|
@ -130,6 +132,35 @@ def test_token_counter_enables_parallelism_only_during_call(monkeypatch):
|
|||
assert os.environ.get("TOKENIZERS_PARALLELISM") == "false" # restored after
|
||||
|
||||
|
||||
def test_sentence_transformer_load_uses_live_cache(monkeypatch, tmp_path):
|
||||
observed = {}
|
||||
|
||||
class FakeSentenceTransformer:
|
||||
def __init__(self, name, **kwargs):
|
||||
observed["name"] = name
|
||||
observed.update(kwargs)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"sentence_transformers",
|
||||
SimpleNamespace(SentenceTransformer = FakeSentenceTransformer),
|
||||
)
|
||||
monkeypatch.setattr(embeddings, "_install_torchao_stub_once", lambda: None)
|
||||
monkeypatch.setattr(embeddings, "_guard_model_security", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(embeddings, "_device", lambda: "cpu")
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.active_hf_hub_cache",
|
||||
lambda: str(tmp_path / "selected-hub"),
|
||||
)
|
||||
embeddings._model = None
|
||||
embeddings._name = None
|
||||
|
||||
embeddings._get("Org/Embedder")
|
||||
|
||||
assert observed["name"] == "Org/Embedder"
|
||||
assert observed["cache_folder"] == str(tmp_path / "selected-hub")
|
||||
|
||||
|
||||
class _SentinelLlamaBackend:
|
||||
"""Stand-in for LlamaServerBackend; never spawns a real server."""
|
||||
|
||||
|
|
|
|||
|
|
@ -68,8 +68,6 @@ def test_skips_mtp_drafter_for_main_weights(tmp_path):
|
|||
|
||||
|
||||
def test_prefers_the_complete_snapshot(tmp_path, monkeypatch):
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
cache = tmp_path / "hub"
|
||||
snaps = cache / "models--org--repo" / "snapshots"
|
||||
# Partial older snapshot: one small shard.
|
||||
|
|
@ -78,7 +76,10 @@ def test_prefers_the_complete_snapshot(tmp_path, monkeypatch):
|
|||
complete_first = _write(snaps / "bbbb" / "model-00001-of-00002-Q4_K_M.gguf", 30)
|
||||
_write(snaps / "bbbb" / "model-00002-of-00002-Q4_K_M.gguf", 40)
|
||||
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(cache))
|
||||
monkeypatch.setattr(
|
||||
"utils.hf_cache_settings.known_hf_hub_caches",
|
||||
lambda: [cache],
|
||||
)
|
||||
|
||||
path, total = models_route._resolve_quant_gguf("org/repo", "Q4_K_M", is_local = False)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ def _isolate_studio_home(monkeypatch, tmp_path):
|
|||
|
||||
|
||||
def _load_storage_roots():
|
||||
# Each test models a fresh backend process. The cache resolver intentionally
|
||||
# snapshots explicit environment variables once per process.
|
||||
sys.modules.pop("utils.hf_cache_settings", None)
|
||||
spec = importlib.util.spec_from_file_location("storage_roots_under_test", _STORAGE_ROOTS_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
|
@ -40,10 +43,10 @@ def _clear_hf_env(monkeypatch):
|
|||
|
||||
|
||||
def test_custom_hf_home_seeds_hub_and_xet(monkeypatch, tmp_path):
|
||||
sr = _load_storage_roots()
|
||||
_clear_hf_env(monkeypatch)
|
||||
custom = tmp_path / "shared" / "huggingface"
|
||||
monkeypatch.setenv("HF_HOME", str(custom))
|
||||
sr = _load_storage_roots()
|
||||
|
||||
sr._setup_cache_env()
|
||||
|
||||
|
|
@ -54,9 +57,9 @@ def test_custom_hf_home_seeds_hub_and_xet(monkeypatch, tmp_path):
|
|||
|
||||
|
||||
def test_default_when_hf_home_unset(monkeypatch, tmp_path):
|
||||
sr = _load_storage_roots()
|
||||
_clear_hf_env(monkeypatch)
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
||||
sr = _load_storage_roots()
|
||||
|
||||
sr._setup_cache_env()
|
||||
|
||||
|
|
@ -67,11 +70,11 @@ def test_default_when_hf_home_unset(monkeypatch, tmp_path):
|
|||
|
||||
|
||||
def test_explicit_hub_cache_is_not_overridden(monkeypatch, tmp_path):
|
||||
sr = _load_storage_roots()
|
||||
_clear_hf_env(monkeypatch)
|
||||
monkeypatch.setenv("HF_HOME", str(tmp_path / "home"))
|
||||
explicit = tmp_path / "explicit" / "hub"
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(explicit))
|
||||
sr = _load_storage_roots()
|
||||
|
||||
sr._setup_cache_env()
|
||||
|
||||
|
|
@ -81,11 +84,11 @@ def test_explicit_hub_cache_is_not_overridden(monkeypatch, tmp_path):
|
|||
|
||||
|
||||
def test_legacy_huggingface_hub_cache_alias_is_honored(monkeypatch, tmp_path):
|
||||
sr = _load_storage_roots()
|
||||
_clear_hf_env(monkeypatch)
|
||||
monkeypatch.setenv("HF_HOME", str(tmp_path / "home"))
|
||||
legacy = tmp_path / "legacy" / "hub"
|
||||
monkeypatch.setenv("HUGGINGFACE_HUB_CACHE", str(legacy))
|
||||
sr = _load_storage_roots()
|
||||
|
||||
sr._setup_cache_env()
|
||||
|
||||
|
|
@ -96,15 +99,16 @@ def test_legacy_huggingface_hub_cache_alias_is_honored(monkeypatch, tmp_path):
|
|||
|
||||
def test_whitespace_hf_home_falls_back_to_default(monkeypatch, tmp_path):
|
||||
# A blank/whitespace HF_HOME must not become " /hub"; fall back to default.
|
||||
sr = _load_storage_roots()
|
||||
_clear_hf_env(monkeypatch)
|
||||
monkeypatch.setenv("HF_HOME", " ")
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
||||
sr = _load_storage_roots()
|
||||
|
||||
sr._setup_cache_env()
|
||||
|
||||
import os
|
||||
|
||||
assert os.environ["HF_HOME"] == str(tmp_path / "xdg" / "huggingface")
|
||||
assert os.environ["HF_HUB_CACHE"] == str(tmp_path / "xdg" / "huggingface" / "hub")
|
||||
|
||||
|
||||
|
|
@ -114,9 +118,9 @@ def test_unwritable_hf_home_does_not_crash(monkeypatch, tmp_path):
|
|||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("not a dir")
|
||||
unwritable = blocker / "hf"
|
||||
sr = _load_storage_roots()
|
||||
_clear_hf_env(monkeypatch)
|
||||
monkeypatch.setenv("HF_HOME", str(unwritable))
|
||||
sr = _load_storage_roots()
|
||||
|
||||
sr._setup_cache_env() # must not raise
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ def test_lora_identifier_resolves_remote_adapter_base(tmp_path: Path):
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
assert repo == "someone/my-remote-lora"
|
||||
assert fn == "adapter_config.json"
|
||||
|
|
@ -128,6 +129,7 @@ def test_lora_identifier_retries_transient_then_resolves(tmp_path: Path):
|
|||
repo,
|
||||
fn,
|
||||
token = None,
|
||||
cache_dir = None,
|
||||
):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
|
|
|
|||
|
|
@ -160,6 +160,25 @@ class TestResolveBaseModel:
|
|||
class TestRemoteLoraBase:
|
||||
"""_remote_lora_base reads a remote adapter's base from its Hub adapter_config.json."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _selected_cache_follows_env(self, monkeypatch):
|
||||
# The cache helpers now read the selected cache (get_hf_cache_paths),
|
||||
# which snapshots env at import; make it follow the HF_HUB_CACHE these
|
||||
# tests set so they keep driving the lookup via env.
|
||||
monkeypatch.setattr(
|
||||
"utils.transformers_version.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(
|
||||
hub_cache = Path(
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.path.join(
|
||||
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"),
|
||||
"hub",
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resp(cfg: dict):
|
||||
class _Resp:
|
||||
|
|
@ -645,6 +664,24 @@ def _hf_response(cfg: dict):
|
|||
class TestConfigJsonHfCacheFallback:
|
||||
"""HF hub cache is consulted only offline or after a failed fetch (never stale online)."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _selected_cache_follows_env(self, monkeypatch):
|
||||
# As above: route the selected-cache lookup through the HF_HUB_CACHE env
|
||||
# these tests set, since get_hf_cache_paths snapshots env at import.
|
||||
monkeypatch.setattr(
|
||||
"utils.transformers_version.get_hf_cache_paths",
|
||||
lambda: _types.SimpleNamespace(
|
||||
hub_cache = Path(
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.path.join(
|
||||
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"),
|
||||
"hub",
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def setup_method(self):
|
||||
_config_json_cache.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ def test_windows_drive_roots_empty_off_windows(monkeypatch):
|
|||
assert external_media.windows_drive_roots() == []
|
||||
|
||||
|
||||
def test_macos_volume_roots_lists_readable_mounts(monkeypatch, tmp_path):
|
||||
volumes = tmp_path / "Volumes"
|
||||
external = volumes / "External SSD"
|
||||
unreadable = volumes / "Unavailable"
|
||||
external.mkdir(parents = True)
|
||||
unreadable.mkdir()
|
||||
monkeypatch.setattr(external_media.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(external_media.os, "access", lambda path, _mode: Path(path) == external)
|
||||
|
||||
assert external_media.macos_volume_roots(volumes) == [external]
|
||||
|
||||
|
||||
def test_windows_drive_roots_lists_readable_drives(monkeypatch):
|
||||
_stub_windows(monkeypatch, {"C", "D", "E"})
|
||||
|
||||
|
|
@ -204,8 +216,10 @@ def test_browse_allowlist_includes_windows_drive_roots(monkeypatch, tmp_path):
|
|||
)
|
||||
fake_external_media = SimpleNamespace(
|
||||
linux_run_media_mount_roots = lambda: [],
|
||||
macos_volume_roots = lambda: [],
|
||||
windows_drive_roots = lambda: [drive_root],
|
||||
)
|
||||
fake_paths.external_media = fake_external_media
|
||||
fake_studio_db = SimpleNamespace(
|
||||
list_scan_folders = lambda: [],
|
||||
contains_sensitive_path_component = lambda _p: False,
|
||||
|
|
@ -270,8 +284,10 @@ def test_build_browse_allowlist_reuses_passed_roots(monkeypatch, tmp_path):
|
|||
)
|
||||
fake_external_media = SimpleNamespace(
|
||||
linux_run_media_mount_roots = _media_roots,
|
||||
macos_volume_roots = lambda: [],
|
||||
windows_drive_roots = _drive_roots,
|
||||
)
|
||||
fake_paths.external_media = fake_external_media
|
||||
fake_studio_db = SimpleNamespace(list_scan_folders = lambda: [])
|
||||
monkeypatch.setitem(sys.modules, "utils.paths", fake_paths)
|
||||
monkeypatch.setitem(sys.modules, "utils.paths.external_media", fake_external_media)
|
||||
|
|
|
|||
|
|
@ -406,10 +406,13 @@ def convert_to_vlm_format(
|
|||
elif _image_lookup is not None and image_data in _image_lookup:
|
||||
# Bare filename → resolve via HF repo lookup
|
||||
from huggingface_hub import hf_hub_download
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
local_path = hf_hub_download(
|
||||
dataset_name,
|
||||
_image_lookup[image_data],
|
||||
repo_type = "dataset",
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
image_data = Image.open(local_path).convert("RGB")
|
||||
else:
|
||||
|
|
@ -774,10 +777,13 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
return Image.open(BytesIO(f.read())).convert("RGB")
|
||||
elif _image_lookup is not None and image_data in _image_lookup:
|
||||
from huggingface_hub import hf_hub_download
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
local_path = hf_hub_download(
|
||||
dataset_name,
|
||||
_image_lookup[image_data],
|
||||
repo_type = "dataset",
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
return Image.open(local_path).convert("RGB")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ def precache_helper_gguf():
|
|||
try:
|
||||
from huggingface_hub import HfApi, hf_hub_download
|
||||
from huggingface_hub.utils import disable_progress_bars, enable_progress_bars
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
disable_progress_bars()
|
||||
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
|
||||
|
|
@ -76,7 +77,11 @@ def precache_helper_gguf():
|
|||
+ (f" (+{len(matching) - 1} shards)" if len(matching) > 1 else "")
|
||||
)
|
||||
for target in matching:
|
||||
hf_hub_download(repo_id = repo, filename = target)
|
||||
hf_hub_download(
|
||||
repo_id = repo,
|
||||
filename = target,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
logger.info(f"Helper GGUF cached: {len(matching)} file(s)")
|
||||
else:
|
||||
logger.warning(f"No GGUF matching variant '{variant}' in {repo}")
|
||||
|
|
|
|||
362
studio/backend/utils/hf_cache_settings.py
Normal file
362
studio/backend/utils/hf_cache_settings.py
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Live, persisted Hugging Face cache routing for Unsloth Studio.
|
||||
|
||||
Hugging Face reads cache environment variables at import time. Studio therefore
|
||||
owns an explicit cache snapshot for each operation instead of trying to refresh
|
||||
``huggingface_hub.constants`` in the long-running API process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Literal, Mapping, Optional
|
||||
|
||||
|
||||
CACHE_HOME_SETTING_KEY = "hugging_face_cache_home"
|
||||
CACHE_HISTORY_SETTING_KEY = "hugging_face_cache_history"
|
||||
MAX_CACHE_HISTORY = 16
|
||||
|
||||
CacheSource = Literal["default", "studio", "environment"]
|
||||
|
||||
_CACHE_ENV_KEYS = (
|
||||
"HF_HOME",
|
||||
"HF_HUB_CACHE",
|
||||
"HUGGINGFACE_HUB_CACHE",
|
||||
"HF_XET_CACHE",
|
||||
)
|
||||
# Imported by storage_roots._setup_cache_env before Studio seeds defaults.
|
||||
_EXPLICIT_CACHE_ENV = {
|
||||
key: value.strip()
|
||||
for key in _CACHE_ENV_KEYS
|
||||
if (value := os.environ.get(key)) is not None and value.strip()
|
||||
}
|
||||
_settings_lock = threading.RLock()
|
||||
_spawn_env_lock = threading.RLock()
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class HuggingFaceCachePaths:
|
||||
cache_home: Path
|
||||
hub_cache: Path
|
||||
xet_cache: Path
|
||||
source: CacheSource
|
||||
environment_variable: Optional[str] = None
|
||||
|
||||
@property
|
||||
def editable(self) -> bool:
|
||||
return self.source != "environment"
|
||||
|
||||
@property
|
||||
def is_custom(self) -> bool:
|
||||
return self.source == "studio"
|
||||
|
||||
def child_env(self, base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
||||
env = dict(os.environ if base is None else base)
|
||||
# Do not rewrite HF_HOME. It also owns HF's token path, and credentials
|
||||
# must not be moved onto a removable cache volume.
|
||||
env["HF_HUB_CACHE"] = str(self.hub_cache)
|
||||
env["HF_XET_CACHE"] = str(self.xet_cache)
|
||||
env.pop("HUGGINGFACE_HUB_CACHE", None)
|
||||
return env
|
||||
|
||||
|
||||
def _default_cache_home() -> Path:
|
||||
xdg = (os.environ.get("XDG_CACHE_HOME") or "").strip()
|
||||
return (Path(xdg).expanduser() if xdg else Path.home() / ".cache") / "huggingface"
|
||||
|
||||
|
||||
def _canonical(path: Path | str) -> Path:
|
||||
return Path(path).expanduser().resolve(strict = False)
|
||||
|
||||
|
||||
def _environment_paths() -> Optional[HuggingFaceCachePaths]:
|
||||
explicit_home = _EXPLICIT_CACHE_ENV.get("HF_HOME")
|
||||
explicit_hub = _EXPLICIT_CACHE_ENV.get("HF_HUB_CACHE") or _EXPLICIT_CACHE_ENV.get(
|
||||
"HUGGINGFACE_HUB_CACHE"
|
||||
)
|
||||
if not explicit_home and not explicit_hub:
|
||||
return None
|
||||
explicit_xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
|
||||
default_home = _default_cache_home()
|
||||
hf_home = _canonical(explicit_home) if explicit_home else default_home
|
||||
hub = _canonical(explicit_hub) if explicit_hub else hf_home / "hub"
|
||||
xet = _canonical(explicit_xet) if explicit_xet else hf_home / "xet"
|
||||
controlling = next(
|
||||
key
|
||||
for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "HF_HOME")
|
||||
if key in _EXPLICIT_CACHE_ENV
|
||||
)
|
||||
# Settings describes model downloads, so an explicit hub path is the
|
||||
# displayed/opened location even when HF_HOME points somewhere else for
|
||||
# credentials or XET data.
|
||||
display_home = (
|
||||
(hub.parent if explicit_hub and hub.name.lower() == "hub" else hub)
|
||||
if explicit_hub
|
||||
else hf_home
|
||||
)
|
||||
return HuggingFaceCachePaths(display_home, hub, xet, "environment", controlling)
|
||||
|
||||
|
||||
def _stored_cache_home() -> Optional[Path]:
|
||||
try:
|
||||
from storage.studio_db import get_app_setting
|
||||
value = get_app_setting(CACHE_HOME_SETTING_KEY, None)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return _canonical(value.strip())
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def get_hf_cache_paths() -> HuggingFaceCachePaths:
|
||||
env_paths = _environment_paths()
|
||||
if env_paths is not None:
|
||||
return env_paths
|
||||
stored = _stored_cache_home()
|
||||
if stored is not None:
|
||||
xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
|
||||
return HuggingFaceCachePaths(
|
||||
stored,
|
||||
stored / "hub",
|
||||
_canonical(xet) if xet else stored / "xet",
|
||||
"studio",
|
||||
)
|
||||
home = _default_cache_home()
|
||||
xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
|
||||
return HuggingFaceCachePaths(
|
||||
home,
|
||||
home / "hub",
|
||||
_canonical(xet) if xet else home / "xet",
|
||||
"default",
|
||||
)
|
||||
|
||||
|
||||
def active_hf_hub_cache() -> str:
|
||||
"""Return the current hub cache as a string for library call kwargs."""
|
||||
|
||||
return str(get_hf_cache_paths().hub_cache)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def child_environment_for_spawn(environment: Mapping[str, str]) -> Iterator[None]:
|
||||
"""Apply captured env before spawn imports the child entrypoint.
|
||||
|
||||
Applying variables only inside the multiprocessing target can be too late
|
||||
for libraries that snapshot environment variables at import. The lock keeps
|
||||
this short parent-process override atomic through ``Process.start()``.
|
||||
"""
|
||||
|
||||
with _spawn_env_lock:
|
||||
missing = object()
|
||||
saved_environment: dict[str, str | object] = {}
|
||||
for key, value in environment.items():
|
||||
saved_environment[key] = os.environ.get(key, missing)
|
||||
os.environ[key] = value
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for key, previous in saved_environment.items():
|
||||
if previous is missing:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = str(previous)
|
||||
|
||||
|
||||
def initialize_hf_cache_environment() -> HuggingFaceCachePaths:
|
||||
"""Seed import-time HF variables once during backend startup."""
|
||||
|
||||
paths = get_hf_cache_paths()
|
||||
# Preserve an explicit HF_HOME, otherwise keep credentials at the platform
|
||||
# default while routing cache bytes through the selected home.
|
||||
if not os.environ.get("HF_HOME", "").strip():
|
||||
os.environ["HF_HOME"] = str(_default_cache_home())
|
||||
os.environ["HF_HUB_CACHE"] = str(paths.hub_cache)
|
||||
os.environ["HF_XET_CACHE"] = str(paths.xet_cache)
|
||||
if "HUGGINGFACE_HUB_CACHE" not in _EXPLICIT_CACHE_ENV:
|
||||
os.environ.pop("HUGGINGFACE_HUB_CACHE", None)
|
||||
for directory in (paths.hub_cache, paths.xet_cache):
|
||||
try:
|
||||
directory.mkdir(parents = True, exist_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
return paths
|
||||
|
||||
|
||||
def _validate_cache_home(raw_path: str) -> Path:
|
||||
value = raw_path.strip()
|
||||
if not value:
|
||||
raise ValueError("Choose a cache folder.")
|
||||
candidate = Path(value).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
raise ValueError("The Hugging Face cache folder must be an absolute path.")
|
||||
try:
|
||||
resolved = candidate.resolve(strict = False)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise ValueError("The Hugging Face cache folder is invalid.") from exc
|
||||
|
||||
if resolved.parent == resolved:
|
||||
raise ValueError("Choose a folder inside the filesystem or drive root.")
|
||||
try:
|
||||
from hub.storage.scan_folders import (
|
||||
contains_sensitive_path_component,
|
||||
is_denied_system_path,
|
||||
)
|
||||
except ImportError:
|
||||
contains_sensitive_path_component = is_denied_system_path = None
|
||||
if is_denied_system_path is not None and is_denied_system_path(str(resolved)):
|
||||
raise ValueError("System folders cannot be used for model downloads.")
|
||||
if contains_sensitive_path_component is not None and contains_sensitive_path_component(
|
||||
str(resolved)
|
||||
):
|
||||
raise ValueError("Credential or config folders cannot be used for model downloads.")
|
||||
|
||||
parent = resolved.parent
|
||||
if not parent.exists() or not parent.is_dir():
|
||||
raise ValueError("The parent folder does not exist.")
|
||||
try:
|
||||
resolved.mkdir(exist_ok = True)
|
||||
if not resolved.is_dir():
|
||||
raise ValueError("The selected cache location is not a folder.")
|
||||
for child in (resolved / "hub", resolved / "xet"):
|
||||
child.mkdir(exist_ok = True)
|
||||
with tempfile.NamedTemporaryFile(prefix = ".unsloth-write-test-", dir = child):
|
||||
pass
|
||||
except PermissionError as exc:
|
||||
raise ValueError("Studio does not have permission to write to this folder.") from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Studio cannot use this cache folder: {exc}") from exc
|
||||
return resolved
|
||||
|
||||
|
||||
def _stored_history() -> list[Path]:
|
||||
try:
|
||||
from storage.studio_db import get_app_setting
|
||||
raw = get_app_setting(CACHE_HISTORY_SETTING_KEY, [])
|
||||
except Exception:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
try:
|
||||
path = _canonical(value)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
key = os.path.normcase(str(path))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(path)
|
||||
return out[:MAX_CACHE_HISTORY]
|
||||
|
||||
|
||||
def set_hf_cache_home(cache_home: Optional[str]) -> HuggingFaceCachePaths:
|
||||
if _environment_paths() is not None:
|
||||
raise RuntimeError("The Hugging Face cache location is managed by an environment variable.")
|
||||
with _settings_lock:
|
||||
previous = _stored_cache_home()
|
||||
next_home = _validate_cache_home(cache_home) if cache_home is not None else None
|
||||
history = _stored_history()
|
||||
if previous is not None and previous != next_home:
|
||||
history.insert(0, previous)
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for path in history:
|
||||
key = os.path.normcase(str(path))
|
||||
if key in seen or path == next_home:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(str(path))
|
||||
if len(deduped) >= MAX_CACHE_HISTORY:
|
||||
break
|
||||
from storage.studio_db import upsert_app_settings
|
||||
|
||||
upsert_app_settings(
|
||||
{
|
||||
CACHE_HOME_SETTING_KEY: str(next_home) if next_home is not None else None,
|
||||
CACHE_HISTORY_SETTING_KEY: deduped,
|
||||
}
|
||||
)
|
||||
# Inventory scans are cached independently from settings. Invalidate after
|
||||
# persistence so the next request sees both the new active root and history.
|
||||
from hub.utils.inventory_scan import invalidate_hf_cache_scans
|
||||
|
||||
invalidate_hf_cache_scans()
|
||||
return get_hf_cache_paths()
|
||||
|
||||
|
||||
def known_hf_cache_homes() -> list[Path]:
|
||||
paths = get_hf_cache_paths()
|
||||
stored = _stored_cache_home()
|
||||
candidates: list[Path] = []
|
||||
if paths.source != "environment":
|
||||
candidates.append(paths.cache_home)
|
||||
elif explicit_home := _EXPLICIT_CACHE_ENV.get("HF_HOME"):
|
||||
candidates.append(_canonical(explicit_home))
|
||||
if stored is not None:
|
||||
candidates.append(stored)
|
||||
candidates.extend([*_stored_history(), _default_cache_home()])
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
try:
|
||||
canonical = _canonical(candidate)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
key = os.path.normcase(str(canonical))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(canonical)
|
||||
return out
|
||||
|
||||
|
||||
def known_hf_hub_caches() -> list[Path]:
|
||||
active = get_hf_cache_paths()
|
||||
out = [active.hub_cache]
|
||||
seen = {os.path.normcase(str(_canonical(active.hub_cache)))}
|
||||
for home in known_hf_cache_homes():
|
||||
hub = _canonical(home / "hub")
|
||||
key = os.path.normcase(str(hub))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(hub)
|
||||
return out
|
||||
|
||||
|
||||
def cache_status(paths: Optional[HuggingFaceCachePaths] = None) -> dict:
|
||||
paths = paths or get_hf_cache_paths()
|
||||
available = paths.cache_home.is_dir()
|
||||
writable = available and os.access(paths.cache_home, os.W_OK | os.X_OK)
|
||||
free_bytes: Optional[int] = None
|
||||
if available:
|
||||
try:
|
||||
free_bytes = int(shutil.disk_usage(paths.cache_home).free)
|
||||
except OSError:
|
||||
pass
|
||||
return {
|
||||
"cache_home": str(paths.cache_home),
|
||||
"hub_cache": str(paths.hub_cache),
|
||||
"xet_cache": str(paths.xet_cache),
|
||||
"source": paths.source,
|
||||
"editable": paths.editable,
|
||||
"is_custom": paths.is_custom,
|
||||
"available": available,
|
||||
"writable": writable,
|
||||
"free_bytes": free_bytes,
|
||||
"environment_variable": paths.environment_variable,
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ never triggers the heavy load.
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# Defaults mirror unsloth_zoo.hf_xet_fallback; plain literals so they resolve (including as
|
||||
|
|
@ -262,13 +264,23 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
def _studio_prepare_for_http(repo_type: str, repo_id: str) -> None:
|
||||
def _studio_prepare_for_http(
|
||||
repo_type: str,
|
||||
repo_id: str,
|
||||
*,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Unsloth's marker-aware purge before an HTTP resume, keeping the download manager's ``.transport``
|
||||
accounting consistent (vs unsloth_zoo's generic default). Guarded: a purge failure is logged,
|
||||
not fatal to the retry."""
|
||||
try:
|
||||
from hub.utils.download_registry import prepare_cache_for_transport
|
||||
prepare_cache_for_transport(repo_type, repo_id, "http")
|
||||
prepare_cache_for_transport(
|
||||
repo_type,
|
||||
repo_id,
|
||||
"http",
|
||||
root = Path(cache_dir) if cache_dir else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from loggers import get_logger
|
||||
|
|
@ -293,9 +305,13 @@ def hf_hub_download_with_xet_fallback(
|
|||
grace_period: float = DEFAULT_GRACE_PERIOD,
|
||||
on_status: Optional[Callable[[str], None]] = None,
|
||||
force_download: bool = False,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Single-file download via the shared fallback with Unsloth's marker-aware HTTP-retry prep.
|
||||
``force_download`` re-fetches a newer blob over a cached one (Unsloth's model-update path)."""
|
||||
if cache_dir is None:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
cache_dir = str(get_hf_cache_paths().hub_cache)
|
||||
return _shared_hf_hub_download_with_xet_fallback(
|
||||
repo_id,
|
||||
filename,
|
||||
|
|
@ -308,11 +324,18 @@ def hf_hub_download_with_xet_fallback(
|
|||
grace_period = grace_period,
|
||||
on_status = on_status,
|
||||
force_download = force_download,
|
||||
prepare_for_http_fn = _studio_prepare_for_http,
|
||||
cache_dir = cache_dir,
|
||||
prepare_for_http_fn = partial(_studio_prepare_for_http, cache_dir = cache_dir),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_download_with_xet_fallback(repo_id: str, **kwargs: Any) -> str:
|
||||
"""Whole-repo download via the shared fallback with Unsloth's marker-aware HTTP-retry prep."""
|
||||
kwargs.setdefault("prepare_for_http_fn", _studio_prepare_for_http)
|
||||
if kwargs.get("cache_dir") is None:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
kwargs["cache_dir"] = str(get_hf_cache_paths().hub_cache)
|
||||
kwargs.setdefault(
|
||||
"prepare_for_http_fn",
|
||||
partial(_studio_prepare_for_http, cache_dir = kwargs["cache_dir"]),
|
||||
)
|
||||
return _shared_snapshot_download_with_xet_fallback(repo_id, **kwargs)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import yaml
|
|||
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.hf_cache_settings import active_hf_hub_cache, get_hf_cache_paths
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
|
|
@ -493,6 +494,7 @@ def load_model_config(
|
|||
trust_remote_code = trust_remote_code,
|
||||
token = token,
|
||||
local_files_only = local_files_only,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
|
||||
if not use_auth:
|
||||
|
|
@ -503,6 +505,7 @@ def load_model_config(
|
|||
trust_remote_code = trust_remote_code,
|
||||
token = None,
|
||||
local_files_only = local_files_only,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
|
||||
# Default auth (cached tokens)
|
||||
|
|
@ -510,6 +513,7 @@ def load_model_config(
|
|||
model_name,
|
||||
trust_remote_code = trust_remote_code,
|
||||
local_files_only = local_files_only,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -624,6 +628,7 @@ def _raw_config_has_vision_config(
|
|||
filename = "config.json",
|
||||
token = hf_token,
|
||||
local_files_only = local_files_only,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
)
|
||||
config = json.loads(config_path.read_text())
|
||||
|
|
@ -770,7 +775,7 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
|
|||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
env = child_env_without_native_path_secret(),
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
||||
|
|
@ -1714,19 +1719,20 @@ def _local_gguf_companion_search_root(selected_path: str, gguf_file: str) -> str
|
|||
return str(gguf_dir)
|
||||
|
||||
|
||||
def _iter_hf_cache_snapshots(repo_id: str):
|
||||
def _iter_hf_cache_snapshots(repo_id: str, cache_dir: Optional[str | Path] = None):
|
||||
"""Yield HF cache snapshot dirs for *repo_id*, newest first.
|
||||
|
||||
Empty if HF_HUB_CACHE is missing, the repo isn't cached, or has no
|
||||
snapshots. Repo name match is case-insensitive to handle casing drift
|
||||
between download time and lookup.
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
except Exception:
|
||||
return
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
if cache_dir is None:
|
||||
try:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
cache_dir = get_hf_cache_paths().hub_cache
|
||||
except Exception:
|
||||
return
|
||||
cache_dir = Path(cache_dir)
|
||||
target = f"models--{repo_id.replace('/', '--')}".lower()
|
||||
repo_dirs: list[Path] = []
|
||||
try:
|
||||
|
|
@ -2068,6 +2074,7 @@ def download_gguf_file(
|
|||
repo_id = repo_id,
|
||||
filename = filename,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
return local_path
|
||||
|
||||
|
|
@ -2516,7 +2523,10 @@ def get_base_model_from_lora_identifier(
|
|||
for _attempt in range(2): # one retry: a transient blip must not skip the base
|
||||
try:
|
||||
cfg_path = hf_hub_download(
|
||||
identifier, "adapter_config.json", token = hf_token if hf_token else None
|
||||
identifier,
|
||||
"adapter_config.json",
|
||||
token = hf_token if hf_token else None,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except (EntryNotFoundError, RepositoryNotFoundError):
|
||||
# No adapter_config.json -> not a resolvable LoRA; caller scans the identifier.
|
||||
|
|
@ -2896,7 +2906,12 @@ class ModelConfig:
|
|||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
config_path = hf_hub_download(identifier, "adapter_config.json", token = hf_token)
|
||||
config_path = hf_hub_download(
|
||||
identifier,
|
||||
"adapter_config.json",
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
with open(config_path, "r") as f:
|
||||
adapter_config = json.load(f)
|
||||
base_model = adapter_config.get("base_model_name_or_path")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import base64
|
|||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import stat as _stat_module
|
||||
|
|
@ -35,7 +36,7 @@ _USED_NONCES: dict[str, int] = {}
|
|||
_REDACTION_LOCK = threading.Lock()
|
||||
_NATIVE_PATH_REDACTIONS: list[str] = []
|
||||
_NATIVE_PATH_LABELS: dict[str, str] = {}
|
||||
_NATIVE_PATH_ENV_LOCK = threading.Lock()
|
||||
_NATIVE_PATH_ENV_LOCK = threading.RLock()
|
||||
_SECRET_INIT_LOCK = threading.Lock()
|
||||
_CACHED_LEASE_SECRET: bytes | None = None
|
||||
_SCRUB_REFCOUNT = 0
|
||||
|
|
@ -80,7 +81,9 @@ def child_env_without_native_path_secret(env: Mapping[str, str] | None = None) -
|
|||
return cleaned
|
||||
|
||||
|
||||
def run_without_native_path_secret(target: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
def run_without_native_path_secret(
|
||||
target: Callable[..., Any] | str, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Run a multiprocessing child target without the native path lease secret."""
|
||||
|
||||
# Runs in the spawned child: bind it to the parent's death (Linux), since
|
||||
|
|
@ -96,6 +99,11 @@ def run_without_native_path_secret(target: Callable[..., Any], *args: Any, **kwa
|
|||
os.environ.pop(LEASE_SECRET_ENV, None)
|
||||
_CACHED_LEASE_SECRET = None
|
||||
_SCRUB_SAVED_SECRET = None
|
||||
if isinstance(target, str):
|
||||
function_name, environment, *args = args
|
||||
for key, value in environment.items():
|
||||
os.environ[key] = value
|
||||
target = getattr(importlib.import_module(target), function_name)
|
||||
return target(*args, **kwargs)
|
||||
|
||||
|
||||
|
|
@ -107,10 +115,9 @@ def native_path_secret_removed_for_child_start() -> Iterator[None]:
|
|||
_SCRUB_SAVED_SECRET = os.environ.pop(LEASE_SECRET_ENV, None)
|
||||
_CACHED_LEASE_SECRET = None
|
||||
_SCRUB_REFCOUNT += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _NATIVE_PATH_ENV_LOCK:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_SCRUB_REFCOUNT -= 1
|
||||
if _SCRUB_REFCOUNT == 0 and _SCRUB_SAVED_SECRET is not None:
|
||||
os.environ[LEASE_SECRET_ENV] = _SCRUB_SAVED_SECRET
|
||||
|
|
|
|||
|
|
@ -131,6 +131,29 @@ def linux_run_media_mount_roots(
|
|||
return roots
|
||||
|
||||
|
||||
def macos_volume_roots(base: Path | str = "/Volumes") -> list[Path]:
|
||||
"""Readable mounted volumes for the macOS folder browser."""
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return []
|
||||
base_path = Path(base)
|
||||
try:
|
||||
entries = list(base_path.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
roots: list[Path] = []
|
||||
for entry in entries:
|
||||
if is_sensitive_path_component(entry.name):
|
||||
continue
|
||||
try:
|
||||
resolved = entry.resolve()
|
||||
if resolved.is_dir() and os.access(resolved, os.R_OK | os.X_OK):
|
||||
roots.append(resolved)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def _active_windows_drive_bitmask() -> int:
|
||||
"""Active-logical-drive bitmask from ``GetLogicalDrives`` (bit 0 = ``A:``), or ``0`` when unavailable.
|
||||
|
||||
|
|
|
|||
|
|
@ -122,15 +122,8 @@ def is_model_cached(model_name: str) -> bool:
|
|||
|
||||
def _hf_hub_cache_dir() -> Path:
|
||||
"""Return HF cache root honoring HF_HUB_CACHE when available."""
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
return Path(HF_HUB_CACHE)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not read huggingface_hub HF_HUB_CACHE, using default hub path: %s",
|
||||
exc,
|
||||
)
|
||||
return Path.home() / ".cache" / "huggingface" / "hub"
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
return get_hf_cache_paths().hub_cache
|
||||
|
||||
|
||||
def resolve_cached_repo_id_case(model_name: str, use_memo: bool = True) -> str:
|
||||
|
|
|
|||
|
|
@ -277,27 +277,15 @@ def well_known_model_dirs() -> list[Path]:
|
|||
def _setup_cache_env() -> None:
|
||||
"""Set cache env vars for HuggingFace, uv, and vLLM.
|
||||
|
||||
Respects the standard HF cache chain (explicit HF_HOME / HF_HUB_CACHE,
|
||||
then XDG_CACHE_HOME, then ~/.cache/huggingface) and only sets vars the
|
||||
user hasn't, so explicit overrides are honored. A user-set HF_HOME also
|
||||
seeds HF_HUB_CACHE / HF_XET_CACHE (HF defaults them to $HF_HOME/hub and
|
||||
$HF_HOME/xet); without this, models download to and load from the standard
|
||||
cache even when HF_HOME points elsewhere, and both the Xet and HTTP-fallback
|
||||
download paths inherit the same wrong root.
|
||||
Explicit Hugging Face environment variables take precedence over Studio's
|
||||
stored location. Studio seeds import-time variables once, while each later
|
||||
worker receives its own captured cache location.
|
||||
"""
|
||||
root = cache_root()
|
||||
xdg_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")).expanduser()
|
||||
# HUGGINGFACE_HUB_CACHE is HF's legacy alias for HF_HUB_CACHE; honor it.
|
||||
if "HF_HUB_CACHE" not in os.environ and os.environ.get("HUGGINGFACE_HUB_CACHE"):
|
||||
os.environ["HF_HUB_CACHE"] = os.environ["HUGGINGFACE_HUB_CACHE"]
|
||||
# Seed the hub/xet caches from HF_HOME when set, else the platform default.
|
||||
# Strip so a blank/whitespace HF_HOME falls back instead of making " /hub".
|
||||
hf_home = (os.environ.get("HF_HOME") or "").strip()
|
||||
hf_base = Path(hf_home).expanduser() if hf_home else xdg_cache / "huggingface"
|
||||
from utils.hf_cache_settings import initialize_hf_cache_environment
|
||||
|
||||
initialize_hf_cache_environment()
|
||||
defaults: dict[str, str] = {
|
||||
"HF_HOME": str(hf_base),
|
||||
"HF_HUB_CACHE": str(hf_base / "hub"),
|
||||
"HF_XET_CACHE": str(hf_base / "xet"),
|
||||
"UV_CACHE_DIR": str(root / "uv"),
|
||||
"VLLM_CACHE_ROOT": str(root / "vllm"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,11 +147,17 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
|
|||
|
||||
from huggingface_hub import hf_hub_download
|
||||
from huggingface_hub.utils import EntryNotFoundError
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
configs = []
|
||||
for name in _REMOTE_CODE_CONFIG_FILES:
|
||||
try:
|
||||
p = hf_hub_download(repo_id = model_name, filename = name, token = hf_token)
|
||||
p = hf_hub_download(
|
||||
repo_id = model_name,
|
||||
filename = name,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except EntryNotFoundError:
|
||||
continue # genuine 404 -> truly absent
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ def _indexed_shard_paths(
|
|||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
from huggingface_hub.utils import EntryNotFoundError
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -166,7 +167,12 @@ def _indexed_shard_paths(
|
|||
for prefix in _index_prefixes(load_subdirs):
|
||||
for filename in _TRANSFORMERS_INDEX_FILES:
|
||||
try:
|
||||
index_path = hf_hub_download(model_name, prefix + filename, token = hf_token or None)
|
||||
index_path = hf_hub_download(
|
||||
model_name,
|
||||
prefix + filename,
|
||||
token = hf_token or None,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except EntryNotFoundError:
|
||||
continue # definitively absent, not an error
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from dataclasses import dataclass, field
|
|||
from typing import Optional
|
||||
|
||||
from loggers import get_logger
|
||||
from utils.hf_cache_settings import active_hf_hub_cache
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -455,7 +456,12 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
|
|||
refs = set()
|
||||
for cfg_name in REMOTE_CODE_CONFIG_FILES:
|
||||
try:
|
||||
cfg_path = hf_hub_download(model_name, cfg_name, token = hf_token)
|
||||
cfg_path = hf_hub_download(
|
||||
model_name,
|
||||
cfg_name,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except EntryNotFoundError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
|
|
@ -499,7 +505,12 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
|
|||
wanted = present_py | (own_refs & repo_file_set)
|
||||
for fn in sorted(wanted):
|
||||
try:
|
||||
fp = hf_hub_download(model_name, fn, token = hf_token)
|
||||
fp = hf_hub_download(
|
||||
model_name,
|
||||
fn,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except Exception as exc:
|
||||
# A .py CONFIRMED PRESENT could not be fetched. A partial set would
|
||||
# fingerprint "clean" while transformers later runs this file, so fail
|
||||
|
|
@ -602,7 +613,12 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
|
|||
|
||||
for cfg_name in REMOTE_CODE_CONFIG_FILES:
|
||||
try:
|
||||
cfg_path = hf_hub_download(model_name, cfg_name, token = hf_token)
|
||||
cfg_path = hf_hub_download(
|
||||
model_name,
|
||||
cfg_name,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except EntryNotFoundError:
|
||||
continue
|
||||
except Exception:
|
||||
|
|
@ -670,7 +686,12 @@ def _add_external_refs(files: dict, refs, hf_token, model_name: str) -> bool:
|
|||
wanted = present_py | set(entry_files)
|
||||
for fn in sorted(wanted):
|
||||
try:
|
||||
fp = hf_hub_download(repo, fn, token = hf_token)
|
||||
fp = hf_hub_download(
|
||||
repo,
|
||||
fn,
|
||||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"repo_remote_code_files(%s): external %s:%s unscannable (%s)",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import time
|
|||
from pathlib import Path
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
|
|
@ -516,13 +517,10 @@ def _adapter_base_from_hf_cache(model_name: str) -> str | None:
|
|||
"""
|
||||
if not _is_canonical_repo_id(model_name):
|
||||
return None
|
||||
hub = (
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.path.join(
|
||||
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"), "hub"
|
||||
)
|
||||
)
|
||||
# Route through the selected cache: after a no-restart /settings switch the
|
||||
# process HF_HUB_CACHE env is stale, but the model loads from the selected
|
||||
# cache, which get_hf_cache_paths() reflects (the DB switch).
|
||||
hub = str(get_hf_cache_paths().hub_cache)
|
||||
repo_dir = Path(hub) / ("models--" + model_name.replace("/", "--"))
|
||||
candidates = []
|
||||
ref_main = repo_dir / "refs" / "main"
|
||||
|
|
@ -681,13 +679,10 @@ def _config_json_from_hf_cache(model_name: str) -> dict | None:
|
|||
# Only a canonical ``owner/repo`` Hub id maps to a cache dir; reject local paths.
|
||||
if not model_name or model_name.count("/") != 1 or model_name[0] in "/.~" or "\\" in model_name:
|
||||
return None
|
||||
hub = (
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.path.join(
|
||||
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"), "hub"
|
||||
)
|
||||
)
|
||||
# Route through the selected cache: after a no-restart /settings switch the
|
||||
# process HF_HUB_CACHE env is stale, but the model loads from the selected
|
||||
# cache, which get_hf_cache_paths() reflects (the DB switch).
|
||||
hub = str(get_hf_cache_paths().hub_cache)
|
||||
repo_dir = Path(hub) / ("models--" + model_name.replace("/", "--"))
|
||||
candidates = []
|
||||
ref_main = repo_dir / "refs" / "main"
|
||||
|
|
@ -1251,7 +1246,7 @@ def _probe_autoconfig(target_dir: str, model_name: str, hf_token: str | None) ->
|
|||
True = parses, False = parse/version failure (escalate), None = transient
|
||||
(auth/network/offline/spawn) so the caller fails safe and does not cache.
|
||||
"""
|
||||
env = child_env_without_native_path_secret()
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret())
|
||||
if hf_token:
|
||||
env["HF_TOKEN"] = hf_token
|
||||
# The probe relies on the implicit HF_TOKEN env (no token= arg). Clear any inherited
|
||||
|
|
@ -1806,7 +1801,7 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
|
|
@ -1829,7 +1824,7 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
|
|
@ -2458,7 +2453,7 @@ def _ensure_venv_llmcompressor_exists() -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
last_out = result.stdout or ""
|
||||
|
|
|
|||
|
|
@ -53,19 +53,41 @@ def _expand_path(raw: str) -> Path:
|
|||
|
||||
|
||||
def _hf_cache_roots() -> list:
|
||||
"""The one cache root the loader resolves to, by its own precedence (it picks ONE
|
||||
cache_folder, no fall-through): SENTENCE_TRANSFORMERS_HOME, else HF_HUB_CACHE, else
|
||||
HF_HOME/hub, else ~/.cache/huggingface/hub. Expanded, read from env, one-element list."""
|
||||
st_home = os.environ.get("SENTENCE_TRANSFORMERS_HOME")
|
||||
if st_home:
|
||||
return [_expand_path(st_home)]
|
||||
hub = os.environ.get("HF_HUB_CACHE") or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
if hub:
|
||||
return [_expand_path(hub)]
|
||||
hf_home = os.environ.get("HF_HOME")
|
||||
if hf_home:
|
||||
return [_expand_path(hf_home) / "hub"]
|
||||
return [Path.home() / ".cache" / "huggingface" / "hub"]
|
||||
"""Cache roots to search for a model's local snapshot, most-authoritative first.
|
||||
|
||||
The app's selected hub cache (set via /settings) is searched first: after a
|
||||
no-restart cache switch the process env is stale, yet the loader reads the
|
||||
selected cache via ``cache_folder=active_hf_hub_cache()``, so the snapshot
|
||||
and offline security lookups must match where it actually loads. The env
|
||||
precedence (SENTENCE_TRANSFORMERS_HOME, HF_HUB_CACHE, HF_HOME/hub,
|
||||
~/.cache/huggingface/hub) follows so a copy still in a previous cache resolves."""
|
||||
roots: list = []
|
||||
seen: set = set()
|
||||
|
||||
def _add(path) -> None:
|
||||
if path is None:
|
||||
return
|
||||
expanded = _expand_path(str(path))
|
||||
key = str(expanded)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
roots.append(expanded)
|
||||
|
||||
try:
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
_add(get_hf_cache_paths().hub_cache)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if st_home := os.environ.get("SENTENCE_TRANSFORMERS_HOME"):
|
||||
_add(st_home)
|
||||
if hub := (os.environ.get("HF_HUB_CACHE") or os.environ.get("HUGGINGFACE_HUB_CACHE")):
|
||||
_add(hub)
|
||||
if hf_home := os.environ.get("HF_HOME"):
|
||||
_add(_expand_path(hf_home) / "hub")
|
||||
if not roots:
|
||||
_add(Path.home() / ".cache" / "huggingface" / "hub")
|
||||
return roots
|
||||
|
||||
|
||||
def hf_cache_snapshot_dir(model_name: str) -> Optional[Path]:
|
||||
|
|
|
|||
|
|
@ -1404,6 +1404,7 @@ const GGUF_KNOWN_QUANT_RE =
|
|||
|
||||
type AutoLoadCandidate = {
|
||||
id: string;
|
||||
loadId?: string | null;
|
||||
kind: LastLocalModelKind;
|
||||
ggufVariant: string | null;
|
||||
maxSeqLength: number;
|
||||
|
|
@ -1530,6 +1531,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
return false;
|
||||
}
|
||||
const currentStore = useChatRuntimeStore.getState();
|
||||
const modelPath = candidate.loadId ?? candidate.id;
|
||||
const { config } = resolveInitialConfig(candidate.id, candidate.ggufVariant);
|
||||
const effectiveMaxSeqLength = resolveLoadMaxSeqLength({
|
||||
modelId: candidate.id,
|
||||
|
|
@ -1582,7 +1584,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
: null;
|
||||
if (
|
||||
!(await canAutoLoad({
|
||||
model_path: candidate.id,
|
||||
model_path: modelPath,
|
||||
max_seq_length: fitMaxSeqLength,
|
||||
is_lora: false,
|
||||
gguf_variant: candidate.ggufVariant,
|
||||
|
|
@ -1602,7 +1604,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
}
|
||||
loadAttempts += 1;
|
||||
const loadResp = await loadModel({
|
||||
model_path: candidate.id,
|
||||
model_path: modelPath,
|
||||
hf_token: hfToken,
|
||||
max_seq_length: fitMaxSeqLength,
|
||||
load_in_4bit: true,
|
||||
|
|
@ -1635,9 +1637,10 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
}
|
||||
// Self-gates on is_gguf (skips diffusion), so persists only for a real GGUF load.
|
||||
persistGpuMemoryModeOnLoad(loadResp, effectiveGpuMemoryMode);
|
||||
const loadedModelId = loadResp.model || modelPath;
|
||||
useChatRuntimeStore
|
||||
.getState()
|
||||
.setCheckpoint(candidate.id, candidate.ggufVariant ?? undefined);
|
||||
.setCheckpoint(loadedModelId, candidate.ggufVariant ?? undefined);
|
||||
const store = useChatRuntimeStore.getState();
|
||||
store.setModelRequiresTrustRemoteCode(
|
||||
loadResp.requires_trust_remote_code ?? false,
|
||||
|
|
@ -1653,7 +1656,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
: effectiveMaxSeqLength,
|
||||
});
|
||||
const autoModel: ChatModelSummary = {
|
||||
id: candidate.id,
|
||||
id: loadedModelId,
|
||||
name: loadResp.display_name ?? candidate.id,
|
||||
isVision: loadResp.is_vision ?? false,
|
||||
isLora: loadResp.is_lora ?? false,
|
||||
|
|
@ -1662,7 +1665,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
audioType: loadResp.audio_type ?? null,
|
||||
hasAudioInput: loadResp.has_audio_input ?? false,
|
||||
};
|
||||
if (!store.models.some((m) => m.id === candidate.id)) {
|
||||
if (!store.models.some((m) => m.id === loadedModelId)) {
|
||||
store.setModels([...store.models, autoModel]);
|
||||
}
|
||||
if (candidate.kind === "gguf") {
|
||||
|
|
@ -1749,7 +1752,10 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
const repo = findCachedRepo(ggufRepos, lastLoaded.id);
|
||||
if (repo && lastLoaded.ggufVariant) {
|
||||
try {
|
||||
const variants = await listGgufVariants(repo.repo_id);
|
||||
const variants = await listGgufVariants(repo.repo_id, undefined, {
|
||||
preferLocalCache: true,
|
||||
localPath: repo.cache_path,
|
||||
});
|
||||
const variant = variants.variants.find(
|
||||
(entry) =>
|
||||
entry.downloaded &&
|
||||
|
|
@ -1766,6 +1772,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
loadId: repo.load_id,
|
||||
kind: "gguf",
|
||||
ggufVariant: variant.quant,
|
||||
maxSeqLength: 0,
|
||||
|
|
@ -1794,6 +1801,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
loadId: repo.load_id,
|
||||
kind: "model",
|
||||
ggufVariant: null,
|
||||
maxSeqLength: store.params.maxSeqLength,
|
||||
|
|
@ -1823,7 +1831,10 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
for (const repo of sorted) {
|
||||
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break;
|
||||
try {
|
||||
const variants = await listGgufVariants(repo.repo_id);
|
||||
const variants = await listGgufVariants(repo.repo_id, undefined, {
|
||||
preferLocalCache: true,
|
||||
localPath: repo.cache_path,
|
||||
});
|
||||
const downloaded = variants.variants
|
||||
.filter((v) => v.downloaded && isAutoLoadableGgufVariant(v))
|
||||
.sort((a, b) => a.size_bytes - b.size_bytes);
|
||||
|
|
@ -1839,6 +1850,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
loadId: repo.load_id,
|
||||
kind: "gguf",
|
||||
ggufVariant: variant.quant,
|
||||
maxSeqLength: 0,
|
||||
|
|
@ -1873,6 +1885,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
loadId: repo.load_id,
|
||||
kind: "model",
|
||||
ggufVariant: null,
|
||||
maxSeqLength: 4096,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export async function resolveToolConfirmation(
|
|||
|
||||
export interface CachedGgufRepo {
|
||||
repo_id: string;
|
||||
load_id?: string | null;
|
||||
size_bytes: number;
|
||||
cache_path: string;
|
||||
/** Epoch seconds of the newest downloaded quant; sorts Downloaded
|
||||
|
|
@ -352,24 +353,28 @@ export async function listLocalModels(
|
|||
export async function listCachedGguf(
|
||||
signal?: AbortSignal,
|
||||
): Promise<CachedGgufRepo[]> {
|
||||
const response = await authFetch("/api/models/cached-gguf", { signal });
|
||||
const response = await authFetch("/api/hub/cached-gguf", { signal });
|
||||
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);
|
||||
return data.cached;
|
||||
}
|
||||
|
||||
export interface CachedModelRepo {
|
||||
repo_id: string;
|
||||
load_id?: string | null;
|
||||
size_bytes: number;
|
||||
/** Epoch seconds of the newest downloaded weight file; sorts Downloaded
|
||||
* newest-first. Optional for older-backend compatibility. */
|
||||
last_modified?: number;
|
||||
/** Owning cache dir; sent so a delete targets this copy, not the active
|
||||
* cache. Optional for older-backend compatibility. */
|
||||
cache_path?: string | null;
|
||||
}
|
||||
|
||||
export async function listCachedModels(
|
||||
hfToken?: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CachedModelRepo[]> {
|
||||
const response = await authFetch("/api/models/cached-models", {
|
||||
const response = await authFetch("/api/hub/cached-models", {
|
||||
headers: hubTokenHeader(hfToken),
|
||||
signal,
|
||||
});
|
||||
|
|
@ -920,8 +925,19 @@ export async function browseFolders(
|
|||
export async function listGgufVariants(
|
||||
repoId: string,
|
||||
hfToken?: string,
|
||||
options?: {
|
||||
preferLocalCache?: boolean;
|
||||
localPath?: string | null;
|
||||
},
|
||||
): Promise<GgufVariantsResponse> {
|
||||
const params = new URLSearchParams({ repo_id: repoId });
|
||||
if (options?.preferLocalCache) {
|
||||
params.set("prefer_local_cache", "true");
|
||||
}
|
||||
const localPath = options?.localPath?.trim();
|
||||
if (localPath) {
|
||||
params.set("local_path", localPath);
|
||||
}
|
||||
const response = await authFetch(`/api/models/gguf-variants?${params}`, {
|
||||
headers: hubTokenHeader(hfToken),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function DatasetDownloadSection({
|
|||
const hfToken = useHfTokenStore((s) => s.token);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const { deleting, runDelete } = useCardDelete({
|
||||
action: () => deleteCachedDataset(repoId),
|
||||
action: () => deleteCachedDataset(repoId, cachePath ?? undefined),
|
||||
resourceName: "dataset",
|
||||
successMessage: () => `Deleted ${repoId}`,
|
||||
onSuccess: () => {
|
||||
|
|
|
|||
|
|
@ -765,7 +765,12 @@ export function GgufDownloadCard({
|
|||
const { deleting, runDelete } = useDeleteConfirmAction({
|
||||
action: async () => {
|
||||
if (!deleteTarget) return;
|
||||
await deleteCachedModel(repoId, deleteTarget, hfToken || undefined);
|
||||
await deleteCachedModel(
|
||||
repoId,
|
||||
deleteTarget,
|
||||
hfToken || undefined,
|
||||
cachePath ?? undefined,
|
||||
);
|
||||
},
|
||||
successMessage: () =>
|
||||
`Deleted ${repoId} ${deleteTargetLabel ?? deleteTarget}`,
|
||||
|
|
|
|||
|
|
@ -247,7 +247,10 @@ export function LocalOnDeviceCard({
|
|||
const { deleting, runDelete } = useCardDelete({
|
||||
action: async () => {
|
||||
if (!repoId) return;
|
||||
await deleteCachedModel(repoId, undefined, hfToken || undefined);
|
||||
// Delete is only offered for hf_cache rows (see canDelete), so `path` is
|
||||
// the cache snapshot path: pass it so the delete targets the cache this
|
||||
// card shows instead of falling back to the active cache.
|
||||
await deleteCachedModel(repoId, undefined, hfToken || undefined, path);
|
||||
},
|
||||
resourceName: "model",
|
||||
successMessage: () => `Deleted ${repoId}`,
|
||||
|
|
|
|||
|
|
@ -768,10 +768,19 @@ export const InventoryRow = memo(function InventoryRow({
|
|||
),
|
||||
successMessage: `Deleted ${cacheDeletableRepoId}`,
|
||||
onConfirm: async () => {
|
||||
// Delete only the copy this row shows: cache rows carry the owning
|
||||
// cache path, so pass it through and leave other caches untouched.
|
||||
const rowCachePath =
|
||||
row.kind === "cache" ? (row.cachePath ?? undefined) : undefined;
|
||||
if (isDataset) {
|
||||
await deleteCachedDataset(cacheDeletableRepoId);
|
||||
await deleteCachedDataset(cacheDeletableRepoId, rowCachePath);
|
||||
} else {
|
||||
await deleteCachedModel(cacheDeletableRepoId);
|
||||
await deleteCachedModel(
|
||||
cacheDeletableRepoId,
|
||||
undefined,
|
||||
undefined,
|
||||
rowCachePath,
|
||||
);
|
||||
// Deleted repos can't stay pinned: drop the repo pin and any of
|
||||
// its per-quant pins so stale rows don't linger up top.
|
||||
const { pinned, togglePinned: toggle } =
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export function SafetensorsDownloadCard({
|
|||
canRun = true,
|
||||
isActive,
|
||||
isLoadingThisModel,
|
||||
cachePath,
|
||||
knownBytes,
|
||||
onLoad,
|
||||
onEject,
|
||||
|
|
@ -75,7 +76,7 @@ export function SafetensorsDownloadCard({
|
|||
canRun?: boolean;
|
||||
isActive: boolean;
|
||||
isLoadingThisModel: boolean;
|
||||
/** Accepted for API parity; the options menu resolves the path itself. */
|
||||
/** Owning cache dir, threaded into delete so it targets this copy. */
|
||||
cachePath?: string | null;
|
||||
knownBytes?: number | null;
|
||||
onLoad: (opts: { ggufVariant?: string; expectedBytes?: number }) => void;
|
||||
|
|
@ -100,7 +101,8 @@ export function SafetensorsDownloadCard({
|
|||
: null;
|
||||
const [deleteRepoOpen, setDeleteRepoOpen] = useState(false);
|
||||
const { deleting, runDelete } = useCardDelete({
|
||||
action: () => deleteCachedModel(repoId, undefined, hfToken || undefined),
|
||||
action: () =>
|
||||
deleteCachedModel(repoId, undefined, hfToken || undefined, cachePath ?? undefined),
|
||||
resourceName: "model",
|
||||
successMessage: () => `Deleted ${repoId}`,
|
||||
onSuccess: () => {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,12 @@ function localResource(
|
|||
? "cached"
|
||||
: "local";
|
||||
const id =
|
||||
row.source === "hf_cache" && repoId && !row.partial ? repoId : row.loadId;
|
||||
row.source === "hf_cache" &&
|
||||
row.activeCache !== false &&
|
||||
repoId &&
|
||||
!row.partial
|
||||
? repoId
|
||||
: row.loadId;
|
||||
return {
|
||||
repoId,
|
||||
localPath: row.path,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export interface LocalModelInfo {
|
|||
capabilities?: BackendModelCapabilities | null;
|
||||
source: LocalSource;
|
||||
model_id?: string | null;
|
||||
active_cache?: boolean | null;
|
||||
base_model?: string | null;
|
||||
base_model_source?: BaseModelSource | null;
|
||||
adapter_type?: string | null;
|
||||
|
|
@ -258,11 +259,18 @@ export async function listCachedDatasets(): Promise<CachedDatasetRepo[]> {
|
|||
return data.cached;
|
||||
}
|
||||
|
||||
export async function deleteCachedDataset(repoId: string): Promise<void> {
|
||||
export async function deleteCachedDataset(
|
||||
repoId: string,
|
||||
cachePath?: string | null,
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = { repo_id: repoId };
|
||||
if (cachePath) {
|
||||
payload.cache_path = cachePath;
|
||||
}
|
||||
const response = await authFetch("/api/hub/datasets/cached", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ repo_id: repoId }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await throwIfNotOk(response, `Failed to delete dataset (${response.status})`);
|
||||
bumpInventoryVersion();
|
||||
|
|
@ -272,11 +280,17 @@ export async function deleteCachedModel(
|
|||
repoId: string,
|
||||
variant?: string,
|
||||
hfToken?: string | null,
|
||||
cachePath?: string | null,
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = { repo_id: repoId };
|
||||
if (variant) {
|
||||
payload.variant = variant;
|
||||
}
|
||||
// Scope the delete to the exact cache this row represents so copies in other,
|
||||
// previously selected caches are not removed.
|
||||
if (cachePath) {
|
||||
payload.cache_path = cachePath;
|
||||
}
|
||||
const response = await authFetch("/api/hub/delete-cached", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...hubTokenHeader(hfToken) },
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export interface LocalInventoryRow {
|
|||
updatedAt: number | null;
|
||||
partial?: boolean;
|
||||
partialTransport?: string | null;
|
||||
activeCache?: boolean | null;
|
||||
pipelineTag?: string | null;
|
||||
tags?: string[];
|
||||
libraryName?: string | null;
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ export function buildLocalInventoryRows(
|
|||
updatedAt: normalizeTimestamp(model.updated_at),
|
||||
partial: model.partial ?? false,
|
||||
partialTransport: model.partial_transport ?? null,
|
||||
activeCache: model.active_cache ?? null,
|
||||
pipelineTag: model.pipeline_tag ?? null,
|
||||
tags: model.tags,
|
||||
libraryName: model.library_name ?? null,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export interface FolderBrowserProps {
|
|||
onSelect: (path: string) => void;
|
||||
/** Optional initial directory. Defaults to the user's home on the server. */
|
||||
initialPath?: string;
|
||||
title?: string;
|
||||
confirmLabel?: string;
|
||||
showModelHints?: boolean;
|
||||
}
|
||||
|
||||
function splitBreadcrumb(path: string): { label: string; value: string }[] {
|
||||
|
|
@ -78,6 +81,9 @@ export function FolderBrowser({
|
|||
onOpenChange,
|
||||
onSelect,
|
||||
initialPath,
|
||||
title = "Select folder to detect models",
|
||||
confirmLabel = "Use this folder",
|
||||
showModelHints = true,
|
||||
}: FolderBrowserProps) {
|
||||
const [data, setData] = useState<BrowseFoldersResponse | null>(null);
|
||||
const [path, setPath] = useState<string | undefined>(initialPath);
|
||||
|
|
@ -150,7 +156,7 @@ export function FolderBrowser({
|
|||
data-testid="folder-browser-dialog"
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>Select folder to detect models</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
|
|
@ -230,12 +236,13 @@ export function FolderBrowser({
|
|||
</button>
|
||||
)}
|
||||
{data.entries.length === 0 &&
|
||||
!(data.model_files_here && data.model_files_here > 0) && (
|
||||
(!showModelHints ||
|
||||
!(data.model_files_here && data.model_files_here > 0)) && (
|
||||
<div className="px-6 py-3 text-xs text-muted-foreground/60">
|
||||
(empty directory)
|
||||
</div>
|
||||
)}
|
||||
{data.model_files_here !== undefined &&
|
||||
{showModelHints && data.model_files_here !== undefined &&
|
||||
data.model_files_here > 0 && (
|
||||
<div className="border-t border-border/30 px-6 py-1.5 text-ui-10 text-foreground/70">
|
||||
{data.model_files_here} model file
|
||||
|
|
@ -272,7 +279,7 @@ export function FolderBrowser({
|
|||
)}
|
||||
/>
|
||||
<span className="truncate font-mono">{e.name}</span>
|
||||
{e.has_models && (
|
||||
{showModelHints && e.has_models && (
|
||||
<span className="ml-auto shrink-0 rounded-full border border-border/50 px-1.5 py-0 text-ui-9 uppercase tracking-wider text-muted-foreground">
|
||||
models
|
||||
</span>
|
||||
|
|
@ -312,7 +319,7 @@ export function FolderBrowser({
|
|||
onClick={handleConfirm}
|
||||
disabled={!path || loading || !!error}
|
||||
>
|
||||
Use this folder
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -2912,7 +2912,12 @@ export function HubModelPicker({
|
|||
updateGgufVariant(c.repo_id, quant, expectedBytes),
|
||||
updateDisabled: loadedModelId === c.repo_id,
|
||||
onDelete: async (quant) => {
|
||||
await deleteCachedModel(c.repo_id, quant, hfToken || undefined);
|
||||
await deleteCachedModel(
|
||||
c.repo_id,
|
||||
quant,
|
||||
hfToken || undefined,
|
||||
c.cache_path || undefined,
|
||||
);
|
||||
prunePinnedQuantValidation(c.repo_id, quant);
|
||||
refreshCachedLists();
|
||||
},
|
||||
|
|
@ -2990,7 +2995,12 @@ export function HubModelPicker({
|
|||
successMessage: `Deleted ${c.repo_id}`,
|
||||
disabled: deleteDisabled,
|
||||
onConfirm: async () => {
|
||||
await deleteCachedModel(c.repo_id, undefined, hfToken || undefined);
|
||||
await deleteCachedModel(
|
||||
c.repo_id,
|
||||
undefined,
|
||||
hfToken || undefined,
|
||||
c.cache_path || undefined,
|
||||
);
|
||||
if (pinnedSet.has(pinKey(c.repo_id))) {
|
||||
togglePinned(c.repo_id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ export async function pickNativeModel(): Promise<NativeIntent | null> {
|
|||
return invokeNative<NativeIntent | null>("pick_native_model");
|
||||
}
|
||||
|
||||
export async function pickHuggingFaceCacheDir(): Promise<string | null> {
|
||||
if (!isTauri) return null;
|
||||
return invokeNative<string | null>("pick_hugging_face_cache_dir");
|
||||
}
|
||||
|
||||
export async function registerNativeModelPath(path: string): Promise<NativeIntent> {
|
||||
return invokeNative<NativeIntent>("register_native_model_path", { path });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
export { NativeModelChip } from "./components/native-model-chip";
|
||||
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
|
||||
export { openModelsDir } from "./api";
|
||||
export { openModelsDir, pickHuggingFaceCacheDir } from "./api";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
export type { NativeIntent } from "./types";
|
||||
export { useChooseNativeModel } from "./use-native-dialogs";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import {
|
||||
bumpInventoryVersion,
|
||||
invalidateGgufVariantsCache,
|
||||
} from "@/features/hub";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
export type HuggingFaceCacheSettings = {
|
||||
cacheHome: string;
|
||||
hubCache: string;
|
||||
xetCache: string;
|
||||
source: "default" | "studio" | "environment";
|
||||
editable: boolean;
|
||||
isCustom: boolean;
|
||||
available: boolean;
|
||||
writable: boolean;
|
||||
freeBytes: number | null;
|
||||
environmentVariable: string | null;
|
||||
};
|
||||
|
||||
type ApiHuggingFaceCacheSettings = {
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
cache_home: string;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
hub_cache: string;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
xet_cache: string;
|
||||
source: HuggingFaceCacheSettings["source"];
|
||||
editable: boolean;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
is_custom: boolean;
|
||||
available: boolean;
|
||||
writable: boolean;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
free_bytes: number | null;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
environment_variable: string | null;
|
||||
};
|
||||
|
||||
function fromApi(value: ApiHuggingFaceCacheSettings): HuggingFaceCacheSettings {
|
||||
return {
|
||||
cacheHome: value.cache_home,
|
||||
hubCache: value.hub_cache,
|
||||
xetCache: value.xet_cache,
|
||||
source: value.source,
|
||||
editable: value.editable,
|
||||
isCustom: value.is_custom,
|
||||
available: value.available,
|
||||
writable: value.writable,
|
||||
freeBytes: value.free_bytes,
|
||||
environmentVariable: value.environment_variable,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadHuggingFaceCacheSettings() {
|
||||
const response = await authFetch("/api/settings/hugging-face-cache");
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readFastApiError(
|
||||
response,
|
||||
"Failed to load the model cache location",
|
||||
),
|
||||
);
|
||||
}
|
||||
return fromApi(await response.json());
|
||||
}
|
||||
|
||||
export async function updateHuggingFaceCacheSettings(cacheHome: string | null) {
|
||||
const response = await authFetch("/api/settings/hugging-face-cache", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
body: JSON.stringify({ cache_home: cacheHome }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readFastApiError(
|
||||
response,
|
||||
"Failed to update the model cache location",
|
||||
),
|
||||
);
|
||||
}
|
||||
const settings = fromApi(await response.json());
|
||||
bumpInventoryVersion();
|
||||
invalidateGgufVariantsCache();
|
||||
return settings;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
export type ModelsFolder = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
// The path is resolved once at backend startup and never changes, so cache it
|
||||
// and dedupe concurrent loads (same shape as the sibling settings loaders).
|
||||
let cachedModelsFolder: ModelsFolder | null = null;
|
||||
let inFlightModelsFolder: Promise<ModelsFolder> | null = null;
|
||||
|
||||
async function fetchModelsFolder(): Promise<ModelsFolder> {
|
||||
const res = await authFetch("/api/hub/models-folder");
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await readFastApiError(res, "Failed to load models folder"),
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { path: string };
|
||||
return { path: data.path };
|
||||
}
|
||||
|
||||
export async function loadModelsFolder(): Promise<ModelsFolder> {
|
||||
if (cachedModelsFolder) {
|
||||
return cachedModelsFolder;
|
||||
}
|
||||
inFlightModelsFolder ??= fetchModelsFolder()
|
||||
.then((folder) => {
|
||||
cachedModelsFolder = folder;
|
||||
return folder;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightModelsFolder = null;
|
||||
});
|
||||
return inFlightModelsFolder;
|
||||
}
|
||||
|
|
@ -16,8 +16,6 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"settings.general.huggingFaceToken",
|
||||
"settings.general.gettingStarted",
|
||||
"settings.general.startOnboarding",
|
||||
"settings.general.storage.sectionTitle",
|
||||
"settings.general.storage.modelsFolder",
|
||||
"settings.appearance.language.title",
|
||||
"settings.appearance.language.label",
|
||||
"settings.general.notifications.sectionTitle",
|
||||
|
|
@ -71,6 +69,7 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"settings.resources.gpu.title",
|
||||
"settings.resources.storage.title",
|
||||
"settings.resources.storage.modelsFolder",
|
||||
"settings.resources.storage.futureDownloads",
|
||||
"settings.resources.storage.systemDisk",
|
||||
"settings.resources.environment.title",
|
||||
"settings.resources.environment.backend",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { PermissionModeDropdown, useChatRuntimeStore } from "@/features/chat";
|
||||
import { openModelsDir } from "@/features/native-intents";
|
||||
import { emitTrainingRunsChanged } from "@/features/training";
|
||||
import {
|
||||
setShowLlamaUpdateBanner,
|
||||
|
|
@ -24,7 +23,6 @@ import {
|
|||
import { useHfTokenValidation } from "@/hooks";
|
||||
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
|
|
@ -43,7 +41,6 @@ import {
|
|||
loadHelperPrecacheSettings,
|
||||
updateHelperPrecacheSettings,
|
||||
} from "../api/helper-precache";
|
||||
import { type ModelsFolder, loadModelsFolder } from "../api/models-folder";
|
||||
import {
|
||||
type PreviewSharingSettings,
|
||||
loadPreviewSharing,
|
||||
|
|
@ -183,7 +180,6 @@ export function GeneralTab() {
|
|||
const [isSavingPreviewSharing, setIsSavingPreviewSharing] = useState(false);
|
||||
const [revokePreviewOpen, setRevokePreviewOpen] = useState(false);
|
||||
const [isRevokingPreview, setIsRevokingPreview] = useState(false);
|
||||
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
|
||||
const [embeddingModel, setEmbeddingModel] =
|
||||
useState<EmbeddingModelSettings | null>(null);
|
||||
const [draftEmbeddingModel, setDraftEmbeddingModel] = useState("");
|
||||
|
|
@ -317,43 +313,6 @@ export function GeneralTab() {
|
|||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadModelsFolder()
|
||||
.then((folder) => {
|
||||
if (cancelled) return;
|
||||
setModelsFolder(folder);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-critical: leave the row hidden if the path can't be resolved.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Desktop opens the folder in the OS file manager; the browser can't, so it
|
||||
// falls back to copying the path (which is the info users actually want).
|
||||
const handleModelsFolder = async () => {
|
||||
const folder = modelsFolder;
|
||||
if (!folder) return;
|
||||
if (isTauri) {
|
||||
try {
|
||||
await openModelsDir(folder.path);
|
||||
} catch (error) {
|
||||
toast.error(t("settings.general.storage.openError"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await copyToClipboard(folder.path)) {
|
||||
toast.success(t("settings.general.storage.copied"));
|
||||
} else {
|
||||
toast.error(t("settings.general.storage.copyError"));
|
||||
}
|
||||
};
|
||||
|
||||
const saveHelperPrecache = async (enabled: boolean) => {
|
||||
setIsSavingHelperPrecache(true);
|
||||
setHelperPrecacheError(null);
|
||||
|
|
@ -588,33 +547,6 @@ export function GeneralTab() {
|
|||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{modelsFolder ? (
|
||||
<SettingsSection title={t("settings.general.storage.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.storage.modelsFolder")}
|
||||
description={t("settings.general.storage.modelsFolderDescription")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
title={modelsFolder.path}
|
||||
className="max-w-[280px] truncate font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{modelsFolder.path}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleModelsFolder()}
|
||||
>
|
||||
{isTauri
|
||||
? t("settings.general.storage.openAction")
|
||||
: t("settings.general.storage.copyAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
<SettingsSection title={t("settings.appearance.language.title")}>
|
||||
<SettingsRow
|
||||
label={t("settings.appearance.language.label")}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { openModelsDir } from "@/features/native-intents";
|
||||
import { FolderBrowser } from "@/features/model-picker";
|
||||
import {
|
||||
openModelsDir,
|
||||
pickHuggingFaceCacheDir,
|
||||
} from "@/features/native-intents";
|
||||
import { useSystemInfo, type GpuDevice } from "@/hooks/use-system";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
|
|
@ -12,11 +17,15 @@ import { toast } from "@/lib/toast";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { useT } from "@/i18n";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { loadModelsFolder, type ModelsFolder } from "../api/models-folder";
|
||||
import {
|
||||
type HuggingFaceCacheSettings,
|
||||
loadHuggingFaceCacheSettings,
|
||||
updateHuggingFaceCacheSettings,
|
||||
} from "../api/hugging-face-cache";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
import { useMonitorOverlayStore } from "../stores/monitor-overlay-store";
|
||||
import { LayersIcon } from "lucide-react";
|
||||
import { CopyIcon, FolderOpenIcon, LayersIcon } from "lucide-react";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
|
|
@ -47,6 +56,12 @@ function formatGb(value: number | null | undefined): string {
|
|||
return `${safe.toFixed(digits)} GB`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | null): string | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
const gib = value / 1024 ** 3;
|
||||
return `${gib >= 10 ? gib.toFixed(1) : gib.toFixed(2)} GiB`;
|
||||
}
|
||||
|
||||
// RAM/VRAM come from the backend in binary units (bytes / 1024**3), matching
|
||||
// nvidia-smi and PyTorch, so label those readouts GiB. Disk stays on formatGb
|
||||
// because the backend reports disk in decimal GB (bytes / 1e9).
|
||||
|
|
@ -165,20 +180,22 @@ export function ResourcesTab() {
|
|||
enabled: liveUpdates,
|
||||
pollMs: liveUpdates ? POLL_MS : undefined,
|
||||
});
|
||||
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
|
||||
const [modelsFolderLoaded, setModelsFolderLoaded] = useState(false);
|
||||
const [hfCache, setHfCache] = useState<HuggingFaceCacheSettings | null>(null);
|
||||
const [hfCacheLoaded, setHfCacheLoaded] = useState(false);
|
||||
const [cacheBrowserOpen, setCacheBrowserOpen] = useState(false);
|
||||
const [cacheSaving, setCacheSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadModelsFolder()
|
||||
.then((folder) => {
|
||||
void loadHuggingFaceCacheSettings()
|
||||
.then((settings) => {
|
||||
if (cancelled) return;
|
||||
setModelsFolder(folder);
|
||||
setModelsFolderLoaded(true);
|
||||
setHfCache(settings);
|
||||
setHfCacheLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setModelsFolderLoaded(true);
|
||||
setHfCacheLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -237,12 +254,11 @@ export function ResourcesTab() {
|
|||
};
|
||||
}, [systemInfo]);
|
||||
|
||||
const handleModelsFolder = async () => {
|
||||
const folder = modelsFolder;
|
||||
if (!folder) return;
|
||||
const handleCacheFolder = async () => {
|
||||
if (!hfCache) return;
|
||||
if (isTauri) {
|
||||
try {
|
||||
await openModelsDir(folder.path);
|
||||
await openModelsDir(hfCache.cacheHome);
|
||||
} catch (error) {
|
||||
toast.error(t("settings.resources.storage.openError"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
|
|
@ -250,13 +266,43 @@ export function ResourcesTab() {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (await copyToClipboard(folder.path)) {
|
||||
if (await copyToClipboard(hfCache.cacheHome)) {
|
||||
toast.success(t("settings.resources.storage.copied"));
|
||||
} else {
|
||||
toast.error(t("settings.resources.storage.copyError"));
|
||||
}
|
||||
};
|
||||
|
||||
const saveCacheFolder = async (path: string | null) => {
|
||||
setCacheSaving(true);
|
||||
try {
|
||||
const settings = await updateHuggingFaceCacheSettings(path);
|
||||
setHfCache(settings);
|
||||
toast.success(t("settings.resources.storage.cacheSaved"));
|
||||
} catch (error) {
|
||||
toast.error(t("settings.resources.storage.cacheSaveError"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setCacheSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeCacheFolder = async () => {
|
||||
if (!isTauri) {
|
||||
setCacheBrowserOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const path = await pickHuggingFaceCacheDir();
|
||||
if (path) await saveCacheFolder(path);
|
||||
} catch (error) {
|
||||
toast.error(t("settings.resources.storage.cachePickerError"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cpuCoresLabel =
|
||||
systemInfo.cpu?.logical_count && systemInfo.cpu?.physical_count
|
||||
? t("settings.resources.liveMonitor.cpuCores", {
|
||||
|
|
@ -270,11 +316,27 @@ export function ResourcesTab() {
|
|||
const backendLabel = (
|
||||
systemInfo.gpu?.backend ?? systemInfo.device_backend ?? "cpu"
|
||||
).toUpperCase();
|
||||
const modelsFolderPath = modelsFolder
|
||||
? modelsFolder.path
|
||||
: modelsFolderLoaded
|
||||
const modelsFolderPath = hfCache
|
||||
? hfCache.cacheHome
|
||||
: hfCacheLoaded
|
||||
? t("settings.resources.environment.unknown")
|
||||
: t("common.loading");
|
||||
const cacheLocationDetail = hfCache
|
||||
? hfCache.source === "environment"
|
||||
? t("settings.resources.storage.environmentManaged", {
|
||||
variable: hfCache.environmentVariable ?? "HF_HOME",
|
||||
})
|
||||
: [
|
||||
t("settings.resources.storage.futureDownloads"),
|
||||
hfCache.freeBytes !== null
|
||||
? t("settings.resources.storage.locationFree", {
|
||||
free: formatBytes(hfCache.freeBytes) ?? "",
|
||||
})
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: null;
|
||||
const unknownLabel = t("settings.resources.environment.unknown");
|
||||
|
||||
return (
|
||||
|
|
@ -467,29 +529,86 @@ export function ResourcesTab() {
|
|||
<SettingsRow
|
||||
label={t("settings.resources.storage.modelsFolder")}
|
||||
description={t("settings.resources.storage.modelsFolderDescription")}
|
||||
className="max-sm:flex-col max-sm:items-start max-sm:gap-2"
|
||||
className="max-[840px]:flex-col max-[840px]:items-stretch max-[840px]:gap-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 max-sm:max-w-[calc(100vw-5rem)]">
|
||||
<span
|
||||
title={modelsFolder?.path}
|
||||
className="min-w-0 max-w-[280px] truncate font-mono text-xs text-muted-foreground max-sm:max-w-[180px]"
|
||||
>
|
||||
{modelsFolderPath}
|
||||
</span>
|
||||
<div className="grid w-[392px] min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-2 gap-y-1.5 max-[840px]:w-full">
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
readOnly
|
||||
aria-label={t("settings.resources.storage.modelsFolder")}
|
||||
value={modelsFolderPath}
|
||||
title={hfCache?.cacheHome}
|
||||
className="h-8 w-full pr-7 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hfCache}
|
||||
onClick={() => void handleCacheFolder()}
|
||||
aria-label={
|
||||
isTauri
|
||||
? t("settings.resources.storage.openAction")
|
||||
: t("settings.resources.storage.copyAction")
|
||||
}
|
||||
title={
|
||||
isTauri
|
||||
? t("settings.resources.storage.openAction")
|
||||
: t("settings.resources.storage.copyAction")
|
||||
}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{isTauri ? (
|
||||
<FolderOpenIcon className="size-3.5" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!modelsFolder}
|
||||
onClick={() => void handleModelsFolder()}
|
||||
className="h-8"
|
||||
disabled={!hfCache?.editable || cacheSaving}
|
||||
onClick={() => void changeCacheFolder()}
|
||||
>
|
||||
{isTauri
|
||||
? t("settings.resources.storage.openAction")
|
||||
: t("settings.resources.storage.copyAction")}
|
||||
{t("settings.resources.storage.changeAction")}
|
||||
</Button>
|
||||
{cacheLocationDetail || hfCache?.isCustom ? (
|
||||
<div className="col-span-2 flex min-w-0 items-center justify-between gap-2 pl-3.5 pr-1 text-xs text-muted-foreground">
|
||||
{cacheLocationDetail ? (
|
||||
<span
|
||||
title={cacheLocationDetail}
|
||||
className="min-w-0 truncate"
|
||||
>
|
||||
{cacheLocationDetail}
|
||||
</span>
|
||||
) : null}
|
||||
{hfCache?.isCustom ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto px-0 text-xs"
|
||||
disabled={cacheSaving}
|
||||
onClick={() => void saveCacheFolder(null)}
|
||||
>
|
||||
{t("settings.resources.storage.resetAction")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<FolderBrowser
|
||||
open={!isTauri && cacheBrowserOpen}
|
||||
onOpenChange={setCacheBrowserOpen}
|
||||
onSelect={(path) => void saveCacheFolder(path)}
|
||||
initialPath={hfCache?.cacheHome}
|
||||
title={t("settings.resources.storage.chooseTitle")}
|
||||
confirmLabel={t("settings.resources.storage.chooseAction")}
|
||||
showModelHints={false}
|
||||
/>
|
||||
|
||||
<SettingsSection title={t("settings.resources.environment.title")}>
|
||||
<InfoRow
|
||||
label={t("settings.resources.environment.backend")}
|
||||
|
|
|
|||
|
|
@ -491,10 +491,20 @@ export const en = {
|
|||
systemDisk: "System disk",
|
||||
diskUsage: "{used} used / {total}",
|
||||
diskFree: "{free} free",
|
||||
modelsFolder: "Models folder",
|
||||
modelsFolderDescription: "Where downloaded models are stored.",
|
||||
modelsFolder: "Model downloads",
|
||||
modelsFolderDescription: "Hugging Face cache used for model downloads.",
|
||||
futureDownloads: "New downloads only",
|
||||
environmentManaged: "Managed by the {variable} environment variable.",
|
||||
locationFree: "{free} free",
|
||||
openAction: "Open",
|
||||
copyAction: "Copy path",
|
||||
changeAction: "Change",
|
||||
resetAction: "Use default",
|
||||
chooseTitle: "Choose model download location",
|
||||
chooseAction: "Use for future downloads",
|
||||
cacheSaved: "Model download location updated",
|
||||
cacheSaveError: "Couldn't update the model download location",
|
||||
cachePickerError: "Couldn't open the folder picker",
|
||||
copied: "Path copied",
|
||||
openError: "Couldn't open the folder",
|
||||
copyError: "Couldn't copy the path",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ fn main() {
|
|||
native_intents::drain_native_intents,
|
||||
native_intents::register_native_model_path,
|
||||
native_intents::pick_native_model,
|
||||
native_intents::pick_hugging_face_cache_dir,
|
||||
native_intents::consume_native_path_token,
|
||||
native_intents::register_artifact_path,
|
||||
native_intents::reveal_path_token,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,25 @@ use tauri_plugin_dialog::DialogExt;
|
|||
|
||||
const TOKEN_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
fn normalize_windows_verbatim_path(path: String) -> String {
|
||||
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
|
||||
return format!(r"\\{rest}");
|
||||
}
|
||||
path.strip_prefix(r"\\?\").unwrap_or(&path).to_string()
|
||||
}
|
||||
|
||||
fn portable_path_string(path: &Path) -> String {
|
||||
let value = path.to_string_lossy().to_string();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return normalize_windows_verbatim_path(value);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct NativePathEntry {
|
||||
token: String,
|
||||
|
|
@ -311,6 +330,34 @@ pub async fn pick_native_model(
|
|||
.map(Some)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_hugging_face_cache_dir(
|
||||
window: WebviewWindow,
|
||||
app: AppHandle,
|
||||
) -> Result<Option<String>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_title("Choose model download location")
|
||||
.pick_folder(move |path| {
|
||||
let _ = tx.send(path);
|
||||
});
|
||||
let Some(folder_path) = rx.await.map_err(|_| "Dialog closed".to_string())? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = folder_path
|
||||
.into_path()
|
||||
.map_err(|_| "Only local filesystem folders are supported.".to_string())?;
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Could not use the selected folder: {e}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("The selected location is not a folder.".to_string());
|
||||
}
|
||||
Ok(Some(portable_path_string(&canonical)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn consume_native_path_token(
|
||||
window: WebviewWindow,
|
||||
|
|
@ -451,6 +498,18 @@ mod tests {
|
|||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_verbatim_paths_are_portable() {
|
||||
assert_eq!(
|
||||
normalize_windows_verbatim_path(r"\\?\C:\models\cache".to_string()),
|
||||
r"C:\models\cache"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_windows_verbatim_path(r"\\?\UNC\server\share\cache".to_string()),
|
||||
r"\\server\share\cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reveal_rejects_symlink_replacement() {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,19 @@ def test_compare_load_clears_stale_native_lease():
|
|||
assert "activeNativePathExpiresAtMs: null" in src
|
||||
|
||||
|
||||
def test_autoload_records_backend_loaded_model_identity():
|
||||
"""An inactive-cache inventory row loads by local path, so startup autoload
|
||||
must key both the active checkpoint and its summary by the backend's loaded
|
||||
model identity instead of the catalog repo id."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
autoload = src.split("async function loadAutoLoadCandidate", 1)[1]
|
||||
autoload = autoload.split("\n try {", 1)[0]
|
||||
assert "const loadedModelId = loadResp.model || modelPath" in autoload
|
||||
assert "setCheckpoint(loadedModelId," in autoload
|
||||
assert "id: loadedModelId" in autoload
|
||||
assert "m.id === loadedModelId" in autoload
|
||||
|
||||
|
||||
def test_rollback_restores_native_lease_expiry_with_token():
|
||||
"""A failed model switch that rolls back to a previously loaded picked GGUF
|
||||
must restore the lease expiry paired with the token, never the token alone
|
||||
|
|
@ -248,6 +261,30 @@ def test_pinned_validation_uses_cached_local_variant_listing():
|
|||
assert "bumpInventoryVersion(" in delete_fn
|
||||
|
||||
|
||||
def test_chat_autoload_scopes_variant_lookup_to_cached_repo_path():
|
||||
"""Autoload must probe the exact cache row it will load, including rows
|
||||
retained from a previously selected Hugging Face cache."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadSmallestModel", 1)[1]
|
||||
assert auto_load.count("preferLocalCache: true") >= 2
|
||||
assert auto_load.count("localPath: repo.cache_path") >= 2
|
||||
|
||||
chat_api = _read("features/chat/api/chat-api.ts")
|
||||
variants_fn = chat_api.split("export async function listGgufVariants", 1)[1]
|
||||
variants_fn = variants_fn.split("export interface KvCacheEstimate", 1)[0]
|
||||
assert 'params.set("prefer_local_cache", "true")' in variants_fn
|
||||
assert 'params.set("local_path", localPath)' in variants_fn
|
||||
|
||||
|
||||
def test_cache_location_update_invalidates_frontend_inventory():
|
||||
"""A successful cache switch must refresh both inventory rows and cached
|
||||
GGUF variant results before any stale active-cache identity can be reused."""
|
||||
src = _read("features/settings/api/hugging-face-cache.ts")
|
||||
update_fn = src.split("export async function updateHuggingFaceCacheSettings", 1)[1]
|
||||
assert "bumpInventoryVersion();" in update_fn
|
||||
assert "invalidateGgufVariantsCache();" in update_fn
|
||||
|
||||
|
||||
def test_downloaded_list_offsets_virtual_rows():
|
||||
"""The On Device virtualized list sits below the Pinned block in the same
|
||||
scroll element, so it must pass its measured offset as scrollMargin or rows
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue