diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py index e90311c4..b2eeb678 100644 --- a/backend/backends/__init__.py +++ b/backend/backends/__init__.py @@ -5,6 +5,13 @@ Provides a unified interface for MLX and PyTorch backends, and a model config registry that eliminates per-engine dispatch maps. """ +# Install HF compatibility patches before any backend imports transformers / +# huggingface_hub. The module runs ``patch_transformers_mistral_regex`` at +# import time, which wraps transformers' tokenizer load against the +# unconditional HuggingFace metadata call that otherwise raises on +# HF_HUB_OFFLINE=1 and on network failures. +from ..utils import hf_offline_patch # noqa: F401 + import threading from dataclasses import dataclass, field from typing import Protocol, Optional, Tuple, List diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py index aba18856..9692e59b 100644 --- a/backend/backends/mlx_backend.py +++ b/backend/backends/mlx_backend.py @@ -20,7 +20,6 @@ ensure_original_qwen_config_cached() from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt -from ..utils.hf_offline_patch import force_offline_if_cached class MLXTTSBackend: @@ -99,8 +98,7 @@ class MLXTTSBackend: logger.info("Loading MLX TTS model %s...", model_size) - with force_offline_if_cached(is_cached, model_name): - self.model = load(model_path) + self.model = load(model_path) self._current_model_size = model_size self.model_size = model_size @@ -311,8 +309,7 @@ class MLXSTTBackend: model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") logger.info("Loading MLX Whisper model %s...", model_size) - with force_offline_if_cached(is_cached, progress_model_name): - self.model = load(model_name) + self.model = load(model_name) self.model_size = model_size logger.info("MLX Whisper model %s loaded successfully", model_size) diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py index 19210cff..f8ae79b8 100644 --- a/backend/backends/pytorch_backend.py +++ b/backend/backends/pytorch_backend.py @@ -21,7 +21,6 @@ from .base import ( ) from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt from ..utils.audio import load_audio -from ..utils.hf_offline_patch import force_offline_if_cached class PyTorchTTSBackend: @@ -106,21 +105,20 @@ class PyTorchTTSBackend: from huggingface_hub import constants as hf_constants tts_cache_dir = hf_constants.HF_HUB_CACHE - with force_offline_if_cached(is_cached, model_name): - if self.device == "cpu": - self.model = Qwen3TTSModel.from_pretrained( - model_path, - cache_dir=tts_cache_dir, - torch_dtype=torch.float32, - low_cpu_mem_usage=False, - ) - else: - self.model = Qwen3TTSModel.from_pretrained( - model_path, - cache_dir=tts_cache_dir, - device_map=self.device, - torch_dtype=torch.bfloat16, - ) + if self.device == "cpu": + self.model = Qwen3TTSModel.from_pretrained( + model_path, + cache_dir=tts_cache_dir, + torch_dtype=torch.float32, + low_cpu_mem_usage=False, + ) + else: + self.model = Qwen3TTSModel.from_pretrained( + model_path, + cache_dir=tts_cache_dir, + device_map=self.device, + torch_dtype=torch.bfloat16, + ) self._current_model_size = model_size self.model_size = model_size @@ -297,9 +295,8 @@ class PyTorchSTTBackend: model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") logger.info("Loading Whisper model %s on %s...", model_size, self.device) - with force_offline_if_cached(is_cached, progress_model_name): - self.processor = WhisperProcessor.from_pretrained(model_name) - self.model = WhisperForConditionalGeneration.from_pretrained(model_name) + self.processor = WhisperProcessor.from_pretrained(model_name) + self.model = WhisperForConditionalGeneration.from_pretrained(model_name) self.model.to(self.device) self.model_size = model_size diff --git a/backend/backends/qwen_custom_voice_backend.py b/backend/backends/qwen_custom_voice_backend.py index 518f8926..74f739bb 100644 --- a/backend/backends/qwen_custom_voice_backend.py +++ b/backend/backends/qwen_custom_voice_backend.py @@ -28,7 +28,6 @@ from .base import ( combine_voice_prompts as _combine_voice_prompts, model_load_progress, ) -from ..utils.hf_offline_patch import force_offline_if_cached logger = logging.getLogger(__name__) @@ -105,19 +104,18 @@ class QwenCustomVoiceBackend: model_path = self._get_model_path(model_size) logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device) - with force_offline_if_cached(is_cached, model_name): - if self.device == "cpu": - self.model = Qwen3TTSModel.from_pretrained( - model_path, - torch_dtype=torch.float32, - low_cpu_mem_usage=False, - ) - else: - self.model = Qwen3TTSModel.from_pretrained( - model_path, - device_map=self.device, - torch_dtype=torch.bfloat16, - ) + if self.device == "cpu": + self.model = Qwen3TTSModel.from_pretrained( + model_path, + torch_dtype=torch.float32, + low_cpu_mem_usage=False, + ) + else: + self.model = Qwen3TTSModel.from_pretrained( + model_path, + device_map=self.device, + torch_dtype=torch.bfloat16, + ) self._current_model_size = model_size self.model_size = model_size diff --git a/backend/tests/test_offline_patch.py b/backend/tests/test_offline_patch.py new file mode 100644 index 00000000..d0569942 --- /dev/null +++ b/backend/tests/test_offline_patch.py @@ -0,0 +1,113 @@ +""" +Unit tests for ``patch_transformers_mistral_regex``. + +Verifies that our wrapper around +``transformers.PreTrainedTokenizerBase._patch_mistral_regex`` catches +exceptions from the unconditional ``huggingface_hub.model_info()`` lookup +and returns the tokenizer unchanged — matching the success-path behavior +for non-Mistral repos (transformers 4.57.3, ``tokenization_utils_base.py:2503``). + +NOTE: These tests mutate ``transformers.PreTrainedTokenizerBase`` globally; +run serially, not under ``pytest-xdist`` with per-worker process isolation. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from huggingface_hub.errors import OfflineModeIsEnabled # noqa: E402 +from transformers.tokenization_utils_base import PreTrainedTokenizerBase # noqa: E402 + +import utils.hf_offline_patch as hf_offline_patch # noqa: E402 + + +@pytest.fixture(autouse=True) +def restore_mistral_regex(): + """Snapshot the current ``_patch_mistral_regex`` and restore after each test.""" + saved = PreTrainedTokenizerBase.__dict__.get("_patch_mistral_regex") + saved_flag = hf_offline_patch._mistral_regex_patched + try: + yield + finally: + if saved is not None: + PreTrainedTokenizerBase._patch_mistral_regex = saved + hf_offline_patch._mistral_regex_patched = saved_flag + + +def _apply_patch(): + hf_offline_patch._mistral_regex_patched = False + hf_offline_patch.patch_transformers_mistral_regex() + + +def test_suppresses_offline_mode_is_enabled(monkeypatch): + _apply_patch() + + import huggingface_hub + + def raise_offline(*_args, **_kwargs): + raise OfflineModeIsEnabled("offline") + + monkeypatch.setattr(huggingface_hub, "model_info", raise_offline) + + sentinel = object() + result = PreTrainedTokenizerBase._patch_mistral_regex( + sentinel, "Qwen/Qwen3-TTS-12Hz-1.7B-Base" + ) + assert result is sentinel + + +def test_suppresses_connection_errors(monkeypatch): + _apply_patch() + + import huggingface_hub + + def raise_connection(*_args, **_kwargs): + raise ConnectionError("network unreachable") + + monkeypatch.setattr(huggingface_hub, "model_info", raise_connection) + + sentinel = object() + result = PreTrainedTokenizerBase._patch_mistral_regex( + sentinel, "Qwen/Qwen3-TTS-12Hz-1.7B-Base" + ) + assert result is sentinel + + +def test_passthrough_on_success(monkeypatch): + """When model_info returns non-Mistral tags the original falls through and returns the tokenizer unchanged.""" + _apply_patch() + + import huggingface_hub + + class FakeInfo: + tags = ["model-type:qwen", "language:en"] + + monkeypatch.setattr(huggingface_hub, "model_info", lambda *_a, **_kw: FakeInfo()) + + sentinel = object() + result = PreTrainedTokenizerBase._patch_mistral_regex( + sentinel, "Qwen/Qwen3-TTS-12Hz-1.7B-Base" + ) + assert result is sentinel + + +def test_idempotent(): + _apply_patch() + first = PreTrainedTokenizerBase._patch_mistral_regex + hf_offline_patch.patch_transformers_mistral_regex() + second = PreTrainedTokenizerBase._patch_mistral_regex + assert first.__func__ is second.__func__ + + +def test_missing_method_is_noop(monkeypatch): + monkeypatch.delattr(PreTrainedTokenizerBase, "_patch_mistral_regex", raising=False) + hf_offline_patch._mistral_regex_patched = False + hf_offline_patch.patch_transformers_mistral_regex() + assert hf_offline_patch._mistral_regex_patched is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/utils/hf_offline_patch.py b/backend/utils/hf_offline_patch.py index 354b6110..734b8f5c 100644 --- a/backend/utils/hf_offline_patch.py +++ b/backend/utils/hf_offline_patch.py @@ -142,6 +142,57 @@ def force_offline_if_cached(is_cached: bool, model_label: str = ""): _saved_transformers_const = None +_mistral_regex_patched = False + + +def patch_transformers_mistral_regex(): + """Make transformers' tokenizer load robust to HuggingFace metadata failures. + + transformers 4.57.x added ``PreTrainedTokenizerBase._patch_mistral_regex`` + which unconditionally calls ``huggingface_hub.model_info(repo_id)`` during + every non-local tokenizer load to check whether the model is a Mistral + variant. That call raises on ``HF_HUB_OFFLINE=1`` and on plain network + failures, killing unrelated loads (Qwen TTS, TADA, etc.). + + Voicebox never loads Mistral models, so the rewrite the function would + apply is a no-op for us anyway. Wrap the method so any exception from the + metadata lookup returns the tokenizer unchanged — matching the success-path + behavior for non-Mistral repos (transformers 4.57.3, + ``tokenization_utils_base.py:2503``). + """ + global _mistral_regex_patched + if _mistral_regex_patched: + return + + try: + from transformers.tokenization_utils_base import PreTrainedTokenizerBase + except ImportError: + logger.debug("transformers not available, skipping mistral-regex patch") + return + + original = getattr(PreTrainedTokenizerBase, "_patch_mistral_regex", None) + if original is None: + logger.debug( + "transformers has no _patch_mistral_regex attribute, skipping patch", + ) + return + + def safe_patch_mistral_regex(cls, tokenizer, pretrained_model_name_or_path, *args, **kwargs): + try: + return original(tokenizer, pretrained_model_name_or_path, *args, **kwargs) + except Exception as exc: + logger.debug( + "[mistral-regex-patch] suppressed %s for %r, returning tokenizer as-is", + type(exc).__name__, + pretrained_model_name_or_path, + ) + return tokenizer + + PreTrainedTokenizerBase._patch_mistral_regex = classmethod(safe_patch_mistral_regex) + _mistral_regex_patched = True + logger.debug("installed _patch_mistral_regex wrapper") + + def patch_huggingface_hub_offline(): """Monkey-patch huggingface_hub to force offline mode.""" try: @@ -215,4 +266,5 @@ def ensure_original_qwen_config_cached(): if os.environ.get("VOICEBOX_OFFLINE_PATCH", "1") != "0": patch_huggingface_hub_offline() + patch_transformers_mistral_regex() ensure_original_qwen_config_cached()