From 68965988cfb626f8de5a8e336484ea3abcec2d00 Mon Sep 17 00:00:00 2001 From: Leo Borcherding Date: Sun, 5 Apr 2026 23:57:45 -0500 Subject: [PATCH 01/11] Fix/studio colab button message: Add fallback message for Colab Studio button when proxy URL fails (#4866) * Add fallback message for Colab Studio button when localhost link doesn't work * Make fallback message darker grey for better readability * Make fallback message bold for better visibility --------- Co-authored-by: LeoBorcherding --- studio/backend/colab.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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}

From 278f4629962358268674de0a102f7537c2b0ab3a Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:41:20 +0400 Subject: [PATCH 02/11] [Studio][Optimization]Add vision detection cache to is_vision_model() (#4853) * Add vision detection cache to is_vision_model() to avoid redundant subprocess spawns is_vision_model() is called 4-5 times per training run for the same model with zero caching. For transformers 5.x models, each call spawns a full subprocess (~6s each). This adds a module-level _vision_detection_cache dict following the same pattern as the existing _audio_detection_cache used by detect_audio_type(). The function is refactored into a thin cache wrapper around _is_vision_model_uncached(), saving ~12s per training run. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Include hf_token in vision cache key for gated model correctness Cache key is now (model_name, hf_token) instead of just model_name. This prevents stale False results when an unauthenticated probe for a gated model is followed by an authenticated call. * Remove test file from main PR - will be submitted separately * Fix vision cache: normalize model names and skip caching transient failures - Normalize model names in cache key using resolve_cached_repo_id_case() to avoid duplicate entries for different casings of the same HF repo (aligns with case normalization from #4822) - Return None instead of False on transient failures (network errors, subprocess timeouts, HF API issues) so the cache layer can distinguish "definitely not a vision model" from "failed to check" - Only cache definitive True/False results; transient failures are retried on the next call instead of being permanently locked in as False * Refine failure handling: cache deterministic failures, guard normalization - Subprocess non-zero exit, JSON errors, and general exceptions return False (deterministic, cached) instead of None (retryable). Only subprocess.TimeoutExpired returns None since timeouts are transient. - Wrap cache key normalization in try/except so resolve_cached_repo_id_case or normalize_path failures fall back to raw model_name instead of crashing callers. * Harden vision detection cache: fix transient failure handling, thread safety, token security - All subprocess failure paths now return None (transient) instead of False, preventing permanent misclassification of VLMs after temporary HF/auth/network errors - Use SHA256 fingerprint for hf_token in cache key instead of raw bearer token - Add threading.Lock with double-checked locking to prevent thundering herd of concurrent subprocess spawns for the same uncached model - Distinguish permanent failures (RepositoryNotFoundError, GatedRepoError, ValueError) from transient ones in _is_vision_model_uncached - Pass resolved/normalized model name to detection (not just cache key) - Log normalization fallback at debug level instead of silent swallow - Thread hf_token through callers in routes/models.py and trainer.py that previously omitted it * Refine lock strategy and token fingerprint - Move detection computation outside the lock to avoid serializing long-running subprocess spawns (60s timeout) and HF API calls across all concurrent model checks. Lock is now only held for cache writes. - Use full SHA256 digest for token fingerprint instead of truncated 16-char prefix to eliminate collision risk. * Fix huggingface_hub import fallback and use atomic cache read - Add fallback import path for RepositoryNotFoundError/GatedRepoError from huggingface_hub.utils (older hub versions) when .errors is not available - Use sentinel-based dict.get() for single atomic cache read instead of two-step in/[] pattern (future-proof for no-GIL runtimes) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/core/training/trainer.py | 12 ++- studio/backend/routes/models.py | 2 +- studio/backend/utils/models/model_config.py | 112 ++++++++++++++++++-- 3 files changed, 115 insertions(+), 11 deletions(-) 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..ea385436b8 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -618,7 +618,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/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f97ea993eb..9d72846ca3 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") From ab65b47c7312b0ac18e78a708180d08c75fed423 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 06:41:40 -0700 Subject: [PATCH 03/11] Add tests for is_vision_model() caching behaviour (#4855) * Add tests for is_vision_model() caching behaviour * Fix review feedback: remove dead helper, fix exception test - Remove unused _make_config() helper function (dead code) - Fix test_exception_result_cached to actually exercise the exception path by mocking load_model_config to raise OSError instead of using side_effect=[False] which only tested normal False returns * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use strict mock specs so tests exercise intended detection paths Use MagicMock(spec=[]) for all config mocks so hasattr() only returns True for explicitly set attributes. Without this, MagicMock defaults make all hasattr checks truthy, allowing tests to pass via unintended detection paths (e.g. img_processor instead of vision_config). --------- Co-authored-by: Roland Tannous Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/tests/test_vision_cache.py | 238 ++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 studio/backend/tests/test_vision_cache.py 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() From 07b6fcc344adff761f2130ac8434bac594a13c68 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 07:33:28 -0700 Subject: [PATCH 04/11] Remove Gemma-4 from FORCE_FLOAT32 (#4875) Gemma-4 does not need FORCE_FLOAT32. Testing shows that both float16 and bfloat16 work correctly without the forced float32 override: - Inference: identical outputs for float16 and bfloat16 (greedy decoding) - Training (100 steps, 4-bit LoRA, SFT on FineTome-100k): - float16 final loss: 3.048 - bfloat16 final loss: 3.065 - Losses converge to within 0.02 by step 60 - Grad norms healthy and comparable for both dtypes The FORCE_FLOAT32 path was actually causing training divergence. With it enabled, the compiled float32 run diverged at step ~28 with grad norms collapsing to near zero and loss plateauing at ~12.4. Without it, both dtypes train normally. This enables float16 on Tesla T4 and other GPUs without bfloat16 support. --- unsloth/models/loader.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index a6cb3eb529..5827711d04 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -108,8 +108,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 From 0835f0a61bad20f286507ffeaee3831944ec782c Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:46:39 +0400 Subject: [PATCH 05/11] fix: skip redundant HfFileSystem().glob() calls in loader.py (#4852) * fix: skip redundant HfFileSystem().glob() calls in loader.py Guard the SUPPORTS_LLAMA32 glob blocks with `is_model and is_peft` so the HfFileSystem HTTP call is only made when both configs could actually exist. This prevents indefinite hangs on slow/unreliable networks since the glob result is redundant when either AutoConfig or PeftConfig already failed to load. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove test file from main PR - moved to separate PR Tests for the glob skip guard belong in their own PR to keep the loader change minimal and reviewable. * Harden HfFileSystem glob: fix Windows path splitting, add try/except - Use str.rsplit("/", 1) instead of os.path.split to extract filenames from HfFileSystem paths. HfFileSystem always returns POSIX-style paths, but os.path.split uses the OS separator, so on Windows the entire path was returned as the "filename" and the config name comparison always failed. - Wrap the HfFileSystem().glob() call in try/except to gracefully handle network failures (offline mode, timeouts, unreachable Hub). On failure both_exist stays False, which is the safe default. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove redundant HfFileSystem().glob() call for remote repos When is_model and is_peft are both True, AutoConfig and PeftConfig have already loaded successfully, proving both config.json and adapter_config.json exist. The HfFileSystem network call to re-verify this was redundant and could cause hangs on slow networks. Replace the glob + try/except block with a direct both_exist = True assignment. * Remove unused HfFileSystem import HfFileSystem was only used for the glob() calls that were replaced with direct both_exist = True assignments in the previous commit. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- unsloth/models/loader.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index 5827711d04..df97c5c7df 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, @@ -506,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( @@ -515,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 @@ -1280,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( @@ -1289,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 From aa4c6010e189c3391bed238110cf46007b32a7d0 Mon Sep 17 00:00:00 2001 From: JYYYYYT <18164100443@163.com> Date: Mon, 6 Apr 2026 23:31:07 +0800 Subject: [PATCH 06/11] fix(studio): custom folder scan fails to find GGUF variants when pointing directly at a model directory (#4860) Fix custom folder scanning when pointing directly at a model directory. When a user adds a custom scan folder that points directly at a model directory (e.g. /path/to/gemma-4-e2b-it-gguf/ containing config.json and gemma-4-E2B-it-BF16.gguf), the model list previously showed individual .gguf files as separate entries instead of recognizing the directory as a single model. Clicking any entry showed "No GGUF variants found" because list_local_gguf_variants received a file path and immediately returned empty. Changes: - Add _is_model_directory() helper that detects directories with both config metadata and actual model weight files (excludes mmproj GGUFs and non-weight .bin files like tokenizer.bin) - _scan_models_dir: detect self-model and return single directory entry - _scan_lmstudio_dir: surface model directories directly instead of descending into them as publisher folders; handle both root and child model directories - Add _resolve_gguf_dir() helper for GGUF path resolution that only falls back to parent directory when parent has model metadata - list_local_gguf_variants / _find_local_gguf_by_variant: use resolver so .gguf file paths inside model directories work correctly --- studio/backend/routes/models.py | 96 +++++++++++++++++++++ studio/backend/utils/models/model_config.py | 28 +++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index ea385436b8..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: diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 9d72846ca3..61226e52cb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1130,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]: @@ -1142,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] = {} @@ -1183,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( From 723bfb236305eb4ae4d0fdbf724fbe801b71da66 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 08:54:36 -0700 Subject: [PATCH 07/11] Add unit tests for HfFileSystem glob skip guard (#4854) Tests verifying that HfFileSystem().glob() is correctly skipped when is_model or is_peft is False, matching the guard added in PR #4852. --- tests/test_loader_glob_skip.py | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/test_loader_glob_skip.py 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() From 4c83e3540ec878db055d35583fa83a3415acc957 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 09:20:17 -0700 Subject: [PATCH 08/11] Update --- pyproject.toml | 4 ++-- unsloth/models/_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/unsloth/models/_utils.py b/unsloth/models/_utils.py index dcb7334417..9ee2ade3db 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.3" __all__ = [ "SUPPORTS_BFLOAT16", From 8c89b84bb678659139e2530cc56ca291583828c7 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:32:54 +0100 Subject: [PATCH 09/11] Studio: Fix empty chat threads on navigation and stabilize new chat flow (#4872) * fix(chat): prevent implicit empty thread creation and stabilize new-chat flow * fix(chat): harden compare thread sync and simplify sidebar thread query * fix(chat): harden new-thread state sync and isolate compare active thread updates * fix(chat): stabilize new-thread state sync and prevent compare/session bleed * Fix thread restoration, handleNewThread guard, sidebar filter, and delete flow - Remove __LOCALID_ filter from getInitialSingleChatView: in this Dexie-backed adapter, AUI's __LOCALID_ prefixed IDs ARE the real persistent thread IDs stored by initialize(). Filtering them out breaks thread restoration on navigation. - Simplify handleNewThread to synchronous: the async Dexie message check is redundant (persistence is already deferred to first append) and strands users on legacy empty threads. Use a simple guard that checks the store's activeThreadId to detect unsent drafts. - Add message-count filter to sidebar: filter threads to only show those with at least one message, hiding legacy empty threads. - Add store-based sidebar highlighting fallback: use activeThreadId from the store when view.threadId is not set (nonce-backed chats). - Fix handleDelete to call onNewThread() instead of onSelect(), and clear activeThreadId, so the runtime properly resets after deleting the active thread. * Fix handleDelete nonce path and restore __LOCALID_ filter handleDelete was calling onNewThread() after clearing activeThreadId, but the handleNewThread guard sees !view.threadId && !activeThreadId and returns early, leaving the UI stuck on the deleted thread. Fix by directly calling onSelect with a new nonce instead. Restore __LOCALID_ filter in getInitialSingleChatView to prevent restoring unpersisted AUI local thread IDs on navigation. Without this filter, navigating away from /chat before sending a message would restore a non-existent thread that Dexie cannot fetch. --------- Co-authored-by: Daniel Han --- .../frontend/src/features/chat/chat-page.tsx | 35 +++++++++-- .../src/features/chat/runtime-provider.tsx | 61 +++++++++++-------- .../src/features/chat/thread-sidebar.tsx | 22 +++++-- 3 files changed, 80 insertions(+), 38 deletions(-) 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() }); } } From b295daf9323d2782fe7d1286b2e1bde89dc6cd29 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 09:39:06 -0700 Subject: [PATCH 10/11] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 9ee2ade3db..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.3" +__version__ = "2026.4.4" __all__ = [ "SUPPORTS_BFLOAT16", From 1d8160376e169d13c386b7ef4bc1fdc8f855de68 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 09:46:35 -0700 Subject: [PATCH 11/11] Bump minimum unsloth version to 2026.4.4 in install scripts (#4876) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) 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