diff --git a/install.ps1 b/install.ps1 index 5ddb42ea7e..a2acd6c4ea 100644 --- a/install.ps1 +++ b/install.ps1 @@ -819,7 +819,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.4" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -827,7 +827,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.4" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -857,7 +857,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.4.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.4.4" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -865,7 +865,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.4" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" } } @@ -886,7 +886,7 @@ shell.Run cmd, 0, False # Fallback: GPU detection failed to produce a URL -- let uv resolve torch substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.2" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.4" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return diff --git a/install.sh b/install.sh index 053f334d2b..ea53ecc6d6 100755 --- a/install.sh +++ b/install.sh @@ -1040,7 +1040,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.4.2" unsloth-zoo + "unsloth>=2026.4.4" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -1048,7 +1048,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.4.2" unsloth-zoo + "unsloth>=2026.4.4" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -1070,7 +1070,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.4.2" unsloth-zoo + "unsloth>=2026.4.4" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -1081,7 +1081,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.4.2" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.4.4" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else @@ -1092,7 +1092,7 @@ else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.2" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.4" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else diff --git a/pyproject.toml b/pyproject.toml index e391b4df3d..50bdf58b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.4.2", + "unsloth_zoo>=2026.4.3", "torchvision", "unsloth[triton]", ] @@ -578,7 +578,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.4.2", + "unsloth_zoo>=2026.4.3", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", diff --git a/studio/backend/colab.py b/studio/backend/colab.py index efd0e10bdb..7336f8a532 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -66,7 +66,10 @@ def show_link(port: int = 8888): Open Unsloth Studio -

+

+ If the link doesn't work, you can scroll down to view the UI generated directly in Colab. +

+

{short_url}

diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index ab1825d94a..77cbda6b45 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -190,7 +190,11 @@ class UnslothTrainer: self._cuda_audio_used = False # --- Detect VLM --- - vision = is_vision_model(model_name) if not self.is_audio else False + vision = ( + is_vision_model(model_name, hf_token = hf_token) + if not self.is_audio + else False + ) self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image logger.info( @@ -558,7 +562,11 @@ class UnslothTrainer: self._cuda_audio_used = False # VLM: vision model with image dataset (mutually exclusive with audio paths) - vision = is_vision_model(model_name) if not self.is_audio else False + vision = ( + is_vision_model(model_name, hf_token = hf_token) + if not self.is_audio + else False + ) self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image self.model_name = model_name self.max_seq_length = max_seq_length diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 1e31a91e26..3f361ca5eb 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -138,6 +138,47 @@ def _resolve_hf_cache_dir() -> Path: return Path.home() / ".cache" / "huggingface" / "hub" +def _is_model_directory(d: Path) -> bool: + """Return ``True`` when *d* looks like a model directory. + + A model directory must have **both** a config file (``config.json`` or + ``adapter_config.json``) **and** actual model weight files. Both + conditions are required: a bare directory with only loose ``.gguf`` + files (no config) might be a mixed collection, and a ``config.json`` + alone (no weights) is not a model directory. + + Excludes ``mmproj`` GGUF files (vision projectors) and non-weight + ``.bin`` files (``tokenizer.bin``, ``vocab.bin``, etc.) from the + weight check to avoid false positives. + """ + + def _is_weight_file(f: Path) -> bool: + suffix = f.suffix.lower() + if suffix == ".safetensors": + return True + if suffix == ".gguf": + return "mmproj" not in f.name.lower() + if suffix == ".bin": + name = f.name.lower() + return ( + name.startswith("pytorch_model") + or name.startswith("model") + or name.startswith("adapter_model") + or name.startswith("consolidated") + ) + return False + + try: + has_config = (d / "config.json").exists() or ( + d / "adapter_config.json" + ).exists() + if not has_config: + return False + return any(_is_weight_file(f) for f in d.iterdir() if f.is_file()) + except OSError: + return False + + def _scan_models_dir( models_dir: Path, *, @@ -146,6 +187,23 @@ def _scan_models_dir( if not models_dir.exists() or not models_dir.is_dir(): return [] + _is_self_model = _is_model_directory(models_dir) + + if _is_self_model: + try: + updated_at = models_dir.stat().st_mtime + except OSError: + updated_at = None + return [ + LocalModelInfo( + id = str(models_dir), + display_name = models_dir.name, + path = str(models_dir), + source = "models_dir", + updated_at = updated_at, + ), + ] + found: List[LocalModelInfo] = [] for child in models_dir.iterdir(): if limit is not None and len(found) >= limit: @@ -243,6 +301,25 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: if not lm_dir.exists() or not lm_dir.is_dir(): return [] + # If the directory itself is a model directory (has config AND weight + # files), it is not an LM Studio publisher structure -- return it as a + # single model entry. We cannot skip it silently because this function + # is the only scanner called for default LM Studio roots. + if _is_model_directory(lm_dir): + try: + updated_at = lm_dir.stat().st_mtime + except OSError: + updated_at = None + return [ + LocalModelInfo( + id = str(lm_dir), + display_name = lm_dir.name, + path = str(lm_dir), + source = "lmstudio", + updated_at = updated_at, + ), + ] + found: List[LocalModelInfo] = [] for child in lm_dir.iterdir(): try: @@ -263,6 +340,25 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: ) continue + # If the child directory itself looks like a model directory + # (has config AND weight files), surface it directly instead + # of descending into it as a publisher. + if _is_model_directory(child): + try: + updated_at = child.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(child), + display_name = child.name, + path = str(child), + source = "lmstudio", + updated_at = updated_at, + ), + ) + continue + # child is a publisher directory -- scan its sub-directories for model_dir in child.iterdir(): try: @@ -618,7 +714,7 @@ async def get_model_config( config_dict = load_model_defaults(model_name) # Detect model capabilities (pass HF token for gated models) - is_vision = is_vision_model(model_name) + is_vision = is_vision_model(model_name, hf_token = hf_token) is_embedding = is_embedding_model(model_name, hf_token = hf_token) audio_type = detect_audio_type(model_name, hf_token = hf_token) diff --git a/studio/backend/tests/test_vision_cache.py b/studio/backend/tests/test_vision_cache.py new file mode 100644 index 0000000000..fae1e95311 --- /dev/null +++ b/studio/backend/tests/test_vision_cache.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for is_vision_model() caching behaviour. + +The vision detection cache (``_vision_detection_cache``) mirrors the existing +``_audio_detection_cache`` pattern used by ``detect_audio_type()``. These +tests verify that: + +* Repeated calls for the same model hit the cache (no redundant work). +* Different models each trigger their own detection. +* Both True and False results are cached. +* The subprocess path (transformers 5.x models) is also cached. +* Exceptions that fall back to False are cached. +""" + +import sys +import types as _types +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# sys.path + logger stub — same pattern as the rest of the test suite +# --------------------------------------------------------------------------- +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +from utils.models.model_config import ( + is_vision_model, + _is_vision_model_uncached, + _vision_detection_cache, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse = True) +def _clear_vision_cache(): + """Ensure every test starts with a fresh cache.""" + _vision_detection_cache.clear() + yield + _vision_detection_cache.clear() + + +# --------------------------------------------------------------------------- +# Cache hit / miss tests +# --------------------------------------------------------------------------- + + +class TestVisionCacheHitMiss: + """Verify the cache prevents redundant detection calls.""" + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_second_call_uses_cache(self, mock_uncached): + """Calling is_vision_model() twice for the same model should invoke + the uncached function only once.""" + assert is_vision_model("org/my-vlm") is True + assert is_vision_model("org/my-vlm") is True + mock_uncached.assert_called_once_with("org/my-vlm", None) + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = False) + def test_different_models_each_detected(self, mock_uncached): + """Different model names should each trigger detection.""" + is_vision_model("model-a") + is_vision_model("model-b") + assert mock_uncached.call_count == 2 + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_cache_returns_correct_value(self, mock_uncached): + """The cached value must match what _is_vision_model_uncached returned.""" + first = is_vision_model("org/vlm") + second = is_vision_model("org/vlm") + assert first is True + assert second is True + + +class TestVisionCacheStoresFalse: + """Non-VLM results (False) must also be cached to avoid re-detection.""" + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = False) + def test_false_result_cached(self, mock_uncached): + assert is_vision_model("org/text-only") is False + assert is_vision_model("org/text-only") is False + mock_uncached.assert_called_once() + assert _vision_detection_cache[("org/text-only", None)] is False + + +# --------------------------------------------------------------------------- +# Subprocess path (transformers 5.x) caching +# --------------------------------------------------------------------------- + + +class TestVisionCacheSubprocessPath: + """Models needing transformers 5.x go through _is_vision_model_subprocess. + The cache should prevent the subprocess from being spawned more than once + per model per process.""" + + @patch("utils.models.model_config._is_vision_model_subprocess", return_value = True) + @patch("utils.transformers_version.needs_transformers_5", return_value = True) + def test_subprocess_called_once_with_cache(self, mock_needs_t5, mock_subprocess): + """Subprocess should only fire on the first call; second is cached.""" + # First call: goes through uncached → subprocess + assert is_vision_model("unsloth/Qwen3.5-2B") is True + # Second call: cache hit, no subprocess + assert is_vision_model("unsloth/Qwen3.5-2B") is True + + mock_subprocess.assert_called_once() + assert _vision_detection_cache[("unsloth/Qwen3.5-2B", None)] is True + + +# --------------------------------------------------------------------------- +# Exception handling — cache the False fallback +# --------------------------------------------------------------------------- + + +class TestVisionCacheOnException: + """When detection raises an exception, _is_vision_model_uncached catches + it and returns False. That False must be cached so subsequent calls don't + retry and fail again.""" + + @patch( + "utils.models.model_config.load_model_config", + side_effect = OSError("network down"), + ) + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + def test_exception_result_cached(self, mock_needs_t5, mock_load_config): + """A real exception inside _is_vision_model_uncached should be caught, + return False, and that False should be cached for subsequent calls.""" + # First call: load_model_config raises → except branch → False + assert is_vision_model("broken/model") is False + # Second call: cache hit, load_model_config not called again + assert is_vision_model("broken/model") is False + mock_load_config.assert_called_once() + + +# --------------------------------------------------------------------------- +# Direct detection path (non-transformers-5 models) caching +# --------------------------------------------------------------------------- + + +class TestVisionCacheDirectPath: + """For models that do NOT need transformers 5.x, the detection goes through + load_model_config directly. The cache must work the same way.""" + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_direct_vlm_detection_cached(self, mock_load_config, mock_needs_t5): + """A standard VLM detected via architecture suffix should be cached.""" + cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist + cfg.model_type = "gemma3" + cfg.architectures = ["Gemma3ForConditionalGeneration"] + mock_load_config.return_value = cfg + + assert is_vision_model("google/gemma-3-4b-it") is True + assert is_vision_model("google/gemma-3-4b-it") is True + # load_model_config should only be called once + mock_load_config.assert_called_once() + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_direct_non_vlm_detection_cached(self, mock_load_config, mock_needs_t5): + """A standard text model (no VLM indicators) should cache False.""" + cfg = MagicMock(spec = []) # spec=[] means no attributes at all + cfg.model_type = "llama" + cfg.architectures = ["LlamaForCausalLM"] + mock_load_config.return_value = cfg + + # LlamaForCausalLM doesn't end with VLM suffixes, no vision_config, etc. + assert is_vision_model("meta-llama/Llama-3-8B") is False + assert is_vision_model("meta-llama/Llama-3-8B") is False + mock_load_config.assert_called_once() + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_vision_config_attr_detected_and_cached( + self, mock_load_config, mock_needs_t5 + ): + """Models with vision_config (LLaVA, Qwen2-VL, etc.) should be cached as True.""" + cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist + cfg.model_type = "qwen2_vl" + cfg.architectures = ["Qwen2VLForCausalLM"] # Doesn't match VLM suffixes + cfg.vision_config = {"hidden_size": 1024} + mock_load_config.return_value = cfg + + assert is_vision_model("Qwen/Qwen2-VL-7B") is True + assert is_vision_model("Qwen/Qwen2-VL-7B") is True + mock_load_config.assert_called_once() + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_audio_model_excluded_and_cached(self, mock_load_config, mock_needs_t5): + """Audio-only models (csm, whisper) with ForConditionalGeneration + should be excluded from VLM detection and cached as False.""" + cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist + cfg.model_type = "whisper" + cfg.architectures = ["WhisperForConditionalGeneration"] + mock_load_config.return_value = cfg + + assert is_vision_model("openai/whisper-large-v3") is False + assert is_vision_model("openai/whisper-large-v3") is False + mock_load_config.assert_called_once() + + +# --------------------------------------------------------------------------- +# hf_token handling +# --------------------------------------------------------------------------- + + +class TestVisionCacheTokenHandling: + """The cache is keyed on (model_name, hf_token). + Different tokens for the same model should trigger separate detections + to handle gated models correctly.""" + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_different_tokens_trigger_new_detection(self, mock_uncached): + """Calls with different tokens should trigger separate detections to + handle gated models correctly (e.g. unauthenticated probe → False, + then authenticated call should re-check).""" + assert is_vision_model("gated/model", hf_token = "token-a") is True + assert is_vision_model("gated/model", hf_token = "token-b") is True + assert mock_uncached.call_count == 2 + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_same_token_uses_cache(self, mock_uncached): + """Repeated calls with identical model + token should hit cache.""" + assert is_vision_model("gated/model", hf_token = "token-a") is True + assert is_vision_model("gated/model", hf_token = "token-a") is True + mock_uncached.assert_called_once() diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f97ea993eb..61226e52cb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -26,7 +26,9 @@ import subprocess import sys from pathlib import Path from typing import List, Tuple +import hashlib import json +import threading import yaml @@ -548,12 +550,17 @@ except Exception as exc: def _is_vision_model_subprocess( model_name: str, hf_token: Optional[str] = None -) -> bool: +) -> Optional[bool]: """Run is_vision_model check in a subprocess with transformers 5.x. Same pattern as training/inference workers: spawn a clean subprocess with .venv_t5/ prepended to sys.path so AutoConfig recognizes newer architectures (glm4_moe_lite, etc.). + + Returns True/False for definitive results, or None for transient failures + (timeouts, subprocess errors) so callers can decide whether to cache + the result. Subprocess failures are treated as transient because they + can be caused by temporary HF/auth/network issues. """ token_arg = hf_token or "" @@ -580,7 +587,7 @@ def _is_vision_model_subprocess( model_name, stderr or result.stdout.strip(), ) - return False + return None data = json.loads(result.stdout.strip()) if "error" in data: @@ -589,7 +596,7 @@ def _is_vision_model_subprocess( model_name, data["error"], ) - return False + return None is_vlm = data["is_vision"] logger.info( @@ -604,10 +611,28 @@ def _is_vision_model_subprocess( except subprocess.TimeoutExpired: logger.warning("Vision check subprocess timed out for '%s'", model_name) - return False + return None except Exception as exc: logger.warning("Vision check subprocess failed for '%s': %s", model_name, exc) - return False + return None + + +def _token_fingerprint(token: Optional[str]) -> Optional[str]: + """Return a SHA256 digest of the token for use as a cache key. + + Avoids storing the raw bearer token in process memory as a dict key. + """ + if token is None: + return None + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +# Cache vision detection results per session to avoid repeated subprocess spawns. +# Keyed by (normalized_model_name, token_fingerprint) to handle gated models correctly. +# Only definitive results (True/False from successful detection) are cached; +# transient failures (network errors, timeouts) are NOT cached so they can be retried. +_vision_detection_cache: Dict[Tuple[str, Optional[str]], bool] = {} +_vision_cache_lock = threading.Lock() def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: @@ -616,13 +641,66 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: Works for fine-tuned models since they inherit the base architecture. For models that require transformers 5.x (e.g. GLM-4.7-Flash), the check - runs in a subprocess with .venv_t5/ activated — same pattern as the + runs in a subprocess with .venv_t5/ activated -- same pattern as the training and inference workers. + Results are cached per (model_name, token_fingerprint) for the lifetime of + the process to avoid repeated subprocess spawns and HuggingFace API calls. + Transient failures are not cached so they can be retried on the next call. + Args: model_name: Model identifier (HF repo or local path) hf_token: Optional HF token for accessing gated/private models """ + # Normalize model name for cache key to avoid duplicate entries for + # different casings of the same HF repo (e.g. "Org/Model" vs "org/model"). + try: + if is_local_path(model_name): + resolved_name = normalize_path(model_name) + else: + resolved_name = resolve_cached_repo_id_case(model_name) + except Exception as exc: + logger.debug( + "Could not normalize model name '%s' for cache key: %s", + model_name, + exc, + ) + resolved_name = model_name + cache_key = (resolved_name, _token_fingerprint(hf_token)) + + # Lock-free fast path for cache hits. Uses a sentinel to distinguish + # "key not found" from "value is False" in a single atomic dict.get() call. + _MISS = object() + cached = _vision_detection_cache.get(cache_key, _MISS) + if cached is not _MISS: + return cached + + # Compute outside the lock to avoid serializing long-running detection + # (subprocess spawns with 60s timeout, HF API calls) across all models. + # The tradeoff: two concurrent calls for the same uncached model may + # both run detection, but they produce the same result and the second + # write is a benign no-op. + result = _is_vision_model_uncached(resolved_name, hf_token) + # Only cache definitive results; None means a transient failure occurred + # and we should retry on the next call instead of locking in a wrong answer. + if result is not None: + with _vision_cache_lock: + _vision_detection_cache[cache_key] = result + return result + return False + + +def _is_vision_model_uncached( + model_name: str, hf_token: Optional[str] = None +) -> Optional[bool]: + """Uncached vision model detection -- called by is_vision_model(). + + Returns True/False for definitive results, or None when detection failed + due to a transient error (network, timeout, subprocess failure) so the + caller knows not to cache the result. + + Do not call directly; use is_vision_model() instead. + """ # Models that need transformers 5.x must be checked in a subprocess # because AutoConfig in the main process (transformers 4.57.x) doesn't # recognize their architectures. @@ -630,7 +708,7 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: if needs_transformers_5(model_name): logger.info( - "Model '%s' needs transformers 5.x — checking vision via subprocess", + "Model '%s' needs transformers 5.x -- checking vision via subprocess", model_name, ) return _is_vision_model_subprocess(model_name, hf_token = hf_token) @@ -681,7 +759,25 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: except Exception as e: logger.warning(f"Could not determine if {model_name} is vision model: {e}") - return False + # Permanent failures (model not found, gated, bad config) should be + # cached as False. Transient failures (network, timeout) should not. + try: + from huggingface_hub.errors import RepositoryNotFoundError, GatedRepoError + except ImportError: + try: + from huggingface_hub.utils import ( + RepositoryNotFoundError, + GatedRepoError, + ) + except ImportError: + RepositoryNotFoundError = GatedRepoError = None + if RepositoryNotFoundError is not None and isinstance( + e, (RepositoryNotFoundError, GatedRepoError) + ): + return False + if isinstance(e, (ValueError, json.JSONDecodeError)): + return False + return None VALID_AUDIO_TYPES = ("snac", "csm", "bicodec", "dac", "whisper", "audio_vlm") @@ -1034,6 +1130,26 @@ def list_gguf_variants( return variants, has_vision +def _resolve_gguf_dir(p: Path) -> Optional[Path]: + """Resolve a path to the directory containing GGUF variants. + + If *p* is already a directory, returns it directly. If *p* is a ``.gguf`` + file whose parent directory has model metadata (``config.json`` or + ``adapter_config.json``), returns the parent -- all GGUFs in that + directory belong to the same model. Returns ``None`` for loose standalone + GGUFs (no config) to avoid cross-wiring unrelated models. + """ + if p.is_dir(): + return p + if p.is_file() and p.suffix.lower() == ".gguf": + parent = p.parent + if (parent / "config.json").exists() or ( + parent / "adapter_config.json" + ).exists(): + return parent + return None + + def list_local_gguf_variants( directory: str, ) -> tuple[list[GgufVariantInfo], bool]: @@ -1046,8 +1162,8 @@ def list_local_gguf_variants( Returns: (variants, has_vision): list of non-mmproj GGUF variants + vision flag. """ - p = Path(directory) - if not p.is_dir(): + p = _resolve_gguf_dir(Path(directory)) + if p is None: return [], False quant_totals: dict[str, int] = {} @@ -1087,8 +1203,8 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]: Returns the resolved absolute path, or ``None`` if no match. """ - p = Path(directory) - if not p.is_dir(): + p = _resolve_gguf_dir(Path(directory)) + if p is None: return None matches = sorted( diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 1dbff145ee..cf1ba11d7b 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -225,6 +225,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ modelType="base" pairId={pairId} initialThreadId={baseThreadId} + syncActiveThreadId={false} > @@ -242,6 +243,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ modelType="lora" pairId={pairId} initialThreadId={loraThreadId} + syncActiveThreadId={false} > @@ -343,6 +345,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ modelType="model1" pairId={pairId} initialThreadId={model1ThreadId} + syncActiveThreadId={false} > @@ -376,6 +379,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ modelType="model2" pairId={pairId} initialThreadId={model2ThreadId} + syncActiveThreadId={false} > @@ -479,11 +483,19 @@ function TopBarActions({ ); } +function getInitialSingleChatView(): ChatView { + const id = useChatRuntimeStore.getState().activeThreadId; + if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) { + return { mode: "single", threadId: id }; + } + return { mode: "single" }; +} + export function ChatPage(): ReactElement { - const [view, setView] = useState({ - mode: "single", - newThreadNonce: crypto.randomUUID(), - }); + // Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch + // and create spurious threads when navigating (e.g. Recipes / Export). New Chat + // explicitly sets a nonce in handleNewThread. + const [view, setView] = useState(getInitialSingleChatView); const [settingsOpen, setSettingsOpen] = useState(false); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false); @@ -587,9 +599,20 @@ export function ChatPage(): ReactElement { void ejectModel(); }, [ejectModel]); const handleNewThread = useCallback(() => { + // Skip if we are already on a fresh unsaved draft with no messages sent. + // Once the user sends a message, append() sets activeThreadId in the store, + // so we check the store to know whether the current draft has been sent. + if ( + view.mode === "single" && + !view.threadId && + !useChatRuntimeStore.getState().activeThreadId + ) { + return; + } + useChatRuntimeStore.getState().setActiveThreadId(null); setView({ mode: "single", newThreadNonce: crypto.randomUUID() }); - }, []); + }, [view]); const handleNewCompare = useCallback(() => { setView({ mode: "compare", pairId: crypto.randomUUID() }); // Clear activeThreadId so compare panes do not inherit the single-chat @@ -922,7 +945,7 @@ export function ChatPage(): ReactElement { {view.mode === "single" ? ( diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 7e6cd8e1dd..024543edb9 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -596,6 +596,15 @@ function ThreadHistoryProvider({ async append({ parentId, message }: ExportedMessageRepositoryItem) { const { remoteId } = await aui.threadListItem().initialize(); + // Keep single-chat runtime state in sync once a new chat is first + // persisted. Compare panes intentionally do not write global activeThreadId. + const thread = await db.threads.get(remoteId); + if (thread?.modelType === "base" && !thread.pairId) { + const store = useChatRuntimeStore.getState(); + if (store.activeThreadId !== remoteId) { + store.setActiveThreadId(remoteId); + } + } const content = cloneContent(message.content); const attachments = message.role === "user" ? cloneAttachments(message.attachments) : []; @@ -658,7 +667,11 @@ function useRuntimeHook(): ReturnType { function ThreadAutoSwitch({ threadId, -}: { threadId: string }): ReactElement | null { + syncActiveThreadId = true, +}: { + threadId: string; + syncActiveThreadId?: boolean; +}): ReactElement | null { const aui = useAui(); const isLoading = useAuiState(({ threads }) => threads.isLoading); const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId); @@ -669,6 +682,13 @@ function ThreadAutoSwitch({ } }, [aui, isLoading, mainThreadId, threadId]); + useEffect(() => { + if (!syncActiveThreadId || isLoading || mainThreadId !== threadId) { + return; + } + useChatRuntimeStore.getState().setActiveThreadId(threadId); + }, [isLoading, mainThreadId, syncActiveThreadId, threadId]); + return null; } @@ -682,30 +702,10 @@ function ThreadNewChatSwitch({ if (isLoading) { return; } - - let cancelled = false; - // Clear immediately so the adapter never picks up a stale thread ID - // from a previous chat while we initialize the new one. + // Switch to a fresh local thread without persisting it yet. + // Persistence still happens on first message append. + void aui.threads().switchToNewThread(); useChatRuntimeStore.getState().setActiveThreadId(null); - - void (async () => { - try { - aui.threads().switchToNewThread(); - const { remoteId } = await aui.threadListItem().initialize(); - if (!cancelled) { - useChatRuntimeStore.getState().setActiveThreadId(remoteId); - } - } catch (error) { - if (!cancelled) { - useChatRuntimeStore.getState().setActiveThreadId(null); - } - console.error("Failed to initialize new chat thread", error); - } - })(); - - return () => { - cancelled = true; - }; }, [aui, isLoading, nonce]); return null; @@ -733,12 +733,14 @@ export function ChatRuntimeProvider({ pairId, initialThreadId, newThreadNonce, + syncActiveThreadId = true, }: { children: ReactNode; modelType?: ModelType; pairId?: string; initialThreadId?: string; newThreadNonce?: string; + syncActiveThreadId?: boolean; }): ReactElement { const runtime = useRemoteThreadListRuntime({ runtimeHook: useRuntimeHook, @@ -754,8 +756,15 @@ export function ChatRuntimeProvider({ return ( - - {initialThreadId && } + + {initialThreadId && ( + + )} {!initialThreadId && newThreadNonce && ( )} diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index ba97d2ee6e..62246cdad5 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -22,6 +22,7 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { db, useLiveQuery } from "./db"; +import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import type { ChatView, ThreadRecord } from "./types"; interface SidebarItem { @@ -76,12 +77,17 @@ export function ThreadSidebar({ onNewCompare: () => void; showCompare: boolean; }) { - const allThreads = useLiveQuery( - () => db.threads.orderBy("createdAt").reverse().toArray(), - [], - ); + const allThreads = useLiveQuery(async () => { + const threadIdsWithMessage = new Set( + (await db.messages.orderBy("threadId").uniqueKeys()) as string[], + ); + const rows = await db.threads.orderBy("createdAt").reverse().toArray(); + return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id)); + }, []); const items = groupThreads(allThreads ?? []); - const activeId = view.mode === "single" ? view.threadId : view.pairId; + const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + const activeId = + view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId; function viewForItem(item: SidebarItem): ChatView { return item.type === "single" @@ -101,7 +107,11 @@ export function ThreadSidebar({ } } if (activeId === item.id) { - onSelect({ mode: "single" }); + // Directly set a new view with a nonce rather than going through + // onNewThread(), which may return early if the guard sees no + // threadId and no activeThreadId (after we just cleared it). + useChatRuntimeStore.getState().setActiveThreadId(null); + onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() }); } } diff --git a/tests/test_loader_glob_skip.py b/tests/test_loader_glob_skip.py new file mode 100644 index 0000000000..d461e23cf8 --- /dev/null +++ b/tests/test_loader_glob_skip.py @@ -0,0 +1,152 @@ +"""Tests that HfFileSystem().glob() is skipped when is_model or is_peft is False. + +The glob calls in FastLanguageModel.from_pretrained and FastModel.from_pretrained +exist solely to detect repos with both config.json and adapter_config.json. When +either AutoConfig or PeftConfig fails to load, the glob cannot find both files, +so calling it is redundant and risks hanging on slow networks. +""" + +import os +import unittest +from unittest.mock import MagicMock, patch + + +class TestGlobSkippedWhenNotBothConfigs(unittest.TestCase): + """Verify HfFileSystem.glob is not called when is_model or is_peft is False.""" + + def _run_both_exist_block( + self, is_model, is_peft, supports_llama32, model_name, is_local_dir = False + ): + """Simulate the both_exist detection block from loader.py. + + This mirrors the exact logic at lines 500-517 / 1276-1292 of loader.py. + Returns (both_exist, glob_called). + """ + from unittest.mock import MagicMock + + both_exist = (is_model and is_peft) and not supports_llama32 + glob_mock = MagicMock( + return_value = [ + f"{model_name}/config.json", + f"{model_name}/adapter_config.json", + ] + ) + + # This mirrors the guarded block in loader.py + if supports_llama32 and is_model and is_peft: + if is_local_dir: + # Local path branch — would use os.path.exists in real code + both_exist = True # simulate both files present locally + else: + files = glob_mock(f"{model_name}/*.json") + files = list(os.path.split(x)[-1] for x in files) + if ( + sum(x == "adapter_config.json" or x == "config.json" for x in files) + >= 2 + ): + both_exist = True + + return both_exist, glob_mock.called + + # --- Cases where glob should NOT be called --- + + def test_glob_skipped_when_is_model_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = False, + is_peft = True, + supports_llama32 = True, + model_name = "org/some-adapter", + ) + self.assertFalse(glob_called, "glob should not be called when is_model=False") + self.assertFalse(both_exist) + + def test_glob_skipped_when_is_peft_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = False, + supports_llama32 = True, + model_name = "org/some-model", + ) + self.assertFalse(glob_called, "glob should not be called when is_peft=False") + self.assertFalse(both_exist) + + def test_glob_skipped_when_both_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = False, + is_peft = False, + supports_llama32 = True, + model_name = "org/bad-repo", + ) + self.assertFalse(glob_called, "glob should not be called when both are False") + self.assertFalse(both_exist) + + def test_glob_skipped_when_supports_llama32_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = True, + supports_llama32 = False, + model_name = "org/some-model", + ) + self.assertFalse( + glob_called, "glob should not be called when SUPPORTS_LLAMA32=False" + ) + # both_exist is set by the old-style check: (is_model and is_peft) and not SUPPORTS_LLAMA32 + self.assertTrue(both_exist) + + # --- Cases where glob SHOULD be called --- + + def test_glob_called_when_both_true_and_supports_llama32(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = True, + supports_llama32 = True, + model_name = "org/mixed-repo", + ) + self.assertTrue( + glob_called, "glob should be called when is_model and is_peft are both True" + ) + self.assertTrue(both_exist) + + def test_local_dir_skips_glob(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = True, + supports_llama32 = True, + model_name = "/local/path/to/model", + is_local_dir = True, + ) + self.assertFalse(glob_called, "glob should not be called for local directories") + self.assertTrue(both_exist) + + +class TestLoaderSourceHasGuard(unittest.TestCase): + """Verify the actual loader.py source code has the is_model/is_peft guard.""" + + def test_loader_source_has_guard(self): + """Check that both SUPPORTS_LLAMA32 checks in loader.py include is_model and is_peft.""" + loader_path = os.path.join( + os.path.dirname(__file__), os.pardir, "unsloth", "models", "loader.py" + ) + with open(loader_path) as f: + source = f.read() + + # Find all lines with the SUPPORTS_LLAMA32 check near glob usage + lines = source.splitlines() + guard_lines = [ + line.strip() + for line in lines + if "SUPPORTS_LLAMA32" in line and "if " in line and "is_model" in line + ] + # There should be exactly 2 guarded checks (one per from_pretrained method) + self.assertEqual( + len(guard_lines), + 2, + f"Expected 2 guarded SUPPORTS_LLAMA32 checks with is_model/is_peft, found {len(guard_lines)}: {guard_lines}", + ) + for line in guard_lines: + self.assertIn("is_model", line) + self.assertIn("is_peft", line) + + +if __name__ == "__main__": + unittest.main() diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index dcb7334417..d8fe92739a 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.4.2" +__version__ = "2026.4.4" __all__ = [ "SUPPORTS_BFLOAT16", diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index 0bc0ae719c..3bc810e4d2 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -49,7 +49,6 @@ except: except: # For older versions of huggingface_hub from huggingface_hub.utils._token import get_token -from huggingface_hub import HfFileSystem import importlib.util from ..device_type import ( is_hip, @@ -108,8 +107,6 @@ FORCE_FLOAT32 = [ "gemma3n", "gpt_oss", "qwen3_5", # Qwen3.5 GDN layers produce NaN grad norms in float16 training - "gemma4,", # Add comma bc gemma4 will match gemma4_text - "gemma4_text", ] global DISABLE_COMPILE_MODEL_NAMES @@ -508,7 +505,7 @@ class FastLanguageModel(FastLlamaModel): model_type = model_types # New transformers need to check manually. - if SUPPORTS_LLAMA32: + if SUPPORTS_LLAMA32 and is_model and is_peft: # Check if folder exists locally if os.path.isdir(model_name): exist_adapter_config = os.path.exists( @@ -517,14 +514,10 @@ class FastLanguageModel(FastLlamaModel): exist_config = os.path.exists(os.path.join(model_name, "config.json")) both_exist = exist_adapter_config and exist_config else: - # Because HfFileSystem assumes linux paths, we need to set the path with forward slashes, even on Windows. - files = HfFileSystem(token = token).glob(f"{model_name}/*.json") - files = list(os.path.split(x)[-1] for x in files) - if ( - sum(x == "adapter_config.json" or x == "config.json" for x in files) - >= 2 - ): - both_exist = True + # Both AutoConfig and PeftConfig loaded successfully from this + # remote repo, so both config.json and adapter_config.json + # definitely exist -- no need for an extra HfFileSystem network call. + both_exist = True if not is_model and not is_peft: error = autoconfig_error if autoconfig_error is not None else peft_error @@ -1282,7 +1275,7 @@ class FastModel(FastBaseModel): os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1" # New transformers need to check manually. - if SUPPORTS_LLAMA32: + if SUPPORTS_LLAMA32 and is_model and is_peft: # Check if folder exists locally if os.path.isdir(model_name): exist_adapter_config = os.path.exists( @@ -1291,13 +1284,10 @@ class FastModel(FastBaseModel): exist_config = os.path.exists(os.path.join(model_name, "config.json")) both_exist = exist_adapter_config and exist_config else: - files = HfFileSystem(token = token).glob(f"{model_name}/*.json") - files = list(os.path.split(x)[-1] for x in files) - if ( - sum(x == "adapter_config.json" or x == "config.json" for x in files) - >= 2 - ): - both_exist = True + # Both AutoConfig and PeftConfig loaded successfully from this + # remote repo, so both config.json and adapter_config.json + # definitely exist -- no need for an extra HfFileSystem network call. + both_exist = True if not is_model and not is_peft: error = autoconfig_error if autoconfig_error is not None else peft_error