From e7b7400dee4d32048fe01f5b82e66734a7dd0e6d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 27 Jul 2026 09:03:08 +0000 Subject: [PATCH] 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. --- .../test_offline_gguf_vlm_tokenizer_7481.py | 85 +++++++++++++++++++ unsloth/models/loader_utils.py | 34 +++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/tests/saving/test_offline_gguf_vlm_tokenizer_7481.py b/tests/saving/test_offline_gguf_vlm_tokenizer_7481.py index 5f4aa01098..02d71bf70e 100644 --- a/tests/saving/test_offline_gguf_vlm_tokenizer_7481.py +++ b/tests/saving/test_offline_gguf_vlm_tokenizer_7481.py @@ -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 diff --git a/unsloth/models/loader_utils.py b/unsloth/models/loader_utils.py index 41a7707879..5c842fd6bd 100644 --- a/unsloth/models/loader_utils.py +++ b/unsloth/models/loader_utils.py @@ -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)