Merge remote-tracking branch 'origin/main' into feature/docker-studio-v0.1.35-beta-2026.4.4
This commit is contained in:
commit
4385226081
14 changed files with 732 additions and 87 deletions
10
install.ps1
10
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
|
||||
|
|
|
|||
10
install.sh
10
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
238
studio/backend/tests/test_vision_cache.py
Normal file
238
studio/backend/tests/test_vision_cache.py
Normal 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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
152
tests/test_loader_glob_skip.py
Normal file
152
tests/test_loader_glob_skip.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue