Keep an explicit local_files_only load local-only at save time

transformers takes local_files_only as an explicit from_pretrained parameter,
so it never lands in tokenizer.init_kwargs, and _offline_aware_load restores
HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE as soon as the load window closes. A VLM
loaded with local_files_only = True but no offline env var therefore came back
with the Hub repo id in name_or_path and nothing recording the request, so
_tokenizer_wants_local_only returned False on the later save and
_has_tokenizer_model fell through to HfApi.model_info - and then
_preserve_sentencepiece_tokenizer_assets fetched tokenizer.model from the Hub
with local_files_only = False. On a disconnected host that is a network wait
before the export gives up.

Stamp the load's local-only mode onto the returned processor and its tokenizer
inside the forced-offline window, and honour that stamp in
_tokenizer_wants_local_only, so the save path inherits the load's contract.

Verified against a real hub-cache layout whose snapshot has tokenizer metadata
but no tokenizer.model: before, one model_info call plus an hf_hub_download with
local_files_only = False; after, zero model_info calls and cache probes only.

Two tests added to tests/saving/test_offline_gguf_vlm_tokenizer_7481.py; both
fail with the loader_utils hunk reverted and pass with it in place.
This commit is contained in:
Daniel Han 2026-07-27 09:03:08 +00:00
commit e7b7400dee
2 changed files with 118 additions and 1 deletions

View file

@ -204,6 +204,91 @@ def test_has_tokenizer_model_probes_cache_before_model_info(tmp_path, monkeypatc
assert _has_tokenizer_model(tok, token = None) is True
def test_offline_aware_load_persists_local_only_for_saving(tmp_path, monkeypatch):
"""An explicit ``local_files_only = True`` load must still be local-only at save time.
``transformers`` takes ``local_files_only`` as an explicit ``from_pretrained``
parameter, so it never reaches ``tokenizer.init_kwargs``, and
``_offline_aware_load`` restores the offline env vars once the load returns.
Without the stamp the request is invisible by the time we save.
"""
from unsloth.save import _TOKENIZER_MODEL_CACHE, _has_tokenizer_model
# Snapshot has tokenizer metadata but deliberately no tokenizer.model, so the
# cache probe misses and only the local-only stamp can stop the Hub request.
_write_gemma4_cache(tmp_path)
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
_TOKENIZER_MODEL_CACHE.clear()
@L._offline_aware_load
def _load(model_name, **kwargs):
assert os.environ.get("HF_HUB_OFFLINE") == "1"
# A processor keeps the Hub repo id and carries no local_files_only.
return object(), SimpleNamespace(
tokenizer = SimpleNamespace(name_or_path = model_name, init_kwargs = {}),
)
_model, processor = _load(_REPO, local_files_only = True)
assert os.environ.get("HF_HUB_OFFLINE") is None
assert processor.tokenizer.init_kwargs.get("local_files_only") is None
assert L._tokenizer_wants_local_only(processor.tokenizer) is True
with patch("huggingface_hub.HfApi.model_info") as model_info:
model_info.return_value = SimpleNamespace(
siblings = [SimpleNamespace(rfilename = "tokenizer.model")],
)
assert _has_tokenizer_model(processor, token = None) is False
assert model_info.call_count == 0
def test_preserve_sentencepiece_after_local_only_load_never_downloads(tmp_path, monkeypatch):
"""The save path inherits the load's local-only mode: no metadata probe, no download."""
import huggingface_hub
from unsloth.save import _TOKENIZER_MODEL_CACHE, _preserve_sentencepiece_tokenizer_assets
_write_gemma4_cache(tmp_path)
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
_TOKENIZER_MODEL_CACHE.clear()
@L._offline_aware_load
def _load(model_name, **kwargs):
return object(), SimpleNamespace(
tokenizer = SimpleNamespace(name_or_path = model_name, init_kwargs = {}),
)
_model, processor = _load(_REPO, local_files_only = True)
save_dir = tmp_path / "export"
save_dir.mkdir()
(save_dir / "tokenizer_config.json").write_text("{}", encoding = "utf-8")
real_download = huggingface_hub.hf_hub_download
seen_local_files_only = []
def _recording_download(*args, **kwargs):
seen_local_files_only.append(kwargs.get("local_files_only"))
return real_download(*args, **kwargs)
monkeypatch.setattr("huggingface_hub.hf_hub_download", _recording_download)
with patch("huggingface_hub.HfApi.model_info") as model_info:
model_info.return_value = SimpleNamespace(
siblings = [SimpleNamespace(rfilename = "tokenizer.model")],
)
_preserve_sentencepiece_tokenizer_assets(processor, str(save_dir), token = None)
assert model_info.call_count == 0
# Every hf_hub_download here must be a cache probe, never a Hub fetch.
assert seen_local_files_only and all(seen_local_files_only)
assert not (save_dir / "tokenizer.model").exists()
def test_has_tokenizer_model_local_files_only_skips_model_info(tmp_path, monkeypatch):
from unsloth.save import _TOKENIZER_MODEL_CACHE, _has_tokenizer_model

View file

@ -843,10 +843,40 @@ def _get_effective_local_files_only(kwargs):
return _env_says_offline()
# Attribute stamped on a tokenizer/processor that was loaded local-only, so a later
# save still knows. transformers takes local_files_only as an explicit from_pretrained
# parameter and never copies it into tokenizer.init_kwargs, and _offline_aware_load
# restores the offline env vars when the load window closes, so without this stamp an
# explicit local_files_only = True load is invisible by the time we save (issue #7481).
_LOCAL_FILES_ONLY_ATTR = "_unsloth_local_files_only"
def _mark_loaded_local_files_only(result):
"""Stamp a load's local-only mode onto the returned tokenizer/processor objects."""
for obj in result if isinstance(result, (tuple, list)) else (result,):
try:
# A processor keeps the tokenizer that _has_tokenizer_model unwraps to,
# so stamp both (a wrapped model can raise from its own __getattr__).
targets = (obj, getattr(obj, "tokenizer", None))
except Exception:
targets = (obj,)
for target in targets:
if target is None:
continue
# Objects that reject new attributes (__slots__) are skipped.
try:
setattr(target, _LOCAL_FILES_ONLY_ATTR, True)
except Exception:
pass
return result
def _tokenizer_wants_local_only(tokenizer):
"""True when Hub metadata probes should be skipped for this tokenizer."""
if _env_says_offline():
return True
if getattr(tokenizer, _LOCAL_FILES_ONLY_ATTR, False):
return True
init_kwargs = getattr(tokenizer, "init_kwargs", None) or {}
return bool(init_kwargs.get("local_files_only"))
@ -1075,7 +1105,9 @@ def _offline_aware_load(fn):
if _get_effective_local_files_only(kwargs):
kwargs["local_files_only"] = True
with _force_hf_offline():
return fn(*args, **kwargs)
# Stamp inside the window: the env vars are restored on exit, so the
# request has to travel on the objects themselves to reach saving.
return _mark_loaded_local_files_only(fn(*args, **kwargs))
_pb_were_disabled = _progress_bars_were_disabled() # restore before any retry
try:
return fn(*args, **kwargs)