Compare commits
40 commits
main
...
studio-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eabeff5aea | ||
|
|
571a0ef35f | ||
|
|
3026ff9e79 | ||
|
|
10bd745d1f | ||
|
|
ed52464558 | ||
|
|
b107b01a4a | ||
|
|
00566662c6 | ||
|
|
224b90eb02 | ||
|
|
efa72431cc | ||
|
|
35cd77e6d8 | ||
|
|
db64cd2ab4 | ||
|
|
1dce5373aa | ||
|
|
d68330080b | ||
|
|
370195c702 | ||
|
|
aa959faea5 | ||
|
|
37d11a967e | ||
|
|
90f9e97302 | ||
|
|
16b67a2dc3 | ||
|
|
d57ca52b83 | ||
|
|
184075a383 | ||
|
|
b7b37e165c | ||
|
|
d594c2389a | ||
|
|
1d582a8781 | ||
|
|
43a1edc2ea | ||
|
|
4791969bc9 | ||
|
|
d74e7066d2 | ||
|
|
cd62c3a0ba | ||
|
|
71341a1046 | ||
|
|
bc9a5f7f86 | ||
|
|
69c614e2e8 | ||
|
|
08cb60c6e1 | ||
|
|
a928900a2e | ||
|
|
5aa0183706 | ||
|
|
44964067ab | ||
|
|
bf46d8bb60 | ||
|
|
db7855538a | ||
|
|
c4f182a581 | ||
|
|
5d06d64a26 | ||
|
|
939ea605ad | ||
|
|
36268b8444 |
20 changed files with 2899 additions and 286 deletions
|
|
@ -432,6 +432,14 @@ def render_native_template(
|
|||
if native_tpl is None:
|
||||
# A LoRA adapter's native template lives on the base model, not the adapter id.
|
||||
template_source = model_info.get("base_model") or active_model_name
|
||||
# A local-only (background) load resolved its weights to a local
|
||||
# snapshot; the template reload must read the SAME files. For a
|
||||
# non-LoRA model prefer the stored load path over the repo id, and
|
||||
# either way pass local_files_only so a cache miss fails the fallback
|
||||
# instead of downloading tokenizer files mid-generation.
|
||||
local_files_only = bool(model_info.get("local_files_only", False))
|
||||
if local_files_only and not model_info.get("base_model"):
|
||||
template_source = model_info.get("model_path") or template_source
|
||||
# Re-use the load-time trust_remote_code so a custom-code tokenizer repo can
|
||||
# instantiate its class (the stored flag already covers template_source).
|
||||
trust_remote_code = bool(model_info.get("trust_remote_code", False))
|
||||
|
|
@ -441,6 +449,7 @@ def render_native_template(
|
|||
template_source,
|
||||
token = hf_token if hf_token and hf_token.strip() else None,
|
||||
trust_remote_code = trust_remote_code,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
native_tpl = nt.chat_template or False
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ class InferenceBackend:
|
|||
hf_token: Optional[str] = None,
|
||||
trust_remote_code: bool = False,
|
||||
gpu_ids: Optional[list[int]] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> bool:
|
||||
"""Load any model: base, LoRA adapter, text, or vision."""
|
||||
# Keep the token so the native-template fallback can fetch a
|
||||
|
|
@ -364,6 +365,9 @@ class InferenceBackend:
|
|||
"trust_remote_code": trust_remote_code,
|
||||
"is_vision": config.is_vision,
|
||||
"is_lora": config.is_lora,
|
||||
# Local-only loads: generation-time repo fallbacks (native
|
||||
# template reload) must also resolve from cache, not the Hub.
|
||||
"local_files_only": local_files_only,
|
||||
"is_audio": config.is_audio,
|
||||
"audio_type": config.audio_type,
|
||||
"has_audio_input": config.has_audio_input,
|
||||
|
|
@ -562,7 +566,11 @@ class InferenceBackend:
|
|||
isinstance(processor, ProcessorMixin) or hasattr(processor, "image_processor")
|
||||
):
|
||||
# LoRA adapters: use base model. Local merged exports: read base from export_metadata.json.
|
||||
processor_source = config.base_model if config.is_lora else config.identifier
|
||||
# Non-LoRA: config.path, not config.identifier. They are the
|
||||
# same for ordinary loads, but a local-only load rewrites
|
||||
# path to the cached snapshot and this fallback must stay
|
||||
# on those local files instead of refetching by repo id.
|
||||
processor_source = config.base_model if config.is_lora else config.path
|
||||
if not config.is_lora and config.is_local:
|
||||
_meta_path = Path(config.path) / "export_metadata.json"
|
||||
try:
|
||||
|
|
@ -578,10 +586,14 @@ class InferenceBackend:
|
|||
)
|
||||
from transformers import AutoProcessor
|
||||
|
||||
# Local-only loads: a LoRA base or export_metadata base_model
|
||||
# is a Hub repo id; resolve it from cache or fail the load
|
||||
# (candidate failover) instead of downloading the processor.
|
||||
processor = AutoProcessor.from_pretrained(
|
||||
processor_source,
|
||||
token = hf_token if hf_token and hf_token.strip() else None,
|
||||
trust_remote_code = trust_remote_code,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
logger.info(f"Loaded {type(processor).__name__} from {processor_source}")
|
||||
|
||||
|
|
|
|||
|
|
@ -515,29 +515,84 @@ def _hf_env_offline() -> bool:
|
|||
return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _hub_offline_env_truthy() -> bool:
|
||||
"""HF_HUB_OFFLINE specifically is truthy. huggingface_hub does not honor
|
||||
TRANSFORMERS_OFFLINE, so a forced local-only guard may only no-op when the
|
||||
Hub flag itself is already set."""
|
||||
return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# Overlapping offline guards share one env override: the count tracks active
|
||||
# guards and the saved values are restored only when the LAST guard exits, so
|
||||
# a request finishing early cannot re-enable network for one still running.
|
||||
_OFFLINE_GUARD_LOCK = threading.Lock()
|
||||
_OFFLINE_GUARD_STATE: dict = {"count": 0, "hub_prev": None, "transformers_prev": None}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _hf_offline_if_dns_dead():
|
||||
def _hf_offline_if_dns_dead(force: bool = False):
|
||||
"""Set HF_HUB_OFFLINE for this block only when DNS to huggingface.co fails;
|
||||
restores env on exit so a transient hiccup can't quarantine the process.
|
||||
No-op if the user already set it."""
|
||||
if "HF_HUB_OFFLINE" in os.environ:
|
||||
No-op when the user already set it to a truthy value. ``force`` skips the
|
||||
DNS probe and goes offline unconditionally (local-only background loads
|
||||
resolve metadata from the cache without any network), overriding even an
|
||||
explicitly falsy HF_HUB_OFFLINE=0 for the block and restoring it after.
|
||||
Guards are refcounted so overlapping loads/validations each keep offline
|
||||
until the last one exits."""
|
||||
entered = False # this guard joined or created the env override
|
||||
owner = False # this guard created it (first in)
|
||||
with _OFFLINE_GUARD_LOCK:
|
||||
if _OFFLINE_GUARD_STATE["count"] > 0:
|
||||
if force:
|
||||
# Join the active override so the env survives until every
|
||||
# local-only guard exits, whichever request finishes first.
|
||||
_OFFLINE_GUARD_STATE["count"] += 1
|
||||
entered = True
|
||||
# An ordinary guard never joins: its block tolerates either env
|
||||
# state (it would have run online), so extending the forced
|
||||
# window would only widen the exposure of concurrent online
|
||||
# work to the process-global override. It no-ops instead.
|
||||
elif _hf_env_offline() and (not force or _hub_offline_env_truthy()):
|
||||
# User-set truthy env (count is 0): already offline, nothing to
|
||||
# arrange or restore. A forced guard only trusts HF_HUB_OFFLINE
|
||||
# itself: TRANSFORMERS_OFFLINE=1 alone leaves hub API calls (e.g.
|
||||
# get_paths_info) online, so force still installs both flags.
|
||||
pass
|
||||
elif not force and "HF_HUB_OFFLINE" in os.environ:
|
||||
# A user-pinned falsy value stays authoritative for ordinary loads.
|
||||
pass
|
||||
elif force or _probe_dns_dead():
|
||||
_OFFLINE_GUARD_STATE["hub_prev"] = os.environ.get("HF_HUB_OFFLINE")
|
||||
_OFFLINE_GUARD_STATE["transformers_prev"] = os.environ.get("TRANSFORMERS_OFFLINE")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
_OFFLINE_GUARD_STATE["count"] = 1
|
||||
entered = True
|
||||
owner = True
|
||||
if not entered:
|
||||
yield False
|
||||
return
|
||||
if not _probe_dns_dead():
|
||||
yield False
|
||||
return
|
||||
|
||||
transformers_was_set = "TRANSFORMERS_OFFLINE" in os.environ
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
if not transformers_was_set:
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
logger.warning("huggingface.co unreachable; using local HF cache for this load.")
|
||||
if owner:
|
||||
if force:
|
||||
logger.info("Local-only load: forcing HF offline for this block.")
|
||||
else:
|
||||
logger.warning("huggingface.co unreachable; using local HF cache for this load.")
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
if not transformers_was_set:
|
||||
os.environ.pop("TRANSFORMERS_OFFLINE", None)
|
||||
with _OFFLINE_GUARD_LOCK:
|
||||
_OFFLINE_GUARD_STATE["count"] -= 1
|
||||
if _OFFLINE_GUARD_STATE["count"] == 0:
|
||||
for key, prev in (
|
||||
("HF_HUB_OFFLINE", _OFFLINE_GUARD_STATE["hub_prev"]),
|
||||
("TRANSFORMERS_OFFLINE", _OFFLINE_GUARD_STATE["transformers_prev"]),
|
||||
):
|
||||
if prev is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = prev
|
||||
_OFFLINE_GUARD_STATE["hub_prev"] = None
|
||||
_OFFLINE_GUARD_STATE["transformers_prev"] = None
|
||||
|
||||
|
||||
try:
|
||||
|
|
@ -5273,6 +5328,7 @@ class LlamaCppBackend:
|
|||
force: bool = False,
|
||||
allow_smaller_fallback: bool = True,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> str:
|
||||
"""Download GGUF file(s) from HuggingFace. Returns local path.
|
||||
|
||||
|
|
@ -5310,16 +5366,19 @@ class LlamaCppBackend:
|
|||
gguf_filename = None
|
||||
gguf_extra_shards: list[str] = []
|
||||
if hf_variant:
|
||||
try:
|
||||
from huggingface_hub import list_repo_files
|
||||
# Local-only loads resolve from the cache alone: the live listing
|
||||
# is both network and unnecessary for files already on disk.
|
||||
if not local_files_only:
|
||||
try:
|
||||
from huggingface_hub import list_repo_files
|
||||
|
||||
files = list_repo_files(hf_repo, token = hf_token)
|
||||
gguf_files = _gguf_files_for_variant(files, hf_variant)
|
||||
if gguf_files:
|
||||
gguf_filename = gguf_files[0]
|
||||
gguf_extra_shards = _gguf_extra_shards(gguf_files, gguf_filename)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not list repo files: {e}")
|
||||
files = list_repo_files(hf_repo, token = hf_token)
|
||||
gguf_files = _gguf_files_for_variant(files, hf_variant)
|
||||
if gguf_files:
|
||||
gguf_filename = gguf_files[0]
|
||||
gguf_extra_shards = _gguf_extra_shards(gguf_files, gguf_filename)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not list repo files: {e}")
|
||||
|
||||
# Fall back to the local cache when the repo listing is unavailable.
|
||||
if not gguf_filename:
|
||||
|
|
@ -5343,10 +5402,16 @@ class LlamaCppBackend:
|
|||
# Resolve by variant so a newer revision's filename does not hide
|
||||
# the complete older copy. Size-check against that older snapshot's
|
||||
# own revision when its metadata remains available.
|
||||
# Local-only loads skip the remote size check outright: the
|
||||
# hub API behind it (get_paths_info) performs no offline-mode
|
||||
# check and HF_HUB_OFFLINE is baked into hub constants at
|
||||
# import, so only a per-call skip reliably keeps this off the
|
||||
# network. A truncated cache then fails the load into
|
||||
# candidate failover instead of being caught up front.
|
||||
cached_main = cached_gguf_for_load(
|
||||
hf_repo,
|
||||
hf_variant,
|
||||
verify_sizes = True,
|
||||
verify_sizes = not local_files_only,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
else:
|
||||
|
|
@ -5354,13 +5419,24 @@ class LlamaCppBackend:
|
|||
cached_main = (
|
||||
candidate[0]
|
||||
if candidate is not None
|
||||
and _cached_candidate_matches_revision_size(hf_repo, candidate, hf_token)
|
||||
and (
|
||||
local_files_only
|
||||
or _cached_candidate_matches_revision_size(hf_repo, candidate, hf_token)
|
||||
)
|
||||
else None
|
||||
)
|
||||
if cached_main is not None:
|
||||
logger.info(f"Reusing cached GGUF: {cached_main}")
|
||||
return cached_main
|
||||
|
||||
# A local-only load reaching this point has no complete cached copy;
|
||||
# fail closed instead of downloading (background loads never fetch).
|
||||
if local_files_only:
|
||||
raise RuntimeError(
|
||||
f"GGUF '{hf_repo}' ({hf_variant or 'default'}) is not fully "
|
||||
"available in the local cache; select it explicitly to download it."
|
||||
)
|
||||
|
||||
# Check disk space; fall back to a smaller variant if needed
|
||||
all_gguf_files = [gguf_filename] + gguf_extra_shards
|
||||
try:
|
||||
|
|
@ -5523,6 +5599,7 @@ class LlamaCppBackend:
|
|||
label: str,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
near_path: Optional[str] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Resolve and fetch a companion GGUF (mmproj / MTP drafter) by name.
|
||||
|
||||
|
|
@ -5544,6 +5621,12 @@ class LlamaCppBackend:
|
|||
logger.info("Reusing cached %s: %s", label, cached)
|
||||
return cached
|
||||
|
||||
# Background loads never download: without a cached copy the model
|
||||
# runs without the optional companion instead of fetching it.
|
||||
if local_files_only:
|
||||
logger.info("Skipping %s fetch (local-only load)", label)
|
||||
return None
|
||||
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
companion_cache_dir = _hub_cache_dir_for_snapshot_path(near_path) or str(
|
||||
|
|
@ -5632,6 +5715,7 @@ class LlamaCppBackend:
|
|||
hf_token: Optional[str] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
near_path: Optional[str] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Download the mmproj (vision projection) file from a GGUF repo.
|
||||
|
||||
|
|
@ -5648,6 +5732,7 @@ class LlamaCppBackend:
|
|||
label = "mmproj",
|
||||
cancel_event = cancel_event,
|
||||
near_path = near_path,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
|
||||
def _cached_repo_mtp_drafter(
|
||||
|
|
@ -5689,6 +5774,7 @@ class LlamaCppBackend:
|
|||
hf_repo: str,
|
||||
hf_token: Optional[str] = None,
|
||||
near_path: Optional[str] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Download the separate MTP drafter (speculative head) from a GGUF repo.
|
||||
|
||||
|
|
@ -5722,7 +5808,7 @@ class LlamaCppBackend:
|
|||
# fetched). Online, _download_companion_gguf/hf_hub_download reuse the
|
||||
# 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():
|
||||
if local_files_only or _hf_env_offline():
|
||||
cached = self._cached_repo_mtp_drafter(
|
||||
hf_repo,
|
||||
cache_dir = _hub_cache_dir_for_snapshot_path(near_path),
|
||||
|
|
@ -5737,6 +5823,7 @@ class LlamaCppBackend:
|
|||
pick = _pick_mtp,
|
||||
label = "MTP drafter",
|
||||
near_path = near_path,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
|
||||
def _resolve_launch_mmproj_path(
|
||||
|
|
@ -6433,6 +6520,9 @@ class LlamaCppBackend:
|
|||
hf_repo: Optional[str] = None,
|
||||
hf_variant: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
# Background auto-loads: resolve every file (main quant, mmproj, MTP
|
||||
# drafter) from the local cache only and never download.
|
||||
local_files_only: bool = False,
|
||||
# Common
|
||||
model_identifier: str,
|
||||
is_vision: bool = False,
|
||||
|
|
@ -6470,6 +6560,7 @@ class LlamaCppBackend:
|
|||
"gguf_path": gguf_path,
|
||||
"mmproj_path": mmproj_path,
|
||||
"mtp_draft_path": mtp_draft_path,
|
||||
"local_files_only": local_files_only,
|
||||
"hf_repo": hf_repo,
|
||||
"hf_variant": hf_variant,
|
||||
"hf_token": hf_token,
|
||||
|
|
@ -6578,11 +6669,15 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
)
|
||||
hf_repo = _resolved_repo
|
||||
with _hf_offline_if_dns_dead():
|
||||
# Same local-only policy as the Phase 2 download below: this
|
||||
# preflight runs FIRST, so without the flag it would be the
|
||||
# download path a background load slips through.
|
||||
with _hf_offline_if_dns_dead(force = local_files_only):
|
||||
_preflight_model_path = self._download_gguf(
|
||||
hf_repo = hf_repo,
|
||||
hf_variant = hf_variant,
|
||||
hf_token = hf_token,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
self._reject_vulkan_diffusion_gpu_ids_before_teardown(
|
||||
_preflight_model_path,
|
||||
|
|
@ -6620,11 +6715,14 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
)
|
||||
hf_repo = _resolved_repo
|
||||
with _hf_offline_if_dns_dead():
|
||||
# Forced offline under local-only so even the cache-size
|
||||
# verification (get_paths_info) stays off the network.
|
||||
with _hf_offline_if_dns_dead(force = local_files_only):
|
||||
model_path = _preflight_model_path or self._download_gguf(
|
||||
hf_repo = hf_repo,
|
||||
hf_variant = hf_variant,
|
||||
hf_token = hf_token,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
# Auto-download mmproj for vision models unless opted out.
|
||||
if is_vision and not mmproj_path and not extra_args_disable_mmproj(extra_args):
|
||||
|
|
@ -6632,6 +6730,7 @@ class LlamaCppBackend:
|
|||
hf_repo = hf_repo,
|
||||
hf_token = hf_token,
|
||||
near_path = model_path,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
# Auto-download the separate MTP drafter (e.g. Gemma) when
|
||||
# the requested spec mode can use it. Repos with the head
|
||||
|
|
@ -6650,6 +6749,7 @@ class LlamaCppBackend:
|
|||
hf_repo = hf_repo,
|
||||
hf_token = hf_token,
|
||||
near_path = model_path,
|
||||
local_files_only = local_files_only,
|
||||
)
|
||||
elif gguf_path:
|
||||
if not Path(gguf_path).is_file():
|
||||
|
|
|
|||
|
|
@ -524,6 +524,9 @@ class MLXInferenceBackend:
|
|||
dtype = None,
|
||||
parallel_mode = None,
|
||||
distributed_group = None,
|
||||
# Accepted for worker parity; the load runs under the worker's forced
|
||||
# offline env when set, which is what enforces local-only here.
|
||||
local_files_only: bool = False,
|
||||
) -> bool:
|
||||
import mlx.core as mx
|
||||
|
||||
|
|
@ -603,8 +606,13 @@ class MLXInferenceBackend:
|
|||
else:
|
||||
load_kwargs["tensor_group"] = distributed_group
|
||||
|
||||
# Registry identity stays the repo id (model_name); the LOAD source
|
||||
# honors config.path so a route-resolved local snapshot (local-only
|
||||
# loads against a moved live cache) is read instead of re-resolving
|
||||
# the id through the import-time cache location.
|
||||
load_source = getattr(config, "path", None) or model_name
|
||||
model, tokenizer_or_processor = FastMLXModel.from_pretrained(
|
||||
model_name,
|
||||
load_source,
|
||||
**load_kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -627,6 +635,10 @@ class MLXInferenceBackend:
|
|||
"hf_token": hf_token,
|
||||
# Per-model trust_remote_code reused by the native-template reload (matches transformers).
|
||||
"trust_remote_code": trust_remote_code,
|
||||
# Local-only loads: generation-time repo fallbacks (native
|
||||
# template reload) must also resolve from cache, not the Hub.
|
||||
"local_files_only": local_files_only,
|
||||
"model_path": load_source,
|
||||
"model": self._model,
|
||||
"tokenizer": self._tokenizer,
|
||||
"processor": self._processor,
|
||||
|
|
|
|||
|
|
@ -973,6 +973,7 @@ class InferenceOrchestrator:
|
|||
subject: Optional[str] = None,
|
||||
tensor_parallel: bool = False,
|
||||
mlx_distributed: bool = False,
|
||||
local_files_only: bool = False,
|
||||
) -> bool:
|
||||
"""Load a model for inference.
|
||||
|
||||
|
|
@ -985,7 +986,24 @@ class InferenceOrchestrator:
|
|||
self.loading_models.add(model_name)
|
||||
|
||||
try:
|
||||
needed_major = "5" if needs_transformers_5(model_name) else "4"
|
||||
# Parent-side metadata preflight stays offline for local-only
|
||||
# loads: the tier probe reads model configs and GPU sizing calls
|
||||
# hf model_info. The guard closes BEFORE the spawn below, so the
|
||||
# worker does not inherit HF_HUB_OFFLINE for its whole lifetime
|
||||
# (its own bootstrap guard scopes the load and restores for
|
||||
# generation-time fetches).
|
||||
import contextlib
|
||||
|
||||
from core.inference.llama_cpp import _hf_offline_if_dns_dead
|
||||
|
||||
def _preflight_offline():
|
||||
# Ordinary loads keep their current (unguarded) behavior.
|
||||
if local_files_only:
|
||||
return _hf_offline_if_dns_dead(force = True)
|
||||
return contextlib.nullcontext()
|
||||
|
||||
with _preflight_offline():
|
||||
needed_major = "5" if needs_transformers_5(model_name) else "4"
|
||||
|
||||
# Build config dict for subprocess
|
||||
sub_config = {
|
||||
|
|
@ -1000,16 +1018,25 @@ class InferenceOrchestrator:
|
|||
"gpu_ids": gpu_ids,
|
||||
"tensor_parallel": bool(tensor_parallel),
|
||||
"mlx_distributed": bool(mlx_distributed),
|
||||
"local_files_only": bool(local_files_only),
|
||||
# Route-resolved local snapshot: the worker rebuilds its
|
||||
# ModelConfig from model_name, which would lose the rewrite.
|
||||
"local_snapshot_path": (
|
||||
getattr(config, "path", None)
|
||||
if local_files_only and getattr(config, "path", None) != model_name
|
||||
else None
|
||||
),
|
||||
"mlx_parallel_mode": ("tensor" if tensor_parallel else "pipeline")
|
||||
if mlx_distributed
|
||||
else None,
|
||||
}
|
||||
resolved_gpu_ids, gpu_selection = prepare_gpu_selection(
|
||||
gpu_ids,
|
||||
model_name = model_name,
|
||||
hf_token = hf_token,
|
||||
load_in_4bit = load_in_4bit,
|
||||
)
|
||||
with _preflight_offline():
|
||||
resolved_gpu_ids, gpu_selection = prepare_gpu_selection(
|
||||
gpu_ids,
|
||||
model_name = model_name,
|
||||
hf_token = hf_token,
|
||||
load_in_4bit = load_in_4bit,
|
||||
)
|
||||
sub_config["resolved_gpu_ids"] = resolved_gpu_ids
|
||||
sub_config["gpu_selection"] = gpu_selection
|
||||
# Parent-detected backend for the worker's apply_gpu_ids().
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ mp.Queue, and exits on shutdown or unload. Pattern follows core/training/worker.
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
from loggers import get_logger
|
||||
import os
|
||||
|
|
@ -95,6 +96,28 @@ def _clean_token(value: str | None) -> str | None:
|
|||
return value if value and value.strip() else None
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _local_only_offline_env(enabled: bool):
|
||||
"""Force HF offline for a local-only background load. The worker is a
|
||||
fresh per-load process, so scoping env here covers config resolution,
|
||||
security gates, and every from_pretrained in the load, then restores it so
|
||||
generation-time fetches (e.g. the chat template fallback) still work."""
|
||||
if not enabled:
|
||||
yield
|
||||
return
|
||||
prev = {key: os.environ.get(key) for key in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")}
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _build_model_config(config: dict):
|
||||
"""Build a ModelConfig from the config dict."""
|
||||
from utils.models import ModelConfig
|
||||
|
|
@ -107,6 +130,12 @@ def _build_model_config(config: dict):
|
|||
)
|
||||
if not mc:
|
||||
raise ValueError(f"Invalid model identifier: {model_name}")
|
||||
# The route resolves a cached repo id to its local snapshot for local-only
|
||||
# loads; from_identifier here rebuilds path as the repo id, losing that
|
||||
# rewrite across the process boundary, so re-apply it.
|
||||
snapshot_override = config.get("local_snapshot_path")
|
||||
if snapshot_override:
|
||||
mc.path = snapshot_override
|
||||
return mc
|
||||
|
||||
|
||||
|
|
@ -175,19 +204,48 @@ def _resolve_lora_4bit(mc, load_in_4bit: bool) -> bool:
|
|||
return load_in_4bit
|
||||
|
||||
|
||||
def _ensure_ssm_kernels(targets: list, resp_queue: Any) -> bool:
|
||||
def _ensure_ssm_kernels(
|
||||
targets: list,
|
||||
resp_queue: Any,
|
||||
local_files_only: bool = False,
|
||||
) -> bool:
|
||||
"""Install the SSM kernels the given model(s) lazy-import in from_pretrained; no-op for
|
||||
non-SSM models, idempotent. Returns True on success; on a fatal mamba-ssm failure sends a
|
||||
'loaded' failure response and returns False. Call BEFORE importing transformers, which
|
||||
snapshots its optional-backend gates at import (a later install may not be picked up).
|
||||
Under ``local_files_only`` nothing is ever installed: kernels already present are used,
|
||||
a missing fatal kernel fails the load into candidate failover, and the optional
|
||||
causal-conv1d fast path is skipped (its torch fallback covers it).
|
||||
"""
|
||||
try:
|
||||
from utils.ssm_runtime import ensure_ssm_runtime
|
||||
from utils.ssm_runtime import ensure_ssm_runtime, model_is_ssm
|
||||
except Exception as exc:
|
||||
logger.debug("ssm_runtime unavailable (%s); skipping SSM kernel pre-install", exc)
|
||||
return True
|
||||
|
||||
_ssm_status = lambda m: _send_response(resp_queue, {"type": "status", "message": m})
|
||||
if local_files_only:
|
||||
import importlib.util
|
||||
for ssm_target in dict.fromkeys(t for t in targets if t):
|
||||
try:
|
||||
needs_mamba = model_is_ssm(ssm_target)
|
||||
except Exception:
|
||||
needs_mamba = False
|
||||
if needs_mamba and importlib.util.find_spec("mamba_ssm") is None:
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"message": (
|
||||
"This model needs the mamba-ssm kernel, which is not "
|
||||
"installed; select the model explicitly to install it."
|
||||
),
|
||||
"error_kind": "ssm_runtime_install_failed",
|
||||
},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
try:
|
||||
for ssm_target in dict.fromkeys(t for t in targets if t):
|
||||
ensure_ssm_runtime(ssm_target, status_cb = _ssm_status)
|
||||
|
|
@ -285,7 +343,13 @@ def _run_security_gates(
|
|||
|
||||
def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
||||
"""Handle a load command: load a model into the backend."""
|
||||
_offline_guard = contextlib.ExitStack()
|
||||
try:
|
||||
# Local-only loads run the WHOLE load offline: config resolution,
|
||||
# security gates, and from_pretrained can otherwise all reach the Hub.
|
||||
_offline_guard.enter_context(
|
||||
_local_only_offline_env(bool(config.get("local_files_only", False)))
|
||||
)
|
||||
mc = _build_model_config(config)
|
||||
|
||||
hf_token = _clean_token(config.get("hf_token"))
|
||||
|
|
@ -335,7 +399,11 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
str(mc.base_model) if (mc.is_lora and getattr(mc, "base_model", None)) else None
|
||||
)
|
||||
ssm_targets = [ssm_probe_identifier(config["model_name"], _ssm_base)]
|
||||
if not _ensure_ssm_kernels(ssm_targets, resp_queue):
|
||||
if not _ensure_ssm_kernels(
|
||||
ssm_targets,
|
||||
resp_queue,
|
||||
local_files_only = bool(config.get("local_files_only", False)),
|
||||
):
|
||||
return
|
||||
|
||||
# Heartbeat keeps the orchestrator's inactivity deadline alive during slow
|
||||
|
|
@ -363,6 +431,7 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
"hf_token": hf_token,
|
||||
"trust_remote_code": trust_remote_code,
|
||||
"gpu_ids": config.get("resolved_gpu_ids"),
|
||||
"local_files_only": bool(config.get("local_files_only", False)),
|
||||
}
|
||||
if getattr(backend, "device", None) == "mlx":
|
||||
load_kwargs["parallel_mode"] = config.get("mlx_parallel_mode")
|
||||
|
|
@ -435,6 +504,8 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
"stack": traceback.format_exc(limit = 20),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
_offline_guard.close()
|
||||
|
||||
|
||||
def _drain_skip_generate(cmd: dict, resp_queue: Any, drain_event) -> bool:
|
||||
|
|
@ -796,6 +867,16 @@ def run_inference_process(
|
|||
|
||||
apply_gpu_ids(config.get("resolved_gpu_ids"), backend = config.get("device_backend"))
|
||||
|
||||
# Local-only background loads keep the ENTIRE bootstrap offline: base
|
||||
# resolution, transformers activation, security gates, kernel probes and
|
||||
# the initial load can all reach the Hub otherwise. Closed before the
|
||||
# command loop so generation-time fetches (e.g. the chat template
|
||||
# fallback) still work; error-path returns end the process anyway.
|
||||
_bootstrap_offline = contextlib.ExitStack()
|
||||
_bootstrap_offline.enter_context(
|
||||
_local_only_offline_env(bool(config.get("local_files_only", False)))
|
||||
)
|
||||
|
||||
model_name = config["model_name"]
|
||||
|
||||
# ── 0. MLX fast-path — skip torch/transformers ──
|
||||
|
|
@ -857,6 +938,7 @@ def run_inference_process(
|
|||
return
|
||||
|
||||
# Enter the same command loop as the GPU path.
|
||||
_bootstrap_offline.close()
|
||||
logger.info("MLX inference subprocess ready, entering command loop")
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -1015,7 +1097,11 @@ def run_inference_process(
|
|||
from utils.ssm_runtime import ssm_probe_identifier
|
||||
|
||||
_ssm_targets = [ssm_probe_identifier(model_name, _base)]
|
||||
if not _ensure_ssm_kernels(_ssm_targets, resp_queue):
|
||||
if not _ensure_ssm_kernels(
|
||||
_ssm_targets,
|
||||
resp_queue,
|
||||
local_files_only = bool(config.get("local_files_only", False)),
|
||||
):
|
||||
return
|
||||
|
||||
# ── 2. Import ML libraries (fresh in this clean process) ──
|
||||
|
|
@ -1077,6 +1163,8 @@ def run_inference_process(
|
|||
)
|
||||
return
|
||||
|
||||
_bootstrap_offline.close()
|
||||
|
||||
# ── 4. Command loop — process commands until shutdown ──
|
||||
# cancel_event is an mp.Event the parent can set anytime to cancel
|
||||
# generation instantly (no queue polling needed).
|
||||
|
|
|
|||
|
|
@ -188,6 +188,13 @@ class CachedModelRepo(CachedRepoBase):
|
|||
pipeline_tag: Optional[str] = None
|
||||
library_name: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
snapshot_size_bytes: Optional[int] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Weight bytes of the newest cached snapshot only (what a load "
|
||||
"resolves); size_bytes sums blobs across every cached revision."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CachedModelsResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
continue
|
||||
repo_id = repo_info.repo_id
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_path = _cached_model_snapshot_path(repo_path)
|
||||
snapshot_path = _cached_gguf_repo_snapshot_path(repo_path)
|
||||
total_size = _repo_gguf_size_bytes(repo_info)
|
||||
has_variant_state, variant_state_size = _gguf_variant_state_summary(
|
||||
repo_id,
|
||||
|
|
@ -415,16 +415,35 @@ async def list_cached_gguf_response(hf_token: Optional[str] = None):
|
|||
|
||||
class _CachedNonGgufPayload(NamedTuple):
|
||||
size_bytes: int
|
||||
snapshot_size_bytes: int
|
||||
has_runnable_weights: bool
|
||||
model_format: ModelFormat
|
||||
last_modified: float
|
||||
|
||||
|
||||
def _snapshot_dir_mtime(revision) -> float:
|
||||
"""mtime of a revision's snapshot dir; the same signal latest_snapshot_dir
|
||||
(and therefore the load-side snapshot resolution) selects by."""
|
||||
snapshot_path = getattr(revision, "snapshot_path", None)
|
||||
if not snapshot_path:
|
||||
return 0.0
|
||||
try:
|
||||
return float(Path(snapshot_path).stat().st_mtime)
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
||||
all_weight_blobs: dict[str, tuple[int, float]] = {}
|
||||
adapter_blobs: dict[str, tuple[int, float]] = {}
|
||||
safetensors_blobs: dict[str, tuple[int, float]] = {}
|
||||
checkpoint_blobs: dict[str, tuple[int, float]] = {}
|
||||
# Per-revision selected-format byte sums, so the row can also report the
|
||||
# size of the ONE snapshot a load resolves (newest by snapshot-dir mtime)
|
||||
# rather than only the all-revisions total.
|
||||
rev_category_sizes: dict[str, dict[str, int]] = {}
|
||||
rev_snapshot_mtimes: dict[str, float] = {}
|
||||
rev_has_config: dict[str, bool] = {}
|
||||
has_config = False
|
||||
has_adapter_config = False
|
||||
has_adapter_weights = False
|
||||
|
|
@ -433,7 +452,7 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
has_checkpoint = False
|
||||
|
||||
def _record_blob(
|
||||
target: dict[str, tuple[int, float]], file_obj, rev_id: str, file_name: str
|
||||
target: dict[str, tuple[int, float]], file_obj, rev_id: str, file_name: str, category: str
|
||||
) -> None:
|
||||
blob_path = getattr(file_obj, "blob_path", None)
|
||||
size = int(file_obj.size_on_disk or 0)
|
||||
|
|
@ -441,9 +460,13 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
value = (size, _blob_mtime(file_obj))
|
||||
target[key] = value
|
||||
all_weight_blobs[key] = value
|
||||
per_rev = rev_category_sizes.setdefault(rev_id, {})
|
||||
per_rev[category] = per_rev.get(category, 0) + size
|
||||
per_rev["all"] = per_rev.get("all", 0) + size
|
||||
|
||||
for revision in repo_info.revisions:
|
||||
rev_id = getattr(revision, "commit_hash", None) or str(id(revision))
|
||||
rev_snapshot_mtimes[rev_id] = _snapshot_dir_mtime(revision)
|
||||
for f in revision.files:
|
||||
file_name = str(f.file_name)
|
||||
lower = file_name.lower()
|
||||
|
|
@ -452,6 +475,7 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
continue
|
||||
if name == "config.json":
|
||||
has_config = True
|
||||
rev_has_config[rev_id] = True
|
||||
continue
|
||||
if name == "adapter_config.json":
|
||||
has_adapter_config = True
|
||||
|
|
@ -461,15 +485,15 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
is_checkpoint = _is_checkpoint_weight_name(name)
|
||||
if is_adapter:
|
||||
has_adapter_weights = True
|
||||
_record_blob(adapter_blobs, f, rev_id, file_name)
|
||||
_record_blob(adapter_blobs, f, rev_id, file_name, "adapter")
|
||||
if is_safetensors:
|
||||
has_safetensors = True
|
||||
if _is_transformers_safetensors_weight_name(name):
|
||||
has_transformers_safetensors = True
|
||||
_record_blob(safetensors_blobs, f, rev_id, file_name)
|
||||
_record_blob(safetensors_blobs, f, rev_id, file_name, "safetensors")
|
||||
if is_checkpoint:
|
||||
has_checkpoint = True
|
||||
_record_blob(checkpoint_blobs, f, rev_id, file_name)
|
||||
_record_blob(checkpoint_blobs, f, rev_id, file_name, "checkpoint")
|
||||
|
||||
model_format = (
|
||||
_classify_non_gguf_model_format(
|
||||
|
|
@ -485,22 +509,134 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
)
|
||||
if model_format == "adapter":
|
||||
selected_blobs = adapter_blobs
|
||||
selected_category = "adapter"
|
||||
elif model_format == "safetensors":
|
||||
selected_blobs = safetensors_blobs
|
||||
selected_category = "safetensors"
|
||||
elif model_format == "checkpoint":
|
||||
selected_blobs = checkpoint_blobs
|
||||
selected_category = "checkpoint"
|
||||
else:
|
||||
selected_blobs = all_weight_blobs
|
||||
selected_category = "all"
|
||||
|
||||
size_bytes = sum(size for size, _mtime in selected_blobs.values())
|
||||
# The one snapshot a load resolves (newest snapshot-dir mtime) among the
|
||||
# revisions holding selected-format weights. For safetensors rows the
|
||||
# snapshot resolvers additionally require config.json in the SAME
|
||||
# revision, so sizing must use that predicate too: a weight-only newer
|
||||
# revision would otherwise be sized while the older complete revision is
|
||||
# what actually loads. Falls back to weight-only revisions, then to the
|
||||
# all-revisions total.
|
||||
weight_revs = [
|
||||
rev_id
|
||||
for rev_id, sizes in rev_category_sizes.items()
|
||||
if sizes.get(selected_category, 0) > 0
|
||||
]
|
||||
if selected_category == "safetensors":
|
||||
complete_revs = [rev_id for rev_id in weight_revs if rev_has_config.get(rev_id, False)]
|
||||
if complete_revs:
|
||||
weight_revs = complete_revs
|
||||
if weight_revs:
|
||||
newest_rev = max(weight_revs, key = lambda rev_id: rev_snapshot_mtimes.get(rev_id, 0.0))
|
||||
snapshot_size_bytes = rev_category_sizes[newest_rev].get(selected_category, 0)
|
||||
else:
|
||||
snapshot_size_bytes = size_bytes
|
||||
|
||||
return _CachedNonGgufPayload(
|
||||
size_bytes = sum(size for size, _mtime in selected_blobs.values()),
|
||||
size_bytes = size_bytes,
|
||||
snapshot_size_bytes = snapshot_size_bytes,
|
||||
has_runnable_weights = model_format != "unknown",
|
||||
model_format = model_format,
|
||||
last_modified = max((mtime for _size, mtime in selected_blobs.values()), default = 0.0),
|
||||
)
|
||||
|
||||
|
||||
def _newest_snapshot_where(repo_path: Path, loadable) -> Optional[Path]:
|
||||
"""Newest snapshots/* dir (by mtime) whose entry names satisfy *loadable*.
|
||||
|
||||
Inactive-cache rows emit the selected snapshot path as their load_id,
|
||||
which the load consumes directly (it bypasses the repo-id resolver), so
|
||||
the snapshot must actually hold the format the row was classified from.
|
||||
"""
|
||||
snapshots = repo_path / "snapshots"
|
||||
try:
|
||||
revisions = [entry for entry in snapshots.iterdir() if entry.is_dir()]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _ok(rev: Path) -> bool:
|
||||
try:
|
||||
names = [entry.name for entry in rev.iterdir()]
|
||||
except OSError:
|
||||
return False
|
||||
return loadable(names)
|
||||
|
||||
candidates = [rev for rev in revisions if _ok(rev)]
|
||||
if not candidates:
|
||||
return None
|
||||
try:
|
||||
return max(candidates, key = lambda rev: rev.stat().st_mtime).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _weightful_snapshot_path(repo_path: Path) -> Optional[Path]:
|
||||
"""Newest snapshot holding config.json plus a safetensors weight (the
|
||||
non-GGUF resolvers' complete-revision predicate)."""
|
||||
return _newest_snapshot_where(
|
||||
repo_path,
|
||||
lambda names: "config.json" in names and any(n.endswith(".safetensors") for n in names),
|
||||
)
|
||||
|
||||
|
||||
def _gguf_snapshot_path(repo_path: Path) -> Optional[Path]:
|
||||
"""Newest snapshot holding a GGUF file (top level or one folder deep,
|
||||
where multi-quant repos keep per-quant subfolders)."""
|
||||
direct = _newest_snapshot_where(
|
||||
repo_path,
|
||||
lambda names: any(_is_gguf_filename(n.lower()) for n in names),
|
||||
)
|
||||
if direct is not None:
|
||||
return direct
|
||||
snapshots = repo_path / "snapshots"
|
||||
try:
|
||||
revisions = [entry for entry in snapshots.iterdir() if entry.is_dir()]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _has_nested_gguf(rev: Path) -> bool:
|
||||
try:
|
||||
return any(True for _ in rev.glob("*/*.gguf"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
candidates = [rev for rev in revisions if _has_nested_gguf(rev)]
|
||||
if not candidates:
|
||||
return None
|
||||
try:
|
||||
return max(candidates, key = lambda rev: rev.stat().st_mtime).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _cached_model_snapshot_path(repo_path: Path) -> Optional[Path]:
|
||||
weightful = _weightful_snapshot_path(repo_path)
|
||||
if weightful is not None:
|
||||
return weightful
|
||||
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_path)
|
||||
if not resolved:
|
||||
return None
|
||||
path = Path(resolved)
|
||||
return path if path.is_dir() else None
|
||||
|
||||
|
||||
def _cached_gguf_repo_snapshot_path(repo_path: Path) -> Optional[Path]:
|
||||
"""Snapshot path for a GGUF row: a safetensors-bearing revision of a
|
||||
mixed repo must not become the GGUF row's load target."""
|
||||
gguf_snapshot = _gguf_snapshot_path(repo_path)
|
||||
if gguf_snapshot is not None:
|
||||
return gguf_snapshot
|
||||
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_path)
|
||||
if not resolved:
|
||||
return None
|
||||
|
|
@ -636,6 +772,7 @@ def _scan_cached_models() -> list[dict]:
|
|||
row = {
|
||||
"repo_id": repo_id,
|
||||
"size_bytes": payload.size_bytes,
|
||||
"snapshot_size_bytes": payload.snapshot_size_bytes,
|
||||
"cache_path": str(repo_info.repo_path),
|
||||
"partial": snapshot_partial,
|
||||
"partial_transport": (
|
||||
|
|
|
|||
87
studio/backend/hub/utils/local_snapshot.py
Normal file
87
studio/backend/hub/utils/local_snapshot.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Local-only snapshot resolution for background model loads."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _snapshot_has_weights(rev: str) -> bool:
|
||||
"""Whether a snapshot dir holds at least one safetensors weight file.
|
||||
|
||||
Background loads only target safetensors-format rows (adapters and
|
||||
pickle checkpoints are excluded upstream), so this is the runnable-weight
|
||||
signal that made the inventory row eligible.
|
||||
"""
|
||||
try:
|
||||
return any(name.endswith(".safetensors") for name in os.listdir(rev))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _snapshot_dir_fallback(repo_id: str, cache_dir: Optional[str]) -> Optional[str]:
|
||||
"""Newest snapshots/* dir (by mtime) holding a config.json, preferring
|
||||
revisions that also hold weights.
|
||||
|
||||
This mirrors the inventory scanner's selection (newest revision that
|
||||
actually carries the inventoried weights), so the load targets the
|
||||
snapshot that made the row eligible: a newest metadata-only revision must
|
||||
not shadow an older complete one. It also covers revision-only layouts
|
||||
(refs/ missing or pruned) that snapshot_download(local_files_only = True)
|
||||
cannot map through refs/main.
|
||||
"""
|
||||
if cache_dir is None:
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
cache_dir = HF_HUB_CACHE
|
||||
except Exception:
|
||||
return None
|
||||
folder = os.path.join(cache_dir, "models--" + repo_id.replace("/", "--"), "snapshots")
|
||||
try:
|
||||
revisions = [
|
||||
os.path.join(folder, name)
|
||||
for name in os.listdir(folder)
|
||||
if os.path.isdir(os.path.join(folder, name))
|
||||
]
|
||||
except OSError:
|
||||
return None
|
||||
candidates = [rev for rev in revisions if os.path.isfile(os.path.join(rev, "config.json"))]
|
||||
if not candidates:
|
||||
return None
|
||||
weightful = [rev for rev in candidates if _snapshot_has_weights(rev)]
|
||||
return max(weightful or candidates, key = os.path.getmtime)
|
||||
|
||||
|
||||
def resolve_local_snapshot_path(
|
||||
repo_id: str,
|
||||
hf_token: Optional[str] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a Hub repo id to its snapshot directory in the local HF cache
|
||||
without any network access; None when the repo is not cached.
|
||||
|
||||
The newest snapshot dir (by mtime) holding a config.json is preferred:
|
||||
that is the same selection the inventory scanner surfaces, so the load
|
||||
targets the revision that made the row eligible. refs/main can lag it
|
||||
when a non-main commit was downloaded later, so consulting refs first
|
||||
could load an older revision or fail on its missing files.
|
||||
``snapshot_download(local_files_only = True)`` stays as the fallback for
|
||||
layouts the directory scan cannot interpret. Either way resolution never
|
||||
touches the network, and a missing-file snapshot still resolves so the
|
||||
subsequent weight load fails instead of downloading the gaps, which is
|
||||
the fail-closed behavior background loads need.
|
||||
"""
|
||||
resolved = _snapshot_dir_fallback(repo_id, cache_dir)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
return snapshot_download(
|
||||
repo_id,
|
||||
local_files_only = True,
|
||||
token = hf_token or None,
|
||||
cache_dir = cache_dir or None,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -40,6 +40,13 @@ class LoadRequest(BaseModel):
|
|||
gguf_variant: Optional[str] = Field(
|
||||
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
|
||||
)
|
||||
local_files_only: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"Resolve a cached repo id against the local snapshot only and "
|
||||
"never download missing files (background auto-loads)."
|
||||
),
|
||||
)
|
||||
trust_remote_code: bool = Field(
|
||||
False,
|
||||
description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
||||
|
|
@ -249,6 +256,14 @@ class ValidateModelRequest(BaseModel):
|
|||
"delegate fitting to llama.cpp, while explicit layers are user-owned."
|
||||
),
|
||||
)
|
||||
local_files_only: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"Background auto-loads: resolve model metadata from the local "
|
||||
"cache only and reject candidates whose runtime would pull "
|
||||
"remote auxiliaries (e.g. audio codec models)."
|
||||
),
|
||||
)
|
||||
include_context_length: bool = Field(
|
||||
False,
|
||||
description = "Also read the native context length from the local GGUF header. "
|
||||
|
|
|
|||
|
|
@ -5045,8 +5045,9 @@ async def _load_model_impl(
|
|||
|
||||
# is_lora auto-detected from adapter_config.json on disk/HF.
|
||||
# DNS-probe wrap so offline loads skip 30-60s of soft-failed network
|
||||
# checks before the worker starts.
|
||||
with _hf_offline_if_dns_dead():
|
||||
# checks before the worker starts. Local-only loads force offline so
|
||||
# metadata resolution itself cannot reach the Hub.
|
||||
with _hf_offline_if_dns_dead(force = request.local_files_only):
|
||||
config = ModelConfig.from_identifier(
|
||||
model_id = model_identifier,
|
||||
hf_token = request.hf_token,
|
||||
|
|
@ -5059,6 +5060,51 @@ async def _load_model_impl(
|
|||
detail = f"Invalid model identifier: {model_log_label}",
|
||||
)
|
||||
|
||||
# A background auto-load promises on-device-only resolution, but a
|
||||
# cache populated outside Studio can pass the partial check while
|
||||
# missing shards, and from_pretrained on a repo id would download the
|
||||
# gaps. Rewriting the load path to the locally resolved snapshot keeps
|
||||
# the registry identity (config.identifier) intact while forcing the
|
||||
# weight load to the files actually on disk, so an incomplete cache
|
||||
# fails over to the next candidate instead of fetching.
|
||||
from utils.paths import is_local_path
|
||||
|
||||
# Python audio runtimes fetch codec auxiliaries (SNAC/DAC/BiCodec repos)
|
||||
# at load time regardless of where the weights live, so a local-only
|
||||
# load of a non-GGUF audio model still hits the network. Refuse it.
|
||||
if request.local_files_only and not config.is_gguf and getattr(config, "is_audio", False):
|
||||
raise HTTPException(
|
||||
status_code = 409,
|
||||
detail = (
|
||||
f"Model '{model_log_label}' needs audio codec downloads; "
|
||||
"select it explicitly to load it."
|
||||
),
|
||||
)
|
||||
|
||||
if request.local_files_only and not config.is_gguf and not is_local_path(config.path):
|
||||
from hub.utils.local_snapshot import resolve_local_snapshot_path
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
|
||||
# Resolve against the LIVE Studio-managed hub cache: Studio can
|
||||
# move the cache at runtime without rewriting Hugging Face's
|
||||
# import-time env constants, so the helper's default would search
|
||||
# the stale location and 409 models the inventory just found.
|
||||
local_snapshot = await asyncio.to_thread(
|
||||
resolve_local_snapshot_path,
|
||||
config.path,
|
||||
request.hf_token,
|
||||
str(get_hf_cache_paths().hub_cache),
|
||||
)
|
||||
if local_snapshot is None:
|
||||
raise HTTPException(
|
||||
status_code = 409,
|
||||
detail = (
|
||||
f"Model '{model_log_label}' is not available on device; "
|
||||
"select it explicitly to download it."
|
||||
),
|
||||
)
|
||||
config.path = local_snapshot
|
||||
|
||||
# Normalize gpu_ids: empty list means auto-selection, same as None
|
||||
effective_gpu_ids = request.gpu_ids if request.gpu_ids else None
|
||||
|
||||
|
|
@ -5089,7 +5135,13 @@ async def _load_model_impl(
|
|||
# to match. Off-loop: tier resolution reads configs.
|
||||
if effective_load_in_4bit and not config.is_gguf:
|
||||
from utils.transformers_version import latest_tier_active_for
|
||||
if await asyncio.to_thread(latest_tier_active_for, config.identifier, request.hf_token):
|
||||
|
||||
# Local-only loads probe the tier against the resolved snapshot:
|
||||
# a repo id would reach _remote_lora_base's raw HTTP request and
|
||||
# Hub config reads, while a local path resolves from config.json
|
||||
# on disk (non-canonical ids skip the remote adapter probe).
|
||||
_tier_target = config.path if request.local_files_only else config.identifier
|
||||
if await asyncio.to_thread(latest_tier_active_for, _tier_target, request.hf_token):
|
||||
effective_load_in_4bit = False
|
||||
logger.info(
|
||||
f"Latest-transformers sidecar active for '{model_log_label}' - "
|
||||
|
|
@ -5111,10 +5163,17 @@ async def _load_model_impl(
|
|||
|
||||
# Apply the training coexistence policy before the unload step below
|
||||
# frees the resident model. Off-loop: the default-mode guard does sync work.
|
||||
# Local-only non-GGUF loads size against the resolved snapshot so the
|
||||
# guard's memory estimation reads local files instead of hf model_info
|
||||
# (which performs no offline-mode check). GGUF sizing already reads
|
||||
# the cached file under its own local-only handling.
|
||||
_guard_identifier = (
|
||||
config.path if request.local_files_only and not config.is_gguf else model_identifier
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_guard_chat_load_against_training,
|
||||
config,
|
||||
model_identifier = model_identifier,
|
||||
model_identifier = _guard_identifier,
|
||||
hf_token = request.hf_token,
|
||||
load_in_4bit = effective_load_in_4bit,
|
||||
max_seq_length = request.max_seq_length,
|
||||
|
|
@ -5201,6 +5260,10 @@ async def _load_model_impl(
|
|||
hf_repo = config.gguf_hf_repo,
|
||||
hf_variant = config.gguf_variant,
|
||||
hf_token = request.hf_token,
|
||||
# Background auto-loads never download: main quant resolves
|
||||
# cache-only and the optional mmproj/MTP companions are
|
||||
# cached-or-skipped instead of fetched.
|
||||
local_files_only = request.local_files_only,
|
||||
)
|
||||
else:
|
||||
# Local mode: llama-server loads via -m <path>
|
||||
|
|
@ -5414,6 +5477,9 @@ async def _load_model_impl(
|
|||
approved_remote_code_fingerprint = request.approved_remote_code_fingerprint,
|
||||
gpu_ids = effective_gpu_ids,
|
||||
subject = current_subject,
|
||||
# Threads the rewritten snapshot path and forced-offline load into
|
||||
# the worker subprocess, which rebuilds its own ModelConfig.
|
||||
local_files_only = request.local_files_only,
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
|
@ -5652,10 +5718,19 @@ async def validate_model(
|
|||
|
||||
native_grant_backed = False
|
||||
model_log_label = request.model_path
|
||||
# Local-only validation (background auto-loads) covers the WHOLE metadata
|
||||
# and security preflight with a forced-offline env, not just the identifier
|
||||
# probe: the upgrade, remote-code, and file-security helpers below all read
|
||||
# Hub configs and would otherwise refetch them for a cache-only candidate.
|
||||
import contextlib
|
||||
|
||||
_local_only_offline = contextlib.ExitStack()
|
||||
try:
|
||||
model_identifier, model_log_label, native_grant_backed = (
|
||||
_resolve_model_identifier_for_request(request, operation = "validate-model")
|
||||
)
|
||||
if request.local_files_only:
|
||||
_local_only_offline.enter_context(_hf_offline_if_dns_dead(force = True))
|
||||
config = ModelConfig.from_identifier(
|
||||
model_id = model_identifier,
|
||||
hf_token = request.hf_token,
|
||||
|
|
@ -5668,6 +5743,22 @@ async def validate_model(
|
|||
detail = f"Invalid model identifier: {model_log_label}",
|
||||
)
|
||||
|
||||
# Same audio gate as /load: non-GGUF audio models pull codec repos at
|
||||
# load time, so a local-only candidate is rejected here before the
|
||||
# frontend burns a load attempt on it.
|
||||
if (
|
||||
request.local_files_only
|
||||
and not getattr(config, "is_gguf", False)
|
||||
and getattr(config, "is_audio", False)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code = 409,
|
||||
detail = (
|
||||
f"Model '{model_log_label}' needs audio codec downloads; "
|
||||
"select it explicitly to load it."
|
||||
),
|
||||
)
|
||||
|
||||
# Apply the same training coexistence policy as /load before the frontend
|
||||
# unloads the current model.
|
||||
effective_gpu_ids = request.gpu_ids if request.gpu_ids else None
|
||||
|
|
@ -5903,6 +5994,8 @@ async def validate_model(
|
|||
status_code = 400,
|
||||
detail = "Invalid model",
|
||||
)
|
||||
finally:
|
||||
_local_only_offline.close()
|
||||
|
||||
|
||||
# studio_router only: admin action, kept off the OpenAI-compatible /v1 mount.
|
||||
|
|
|
|||
252
studio/backend/tests/test_local_snapshot_resolution.py
Normal file
252
studio/backend/tests/test_local_snapshot_resolution.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Local-only snapshot resolution for background auto-loads.
|
||||
|
||||
A cache populated outside Studio (no download manifest) passes the partial
|
||||
check while missing shard files, and ``from_pretrained`` on a repo id would
|
||||
download the gaps. Background loads therefore rewrite the load path to the
|
||||
LOCALLY resolved snapshot: resolution never touches the network, an uncached
|
||||
repo resolves to None (409 upstream), and an incomplete snapshot still
|
||||
resolves so the weight load fails on the missing files instead of fetching
|
||||
them. No GPU or network required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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)
|
||||
|
||||
pytest.importorskip("huggingface_hub")
|
||||
|
||||
from hub.utils.local_snapshot import resolve_local_snapshot_path
|
||||
|
||||
|
||||
_REV = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
|
||||
def _build_cached_repo(
|
||||
cache_dir: Path,
|
||||
repo_id: str,
|
||||
files: dict[str, str],
|
||||
with_refs: bool = True,
|
||||
rev: str = _REV,
|
||||
) -> Path:
|
||||
"""Lay out a minimal HF hub cache entry the way huggingface_hub expects:
|
||||
``models--org--name/refs/main`` pointing at a snapshot directory.
|
||||
``with_refs = False`` builds the revision-only layout (pruned or foreign
|
||||
caches) the inventory scanner accepts."""
|
||||
repo_dir = cache_dir / f"models--{repo_id.replace('/', '--')}"
|
||||
snapshot = repo_dir / "snapshots" / rev
|
||||
snapshot.mkdir(parents = True)
|
||||
if with_refs:
|
||||
(repo_dir / "refs").mkdir(exist_ok = True)
|
||||
(repo_dir / "refs" / "main").write_text(rev)
|
||||
for name, content in files.items():
|
||||
(snapshot / name).write_text(content)
|
||||
return snapshot
|
||||
|
||||
|
||||
def test_cached_repo_resolves_to_its_snapshot_dir(tmp_path):
|
||||
snapshot = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/tiny-model",
|
||||
{"config.json": "{}", "model.safetensors": "weights"},
|
||||
)
|
||||
resolved = resolve_local_snapshot_path("org/tiny-model", cache_dir = str(tmp_path))
|
||||
assert resolved is not None
|
||||
assert Path(resolved).resolve() == snapshot.resolve()
|
||||
|
||||
|
||||
def test_incomplete_snapshot_still_resolves_locally(tmp_path):
|
||||
"""Missing shards must not block resolution: the local path is what makes
|
||||
the subsequent weight load fail closed instead of downloading."""
|
||||
snapshot = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/half-downloaded",
|
||||
{
|
||||
"config.json": "{}",
|
||||
"model-00001-of-00002.safetensors": "first shard only",
|
||||
},
|
||||
)
|
||||
resolved = resolve_local_snapshot_path("org/half-downloaded", cache_dir = str(tmp_path))
|
||||
assert resolved is not None
|
||||
assert Path(resolved).resolve() == snapshot.resolve()
|
||||
|
||||
|
||||
def test_uncached_repo_resolves_to_none(tmp_path):
|
||||
assert resolve_local_snapshot_path("org/never-downloaded", cache_dir = str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_newest_snapshot_preferred_over_refs_main(tmp_path):
|
||||
"""A newer snapshot downloaded at an explicit revision outranks the older
|
||||
refs/main target: the inventory surfaces the newest snapshot by mtime, so
|
||||
the load must resolve the same one instead of an older (possibly
|
||||
incomplete) main revision."""
|
||||
import os
|
||||
import time
|
||||
|
||||
old_main = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/newer-rev",
|
||||
{"config.json": "{}"},
|
||||
rev = "a" * 40,
|
||||
)
|
||||
stale = time.time() - 1000
|
||||
os.utime(old_main, (stale, stale))
|
||||
newer = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/newer-rev",
|
||||
{"config.json": "{}", "model.safetensors": "weights"},
|
||||
with_refs = False,
|
||||
rev = "b" * 40,
|
||||
)
|
||||
resolved = resolve_local_snapshot_path("org/newer-rev", cache_dir = str(tmp_path))
|
||||
assert resolved is not None
|
||||
assert Path(resolved).resolve() == newer.resolve()
|
||||
|
||||
|
||||
def test_revision_only_snapshot_resolves_without_refs(tmp_path):
|
||||
"""snapshot_download(local_files_only = True) needs refs/main, but the
|
||||
inventory scanner accepts revision-only layouts (pruned refs), so the
|
||||
resolver must fall back to the snapshot directory itself."""
|
||||
snapshot = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/no-refs",
|
||||
{"config.json": "{}", "model.safetensors": "weights"},
|
||||
with_refs = False,
|
||||
)
|
||||
resolved = resolve_local_snapshot_path("org/no-refs", cache_dir = str(tmp_path))
|
||||
assert resolved is not None
|
||||
assert Path(resolved).resolve() == snapshot.resolve()
|
||||
|
||||
|
||||
def test_refless_fallback_picks_newest_snapshot_with_config(tmp_path):
|
||||
"""With several revision dirs, the fallback must pick the newest one that
|
||||
actually holds a config.json, skipping empty or partial revisions."""
|
||||
import os
|
||||
import time
|
||||
|
||||
old = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/multi-rev",
|
||||
{"config.json": "{}"},
|
||||
with_refs = False,
|
||||
rev = "a" * 40,
|
||||
)
|
||||
stale = time.time() - 1000
|
||||
os.utime(old, (stale, stale))
|
||||
new = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/multi-rev",
|
||||
{"config.json": "{}"},
|
||||
with_refs = False,
|
||||
rev = "b" * 40,
|
||||
)
|
||||
configless = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/multi-rev",
|
||||
{"tokenizer.json": "{}"},
|
||||
with_refs = False,
|
||||
rev = "c" * 40,
|
||||
)
|
||||
assert configless.exists()
|
||||
resolved = resolve_local_snapshot_path("org/multi-rev", cache_dir = str(tmp_path))
|
||||
assert resolved is not None
|
||||
assert Path(resolved).resolve() == new.resolve()
|
||||
|
||||
|
||||
def test_gguf_rows_select_gguf_bearing_snapshot_in_mixed_repos(tmp_path):
|
||||
"""A mixed repo caching a newer safetensors revision beside an older GGUF
|
||||
revision: the GGUF row's snapshot selection must return the GGUF-bearing
|
||||
revision, not the safetensors one the model row prefers."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
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.cache_inventory import (
|
||||
_cached_gguf_repo_snapshot_path,
|
||||
_cached_model_snapshot_path,
|
||||
)
|
||||
|
||||
repo_dir = tmp_path / "models--org--mixed"
|
||||
gguf_rev = repo_dir / "snapshots" / ("a" * 40)
|
||||
gguf_rev.mkdir(parents = True)
|
||||
(gguf_rev / "mixed-Q4_K_M.gguf").write_text("gguf-bytes")
|
||||
stale = time.time() - 1000
|
||||
os.utime(gguf_rev, (stale, stale))
|
||||
st_rev = repo_dir / "snapshots" / ("b" * 40)
|
||||
st_rev.mkdir(parents = True)
|
||||
(st_rev / "config.json").write_text("{}")
|
||||
(st_rev / "model.safetensors").write_text("weights")
|
||||
|
||||
gguf_pick = _cached_gguf_repo_snapshot_path(repo_dir)
|
||||
assert gguf_pick is not None
|
||||
assert Path(gguf_pick).resolve() == gguf_rev.resolve()
|
||||
model_pick = _cached_model_snapshot_path(repo_dir)
|
||||
assert model_pick is not None
|
||||
assert Path(model_pick).resolve() == st_rev.resolve()
|
||||
|
||||
|
||||
def test_weightless_newest_snapshot_does_not_shadow_complete_older_one(tmp_path):
|
||||
"""A newest metadata-only revision (config.json, no weights) must not win
|
||||
over an older revision holding the inventoried safetensors weights: the
|
||||
inventory made the row eligible from the weightful revision, so the load
|
||||
must resolve that one instead of failing on the weightless dir."""
|
||||
import os
|
||||
import time
|
||||
|
||||
complete = _build_cached_repo(
|
||||
tmp_path,
|
||||
"org/meta-newest",
|
||||
{"config.json": "{}", "model.safetensors": "weights"},
|
||||
with_refs = False,
|
||||
rev = "a" * 40,
|
||||
)
|
||||
stale = time.time() - 1000
|
||||
os.utime(complete, (stale, stale))
|
||||
_build_cached_repo(
|
||||
tmp_path,
|
||||
"org/meta-newest",
|
||||
{"config.json": "{}"},
|
||||
with_refs = False,
|
||||
rev = "b" * 40,
|
||||
)
|
||||
resolved = resolve_local_snapshot_path("org/meta-newest", cache_dir = str(tmp_path))
|
||||
assert resolved is not None
|
||||
assert Path(resolved).resolve() == complete.resolve()
|
||||
|
||||
|
||||
def test_refless_fallback_without_config_resolves_to_none(tmp_path):
|
||||
"""A snapshots dir with no config.json anywhere is not a loadable text
|
||||
model cache; resolution must stay None (409 upstream), not guess."""
|
||||
_build_cached_repo(
|
||||
tmp_path,
|
||||
"org/no-config",
|
||||
{"tokenizer.json": "{}"},
|
||||
with_refs = False,
|
||||
)
|
||||
assert resolve_local_snapshot_path("org/no-config", cache_dir = str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_resolution_never_uses_the_network(tmp_path, monkeypatch):
|
||||
"""local_files_only resolution must not open any connection even when the
|
||||
repo is absent (the tempting fallback would be a Hub metadata call)."""
|
||||
import socket
|
||||
|
||||
def _no_network(*_args, **_kwargs):
|
||||
raise AssertionError("network access attempted during local resolution")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", _no_network)
|
||||
_build_cached_repo(tmp_path, "org/offline-ok", {"config.json": "{}"})
|
||||
assert resolve_local_snapshot_path("org/offline-ok", cache_dir = str(tmp_path))
|
||||
assert resolve_local_snapshot_path("org/absent", cache_dir = str(tmp_path)) is None
|
||||
146
studio/backend/tests/test_offline_guard_refcount.py
Normal file
146
studio/backend/tests/test_offline_guard_refcount.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Refcounted HF offline guard semantics.
|
||||
|
||||
Overlapping local-only validations/loads share one process-global env
|
||||
override. The refcount means a request finishing first cannot restore the
|
||||
environment while another local-only request still runs (which would let its
|
||||
remaining metadata checks reach the Hub), and forced mode overrides an
|
||||
explicitly falsy HF_HUB_OFFLINE=0 then restores it. The guard is extracted
|
||||
from source and exercised with stubbed logging/DNS so no ML dependencies are
|
||||
needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_LLAMA_CPP = Path(__file__).resolve().parent.parent / "core" / "inference" / "llama_cpp.py"
|
||||
|
||||
|
||||
class _NullLogger:
|
||||
def info(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def warning(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def _load_guard(dns_dead: bool = False):
|
||||
src = _LLAMA_CPP.read_text()
|
||||
start = src.index("def _hub_offline_env_truthy")
|
||||
end = src.index("_SLOT_SAVE_MAX_BYTES")
|
||||
end = src.rindex("try:", start, end)
|
||||
block = src[start:end]
|
||||
ns = {
|
||||
"threading": threading,
|
||||
"contextlib": contextlib,
|
||||
"os": os,
|
||||
"logger": _NullLogger(),
|
||||
"_hf_env_offline": lambda: os.environ.get("HF_HUB_OFFLINE", "").strip().lower()
|
||||
in {"1", "true", "yes", "on"},
|
||||
"_probe_dns_dead": lambda: dns_dead,
|
||||
}
|
||||
exec(block, ns)
|
||||
return ns["_hf_offline_if_dns_dead"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
||||
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
|
||||
|
||||
|
||||
def test_overlapping_guards_restore_only_after_last_exit(clean_env):
|
||||
guard = _load_guard()
|
||||
a = guard(force = True)
|
||||
b = guard(force = True)
|
||||
assert a.__enter__() is True
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
assert b.__enter__() is True
|
||||
a.__exit__(None, None, None)
|
||||
assert (
|
||||
os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
), "first exit must not restore while another guard is active"
|
||||
b.__exit__(None, None, None)
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
|
||||
def test_force_overrides_and_restores_falsy_env(clean_env):
|
||||
guard = _load_guard()
|
||||
os.environ["HF_HUB_OFFLINE"] = "0"
|
||||
g = guard(force = True)
|
||||
assert g.__enter__() is True
|
||||
assert os.environ["HF_HUB_OFFLINE"] == "1"
|
||||
g.__exit__(None, None, None)
|
||||
assert os.environ["HF_HUB_OFFLINE"] == "0"
|
||||
|
||||
|
||||
def test_falsy_env_stays_authoritative_for_ordinary_loads(clean_env):
|
||||
guard = _load_guard(dns_dead = True)
|
||||
os.environ["HF_HUB_OFFLINE"] = "0"
|
||||
g = guard(force = False)
|
||||
assert g.__enter__() is False
|
||||
assert os.environ["HF_HUB_OFFLINE"] == "0"
|
||||
g.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_truthy_user_env_is_a_noop(clean_env):
|
||||
guard = _load_guard()
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
g = guard(force = True)
|
||||
assert g.__enter__() is False
|
||||
g.__exit__(None, None, None)
|
||||
assert os.environ["HF_HUB_OFFLINE"] == "1"
|
||||
|
||||
|
||||
def test_transformers_only_env_does_not_satisfy_forced_guard(clean_env):
|
||||
"""TRANSFORMERS_OFFLINE=1 alone is not hub-offline: huggingface_hub
|
||||
ignores it, so a forced guard must still install HF_HUB_OFFLINE for the
|
||||
block and restore the prior state after."""
|
||||
guard = _load_guard()
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
g = guard(force = True)
|
||||
assert g.__enter__() is True
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
g.__exit__(None, None, None)
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
|
||||
|
||||
|
||||
def test_nonforce_never_extends_forced_window(clean_env):
|
||||
"""An ordinary (non-force) guard entering while a forced override is
|
||||
active must no-op, not join: its block tolerates either env state, and
|
||||
joining would only widen the exposure of concurrent online work to the
|
||||
process-global override."""
|
||||
guard = _load_guard()
|
||||
forced = guard(force = True)
|
||||
plain = guard(force = False)
|
||||
assert forced.__enter__() is True
|
||||
assert plain.__enter__() is False
|
||||
forced.__exit__(None, None, None)
|
||||
assert (
|
||||
"HF_HUB_OFFLINE" not in os.environ
|
||||
), "the forced owner's exit restores; the ordinary no-op guard holds nothing"
|
||||
plain.__exit__(None, None, None)
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
|
||||
def test_forced_guards_share_windows_with_dns_dead_owners(clean_env):
|
||||
"""A forced guard joining a DNS-dead override keeps the env until the
|
||||
forced guard itself exits, even when the owner exits first."""
|
||||
guard = _load_guard(dns_dead = True)
|
||||
plain = guard(force = False)
|
||||
forced = guard(force = True)
|
||||
assert plain.__enter__() is True
|
||||
assert forced.__enter__() is True
|
||||
plain.__exit__(None, None, None)
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
forced.__exit__(None, None, None)
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -169,6 +169,9 @@ export async function validateModel(
|
|||
// --fit, while a pinned layer count is owned by the user. Tell validate
|
||||
// so it applies the same training-guard policy as /load.
|
||||
gpu_memory_mode: payload.gpu_memory_mode,
|
||||
// Background auto-loads: resolve metadata from the local cache only
|
||||
// and reject candidates whose runtime pulls remote auxiliaries.
|
||||
local_files_only: payload.local_files_only ?? false,
|
||||
}),
|
||||
});
|
||||
return parseJsonOrThrow<ValidateModelResponse>(response);
|
||||
|
|
@ -380,6 +383,9 @@ export interface CachedModelRepo {
|
|||
repo_id: string;
|
||||
load_id?: string | null;
|
||||
size_bytes: number;
|
||||
/** Weight bytes of the newest cached snapshot only (what a load resolves);
|
||||
* size_bytes sums selected-format blobs across every cached revision. */
|
||||
snapshot_size_bytes?: number | null;
|
||||
/** Epoch seconds of the newest downloaded weight file; sorts Downloaded
|
||||
* newest-first. Optional for older-backend compatibility. */
|
||||
last_modified?: number;
|
||||
|
|
@ -946,6 +952,9 @@ export async function listGgufVariants(
|
|||
options?: {
|
||||
preferLocalCache?: boolean;
|
||||
localPath?: string | null;
|
||||
/** Aborts the underlying fetch; background scans pass a timeout signal so
|
||||
* one hung request cannot gate Send forever. */
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<GgufVariantsResponse> {
|
||||
const params = new URLSearchParams({ repo_id: repoId });
|
||||
|
|
@ -958,6 +967,7 @@ export async function listGgufVariants(
|
|||
}
|
||||
const response = await authFetch(`/api/models/gguf-variants?${params}`, {
|
||||
headers: hubTokenHeader(hfToken),
|
||||
signal: options?.signal,
|
||||
});
|
||||
return parseJsonOrThrow<GgufVariantsResponse>(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1080,21 +1080,39 @@ export function useChatModelRuntime() {
|
|||
}
|
||||
}
|
||||
await refresh({ signal: abortCtrl.signal });
|
||||
// Native-picked files are never remembered: their access depends
|
||||
// on a signed, expiring path lease that a raw remembered path
|
||||
// would bypass. Backend-indexed local rows (models dir, LM
|
||||
// Studio, custom scan folders; selection source "local") are
|
||||
// remembered and re-resolved through inventory on auto-load.
|
||||
// Other arbitrary paths keep the isLocalModelPath protection.
|
||||
const indexedLocalSelection =
|
||||
typeof selection !== "string" && selection.source === "local";
|
||||
if (
|
||||
!isLora &&
|
||||
!(loadResponse.is_lora ?? false) &&
|
||||
!nativePathToken &&
|
||||
!isLocalModelPath(modelId) &&
|
||||
!isExternalModelId(modelId)
|
||||
!isExternalModelId(modelId) &&
|
||||
(indexedLocalSelection || !isLocalModelPath(modelId))
|
||||
) {
|
||||
if (loadResponse.is_gguf || isGguf || ggufVariant) {
|
||||
const kind =
|
||||
loadResponse.is_gguf || isGguf || ggufVariant
|
||||
? ("gguf" as const)
|
||||
: ("model" as const);
|
||||
if (isLocalModelPath(modelId)) {
|
||||
recordLastLocalModelLoad({
|
||||
id: modelId,
|
||||
kind: "gguf",
|
||||
kind,
|
||||
ggufVariant: ggufVariant ?? null,
|
||||
loadId: modelId,
|
||||
source: "local",
|
||||
});
|
||||
} else {
|
||||
recordLastLocalModelLoad({ id: modelId, kind: "model" });
|
||||
recordLastLocalModelLoad({
|
||||
id: modelId,
|
||||
kind,
|
||||
ggufVariant: ggufVariant ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ export interface LoadModelRequest {
|
|||
load_in_4bit: boolean;
|
||||
is_lora: boolean;
|
||||
gguf_variant?: string | null;
|
||||
/** Resolve a cached repo id against the local snapshot only and never
|
||||
* download missing files (background auto-loads). */
|
||||
local_files_only?: boolean;
|
||||
/** Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust. */
|
||||
trust_remote_code?: boolean;
|
||||
/** sha256 fingerprint pinning user approval of this exact custom-code version. */
|
||||
|
|
|
|||
|
|
@ -3,13 +3,46 @@
|
|||
|
||||
export type LastLocalModelKind = "gguf" | "model";
|
||||
|
||||
/**
|
||||
* Where the remembered model came from:
|
||||
* - "hf_cache": a managed Hugging Face cache repo (active or inactive cache),
|
||||
* resolved through the cached-gguf / cached-models inventory.
|
||||
* - "models_dir" / "lmstudio" / "custom": a backend-indexed local inventory
|
||||
* row, resolved through the /api/hub/local inventory.
|
||||
* - "local": an indexed local row whose exact scan source was not known at
|
||||
* record time (interactive picker loads); resolved like the other local
|
||||
* sources.
|
||||
*
|
||||
* Native file-picker selections are never recorded here: their access depends
|
||||
* on a signed, expiring native path lease that must not be bypassed by a raw
|
||||
* remembered path (the caller skips recording when a lease token is present).
|
||||
*/
|
||||
export type LastLocalModelSource =
|
||||
| "hf_cache"
|
||||
| "models_dir"
|
||||
| "lmstudio"
|
||||
| "custom"
|
||||
| "local";
|
||||
|
||||
export type LastLocalModelLoad = {
|
||||
/** Display / repository identity (HF repo id, or a local path for indexed rows). */
|
||||
id: string;
|
||||
kind: LastLocalModelKind;
|
||||
/** Managed-cache GGUF quant. Null is valid when the load target itself
|
||||
* identifies the actual GGUF (a local file or directory). */
|
||||
ggufVariant: string | null;
|
||||
/** Backend-provided load target when it differs from `id` (e.g. an
|
||||
* inactive-cache row's load_id or an indexed local path). */
|
||||
loadId: string | null;
|
||||
/** Stable backend inventory row identity, when available. */
|
||||
inventoryId: string | null;
|
||||
source: LastLocalModelSource;
|
||||
loadedAt: number;
|
||||
};
|
||||
|
||||
// Kept at v1: new fields are parsed backward-compatibly, so existing records
|
||||
// (which predate loadId/inventoryId/source) keep resolving as managed-cache
|
||||
// entries without a migration pass.
|
||||
const STORAGE_KEY = "unsloth.last-local-model-load.v1";
|
||||
|
||||
function storage(): Storage | null {
|
||||
|
|
@ -24,6 +57,26 @@ function isLastLocalModelKind(value: unknown): value is LastLocalModelKind {
|
|||
return value === "gguf" || value === "model";
|
||||
}
|
||||
|
||||
const LOCAL_MODEL_SOURCES: readonly LastLocalModelSource[] = [
|
||||
"hf_cache",
|
||||
"models_dir",
|
||||
"lmstudio",
|
||||
"custom",
|
||||
"local",
|
||||
];
|
||||
|
||||
function isLastLocalModelSource(value: unknown): value is LastLocalModelSource {
|
||||
return LOCAL_MODEL_SOURCES.includes(value as LastLocalModelSource);
|
||||
}
|
||||
|
||||
export function isManagedCacheSource(source: LastLocalModelSource): boolean {
|
||||
return source === "hf_cache";
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
export function readLastLocalModelLoad(): LastLocalModelLoad | null {
|
||||
try {
|
||||
const raw = storage()?.getItem(STORAGE_KEY);
|
||||
|
|
@ -39,17 +92,25 @@ export function readLastLocalModelLoad(): LastLocalModelLoad | null {
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsed.kind === "gguf" &&
|
||||
(typeof parsed.ggufVariant !== "string" || !parsed.ggufVariant.trim())
|
||||
) {
|
||||
// Legacy v1 records carry no source; they were only ever written for
|
||||
// managed-cache repos.
|
||||
const source = isLastLocalModelSource(parsed.source)
|
||||
? parsed.source
|
||||
: "hf_cache";
|
||||
const ggufVariant = normalizeOptionalString(parsed.ggufVariant);
|
||||
// A managed-cache GGUF loads by repo + quant, so the quant is required.
|
||||
// An indexed local GGUF's load target identifies the file itself, so a
|
||||
// null variant stays valid.
|
||||
if (parsed.kind === "gguf" && source === "hf_cache" && !ggufVariant) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: parsed.id,
|
||||
kind: parsed.kind,
|
||||
ggufVariant:
|
||||
typeof parsed.ggufVariant === "string" ? parsed.ggufVariant : null,
|
||||
ggufVariant,
|
||||
loadId: normalizeOptionalString(parsed.loadId),
|
||||
inventoryId: normalizeOptionalString(parsed.inventoryId),
|
||||
source,
|
||||
loadedAt: parsed.loadedAt,
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -61,22 +122,31 @@ export function recordLastLocalModelLoad(input: {
|
|||
id: string;
|
||||
kind: LastLocalModelKind;
|
||||
ggufVariant?: string | null;
|
||||
loadId?: string | null;
|
||||
inventoryId?: string | null;
|
||||
source?: LastLocalModelSource;
|
||||
}): void {
|
||||
const id = input.id.trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const source = input.source ?? "hf_cache";
|
||||
const ggufVariant = input.ggufVariant?.trim() || null;
|
||||
if (input.kind === "gguf" && !ggufVariant) {
|
||||
if (input.kind === "gguf" && source === "hf_cache" && !ggufVariant) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Only inventory identity is stored: never tokens, native path leases, or
|
||||
// security approvals.
|
||||
storage()?.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
id,
|
||||
kind: input.kind,
|
||||
ggufVariant: input.kind === "gguf" ? ggufVariant : null,
|
||||
loadId: input.loadId?.trim() || null,
|
||||
inventoryId: input.inventoryId?.trim() || null,
|
||||
source,
|
||||
loadedAt: Date.now(),
|
||||
} satisfies LastLocalModelLoad),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export interface CachedModelRepo {
|
|||
format_variant?: string | null;
|
||||
capabilities?: BackendModelCapabilities | null;
|
||||
size_bytes: number;
|
||||
/** Weight bytes of the newest cached snapshot only (what a load resolves);
|
||||
* size_bytes sums selected-format blobs across every cached revision. */
|
||||
snapshot_size_bytes?: number | null;
|
||||
cache_path?: string;
|
||||
last_modified?: number | null;
|
||||
partial?: boolean;
|
||||
|
|
|
|||
|
|
@ -296,9 +296,14 @@ 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
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
# Both cached-repo lookups (remembered model and cascade) route through
|
||||
# the memoized scanRepoVariants, which carries the cache-scoped params.
|
||||
scan_fn = auto_load.split("const scanRepoVariants = (", 1)[1]
|
||||
scan_fn = scan_fn.split("return pending;", 1)[0]
|
||||
assert "preferLocalCache: true" in scan_fn
|
||||
assert "localPath," in scan_fn
|
||||
assert auto_load.count("await scanRepoVariants(repo.repo_id, repo.cache_path)") == 2
|
||||
|
||||
chat_api = _read("features/chat/api/chat-api.ts")
|
||||
variants_fn = chat_api.split("export async function listGgufVariants", 1)[1]
|
||||
|
|
@ -580,6 +585,802 @@ def test_legacy_migration_is_idempotent_and_non_destructive():
|
|||
assert "if (isDefaultConfig(migrated) || Object.hasOwn(map, key)) {" in src
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Send-with-no-model auto-load (issue #7374): on-device discovery must cover
|
||||
# every picker inventory source, the remembered model must survive local
|
||||
# (non-cache) loads, and the send path must never start a remote download.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _autoload_section() -> str:
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
return src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
|
||||
|
||||
def test_send_path_cannot_reach_hardcoded_default_download():
|
||||
"""Pressing Send with no model loaded must never fetch the hard-coded
|
||||
default repo from Hugging Face (the unconsented download in the bug
|
||||
report). Any recommended download must stay an explicit user action."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "Qwen3.5-4B-MTP-GGUF" not in src
|
||||
assert "Downloading a small model" not in src
|
||||
assert "No downloaded models found" not in src
|
||||
# The old entry point must not linger anywhere.
|
||||
assert "autoLoadSmallestModel" not in src
|
||||
# The renamed entry point runs exactly once per send, so the submitted
|
||||
# prompt executes exactly once after a successful load.
|
||||
assert src.count("await autoLoadOnDeviceModel())") == 1
|
||||
|
||||
|
||||
def test_autoload_no_model_error_is_actionable():
|
||||
"""With no valid on-device candidate the user is told to select or
|
||||
explicitly download a model instead of getting a silent remote load."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "Select a model in the top bar, or download one from the Hub, then retry." in src
|
||||
|
||||
|
||||
def test_autoload_inventory_failure_is_not_empty_inventory():
|
||||
"""A failed cached/local inventory request must stop the automatic
|
||||
selection path, not be swallowed into an empty list that used to fall
|
||||
through to the remote default download."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert ".catch(() => [])" not in src
|
||||
auto_load = _autoload_section()
|
||||
assert "inventoryErrorSurfaced: true" in auto_load
|
||||
# All three inventory sources are queried together and fail closed.
|
||||
for needle in (
|
||||
"listCachedGguf(hfToken)",
|
||||
"listCachedModels(hfToken)",
|
||||
"listLocalModels()",
|
||||
):
|
||||
assert needle in auto_load, needle
|
||||
|
||||
|
||||
def test_autoload_uses_unified_backend_inventory():
|
||||
"""Auto-load must consume the same non-React backend inventory the
|
||||
unified picker uses (no second frontend filesystem scanner), covering
|
||||
the models dir, LM Studio dirs, and custom scan folders."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert re.search(
|
||||
r'import \{[^}]*listLocalModels[^}]*\} from "@/features/hub/inventory/api"',
|
||||
src,
|
||||
re.S,
|
||||
)
|
||||
sources = re.search(r"const AUTO_LOAD_LOCAL_SOURCES[^;]*;", src, re.S)
|
||||
assert sources, "AUTO_LOAD_LOCAL_SOURCES not found"
|
||||
for source in ('"models_dir"', '"lmstudio"', '"custom"'):
|
||||
assert source in sources.group(0), source
|
||||
|
||||
|
||||
def test_autoload_filters_match_picker_policy():
|
||||
"""Only complete, chat-capable, non-hidden rows may auto-load: partial
|
||||
downloads, weightless/non-chat folders, and infrastructure models are
|
||||
excluded with the same policy the picker applies."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
local_fn = src.split("function isAutoLoadableLocalRow", 1)[1]
|
||||
local_fn = local_fn.split("\nfunction ", 1)[0]
|
||||
assert "row.capabilities?.can_chat !== true" in local_fn
|
||||
assert "row.partial" in local_fn
|
||||
# Adapters resolve their base model on load; a Hub-id base would start
|
||||
# the implicit remote fetch a background auto-load must never trigger.
|
||||
assert 'row.model_format === "adapter"' in local_fn
|
||||
assert "isHiddenModelId(row.model_id, row.id, row.path)" in local_fn
|
||||
# The name-based marker only applies to direct .gguf files; a directory
|
||||
# named e.g. /models/foo-be says nothing about the files inside it, which
|
||||
# are filtered per-file during variant resolution.
|
||||
assert 'row.path.toLowerCase().endsWith(".gguf") &&' in local_fn
|
||||
assert "hasBigEndianGgufMarker(row.path, row.format_variant)" in local_fn
|
||||
cached_fn = src.split("function isAutoLoadableCachedRepo", 1)[1]
|
||||
cached_fn = cached_fn.split("\nconst ", 1)[0]
|
||||
assert "repo.partial" in cached_fn
|
||||
# Cached adapter repos are chat-capable too and resolve a base model on
|
||||
# load, so they must be excluded exactly like local adapter rows.
|
||||
assert 'repo.model_format === "adapter"' in cached_fn
|
||||
assert "repo.capabilities?.can_chat === false" in cached_fn
|
||||
assert "isHiddenModelId(repo.repo_id)" in cached_fn
|
||||
|
||||
|
||||
def test_autoload_local_rows_load_via_backend_target():
|
||||
"""Indexed local rows (models dir, LM Studio, custom scan folders) must
|
||||
load through the backend-provided target and record their stable
|
||||
inventory identity, never a reconstructed path or synthetic variant."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "function localRowLoadTarget" in src
|
||||
assert "row.load_id || row.id" in src
|
||||
candidate_fn = src.split("function localRowToCandidate", 1)[1]
|
||||
candidate_fn = candidate_fn.split("\n/**", 1)[0]
|
||||
assert "loadId: localRowLoadTarget(row)" in candidate_fn
|
||||
assert "inventoryId: row.inventory_id ?? null" in candidate_fn
|
||||
# Inactive-cache rows keep loading by their backend load_id.
|
||||
auto_load = _autoload_section()
|
||||
assert auto_load.count("loadId: repo.load_id") >= 3
|
||||
|
||||
|
||||
def test_autoload_remembers_last_model_across_all_sources():
|
||||
"""The remembered model resolves against managed caches AND the indexed
|
||||
local inventory; a stale entry only falls through to other on-device
|
||||
candidates (there is no remote branch left to reach)."""
|
||||
auto_load = _autoload_section()
|
||||
assert "isManagedCacheSource(lastLoaded.source)" in auto_load
|
||||
assert "matchesRememberedLocalRow(candidateRow, lastLoaded)" in auto_load
|
||||
assert "await resolveLocalRowCandidate(" in auto_load
|
||||
assert "lastLoaded.ggufVariant," in auto_load
|
||||
# Managed-cache candidates record their provenance for later resolution.
|
||||
assert auto_load.count('source: "hf_cache"') >= 4
|
||||
|
||||
|
||||
def test_autoload_deduplicates_cached_and_local_candidates():
|
||||
"""Candidates resolving to the same load target (e.g. a custom scan
|
||||
folder pointing into an HF cache) must not be tried twice, but the
|
||||
dedupe must key on actual load targets/paths only: a local copy that
|
||||
merely shares a repo model_id is a distinct set of files and must stay
|
||||
available when the cached copy fails or has no usable quant."""
|
||||
auto_load = _autoload_section()
|
||||
assert "const seenLoadTargets = new Set<string>()" in auto_load
|
||||
# Keys carry the model kind: a folder emitting both GGUF and safetensors
|
||||
# rows shares a path while holding two different models.
|
||||
assert "seenLoadTargets.add(`${kind}:${normalizeLoadTargetKey(alias)}`)" in auto_load
|
||||
assert 'markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path)' in auto_load
|
||||
assert 'markSeen("model", repo.load_id || repo.repo_id, repo.cache_path)' in auto_load
|
||||
assert "isSeen(localCandidate.kind, row.load_id, row.id, row.path)" in auto_load
|
||||
# The repo-id-based dedupe that shadowed distinct local copies is gone.
|
||||
assert "isSeen(row.load_id, row.id, row.path, row.model_id)" not in auto_load
|
||||
assert "markSeen(repo.repo_id," not in auto_load
|
||||
|
||||
|
||||
def test_local_quant_resolution_skips_failed_quants():
|
||||
"""When a folder's smallest quant already failed or was blocked, the
|
||||
resolver must return the next complete quant instead of abandoning the
|
||||
whole folder (which made Send falsely report no model)."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
|
||||
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
|
||||
assert "for (const entry of downloaded)" in resolve_fn
|
||||
assert "if (isSkippedCandidate?.(candidate)) continue;" in resolve_fn
|
||||
# The fallback loop feeds the skip set into resolution.
|
||||
auto_load = _autoload_section()
|
||||
assert "isSkippedAutoLoadCandidate," in auto_load
|
||||
|
||||
|
||||
def test_autoload_trust_guard_still_blocks_background_loads():
|
||||
"""A model needing custom-code approval or a security review is never
|
||||
silently auto-loaded, and a blocked candidate can only cascade to other
|
||||
on-device candidates."""
|
||||
auto_load = _autoload_section()
|
||||
assert "validation.requires_trust_remote_code" in auto_load
|
||||
assert "validation.requires_security_review" in auto_load
|
||||
assert "MAX_AUTO_LOAD_ATTEMPTS" in auto_load
|
||||
|
||||
|
||||
def test_remembered_model_record_supports_local_sources():
|
||||
"""last-local-model-load must represent managed-cache models AND
|
||||
backend-indexed local models: a local GGUF is valid with a null variant,
|
||||
legacy v1 records keep resolving as managed-cache entries, and no
|
||||
secrets (tokens/leases) are ever persisted."""
|
||||
src = _read("features/chat/utils/last-local-model-load.ts")
|
||||
# Same storage key: v1 records parse backward-compatibly, no migration.
|
||||
assert 'const STORAGE_KEY = "unsloth.last-local-model-load.v1";' in src
|
||||
# Legacy records carry no source and default to the managed cache.
|
||||
assert "isLastLocalModelSource(parsed.source)" in src
|
||||
assert ': "hf_cache";' in src
|
||||
# The GGUF-variant requirement is scoped to managed-cache records; a
|
||||
# local GGUF's load target identifies the file, so null stays valid.
|
||||
assert src.count('source === "hf_cache" && !ggufVariant') == 2
|
||||
# Indexed local scan sources are representable.
|
||||
for source in ('"models_dir"', '"lmstudio"', '"custom"'):
|
||||
assert source in src, source
|
||||
# Identity only: never tokens, native path leases, or approvals.
|
||||
assert "nativePath" not in src
|
||||
assert "hfToken" not in src and "hf_token" not in src
|
||||
assert "fingerprint" not in src
|
||||
|
||||
|
||||
def test_interactive_local_loads_are_remembered_without_lease_bypass():
|
||||
"""A successful interactive load of a backend-indexed local model
|
||||
(picker source "local") must be remembered so auto-load can reuse it,
|
||||
while native-picked files (signed, expiring path lease) and other
|
||||
arbitrary paths must never be recorded."""
|
||||
src = _read("features/chat/hooks/use-chat-model-runtime.ts")
|
||||
record_block = src.split("const indexedLocalSelection", 1)[1]
|
||||
record_block = record_block.split("} catch (error) {", 1)[0]
|
||||
assert 'selection.source === "local"' in src
|
||||
assert "!nativePathToken &&" in record_block
|
||||
assert "(indexedLocalSelection || !isLocalModelPath(modelId))" in record_block
|
||||
assert 'source: "local",' in record_block
|
||||
|
||||
|
||||
def test_remembered_local_row_match_requires_kind_agreement():
|
||||
"""A folder holding both GGUF and safetensors weights yields two inventory
|
||||
rows with the same path/load target, so the remembered kind must gate the
|
||||
identifier match or a remembered safetensors load can resolve to the GGUF
|
||||
row (and vice versa)."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
match_fn = src.split("function matchesRememberedLocalRow", 1)[1]
|
||||
match_fn = match_fn.split("\nasync function ", 1)[0].split("\nfunction ", 1)[0]
|
||||
assert '(row.model_format === "gguf") !== (remembered.kind === "gguf")' in match_fn
|
||||
|
||||
|
||||
def test_directory_gguf_rows_resolve_variant_like_picker():
|
||||
"""Directory-based local GGUFs (LM Studio, models dir, custom folders) are
|
||||
flagged requires_variant by the backend, so the fallback must resolve a
|
||||
quant through the variants API (as the picker card does) instead of
|
||||
silently dropping every directory row; non-GGUF variant-requiring rows
|
||||
have no background resolution and stay excluded."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
|
||||
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
|
||||
assert "row.capabilities?.requires_variant === true" in resolve_fn
|
||||
assert "if (!isGguf) return null;" in resolve_fn
|
||||
# Quants must be resolved from the folder the row will load from, not
|
||||
# from a same-id HF cache repo whose quants may be absent locally.
|
||||
assert "const variantScanTarget = isLocalModelPath(row.id) ? row.id : row.path;" in resolve_fn
|
||||
assert "listGgufVariantsBounded(variantScanTarget" in resolve_fn
|
||||
assert "localPath: row.path" in resolve_fn
|
||||
assert "entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry)" in resolve_fn
|
||||
# The cascade must keep directory GGUF rows as candidates.
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert 'row.model_format === "gguf" ||' in auto_load
|
||||
assert "await resolveLocalRowCandidate(" in auto_load
|
||||
|
||||
|
||||
def test_remembered_local_failure_does_not_block_folder_fallback():
|
||||
"""A failed remembered model must exclude only that exact candidate key,
|
||||
not mark the whole row or repo as seen; otherwise a folder or cache repo
|
||||
with another complete quant can never fall back and Send falsely reports
|
||||
no model. Applies to local rows and managed-cache repos alike."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
remembered_block = auto_load.split("if (lastLoaded) {", 1)[1]
|
||||
remembered_block = remembered_block.split("// On-device fallback", 1)[0]
|
||||
assert (
|
||||
"markSeen(" not in remembered_block
|
||||
), "remembered paths must not pre-mark their row/repo as deduped"
|
||||
assert "autoLoadSkipKey(rememberedCandidate)" in remembered_block
|
||||
|
||||
|
||||
def test_fallback_orders_by_resolved_quant_size():
|
||||
"""A GGUF folder or cache repo row's size_bytes sums every quant in it, so
|
||||
the smallest-first fallback must order both local rows and cached repos by
|
||||
the resolved quant's own size; otherwise a repo holding one small quant
|
||||
loses to a larger single-quant model."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
|
||||
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
|
||||
assert "sizeBytes: sizeOrUnknownBytes(entry.size_bytes)" in resolve_fn
|
||||
auto_load = _autoload_section()
|
||||
# Local rows order on the resolved quant size.
|
||||
assert "sizeBytes: resolved.sizeBytes" in auto_load
|
||||
# Cached GGUF repos order on the resolved quant size too.
|
||||
assert "const resolveCachedGgufEntry" in auto_load
|
||||
assert "sizeBytes: sizeOrUnknownBytes(variant.size_bytes)" in auto_load
|
||||
# Non-GGUF cached repos order on the SELECTED snapshot's size (falling
|
||||
# back to the all-revisions row sum for older backends).
|
||||
seed_block = auto_load.split("for (const repo of platform.chatOnly ? [] : modelRepos)", 1)[1]
|
||||
seed_block = seed_block.split("const resolveCachedGgufEntry", 1)[0]
|
||||
assert "repo.snapshot_size_bytes ?? repo.size_bytes" in seed_block
|
||||
|
||||
|
||||
def test_cascade_retries_next_quant_after_load_failure():
|
||||
"""A failed /api/inference/load (not just a blocked validation) must mark
|
||||
that quant skipped and re-enter the folder's or repo's next complete quant
|
||||
into the GLOBAL size order (still ahead of the safetensors group) instead
|
||||
of retrying inline, so one folder of failing quants cannot starve a
|
||||
smaller model elsewhere; single-candidate rows resolve to null once
|
||||
skipped, and the attempt cap bounds total loads."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
# No inline retry loop: retries re-enter the shared ordered pool.
|
||||
assert "while (localCandidate" not in auto_load
|
||||
assert "retry: true," in auto_load
|
||||
assert "insertReady({ ...next, retry: true })" in auto_load
|
||||
# Ordered insertion respects the GGUF-before-safetensors group boundary
|
||||
# and the ascending size order among the remaining candidates.
|
||||
assert "!isModelKindEntry(readyPool[at])" in auto_load
|
||||
assert "readyPool[at].sizeBytes <= entry.sizeBytes" in auto_load
|
||||
# Requeued entries bypass the seen gate; fresh rows still dedupe.
|
||||
assert "if (!candidate.retry) {" in auto_load
|
||||
# The cascade catch records the failed quant before requeueing.
|
||||
assert "skippedAutoLoadCandidates.add(skipKey)" in auto_load
|
||||
assert "skippedAutoLoadCandidates.add(autoLoadSkipKey(localCandidate))" in auto_load
|
||||
# Termination guard: a skipped single candidate resolves to null.
|
||||
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
|
||||
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
|
||||
assert "if (isSkippedCandidate?.(candidate)) return null;" in resolve_fn
|
||||
|
||||
|
||||
def test_cached_rows_deduped_against_local_aliases():
|
||||
"""A cached repo and an indexed local row can alias the same files (e.g.
|
||||
a scan folder pointing into an HF cache). The fallback must not spend a
|
||||
second load attempt re-trying files already visited or failed through the
|
||||
other row: cached branches apply the same seen gate local rows use, and
|
||||
skip keys are scoped to the backend load target both rows share."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert 'if (isSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path))' in auto_load
|
||||
assert 'if (isSeen("model", repo.load_id || repo.repo_id, repo.cache_path))' in auto_load
|
||||
key_fn = src.split("function autoLoadSkipKey", 1)[1]
|
||||
key_fn = key_fn.split("\nfunction ", 1)[0]
|
||||
assert "candidate.loadId ?? candidate.id" in key_fn
|
||||
|
||||
|
||||
def test_send_not_blocked_by_full_inventory_resolution():
|
||||
"""Pressing Send must not wait for every /gguf-variants folder scan before
|
||||
the first load attempt: candidates resolve through a bounded worker pool
|
||||
and are consumed incrementally after a short settle grace, so one slow
|
||||
folder cannot stall the send path behind the transport timeout."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "const AUTO_LOAD_RESOLVE_GRACE_MS" in src
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
# The consumer never awaits full resolution; it waits for the grace
|
||||
# window (cut short when resolution finishes) and then per-completion.
|
||||
assert "await resolutionDone" not in auto_load
|
||||
assert "clearTimeout(graceTimer)" in auto_load
|
||||
assert "await nextProgress();" in auto_load
|
||||
assert "if (pendingJobs <= 0) {" in auto_load
|
||||
# Rows that need no backend scan seed the pool before the workers start,
|
||||
# so they can never queue behind slow folder scans.
|
||||
assert "const needsVariantScan" in auto_load
|
||||
assert ".filter((row) => !needsVariantScan(row))" in auto_load
|
||||
assert auto_load.index(".filter((row) => !needsVariantScan(row))") < auto_load.index(
|
||||
"const cachedScanJobs"
|
||||
)
|
||||
# Cached and local scans interleave so one slow source cannot
|
||||
# monopolize every worker.
|
||||
assert "resolutionJobs.push(cachedScanJobs[jobIndex])" in auto_load
|
||||
assert "resolutionJobs.push(localScanJobs[jobIndex])" in auto_load
|
||||
|
||||
|
||||
def test_pending_gguf_scans_gate_safetensors_candidates():
|
||||
"""GGUF-first is the documented preference order, and incremental
|
||||
consumption must not let an instantly-resolved safetensors row claim a
|
||||
load slot while a pending folder scan can still yield a GGUF candidate;
|
||||
resolved GGUF entries are never gated."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert "if (isModelKindEntry(candidate) && pendingJobs > 0) {" in auto_load
|
||||
|
||||
|
||||
def test_only_first_attempt_leapfrogs_pending_scans():
|
||||
"""Fast-resolving candidates whose loads fail must not exhaust the attempt
|
||||
cap while pending scans can still yield smaller loadable quants: only the
|
||||
first (latency-critical) attempt may run ahead of pending scans; once any
|
||||
budget is spent, the remaining attempts wait for the settled global
|
||||
smallest-first order."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert "if (pendingJobs > 0 && loadAttempts > 0) {" in auto_load
|
||||
|
||||
|
||||
def test_hidden_matcher_fetch_never_blocks_send_unbounded():
|
||||
"""ensureHiddenModelMatchers has no timeout of its own (unlike the
|
||||
30s-bounded inventory calls), so the send path must not await it serially:
|
||||
it runs alongside inventory discovery and is only awaited through a short
|
||||
grace, after which the static needles filter alone."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert "await ensureHiddenModelMatchers()" not in auto_load
|
||||
assert "const hiddenMatchersReady = ensureHiddenModelMatchers().catch(" in auto_load
|
||||
# The platform probe backing the picker's format gates is unbounded too
|
||||
# (raw fetch, no signal), so it joins the same bounded prefetch wait.
|
||||
assert "await fetchDeviceType();" not in auto_load
|
||||
assert "const platformReady: Promise<void> = fetchDeviceType().then(" in auto_load
|
||||
assert "const BEST_EFFORT_PREFETCH_GRACE_MS" in src
|
||||
assert "setTimeout(resolve, BEST_EFFORT_PREFETCH_GRACE_MS)" in auto_load
|
||||
assert "clearTimeout(matcherTimer)" in auto_load
|
||||
|
||||
|
||||
def test_resolution_workers_stop_on_terminal_result():
|
||||
"""A successful early load (or any other terminal outcome) must stop the
|
||||
workers from claiming further folder scans, so autoload cannot leave
|
||||
background scans contending with inference for the backend and disk."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert "let resolutionStopped = false;" in auto_load
|
||||
assert "while (!resolutionStopped && nextJob < resolutionJobs.length)" in auto_load
|
||||
# The flag is set in a finally so every exit path (return, break, throw)
|
||||
# stops the workers.
|
||||
assert "resolutionStopped = true;" in auto_load
|
||||
assert auto_load.index("} finally {") < auto_load.index("resolutionStopped = true;")
|
||||
|
||||
|
||||
def test_local_rows_apply_picker_platform_gate():
|
||||
"""Chat-only installs run GGUF (any host) and MLX (Mac only); the picker
|
||||
hides other local formats and all cached non-GGUF rows there, so the
|
||||
background cascade must not load a row the user could not have picked.
|
||||
The remembered path stays ungated: a recorded load is user precedent."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
local_fn = src.split("function isAutoLoadableLocalRow", 1)[1]
|
||||
local_fn = local_fn.split("\nfunction ", 1)[0]
|
||||
assert "platform.chatOnly" in local_fn
|
||||
assert "localRowIsGgufLike(row)" in local_fn
|
||||
assert "platform.isMac && localRowIsMlxNamed(row)" in local_fn
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
# The platform snapshot hydrates through the bounded best-effort prefetch
|
||||
# (see test_hidden_matcher_fetch_never_blocks_send_unbounded).
|
||||
assert "fetchDeviceType().then(" in auto_load
|
||||
# Cascade seeding of cached non-GGUF repos mirrors the picker's
|
||||
# chat-only exclusion; the remembered lookup above it stays unfiltered.
|
||||
assert "platform.chatOnly ? [] : modelRepos" in auto_load
|
||||
|
||||
|
||||
def test_autoload_keys_preserve_posix_path_case():
|
||||
"""Linux filesystems distinguish /models/Foo from /models/foo, so seen
|
||||
keys and remembered-model matching must not fold case on POSIX paths;
|
||||
Windows-style paths and Hub repo ids keep case-insensitive matching."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
norm_fn = src.split("function normalizeLoadTargetKey", 1)[1]
|
||||
norm_fn = norm_fn.split("\nfunction ", 1)[0].split("\nconst ", 1)[0]
|
||||
assert "looksWindowsPath" in norm_fn
|
||||
assert 'value.startsWith("/") || value.startsWith("~")' in norm_fn
|
||||
assert "return value;" in norm_fn
|
||||
assert "return value.toLowerCase();" in norm_fn
|
||||
match_fn = src.split("function matchesRememberedLocalRow", 1)[1]
|
||||
match_fn = match_fn.split("\nfunction ", 1)[0]
|
||||
assert "normalizeLoadTargetKey" in match_fn
|
||||
# Inventory ids compare exactly (same backend generator on both sides).
|
||||
assert "row.inventory_id === remembered.inventoryId" in match_fn
|
||||
assert "row.inventory_id.toLowerCase()" not in match_fn
|
||||
# Skip keys use the same semantics: a failure recorded for /models/Foo
|
||||
# must not also skip /models/foo.
|
||||
key_fn = src.split("function autoLoadCandidateKey", 1)[1]
|
||||
key_fn = key_fn.split("\nfunction ", 1)[0]
|
||||
assert "normalizeLoadTargetKey(id)" in key_fn
|
||||
assert "id.toLowerCase()" not in key_fn
|
||||
|
||||
|
||||
def test_local_variant_scans_bounded_concurrency():
|
||||
"""Each /gguf-variants call triggers a recursive backend directory scan,
|
||||
so pre-resolution must not fan out unbounded over every indexed folder
|
||||
at once."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "const AUTO_LOAD_VARIANT_SCAN_CONCURRENCY" in src
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert "Math.min(AUTO_LOAD_VARIANT_SCAN_CONCURRENCY, resolutionJobs.length)" in auto_load
|
||||
assert "await Promise.all(\n cascadeLocalRows.map(" not in auto_load
|
||||
|
||||
|
||||
BACKEND = WORKDIR / "studio" / "backend"
|
||||
|
||||
|
||||
def _read_backend(rel: str) -> str:
|
||||
path = BACKEND / rel
|
||||
assert path.exists(), f"missing backend source file: {path}"
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def test_background_loads_resolve_local_files_only():
|
||||
"""A cache populated outside Studio can pass the partial check while
|
||||
missing shard files, and from_pretrained on a repo id downloads the gaps.
|
||||
Background auto-loads therefore send local_files_only and the load route
|
||||
rewrites the path to the locally resolved snapshot (identity intact), so
|
||||
an incomplete cache fails over to the next candidate instead of
|
||||
downloading on Send."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
load_fn = src.split("async function loadAutoLoadCandidate", 1)[1]
|
||||
load_fn = load_fn.split("loadAttempts += 1;", 1)[1]
|
||||
assert "local_files_only: true," in load_fn
|
||||
types = _read("features/chat/types/api.ts")
|
||||
assert "local_files_only?: boolean;" in types
|
||||
|
||||
request_model = _read_backend("models/inference.py")
|
||||
assert "local_files_only: bool = Field(" in request_model
|
||||
|
||||
route = _read_backend("routes/inference.py")
|
||||
assert "request.local_files_only" in route
|
||||
assert "resolve_local_snapshot_path" in route
|
||||
assert "config.path = local_snapshot" in route
|
||||
# Uncached repos fail closed with a conflict, never a download.
|
||||
rewrite = route.split("request.local_files_only", 1)[1]
|
||||
rewrite = rewrite.split("config.path = local_snapshot", 1)[0]
|
||||
assert "status_code = 409" in rewrite
|
||||
|
||||
helper = _read_backend("hub/utils/local_snapshot.py")
|
||||
assert "local_files_only = True" in helper
|
||||
# The rewrite resolves against the LIVE Studio-managed cache location,
|
||||
# not huggingface_hub's import-time default.
|
||||
assert "str(get_hf_cache_paths().hub_cache)," in route
|
||||
|
||||
|
||||
def test_background_candidate_filters_have_no_side_effects():
|
||||
"""Round-13 gates. Local checkpoint rows carry pickle weights with no Hub
|
||||
security scan, so they are never background-picked. The canAutoLoad probe
|
||||
validates with the same local-only policy /load enforces, and the validate
|
||||
route runs it offline so the metadata probe cannot reach the Hub. Non-GGUF
|
||||
audio models are refused under local-only in both routes because their
|
||||
codec runtimes download auxiliaries at load time. Revision-only caches
|
||||
(refs pruned) still resolve through the snapshot-dir fallback."""
|
||||
adapter = _read("features/chat/api/chat-adapter.ts")
|
||||
assert 'if (row.model_format === "checkpoint") {' in adapter
|
||||
|
||||
can_fn = adapter.split("async function canAutoLoad", 1)[1]
|
||||
can_fn = can_fn.split("async function", 1)[0]
|
||||
assert "local_files_only: true," in can_fn
|
||||
|
||||
chat_api = _read("features/chat/api/chat-api.ts")
|
||||
assert "local_files_only: payload.local_files_only ?? false," in chat_api
|
||||
|
||||
request_model = _read_backend("models/inference.py")
|
||||
validate_schema = request_model.split("class ValidateModelRequest", 1)[1]
|
||||
assert "local_files_only: bool = Field(" in validate_schema
|
||||
|
||||
route = _read_backend("routes/inference.py")
|
||||
# /load forces offline resolution under local-only; /validate covers its
|
||||
# whole preflight via the ExitStack wrap (asserted separately below).
|
||||
assert route.count("with _hf_offline_if_dns_dead(force = request.local_files_only):") == 1
|
||||
# Audio gate present in both routes, GGUF exempt (llama.cpp path already
|
||||
# resolves companions cached-or-skipped under the flag).
|
||||
assert route.count("needs audio codec downloads") == 2
|
||||
for gate in route.split("needs audio codec downloads")[:-1]:
|
||||
tail = gate.rsplit("if request.local_files_only", 1)[1]
|
||||
assert "is_gguf" in tail and "is_audio" in tail
|
||||
|
||||
llama = _read_backend("core/inference/llama_cpp.py")
|
||||
assert "def _hf_offline_if_dns_dead(force: bool = False):" in llama
|
||||
# force must also override an explicitly falsy HF_HUB_OFFLINE=0 (only a
|
||||
# TRUTHY env value short-circuits), and overlapping guards are refcounted
|
||||
# so the env is restored only when the LAST one exits; behavior is
|
||||
# exercised directly in test_offline_guard_refcount.py.
|
||||
assert "elif _hf_env_offline() and (not force or _hub_offline_env_truthy()):" in llama
|
||||
assert "_OFFLINE_GUARD_LOCK" in llama
|
||||
assert '_OFFLINE_GUARD_STATE["count"] += 1' in llama
|
||||
|
||||
helper = _read_backend("hub/utils/local_snapshot.py")
|
||||
assert "def _snapshot_dir_fallback(" in helper
|
||||
assert "config.json" in helper
|
||||
|
||||
|
||||
def test_local_only_covers_every_load_and_validate_network_path():
|
||||
"""Round-14 gates. The Vulkan-ordinal GGUF preflight downloads FIRST, so it
|
||||
takes the same local-only flag and forced-offline wrap as the Phase 2
|
||||
download (whose cache-size check would otherwise call get_paths_info).
|
||||
The validate route keeps its whole metadata/security preflight offline, not
|
||||
just the identifier probe. The python load path survives the process
|
||||
boundary: the orchestrator forwards the flag and the route-resolved
|
||||
snapshot path into the worker, which runs the entire load under a scoped
|
||||
offline env and passes the flag to the vision processor fallback."""
|
||||
llama = _read_backend("core/inference/llama_cpp.py")
|
||||
# Both GGUF download blocks in load_model force offline under local-only.
|
||||
assert llama.count("with _hf_offline_if_dns_dead(force = local_files_only):") == 2
|
||||
preflight = llama.split("_preflight_model_path = self._download_gguf(", 1)[1]
|
||||
preflight = preflight.split(")", 1)[0]
|
||||
assert "local_files_only = local_files_only," in preflight
|
||||
|
||||
route = _read_backend("routes/inference.py")
|
||||
validate_src = route.split('operation = "validate-model"', 1)[1]
|
||||
assert (
|
||||
"_local_only_offline.enter_context(_hf_offline_if_dns_dead(force = True))" in validate_src
|
||||
)
|
||||
assert "_local_only_offline.close()" in validate_src
|
||||
# The non-GGUF load threads the flag into the subprocess backend.
|
||||
load_call = route.split("backend.load_model,\n config = config,", 1)[1]
|
||||
load_call = load_call.split(")", 1)[0]
|
||||
assert "local_files_only = request.local_files_only," in load_call
|
||||
|
||||
orchestrator = _read_backend("core/inference/orchestrator.py")
|
||||
assert '"local_files_only": bool(local_files_only),' in orchestrator
|
||||
assert '"local_snapshot_path"' in orchestrator
|
||||
|
||||
worker = _read_backend("core/inference/worker.py")
|
||||
assert "def _local_only_offline_env(" in worker
|
||||
assert "_offline_guard.enter_context(" in worker
|
||||
assert "_offline_guard.close()" in worker
|
||||
# The route's snapshot rewrite is re-applied after the worker rebuilds
|
||||
# its ModelConfig from the identifier.
|
||||
assert 'snapshot_override = config.get("local_snapshot_path")' in worker
|
||||
assert "mc.path = snapshot_override" in worker
|
||||
assert '"local_files_only": bool(config.get("local_files_only", False)),' in worker
|
||||
|
||||
inference = _read_backend("core/inference/inference.py")
|
||||
processor_call = inference.split("processor = AutoProcessor.from_pretrained(", 1)[1]
|
||||
processor_call = processor_call.split("logger.info", 1)[0]
|
||||
assert "local_files_only = local_files_only," in processor_call
|
||||
|
||||
|
||||
def test_background_picks_mirror_inventory_and_skip_installers():
|
||||
"""Round-15 gates. Cached checkpoint repos (pickle weights) are excluded
|
||||
from background picks like local checkpoint rows. The worker keeps its
|
||||
ENTIRE bootstrap offline under local-only and never pip-installs SSM
|
||||
kernels (missing fatal kernels fail into candidate failover). Snapshot
|
||||
resolution prefers the newest snapshot dir, matching the inventory
|
||||
scanner's latest_snapshot_dir selection, before consulting refs/main.
|
||||
MLX loads read config.path so the live-cache rewrite is honored. Cached
|
||||
non-GGUF ordering uses the selected snapshot's size, not the
|
||||
all-revisions blob total."""
|
||||
adapter = _read("features/chat/api/chat-adapter.ts")
|
||||
cached_filter = adapter.split("function isAutoLoadableCachedRepo", 1)[1]
|
||||
cached_filter = cached_filter.split("AUTO_LOAD_LOCAL_SOURCES", 1)[0]
|
||||
assert 'if (repo.model_format === "checkpoint") {' in cached_filter
|
||||
assert "repo.snapshot_size_bytes ?? repo.size_bytes" in adapter
|
||||
|
||||
worker = _read_backend("core/inference/worker.py")
|
||||
assert "_bootstrap_offline = contextlib.ExitStack()" in worker
|
||||
# Entered before base resolution / gates / kernels, closed before BOTH
|
||||
# command loops (MLX and GPU paths).
|
||||
bootstrap = worker.split("_bootstrap_offline = contextlib.ExitStack()", 1)[1]
|
||||
assert bootstrap.count("_bootstrap_offline.close()") == 2
|
||||
ssm_sig = worker.split("def _ensure_ssm_kernels(", 1)[1].split(") -> bool:", 1)[0]
|
||||
assert "local_files_only: bool = False" in ssm_sig
|
||||
ssm = worker.split("def _ensure_ssm_kernels", 1)[1]
|
||||
ssm = ssm.split("def _run_security_gates", 1)[0]
|
||||
assert "if local_files_only:" in ssm
|
||||
assert 'importlib.util.find_spec("mamba_ssm") is None' in ssm
|
||||
|
||||
helper = _read_backend("hub/utils/local_snapshot.py")
|
||||
resolve = helper.split("def resolve_local_snapshot_path", 1)[1]
|
||||
# Newest-snapshot scan runs BEFORE the refs/main-based resolver (compare
|
||||
# the actual calls, not docstring mentions).
|
||||
assert resolve.index("resolved = _snapshot_dir_fallback(") < resolve.index(
|
||||
"return snapshot_download("
|
||||
)
|
||||
|
||||
mlx = _read_backend("core/inference/mlx_inference.py")
|
||||
assert 'load_source = getattr(config, "path", None) or model_name' in mlx
|
||||
|
||||
inventory = _read_backend("hub/services/models/cache_inventory.py")
|
||||
assert "snapshot_size_bytes" in inventory
|
||||
assert "def _snapshot_dir_mtime(" in inventory
|
||||
schema = _read_backend("hub/schemas/inventory.py")
|
||||
assert "snapshot_size_bytes" in schema
|
||||
|
||||
|
||||
def test_forced_offline_is_hub_specific_and_covers_parent_preflight():
|
||||
"""Round-16 gates. A forced guard may only no-op when HF_HUB_OFFLINE
|
||||
itself is truthy (huggingface_hub ignores TRANSFORMERS_OFFLINE), snapshot
|
||||
selection prefers revisions that hold the inventoried safetensors weights
|
||||
so a metadata-only newest revision cannot shadow a complete older one,
|
||||
and the orchestrator's parent-side preflight (transformers tier probe,
|
||||
GPU sizing via hf model_info) runs under the local-only guard, closed
|
||||
before the worker spawn so the child does not inherit the env."""
|
||||
llama = _read_backend("core/inference/llama_cpp.py")
|
||||
assert "def _hub_offline_env_truthy(" in llama
|
||||
assert "not force or _hub_offline_env_truthy()" in llama
|
||||
|
||||
helper = _read_backend("hub/utils/local_snapshot.py")
|
||||
assert "def _snapshot_has_weights(" in helper
|
||||
assert "max(weightful or candidates, key = os.path.getmtime)" in helper
|
||||
|
||||
orchestrator = _read_backend("core/inference/orchestrator.py")
|
||||
preflight = orchestrator.split("def _preflight_offline():", 1)[1]
|
||||
assert "_hf_offline_if_dns_dead(force = True)" in preflight
|
||||
# Both metadata call sites are guarded, and the guard closes before spawn.
|
||||
assert preflight.count("with _preflight_offline():") == 2
|
||||
tier_probe = preflight.split("with _preflight_offline():", 1)[1]
|
||||
assert "needs_transformers_5" in tier_probe.split("with _preflight_offline():", 1)[0]
|
||||
assert "prepare_gpu_selection(" in tier_probe.split("_spawn_subprocess", 1)[0]
|
||||
|
||||
|
||||
def test_generation_and_scan_paths_stay_bounded_and_local():
|
||||
"""Round-17 gates. Inactive-cache rows emit a weight-bearing snapshot as
|
||||
their load_id (the load consumes it directly, bypassing the repo-id
|
||||
resolver). Ordinary offline guards never extend a forced window. The
|
||||
generation-time native template reload honors the load's local-only flag
|
||||
and resolved path instead of refetching the repo id online. Every GGUF
|
||||
variant scan behind the model-kind gate is bounded by an abortable
|
||||
timeout, and an HF cache snapshot registered as a custom folder dedupes
|
||||
against its cached row through the shared cache root."""
|
||||
inventory = _read_backend("hub/services/models/cache_inventory.py")
|
||||
assert "def _weightful_snapshot_path(" in inventory
|
||||
resolver = inventory.split("def _cached_model_snapshot_path(", 1)[1]
|
||||
resolver = resolver.split("def ", 1)[0]
|
||||
assert "_weightful_snapshot_path(repo_path)" in resolver
|
||||
|
||||
llama = _read_backend("core/inference/llama_cpp.py")
|
||||
join_branch = llama.split('if _OFFLINE_GUARD_STATE["count"] > 0:', 1)[1]
|
||||
join_branch = join_branch.split("elif", 1)[0]
|
||||
assert "if force:" in join_branch
|
||||
|
||||
helpers = _read_backend("core/inference/chat_template_helpers.py")
|
||||
reload_block = helpers.split("native_chat_template", 1)[1]
|
||||
reload_block = reload_block.split("model_info[", 1)[0]
|
||||
assert 'local_files_only = bool(model_info.get("local_files_only", False))' in reload_block
|
||||
assert 'template_source = model_info.get("model_path") or template_source' in reload_block
|
||||
assert "local_files_only = local_files_only," in reload_block
|
||||
inference = _read_backend("core/inference/inference.py")
|
||||
assert '"local_files_only": local_files_only,' in inference
|
||||
mlx = _read_backend("core/inference/mlx_inference.py")
|
||||
assert '"local_files_only": local_files_only,' in mlx
|
||||
assert '"model_path": load_source,' in mlx
|
||||
|
||||
adapter = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "AUTO_LOAD_VARIANT_SCAN_TIMEOUT_MS" in adapter
|
||||
assert "async function listGgufVariantsBounded(" in adapter
|
||||
assert "controller.abort()" in adapter
|
||||
# No unbounded scan calls remain in the adapter: every call site routes
|
||||
# through the bounded wrapper (the wrapper itself holds the one direct
|
||||
# call, with the timeout signal attached).
|
||||
assert adapter.count("await listGgufVariants(") == 1
|
||||
chat_api = _read("features/chat/api/chat-api.ts")
|
||||
assert "signal: options?.signal," in chat_api
|
||||
|
||||
assert "function expandSeenValues(" in adapter
|
||||
assert adapter.count("expandSeenValues(value)") == 2
|
||||
|
||||
|
||||
def test_local_only_gguf_reuse_and_platform_gates_are_authoritative():
|
||||
"""Round-18 gates. get_paths_info performs no offline-mode check and
|
||||
HF_HUB_OFFLINE is baked into hub constants at import, so local-only GGUF
|
||||
reuse skips the remote size verification per-call instead of relying on
|
||||
the env guard. Platform format gates only apply once the BACKEND-reported
|
||||
platform is fetched (the browser fallback may describe a different
|
||||
machine). snapshot_size_bytes uses the resolvers' complete-revision
|
||||
predicate (config plus weights in the SAME revision). Cached-repo variant
|
||||
scans are memoized per run so a stalled repo times out once."""
|
||||
llama = _read_backend("core/inference/llama_cpp.py")
|
||||
assert "verify_sizes = not local_files_only," in llama
|
||||
reuse = llama.split("_cached_complete_candidate(hf_repo, gguf_filename, gguf_extra_shards)", 1)[
|
||||
1
|
||||
]
|
||||
reuse = reuse.split("cached_main is not None", 1)[0]
|
||||
assert "local_files_only" in reuse and "_cached_candidate_matches_revision_size" in reuse
|
||||
|
||||
adapter = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "chatOnly: platformState.fetched ? platformState.isChatOnly() : false," in adapter
|
||||
assert "const repoVariantScans = new Map<" in adapter
|
||||
assert "const scanRepoVariants = (" in adapter
|
||||
assert adapter.count("await scanRepoVariants(repo.repo_id, repo.cache_path)") == 2
|
||||
|
||||
inventory = _read_backend("hub/services/models/cache_inventory.py")
|
||||
assert "rev_has_config" in inventory
|
||||
assert 'if selected_category == "safetensors":' in inventory
|
||||
|
||||
|
||||
def test_route_preflights_and_gguf_rows_stay_format_true():
|
||||
"""Round-19 gates. The /load route's sidecar tier probe and training
|
||||
guard size local-only candidates against the resolved snapshot path (a
|
||||
repo id would reach _remote_lora_base's raw HTTP request and hf
|
||||
model_info, neither of which honors offline mode). GGUF cached rows
|
||||
select a GGUF-bearing snapshot, so a mixed repo's safetensors revision
|
||||
cannot become the GGUF row's load target."""
|
||||
route = _read_backend("routes/inference.py")
|
||||
assert "_tier_target = config.path if request.local_files_only else config.identifier" in route
|
||||
guard_block = route.split("_guard_identifier = (", 1)[1]
|
||||
guard_block = guard_block.split("await asyncio.to_thread(", 1)[0]
|
||||
assert "config.path" in guard_block
|
||||
assert "request.local_files_only and not config.is_gguf" in guard_block
|
||||
assert "model_identifier = _guard_identifier," in route
|
||||
|
||||
inventory = _read_backend("hub/services/models/cache_inventory.py")
|
||||
assert "def _newest_snapshot_where(" in inventory
|
||||
assert "def _gguf_snapshot_path(" in inventory
|
||||
assert "def _cached_gguf_repo_snapshot_path(" in inventory
|
||||
gguf_scan = inventory.split("def _scan_cached_gguf(", 1)[1]
|
||||
gguf_scan = gguf_scan.split("def ", 1)[0]
|
||||
assert "_cached_gguf_repo_snapshot_path(repo_path)" in gguf_scan
|
||||
assert "_cached_model_snapshot_path(repo_path)" not in gguf_scan
|
||||
|
||||
|
||||
def test_gguf_background_loads_never_download_companions():
|
||||
"""A cached GGUF load can still fetch from the Hub through its optional
|
||||
companions (mmproj, MTP drafter) or a cache-miss main quant. Background
|
||||
loads pass local_files_only into the llama.cpp path: companions resolve
|
||||
cached-or-skipped, and a main-quant cache miss raises instead of
|
||||
downloading."""
|
||||
route = _read_backend("routes/inference.py")
|
||||
gguf_source = route.split("if config.gguf_hf_repo:", 1)[1]
|
||||
gguf_source = gguf_source.split("else:", 1)[0]
|
||||
assert "local_files_only = request.local_files_only," in gguf_source
|
||||
|
||||
llama = _read_backend("core/inference/llama_cpp.py")
|
||||
# The flag flows to the main quant and both companion helpers, and the
|
||||
# crash-replay kwargs keep it so a reload stays local-only.
|
||||
assert llama.count("local_files_only = local_files_only,") >= 3
|
||||
assert '"local_files_only": local_files_only,' in llama
|
||||
# Companions: cached-or-skipped, never fetched.
|
||||
assert 'logger.info("Skipping %s fetch (local-only load)", label)' in llama
|
||||
assert "if local_files_only or _hf_env_offline():" in llama
|
||||
# Main quant: a cache miss fails closed.
|
||||
download = llama.split("def _download_gguf", 1)[1]
|
||||
download = download.split("def _download_companion_gguf", 1)[0]
|
||||
assert "if local_files_only:" in download
|
||||
assert "select it explicitly to download it." in download
|
||||
|
||||
inference = _read_backend("core/inference/inference.py")
|
||||
# The vision processor fallback stays on the (possibly rewritten local)
|
||||
# load path instead of refetching by repo id.
|
||||
assert "config.base_model if config.is_lora else config.path" in inference
|
||||
assert "config.base_model if config.is_lora else config.identifier" not in inference
|
||||
|
||||
|
||||
def test_vulkan_inference_devices_are_the_pickable_set():
|
||||
"""GGUF loads run through llama-server, so on a Vulkan build the picker must
|
||||
offer the inference inventory (ggml ordinals, the space `--device Vulkan<i>`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue