unsloth/studio/backend/hub/services/models/downloads.py
Michael Han 93572648b6
Studio Hub: default downloads to Xet transport (#6433)
* Studio Hub: default downloads to Xet transport

Model and dataset downloads defaulted to HTTP; flip the default to Xet for faster parallel chunked transfers.

- Frontend: DEFAULT_TRANSPORT_MODE is now Xet, so a user with no saved preference starts on Xet. effectiveTransportMode() already downgrades to HTTP and warns when hf_xet is unavailable, so this degrades gracefully.
- Backend: DownloadModelRequest.use_xet and DownloadDatasetRequest.use_xet default to True, keeping the API in step with the UI. Set use_xet=False for sequential HTTP Range-resume.
- Align the internal _spawn_download_worker default so no caller silently falls back to HTTP.

Inference and training model loads were already Xet-first with an HTTP stall fallback, so this brings explicit downloads in line with the rest of Studio.

* Studio Hub: gracefully fall back to HTTP when Xet is unavailable

With Xet now the default, an omitted or explicit use_xet=True from a non-UI API caller would 400 on installs without hf_xet, since resolve_transport raises when the transport is unavailable.

Add resolve_effective_use_xet(), which downgrades a Xet request to HTTP (with a warning) when hf_xet is missing, mirroring the frontend's own downgrade. Both the model and dataset flows now derive a single effective use_xet and feed it to resolve_transport and spawn_worker, so the recorded transport and the worker env can never disagree. The UI is unaffected: it already resolves availability and passes use_xet explicitly.

* Add tests for resolve_effective_use_xet Xet to HTTP fallback

* Trim comments for PR #6433

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-18 04:39:07 -07:00

412 lines
14 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Download orchestration."""
from __future__ import annotations
import asyncio
from typing import Optional, TYPE_CHECKING
from fastapi import HTTPException
from loggers import get_logger
from hub.schemas.downloads import (
ActiveDownloadsResponse,
CancelDownloadRequest,
DownloadJobStatus,
DownloadModelRequest,
)
from hub.utils import download_registry
from hub.utils import download_manifest
from hub.utils import inventory_scan as hf_cache_scan
from hub.utils.hf_cache_state import has_active_incomplete_blobs
from hub.utils.paths import (
is_valid_gguf_variant as _is_valid_gguf_variant,
is_valid_repo_id as _is_valid_repo_id,
resolve_cached_repo_id_case,
)
from hub.services import snapshot_progress
from hub.services import download_lifecycle
from hub.services.models import cache_inventory, gguf_variants
logger = get_logger(__name__)
if TYPE_CHECKING:
import subprocess
_registry = download_registry.get_models_registry()
def _download_job_key(repo_id: str, variant: Optional[str]) -> str:
return download_registry.normalize_job_key(
f"{download_registry.normalize_repo_key(repo_id)}::{variant or ''}"
)
def _job_status(
key: str,
*,
repo_id: Optional[str] = None,
variant: Optional[str] = None,
) -> DownloadJobStatus:
state, error, generation = download_lifecycle.idle_status(
_registry,
key,
repo_type = "model",
repo_id = repo_id,
variant = variant,
)
return DownloadJobStatus(state = state, error = error, generation = generation)
def _spawn_download_worker(
repo_id: str,
variant: Optional[str],
hf_token: Optional[str],
use_xet: bool = True,
protected_blob_hashes: Optional[frozenset[str]] = None,
) -> subprocess.Popen:
args = ["--repo-id", repo_id]
if variant:
args.extend(["--variant", variant])
return download_lifecycle.spawn_worker(
args,
hf_token,
use_xet = use_xet,
protected_blob_hashes = protected_blob_hashes,
)
async def download_model_response(body: DownloadModelRequest, hf_token: Optional[str] = None):
"""Start a background download for a HuggingFace model."""
repo_id = body.repo_id.strip()
if not _is_valid_repo_id(repo_id):
raise HTTPException(
status_code = 400,
detail = f"Invalid repo_id: {repo_id!r}",
)
# Canonicalize so two different-cased paste-ins share one job + cache dir.
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
variant = (body.gguf_variant or "").strip() or None
if variant is not None and not _is_valid_gguf_variant(variant):
raise HTTPException(
status_code = 400,
detail = f"Invalid gguf_variant: {variant!r}",
)
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)
variant_blob_hashes = frozenset()
variant_progress_blob_hashes = frozenset()
completed_baseline_bytes = 0
if variant is not None:
try:
variant_blob_hashes = await asyncio.to_thread(
gguf_variants.gguf_variant_blob_hashes,
repo_id,
variant,
hf_token,
include_companions = False,
)
variant_progress_blob_hashes = await asyncio.to_thread(
gguf_variants.gguf_variant_blob_hashes,
repo_id,
variant,
hf_token,
include_companions = True,
)
except Exception as e:
logger.warning(
"GGUF hash pre-resolution failed for %s [%s]; continuing without "
"a completed-bytes baseline or peer-protection hashes (the worker "
"re-resolves its own blobs before purging): %s",
repo_id,
variant,
download_registry.scrub_secrets(str(e), hf_token = hf_token),
)
has_variant_resume_state = (
download_manifest.has_cancel_marker("model", repo_id, variant)
or download_manifest.read_manifest("model", repo_id, variant) is not None
)
if variant_progress_blob_hashes and not has_variant_resume_state:
completed_baseline_bytes = await asyncio.to_thread(
download_registry.completed_blob_bytes,
"model",
repo_id,
variant_progress_blob_hashes,
)
claimed, claim_state = _registry.claim(
key,
transport,
repo_type = "model",
repo_id = repo_id,
variant = variant,
blob_hashes = variant_blob_hashes,
progress_blob_hashes = variant_progress_blob_hashes,
completed_baseline_bytes = completed_baseline_bytes,
)
generation = _registry.current_generation(key)
if not claimed:
# claim_state is the blocking job's state. The client can attach only
# when the blocker is this key's own in-flight job (adoptable); a
# cross-variant conflict or in-progress delete is not accepted.
return {
"job_key": key,
"state": claim_state,
"accepted": _registry.adoptable(key),
"generation": generation,
}
download_manifest.clear_cancel_marker("model", repo_id, variant)
# 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()
label = f"{repo_id}{f' [{variant}]' if variant else ''}"
state = download_lifecycle.launch_worker(
_registry,
key,
spawn = lambda: _spawn_download_worker(
repo_id,
variant,
hf_token,
use_xet = use_xet,
protected_blob_hashes = protected_blob_hashes,
),
hf_token = hf_token,
label = label,
log_prefix = "Download",
logger = logger,
repo_type = "model",
repo_id = repo_id,
transport = transport,
watch_name = f"hf-download-watch-{repo_id}",
)
return {
"job_key": key,
"state": state,
"accepted": True,
"generation": generation,
}
async def cancel_download_model_response(body: CancelDownloadRequest):
"""Cancel an in-flight model download (SIGKILL; HF cache resumes on next download)."""
repo_id = body.repo_id.strip()
if not _is_valid_repo_id(repo_id):
raise HTTPException(
status_code = 400,
detail = f"Invalid repo_id: {repo_id!r}",
)
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
variant = (body.gguf_variant or "").strip() or None
if variant is not None and not _is_valid_gguf_variant(variant):
raise HTTPException(
status_code = 400,
detail = f"Invalid gguf_variant: {variant!r}",
)
key = _download_job_key(repo_id, variant)
state = download_lifecycle.cancel_worker(
_registry,
key,
generation = body.generation,
label = repo_id,
logger = logger,
)
return {"job_key": key, "state": state}
async def get_download_status_response(repo_id: str, gguf_variant: str = "") -> DownloadJobStatus:
"""Return the latest state of a background download job."""
repo_id = repo_id.strip()
if not _is_valid_repo_id(repo_id):
return DownloadJobStatus(state = "idle")
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
variant = (gguf_variant or "").strip() or None
key = _download_job_key(repo_id, variant)
return _job_status(key, repo_id = repo_id, variant = variant)
async def get_active_downloads_response(repo_id: str = "") -> ActiveDownloadsResponse:
"""Return every in-flight download for a repo in a single call."""
repo_id = repo_id.strip()
if repo_id and not _is_valid_repo_id(repo_id):
return ActiveDownloadsResponse(downloads = [])
canonical_repo_id = (
await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
if repo_id
else None
)
return ActiveDownloadsResponse(
downloads = download_lifecycle.active_download_refs(
_registry,
canonical_repo_id,
with_variant = True,
)
)
def _variant_transport_status(repo_id: str, variant: str, hf_token: Optional[str]) -> dict:
incomplete_hashes = download_registry.incomplete_blob_hashes(
"model",
repo_id,
active_only = True,
)
variant_hashes = gguf_variants.gguf_variant_blob_hashes(
repo_id,
variant,
hf_token,
allow_remote = False,
)
has_partial = hf_cache_scan.is_variant_partial(
repo_id,
variant,
incomplete_blob_hashes = incomplete_hashes,
variant_blob_hashes = variant_hashes,
)
last_transport = hf_cache_scan.partial_transport_for("model", repo_id, variant)
if (
last_transport is None
and has_partial
and incomplete_hashes
and variant_hashes
and incomplete_hashes.intersection(variant_hashes)
):
last_transport = download_registry.read_active_transport_marker(
"model",
repo_id,
variant,
)
has_matching_incomplete = bool(
incomplete_hashes and variant_hashes and incomplete_hashes.intersection(variant_hashes)
)
return {
"has_partial": has_partial,
"last_transport": last_transport,
"resumable": (
has_matching_incomplete and last_transport == download_registry.TRANSPORT_HTTP
),
}
async def get_model_transport_status_response(
repo_id: str,
gguf_variant: str = "",
hf_token: Optional[str] = None,
) -> dict:
"""Return last transport used for this repo + whether any partial blobs
exist + whether that partial supports byte-level resume.
``resumable`` is True only when an HTTP partial exists. XET partials
are reported via ``has_partial`` but always have ``resumable=False``
because ``hf_xet`` rewrites the destination from scratch on every
call (network resume happens transparently via its chunk cache).
"""
repo_id = repo_id.strip()
if not _is_valid_repo_id(repo_id):
return {"has_partial": False, "last_transport": None, "resumable": False}
variant = (gguf_variant or "").strip()
if variant:
if not _is_valid_gguf_variant(variant):
return {"has_partial": False, "last_transport": None, "resumable": False}
return _variant_transport_status(repo_id, variant, hf_token)
return {
"has_partial": has_active_incomplete_blobs("model", repo_id),
"last_transport": download_registry.read_active_transport_marker("model", repo_id),
"resumable": download_registry.is_resumable_partial("model", repo_id),
}
async def get_gguf_download_progress_response(
repo_id: str,
variant: str = "",
expected_bytes: int = 0,
hf_token: Optional[str] = None,
) -> dict:
"""Return download progress for a specific GGUF variant."""
expected_total = max(expected_bytes, 0)
progress_variant = variant.strip() or None
if progress_variant is not None and not _is_valid_gguf_variant(progress_variant):
return {
"downloaded_bytes": 0,
"completed_bytes": 0,
"complete_on_disk": False,
"expected_bytes": expected_total,
"progress": 0,
"cache_path": None,
}
def _metadata_resolver(
resolved_repo_id: str, token: Optional[str]
) -> tuple[int, frozenset[str]]:
if progress_variant is None:
return expected_total, frozenset()
requirement = gguf_variants.gguf_variant_requirements(
resolved_repo_id,
progress_variant,
token,
)
if requirement is not None:
return requirement.download_size_bytes, requirement.required_hashes
manifest = download_manifest.read_manifest(
"model",
resolved_repo_id,
progress_variant,
)
if manifest is not None:
return (
sum(max(0, int(file.size or 0)) for file in manifest.expected_files),
frozenset(file.sha256 for file in manifest.expected_files if file.sha256),
)
return (
expected_total,
gguf_variants.gguf_variant_blob_hashes(
resolved_repo_id,
progress_variant,
token,
allow_remote = False,
),
)
return await snapshot_progress.snapshot_progress_response(
repo_type = "model",
repo_id = repo_id,
job_key = _download_job_key(repo_id, progress_variant),
expected_bytes = expected_total,
hf_token = hf_token,
registry = _registry,
metadata_resolver = _metadata_resolver,
variant = progress_variant,
)
async def get_download_progress_response(
repo_id: str,
expected_bytes: int = 0,
hf_token: Optional[str] = None,
) -> dict:
"""Return download progress for any HuggingFace model repo.
Checks the local HF cache for completed blobs and in-progress
(.incomplete) downloads. Uses the caller-supplied expected total
when available; otherwise queries HF metadata and caches it.
Also returns ``cache_path``: the realpath of the snapshot directory
(or the cache repo root if no snapshot exists yet) so the UI can
show users where the weights actually live on disk.
"""
return await snapshot_progress.snapshot_progress_response(
repo_type = "model",
repo_id = repo_id,
job_key = _download_job_key(repo_id, None),
expected_bytes = expected_bytes,
hf_token = hf_token,
registry = _registry,
metadata_resolver = cache_inventory.get_repo_snapshot_metadata_cached,
)
registry = _registry