Merge remote-tracking branch 'origin/main' into feature/docker-studio-v0.1.35-beta-2026.4.4

This commit is contained in:
Roland Tannous 2026-04-06 23:11:20 +00:00
commit 4385226081
14 changed files with 732 additions and 87 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -66,7 +66,10 @@ def show_link(port: int = 8888):
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="white"><polygon points="5,3 19,12 5,21"/></svg>
Open Unsloth Studio
</a>
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace;">
<p style="color: #333333; margin: 12px 0 0 0; font-size: 14px; font-weight: bold;">
If the link doesn't work, you can scroll down to view the UI generated directly in Colab.
</p>
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace; font-weight: bold;">
{short_url}
</p>
</div>

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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(

View file

@ -225,6 +225,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
modelType="base"
pairId={pairId}
initialThreadId={baseThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="base" />
<Thread hideComposer={true} hideWelcome={true} />
@ -242,6 +243,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
modelType="lora"
pairId={pairId}
initialThreadId={loraThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="lora" />
<Thread hideComposer={true} hideWelcome={true} />
@ -343,6 +345,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
modelType="model1"
pairId={pairId}
initialThreadId={model1ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model1" />
<Thread hideComposer={true} hideWelcome={true} />
@ -376,6 +379,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
modelType="model2"
pairId={pairId}
initialThreadId={model2ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model2" />
<Thread hideComposer={true} hideWelcome={true} />
@ -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<ChatView>({
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<ChatView>(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" ? (
<SingleContent
key={view.threadId ?? view.newThreadNonce ?? "new"}
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>

View file

@ -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<typeof useLocalRuntime> {
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 (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync enabled={modelType === "base" && !pairId && !newThreadNonce} />
{initialThreadId && <ThreadAutoSwitch threadId={initialThreadId} />}
<ActiveThreadSync
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
/>
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}
syncActiveThreadId={syncActiveThreadId}
/>
)}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />
)}

View file

@ -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() });
}
}

View file

@ -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()

View file

@ -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",

View file

@ -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