Studio: fix Gemma-4-12B-it not loading (#6054)

* Fix Studio Python, Gemma 4 Unified sidecar, and worker crash messages

* Clean up Gemma 4 sidecar test patch contexts

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Polish inference worker crash message

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address transformers tier review feedback

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Route Gemma 4 assistant models to transformers 5.10

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lee Jackson 2026-06-10 16:39:07 +01:00 committed by GitHub
commit 0f00bc1e2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 657 additions and 100 deletions

View file

@ -17,6 +17,7 @@ Pattern follows core/training/training.py.
import atexit
import base64
import os
import signal
import structlog
from loggers import get_logger
import multiprocessing as mp
@ -239,6 +240,43 @@ class InferenceOrchestrator:
"""True if the subprocess is alive."""
return self._proc is not None and self._proc.is_alive()
def _subprocess_crash_message(self, context: str) -> str:
"""Return a user-facing crash message with the worker exit status."""
context_label = {
"wait": "loading the model",
"generation": "generating a response",
"audio generation": "generating audio",
"audio input generation": "processing audio input",
}.get(context, context)
message = f"The inference worker stopped unexpectedly while {context_label}."
if self._proc is None:
return f"{message} Details: process missing."
exitcode = self._proc.exitcode
pid = self._proc.pid
if exitcode is None:
return f"{message} Details: pid={pid}."
if exitcode < 0:
signum = -exitcode
try:
sig_name = signal.Signals(signum).name
except ValueError:
sig_name = f"SIG{signum}"
suffix = ""
if sig_name == "SIGKILL":
suffix = (
" This usually means the system killed it under memory pressure. "
"Try a smaller model, lower context length, or close other GPU-heavy apps."
)
return (
f"{message}{suffix} " f"Details: pid={pid}, signal={sig_name}, exitcode={exitcode}."
)
return f"{message} Details: pid={pid}, exitcode={exitcode}."
# ------------------------------------------------------------------
# Queue helpers
# ------------------------------------------------------------------
@ -286,7 +324,7 @@ class InferenceOrchestrator:
if resp is None:
# Check subprocess health
if not self._ensure_subprocess_alive():
raise RuntimeError("Inference subprocess crashed during wait")
raise RuntimeError(self._subprocess_crash_message("wait"))
continue
rtype = resp.get("type", "")
@ -510,7 +548,7 @@ class InferenceOrchestrator:
except queue.Empty:
# Timeout — check subprocess health
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess crashed during generation"
yield f"Error: {self._subprocess_crash_message('generation')}"
return
continue
@ -1028,7 +1066,7 @@ class InferenceOrchestrator:
if resp is None:
# Check subprocess health
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess crashed during generation"
yield f"Error: {self._subprocess_crash_message('generation')}"
return
continue
@ -1125,7 +1163,7 @@ class InferenceOrchestrator:
if resp is None:
if not self._ensure_subprocess_alive():
raise RuntimeError("Inference subprocess crashed during audio generation")
raise RuntimeError(self._subprocess_crash_message("audio generation"))
continue
rtype = resp.get("type", "")
@ -1247,7 +1285,7 @@ class InferenceOrchestrator:
if resp is None:
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess crashed during audio input generation"
yield ("Error: " + self._subprocess_crash_message("audio input generation"))
return
continue

View file

@ -1951,7 +1951,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
}
)
return
# Activate correct transformers version (Gemma-4 needs 5.5.0, etc.)
# Activate correct transformers version (Gemma-4 needs a 5.x sidecar, etc.)
# before any transformers/mlx-lm imports in _run_mlx_training.
try:
_activate_transformers_version(model_name)

View file

@ -0,0 +1,33 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from pathlib import Path
from types import SimpleNamespace
import importlib.util
import sys
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
def test_subprocess_crash_message_includes_signal_and_oom_hint():
spec = importlib.util.spec_from_file_location(
"inference_orchestrator_under_test",
Path(__file__).resolve().parent.parent / "core/inference/orchestrator.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
orchestrator = module.InferenceOrchestrator.__new__(module.InferenceOrchestrator)
orchestrator._proc = SimpleNamespace(pid = 1234, exitcode = -9)
msg = orchestrator._subprocess_crash_message("wait")
assert msg.startswith("The inference worker stopped unexpectedly while loading the model.")
assert "memory pressure" in msg
assert "smaller model" in msg
assert "Details:" in msg
assert "signal=SIGKILL" in msg
assert "exitcode=-9" in msg

View file

@ -30,8 +30,11 @@ sys.modules.setdefault("loggers", _loggers_stub)
from utils.transformers_version import (
_resolve_base_model,
_check_tokenizer_config_needs_v5,
_check_config_needs_510,
_check_config_needs_550,
_config_json_cache,
_tokenizer_class_cache,
_config_needs_510_cache,
_config_needs_550_cache,
needs_transformers_5,
get_transformers_tier,
@ -200,6 +203,7 @@ class TestCheckConfigNeeds550:
"""Tests for _check_config_needs_550() local config.json checks."""
def setup_method(self):
_config_json_cache.clear()
_config_needs_550_cache.clear()
def test_gemma4_architecture(self, tmp_path: Path):
@ -253,6 +257,106 @@ class TestCheckConfigNeeds550:
mock_urlopen.assert_not_called()
# ---------------------------------------------------------------------------
# _check_config_needs_510 — config.json architecture/model_type check
# ---------------------------------------------------------------------------
class TestCheckConfigNeeds510:
"""Tests for _check_config_needs_510() local config.json checks."""
def setup_method(self):
_config_json_cache.clear()
_config_needs_510_cache.clear()
def test_gemma4_unified_architecture(self, tmp_path: Path):
"""config.json with Gemma4UnifiedForConditionalGeneration should return True."""
cfg = {
"architectures": ["Gemma4UnifiedForConditionalGeneration"],
"model_type": "gemma4_unified",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_unified_model_type_only(self, tmp_path: Path):
"""config.json with model_type=gemma4_unified should return True."""
cfg = {"model_type": "gemma4_unified"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_unified_assistant_architecture(self, tmp_path: Path):
"""Assistant Gemma 4 Unified configs should return True."""
cfg = {
"architectures": ["Gemma4UnifiedAssistantForCausalLM"],
"model_type": "gemma4_unified_assistant",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_unified_assistant_model_type_only(self, tmp_path: Path):
"""Assistant Gemma 4 Unified model_type should return True."""
cfg = {"model_type": "gemma4_unified_assistant"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_assistant_architecture(self, tmp_path: Path):
"""Assistant Gemma 4 configs should return True."""
cfg = {
"architectures": ["Gemma4AssistantForCausalLM"],
"model_type": "gemma4_assistant",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_assistant_model_type_only(self, tmp_path: Path):
"""Assistant Gemma 4 model_type should return True."""
cfg = {"model_type": "gemma4_assistant"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_non_unified_returns_false(self, tmp_path: Path):
"""Older Gemma 4 config should stay on the 550 tier."""
cfg = {
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is False
def test_no_config_json(self, tmp_path: Path):
"""Missing config.json should return False (fail-open)."""
# Patch network call to avoid real fetch
with patch("urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = Exception("no network")
assert _check_config_needs_510(str(tmp_path)) is False
def test_result_is_cached(self, tmp_path: Path):
"""Subsequent calls should use the cache."""
cfg = {"architectures": ["Gemma4UnifiedForConditionalGeneration"]}
(tmp_path / "config.json").write_text(json.dumps(cfg))
key = str(tmp_path)
_check_config_needs_510(key)
assert key in _config_needs_510_cache
assert _config_needs_510_cache[key] is True
def test_local_file_skips_network(self, tmp_path: Path):
"""When local config.json exists, no network request should be made."""
cfg = {"architectures": ["LlamaForCausalLM"]}
(tmp_path / "config.json").write_text(json.dumps(cfg))
with patch("urllib.request.urlopen") as mock_urlopen:
_check_config_needs_510(str(tmp_path))
mock_urlopen.assert_not_called()
# ---------------------------------------------------------------------------
# get_transformers_tier — tier detection
# ---------------------------------------------------------------------------
@ -263,11 +367,19 @@ class TestGetTransformersTier:
def setup_method(self):
_tokenizer_class_cache.clear()
_config_json_cache.clear()
_config_needs_510_cache.clear()
_config_needs_550_cache.clear()
def test_gemma4_substring_returns_550(self):
assert get_transformers_tier("google/gemma-4-E2B-it") == "550"
def test_gemma4_12b_substring_returns_510(self):
assert get_transformers_tier("unsloth/gemma-4-12b-it") == "510"
def test_gemma4_assistant_substring_returns_510(self):
assert get_transformers_tier("google/gemma-4-E2B-it-assistant") == "510"
def test_gemma4_alt_substring_returns_550(self):
assert get_transformers_tier("unsloth/gemma4-E4B-it") == "550"
@ -281,17 +393,92 @@ class TestGetTransformersTier:
assert get_transformers_tier(str(tmp_path)) == "550"
def test_gemma4_unified_config_json_returns_510(self, tmp_path: Path):
"""Local checkpoint with Gemma4 Unified architecture → 510."""
cfg = {
"architectures": ["Gemma4UnifiedForConditionalGeneration"],
"model_type": "gemma4_unified",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert get_transformers_tier(str(tmp_path)) == "510"
def test_gemma4_assistant_config_json_returns_510(self, tmp_path: Path):
"""Local checkpoint with Gemma4 Assistant architecture → 510."""
cfg = {
"architectures": ["Gemma4AssistantForCausalLM"],
"model_type": "gemma4_assistant",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert get_transformers_tier(str(tmp_path)) == "510"
def test_local_config_json_short_circuits_path_substrings(self, tmp_path: Path):
"""Local config.json should prevent false matches from parent directory names."""
model_dir = tmp_path / "gemma-4-12b-experiment" / "llama-checkpoint"
model_dir.mkdir(parents = True)
(model_dir / "config.json").write_text(
json.dumps(
{
"architectures": ["LlamaForCausalLM"],
"model_type": "llama",
}
)
)
(model_dir / "tokenizer_config.json").write_text(
json.dumps({"tokenizer_class": "LlamaTokenizerFast"})
)
with patch("urllib.request.urlopen") as mock_urlopen:
assert get_transformers_tier(str(model_dir)) == "default"
mock_urlopen.assert_not_called()
def test_remote_config_json_is_fetched_once_for_config_tiers(self):
"""510 and 550 slow-path checks should share one config.json fetch."""
class _Response:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(
{
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
}
).encode()
with patch("urllib.request.urlopen", return_value = _Response()) as mock_urlopen:
assert get_transformers_tier("org/no-fast-substring-model") == "550"
assert mock_urlopen.call_count == 1
def test_qwen35_returns_530(self):
with patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
):
assert get_transformers_tier("Qwen/Qwen3.5-9B") == "530"
def test_ministral_returns_530(self):
with patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
):
assert get_transformers_tier("mistralai/Ministral-3-8B-Instruct-2512") == "530"
@ -301,6 +488,10 @@ class TestGetTransformersTier:
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
patch(
"utils.transformers_version._check_tokenizer_config_needs_v5",
return_value = False,
@ -309,15 +500,22 @@ class TestGetTransformersTier:
assert get_transformers_tier("meta-llama/Llama-3-8B") == "default"
def test_550_checked_before_530(self):
"""5.5.0 is checked first — a model matching both gets 550."""
"""5.5.0 is checked before 5.3.0 - a model matching both gets 550."""
assert get_transformers_tier("gemma-4-model") == "550"
def test_needs_transformers_5_compat(self):
"""needs_transformers_5 should return True for both 530 and 550 models."""
"""needs_transformers_5 should return True for 510, 530, and 550 models."""
assert needs_transformers_5("unsloth/gemma-4-12b-it") is True
assert needs_transformers_5("google/gemma-4-E2B-it") is True
with patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
):
assert needs_transformers_5("Qwen/Qwen3.5-9B") is True
with (
@ -325,6 +523,10 @@ class TestGetTransformersTier:
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
patch(
"utils.transformers_version._check_tokenizer_config_needs_v5",
return_value = False,

View file

@ -510,7 +510,7 @@ _VLM_MODEL_TYPES = {
_AUDIO_ONLY_MODEL_TYPES = {"csm", "whisper"}
# Pre-computed .venv_t5 paths and backend dir for subprocess version switching.
# Vision check uses 5.5.0 (newest, recognizes all architectures).
# Vision check uses the Gemma 4 5.5 sidecar for existing Gemma 4 architectures.
from utils.paths.storage_roots import studio_root as _studio_root # noqa: E402
_VENV_T5_DIR = str(_studio_root() / ".venv_t5_550")

View file

@ -3,12 +3,27 @@
"""Automatic transformers version switching.
Some newer architectures need transformers>=5.3.0 (.venv_t5_530/); Gemma 4
needs >=5.5.0 (.venv_t5_550/). Everything else uses the default 4.57.x. A
custom-named LoRA adapter's base model is resolved from adapter_config.json.
Some newer model architectures (Ministral-3, GLM-4.7-Flash, Qwen3-30B-A3B MoE,
tiny_qwen3_moe) require transformers>=5.3.0, while Gemma 4 models require a
newer 5.x sidecar. Everything else needs the default 4.57.x that ships with
Unsloth.
Training/inference run in subprocesses that activate the right version via
sys.path; export (in-process) uses ensure_transformers_version() for the swap.
Two separate target directories are maintained:
- .venv_t5_530/ transformers 5.3.0 (Ministral-3, GLM, Qwen3 MoE, etc.)
- .venv_t5_550/ transformers 5.5.0 (Gemma 4)
- .venv_t5_510/ transformers 5.10.2 (Gemma 4 Unified / 12B)
When loading a LoRA adapter with a custom name, we resolve the base model from
``adapter_config.json`` and check *that* against the model list.
Strategy:
Training and inference run in subprocesses that activate the correct version
via sys.path (prepending the appropriate .venv_t5_*/ directory). See:
- core/training/worker.py
- core/inference/worker.py
For export (still in-process), ensure_transformers_version() does a lightweight
sys.path swap using the same directories pre-installed by setup.sh.
"""
import importlib
@ -53,13 +68,32 @@ TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = (
"lfm2.5-vl-450m", # LiquidAI/LFM2.5-VL-450M
)
# Lowercase substrings for models that require transformers 5.5.0 (checked first).
# Lowercase substrings for models that require transformers 5.10.x (checked first).
TRANSFORMERS_510_MODEL_SUBSTRINGS: tuple[str, ...] = (
"gemma-4-12b", # Gemma 4 Unified 12B
"gemma4-12b",
)
# Lowercase substrings for models that require the Gemma 4 transformers 5.5 sidecar.
TRANSFORMERS_550_MODEL_SUBSTRINGS: tuple[str, ...] = (
"gemma-4", # Gemma-4 (E2B-it, E4B-it, 31B-it, 26B-A4B-it)
"gemma4", # Gemma-4 alternate naming
"qwen3.6",
)
# Architecture classes / model_type values that require transformers 5.10.x.
# Checked via config.json (local or HuggingFace).
_TRANSFORMERS_510_ARCHITECTURES: set[str] = {
"Gemma4UnifiedForConditionalGeneration",
"Gemma4AssistantForCausalLM",
"Gemma4UnifiedAssistantForCausalLM",
}
_TRANSFORMERS_510_MODEL_TYPES: set[str] = {
"gemma4_unified",
"gemma4_assistant",
"gemma4_unified_assistant",
}
# Architecture classes / model_type values that require transformers 5.5.0.
# Checked via config.json (local or HuggingFace).
_TRANSFORMERS_550_ARCHITECTURES: set[str] = {
@ -78,21 +112,26 @@ _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = {
_tokenizer_class_cache: dict[str, bool] = {}
# Cache for dynamic config.json lookups (architecture/model_type checks).
_config_json_cache: dict[str, dict | None] = {}
_config_needs_510_cache: dict[str, bool] = {}
_config_needs_550_cache: dict[str, bool] = {}
# Versions
TRANSFORMERS_510_VERSION = "5.10.2"
TRANSFORMERS_550_VERSION = "5.5.0"
TRANSFORMERS_530_VERSION = "5.3.0"
TRANSFORMERS_DEFAULT_VERSION = "4.57.6"
# Backwards-compat alias — points to 5.5.0 (the highest 5.x tier).
# Consumers should prefer TRANSFORMERS_530_VERSION / TRANSFORMERS_550_VERSION.
TRANSFORMERS_5_VERSION = TRANSFORMERS_550_VERSION
# Backwards-compat alias — points to the highest 5.x tier.
# Consumers should prefer TRANSFORMERS_510_VERSION / TRANSFORMERS_550_VERSION /
# TRANSFORMERS_530_VERSION.
TRANSFORMERS_5_VERSION = TRANSFORMERS_510_VERSION
# Pre-installed directories — created by setup.sh / setup.ps1.
from utils.paths.storage_roots import studio_root as _studio_root # noqa: E402
_VENV_T5_530_DIR = str(_studio_root() / ".venv_t5_530")
_VENV_T5_550_DIR = str(_studio_root() / ".venv_t5_550")
_VENV_T5_510_DIR = str(_studio_root() / ".venv_t5_510")
# Backwards-compat alias
_VENV_T5_DIR = _VENV_T5_550_DIR
@ -108,15 +147,34 @@ def activate_transformers_for_subprocess(model_name: str) -> None:
resolved = _resolve_base_model(model_name)
tier = get_transformers_tier(resolved)
if tier == "550":
if tier == "510":
if not _ensure_venv_t5_510_exists():
raise RuntimeError(
f"Cannot activate transformers {TRANSFORMERS_510_VERSION}: "
f".venv_t5_510 missing at {_VENV_T5_510_DIR}"
)
if _VENV_T5_510_DIR not in sys.path:
sys.path.insert(0, _VENV_T5_510_DIR)
logger.info(
"Activated transformers %s from %s",
TRANSFORMERS_510_VERSION,
_VENV_T5_510_DIR,
)
_pp = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = _VENV_T5_510_DIR + (os.pathsep + _pp if _pp else "")
elif tier == "550":
if not _ensure_venv_t5_550_exists():
raise RuntimeError(
f"Cannot activate transformers 5.5.0: "
f"Cannot activate transformers {TRANSFORMERS_550_VERSION}: "
f".venv_t5_550 missing at {_VENV_T5_550_DIR}"
)
if _VENV_T5_550_DIR not in sys.path:
sys.path.insert(0, _VENV_T5_550_DIR)
logger.info("Activated transformers 5.5.0 from %s", _VENV_T5_550_DIR)
logger.info(
"Activated transformers %s from %s",
TRANSFORMERS_550_VERSION,
_VENV_T5_550_DIR,
)
_pp = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = _VENV_T5_550_DIR + (os.pathsep + _pp if _pp else "")
elif tier == "530":
@ -260,6 +318,67 @@ def _check_tokenizer_config_needs_v5(model_name: str) -> bool:
return False
def _load_config_json(model_name: str) -> dict | None:
"""Return parsed ``config.json`` for *model_name*, checking local files first."""
if model_name in _config_json_cache:
return _config_json_cache[model_name]
local_cfg = Path(model_name) / "config.json"
if local_cfg.is_file():
try:
with open(local_cfg) as f:
cfg = json.load(f)
_config_json_cache[model_name] = cfg
return cfg
except Exception as exc:
logger.debug("Could not read %s: %s", local_cfg, exc)
_config_json_cache[model_name] = None
return None
if _env_offline():
_config_json_cache[model_name] = None
return None
import urllib.request
url = f"https://huggingface.co/{model_name}/raw/main/config.json"
try:
req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-studio"})
with urllib.request.urlopen(req, timeout = 10) as resp:
cfg = json.loads(resp.read().decode())
_config_json_cache[model_name] = cfg
return cfg
except Exception as exc:
logger.debug("Could not fetch config.json for '%s': %s", model_name, exc)
_config_json_cache[model_name] = None
return None
def _config_matches_tier(cfg: dict, architectures: set[str], model_types: set[str]) -> bool:
archs = cfg.get("architectures", [])
if any(a in architectures for a in archs):
return True
if cfg.get("model_type") in model_types:
return True
return False
def _config_needs_550(cfg: dict) -> bool:
return _config_matches_tier(
cfg,
_TRANSFORMERS_550_ARCHITECTURES,
_TRANSFORMERS_550_MODEL_TYPES,
)
def _config_needs_510(cfg: dict) -> bool:
return _config_matches_tier(
cfg,
_TRANSFORMERS_510_ARCHITECTURES,
_TRANSFORMERS_510_MODEL_TYPES,
)
def _check_config_needs_550(model_name: str) -> bool:
"""True if ``config.json`` has architectures/model_type needing transformers
5.5.0 (e.g. Gemma 4).
@ -270,81 +389,88 @@ def _check_config_needs_550(model_name: str) -> bool:
if model_name in _config_needs_550_cache:
return _config_needs_550_cache[model_name]
def _check_cfg(cfg: dict) -> bool:
archs = cfg.get("architectures", [])
if any(a in _TRANSFORMERS_550_ARCHITECTURES for a in archs):
return True
if cfg.get("model_type") in _TRANSFORMERS_550_MODEL_TYPES:
return True
return False
# --- Check local config.json first ------------------------------------
local_path = Path(model_name)
local_cfg = local_path / "config.json"
if local_cfg.is_file():
try:
with open(local_cfg) as f:
cfg = json.load(f)
result = _check_cfg(cfg)
if result:
logger.info(
"Local config.json check: %s needs transformers 5.5.0 "
"(architectures=%s, model_type=%s)",
model_name,
cfg.get("architectures", []),
cfg.get("model_type"),
)
_config_needs_550_cache[model_name] = result
return result
except Exception as exc:
logger.debug("Could not read %s: %s", local_cfg, exc)
# Offline: skip the 10s urllib fetch (fail-open to lower tier).
if _env_offline():
cfg = _load_config_json(model_name)
if cfg is None:
_config_needs_550_cache[model_name] = False
return False
# --- Fall back to fetching from HuggingFace ---------------------------
import urllib.request
result = _config_needs_550(cfg)
if result:
logger.info(
"config.json check: %s needs transformers %s (architectures=%s, model_type=%s)",
model_name,
TRANSFORMERS_550_VERSION,
cfg.get("architectures", []),
cfg.get("model_type"),
)
_config_needs_550_cache[model_name] = result
return result
url = f"https://huggingface.co/{model_name}/raw/main/config.json"
try:
req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-studio"})
with urllib.request.urlopen(req, timeout = 10) as resp:
cfg = json.loads(resp.read().decode())
result = _check_cfg(cfg)
if result:
logger.info(
"Dynamic config.json check: %s needs transformers 5.5.0 "
"(architectures=%s, model_type=%s)",
model_name,
cfg.get("architectures", []),
cfg.get("model_type"),
)
_config_needs_550_cache[model_name] = result
return result
except Exception as exc:
logger.debug("Could not fetch config.json for '%s': %s", model_name, exc)
_config_needs_550_cache[model_name] = False
def _check_config_needs_510(model_name: str) -> bool:
"""Check ``config.json`` for Gemma 4 Unified / 12B architectures."""
if model_name in _config_needs_510_cache:
return _config_needs_510_cache[model_name]
cfg = _load_config_json(model_name)
if cfg is None:
_config_needs_510_cache[model_name] = False
return False
result = _config_needs_510(cfg)
if result:
logger.info(
"config.json check: %s needs transformers %s (architectures=%s, model_type=%s)",
model_name,
TRANSFORMERS_510_VERSION,
cfg.get("architectures", []),
cfg.get("model_type"),
)
_config_needs_510_cache[model_name] = result
return result
def get_transformers_tier(model_name: str) -> str:
"""Return the transformers tier required for *model_name*.
``"550"`` for transformers 5.5.0 (e.g. Gemma 4), ``"530"`` for 5.3.0
(e.g. Ministral-3, Qwen3 MoE), or ``"default"`` for everything else (4.57.x).
The 5.5.0 check runs first, then 5.3.0.
Returns ``"510"`` for models needing transformers 5.10.x (Gemma 4 Unified),
``"550"`` for models needing transformers 5.5.0 (Gemma 4),
``"530"`` for models needing transformers 5.3.0 (e.g. Ministral-3, Qwen3 MoE),
or ``"default"`` for everything else (4.57.x).
Higher 5.x tiers run first.
"""
lowered = model_name.lower()
# Local checkpoint names can contain architecture substrings in their
# directory names (for example a pytest temp dir). If config.json exists,
# trust it before using name heuristics.
local_cfg = Path(model_name) / "config.json"
if local_cfg.is_file():
cfg = _load_config_json(model_name)
if cfg is not None and _config_needs_510(cfg):
return "510"
if cfg is not None and _config_needs_550(cfg):
return "550"
if cfg is not None:
local_tc = Path(model_name) / "tokenizer_config.json"
if local_tc.is_file() and _check_tokenizer_config_needs_v5(model_name):
return "530"
return "default"
# --- Fast substring checks (no I/O) ------------------------------------
if "assistant" in lowered and ("gemma-4" in lowered or "gemma4" in lowered):
return "510"
if any(sub in lowered for sub in TRANSFORMERS_510_MODEL_SUBSTRINGS):
return "510"
if any(sub in lowered for sub in TRANSFORMERS_550_MODEL_SUBSTRINGS):
return "550"
if any(sub in lowered for sub in TRANSFORMERS_5_MODEL_SUBSTRINGS):
return "530"
# --- Slow config fallbacks (local file first, then network) -----------
# --- Slow config fallbacks (network for HF IDs) ------------------------
if _check_config_needs_510(model_name):
return "510"
if _check_config_needs_550(model_name):
return "550"
if _check_tokenizer_config_needs_v5(model_name):
@ -419,6 +545,13 @@ _VENV_T5_530_PACKAGES = (
"tiktoken",
)
_VENV_T5_510_PACKAGES = (
f"transformers=={TRANSFORMERS_510_VERSION}",
"huggingface_hub==1.8.0",
"hf_xet==1.4.2",
"tiktoken",
)
_VENV_T5_550_PACKAGES = (
f"transformers=={TRANSFORMERS_550_VERSION}",
"huggingface_hub==1.8.0",
@ -475,7 +608,7 @@ def _venv_dir_is_valid(venv_dir: str, packages: tuple[str, ...]) -> bool:
def _venv_t5_is_valid() -> bool:
"""Backwards-compat: check the 5.5.0 venv."""
"""Backwards-compat: check the Gemma 4 sidecar venv."""
return _venv_dir_is_valid(_VENV_T5_550_DIR, _VENV_T5_550_PACKAGES)
@ -553,11 +686,24 @@ def _ensure_venv_t5_530_exists() -> bool:
def _ensure_venv_t5_550_exists() -> bool:
"""Ensure .venv_t5_550/ exists with transformers 5.5.0."""
return _ensure_venv_dir(_VENV_T5_550_DIR, _VENV_T5_550_PACKAGES, "transformers 5.5.0")
return _ensure_venv_dir(
_VENV_T5_550_DIR,
_VENV_T5_550_PACKAGES,
f"transformers {TRANSFORMERS_550_VERSION}",
)
def _ensure_venv_t5_510_exists() -> bool:
"""Ensure .venv_t5_510/ exists with transformers 5.10.x."""
return _ensure_venv_dir(
_VENV_T5_510_DIR,
_VENV_T5_510_PACKAGES,
f"transformers {TRANSFORMERS_510_VERSION}",
)
def _ensure_venv_t5_exists() -> bool:
"""Backwards-compat: ensure the 5.5.0 venv exists."""
"""Backwards-compat: ensure the Gemma 4 5.5 sidecar venv exists."""
return _ensure_venv_t5_550_exists()
@ -577,7 +723,7 @@ def _activate_venv(venv_dir: str, label: str) -> None:
def _deactivate_5x() -> None:
"""Remove all .venv_t5_*/ dirs from sys.path, purge stale modules, reimport."""
for d in (_VENV_T5_530_DIR, _VENV_T5_550_DIR):
for d in (_VENV_T5_530_DIR, _VENV_T5_550_DIR, _VENV_T5_510_DIR):
while d in sys.path:
sys.path.remove(d)
logger.info("Removed venv_t5 dirs from sys.path")
@ -593,7 +739,9 @@ def _deactivate_5x() -> None:
def ensure_transformers_version(model_name: str) -> None:
"""Ensure the correct ``transformers`` version is active for *model_name*.
Uses sys.path with .venv_t5_530/ or .venv_t5_550/ (pre-installed by setup.sh):
Uses sys.path with .venv_t5_510/, .venv_t5_550/, or .venv_t5_530/
(pre-installed by setup.sh):
Need 5.10.x prepend .venv_t5_510/ to sys.path, purge modules.
Need 5.5.0 prepend .venv_t5_550/ to sys.path, purge modules.
Need 5.3.0 prepend .venv_t5_530/ to sys.path, purge modules.
Need 4.x remove all .venv_t5_*/ from sys.path, purge modules.
@ -608,7 +756,11 @@ def ensure_transformers_version(model_name: str) -> None:
resolved = _resolve_base_model(model_name)
tier = get_transformers_tier(resolved)
if tier == "550":
if tier == "510":
target_version = TRANSFORMERS_510_VERSION
venv_dir = _VENV_T5_510_DIR
ensure_fn = _ensure_venv_t5_510_exists
elif tier == "550":
target_version = TRANSFORMERS_550_VERSION
venv_dir = _VENV_T5_550_DIR
ensure_fn = _ensure_venv_t5_550_exists
@ -643,7 +795,7 @@ def ensure_transformers_version(model_name: str) -> None:
model_name,
)
return
# Different 5.x → must switch (e.g. 5.3.0 loaded but need 5.5.0).
# Different 5.x -> need to switch (e.g. 5.3.0 loaded but need 5.10.x).
in_memory_major = int(in_memory.split(".")[0])
if in_memory_major == target_major and venv_dir is None:
# Both are default (4.x) — close enough.

View file

@ -1499,13 +1499,60 @@ if ($IsPipInstall) {
}
}
# 1g. Python (>= 3.11 and < 3.14). Prefer py.exe so a 3.14 ahead of 3.13 on PATH does not trip the gate.
# 1g. Python (>= 3.11 and < 3.14). Prefer the Studio venv that install.ps1
# just created, then py.exe so a 3.14 ahead of 3.13 on PATH does not trip the gate.
$HasPython = $null -ne (Get-Command python -ErrorAction SilentlyContinue)
$PyLauncher = Get-Command py -CommandType Application -ErrorAction SilentlyContinue
$PythonOk = $false
$DetectedPyVer = $null
if ($PyLauncher) {
function Get-CompatiblePythonVersion {
param([string]$PythonExe)
try {
$out = & $PythonExe --version 2>&1 | Out-String
if ($out -match 'Python (3\.(11|12|13)(\.\d+)?)') {
return $Matches[1]
}
} catch { }
return $null
}
function Add-PythonDirToProcessPath {
param([string]$PythonExe)
try {
if ($PythonExe -and (Test-Path -LiteralPath $PythonExe)) {
$resolvedDir = Split-Path -Parent $PythonExe
$alreadyOnPath = ($env:PATH -split ';' | Where-Object { $_.TrimEnd('\') -ieq $resolvedDir.TrimEnd('\') }).Count -gt 0
if (-not $alreadyOnPath) {
$env:PATH = "$resolvedDir;$env:PATH"
}
$script:HasPython = $true
}
} catch { }
}
$_prereqStudioHome = $null
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
$_prereqStudioHome = $env:UNSLOTH_STUDIO_HOME.Trim()
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
$_prereqStudioHome = $env:STUDIO_HOME.Trim()
} else {
$_prereqStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
}
if ($_prereqStudioHome -eq "~" -or $_prereqStudioHome -like "~/*" -or $_prereqStudioHome -like "~\*") {
$_prereqStudioHome = (Join-Path $env:USERPROFILE $_prereqStudioHome.Substring(1).TrimStart('/','\'))
}
$_prereqVenvPython = Join-Path $_prereqStudioHome "unsloth_studio\Scripts\python.exe"
if (Test-Path -LiteralPath $_prereqVenvPython) {
$_venvPyVer = Get-CompatiblePythonVersion $_prereqVenvPython
if ($_venvPyVer) {
$DetectedPyVer = $_venvPyVer
Add-PythonDirToProcessPath $_prereqVenvPython
$PythonOk = $true
}
}
if (-not $PythonOk -and $PyLauncher) {
foreach ($minor in @("3.13", "3.12", "3.11")) {
try {
$out = & $PyLauncher.Source "-$minor" --version 2>&1 | Out-String
@ -1517,12 +1564,7 @@ if ($PyLauncher) {
try {
$resolvedExe = (& $PyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Select-Object -First 1)
if ($resolvedExe -and (Test-Path $resolvedExe)) {
$resolvedDir = Split-Path -Parent $resolvedExe
$alreadyOnPath = ($env:PATH -split ';' | Where-Object { $_.TrimEnd('\') -ieq $resolvedDir.TrimEnd('\') }).Count -gt 0
if (-not $alreadyOnPath) {
$env:PATH = "$resolvedDir;$env:PATH"
}
$HasPython = $true
Add-PythonDirToProcessPath $resolvedExe
}
} catch { }
$PythonOk = $true
@ -2340,14 +2382,35 @@ if ($stackExit -ne 0) {
$ErrorActionPreference = $prevEAP
}
# ── Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ──
# ── Pre-install transformers 5.x into .venv_t5_530/, .venv_t5_550/, and .venv_t5_510/ ──
# Runs outside the deps fast-path gate so that upgrades from the legacy
# single .venv_t5 are always migrated to the tiered layout.
# T5 sidecar venvs live under the resolved $StudioHome so custom installs are self-contained.
$VenvT5_530Dir = Join-Path $StudioHome ".venv_t5_530"
$VenvT5_550Dir = Join-Path $StudioHome ".venv_t5_550"
$VenvT5_510Dir = Join-Path $StudioHome ".venv_t5_510"
$VenvT5Legacy = Join-Path $StudioHome ".venv_t5"
function Test-TargetPackageVersion {
param(
[Parameter(Mandatory = $true)][string]$TargetDir,
[Parameter(Mandatory = $true)][string]$PackageName,
[Parameter(Mandatory = $true)][string]$ExpectedVersion
)
if (-not (Test-Path -LiteralPath $TargetDir -PathType Container)) { return $false }
$packageNorm = $PackageName.Replace("-", "_")
foreach ($pattern in @("$packageNorm-*.dist-info", "$PackageName-*.dist-info")) {
foreach ($distInfo in @(Get-ChildItem -LiteralPath $TargetDir -Directory -Filter $pattern -ErrorAction SilentlyContinue)) {
$metadata = Join-Path $distInfo.FullName "METADATA"
if (-not (Test-Path -LiteralPath $metadata -PathType Leaf)) { continue }
foreach ($line in (Get-Content -LiteralPath $metadata -ErrorAction SilentlyContinue)) {
if ($line -eq "Version: $ExpectedVersion") { return $true }
}
}
}
return $false
}
$_NeedT5Install = $false
if (Test-Path -LiteralPath $VenvT5Legacy) {
Assert-StudioOwnedOrAbsent -Path $VenvT5Legacy -Label "legacy transformers sidecar venv"
@ -2356,6 +2419,10 @@ if (Test-Path -LiteralPath $VenvT5Legacy) {
}
if (-not (Test-Path -LiteralPath $VenvT5_530Dir)) { $_NeedT5Install = $true }
if (-not (Test-Path -LiteralPath $VenvT5_550Dir)) { $_NeedT5Install = $true }
if (-not (Test-Path -LiteralPath $VenvT5_510Dir)) { $_NeedT5Install = $true }
if (-not (Test-TargetPackageVersion -TargetDir $VenvT5_530Dir -PackageName "transformers" -ExpectedVersion "5.3.0")) { $_NeedT5Install = $true }
if (-not (Test-TargetPackageVersion -TargetDir $VenvT5_550Dir -PackageName "transformers" -ExpectedVersion "5.5.0")) { $_NeedT5Install = $true }
if (-not (Test-TargetPackageVersion -TargetDir $VenvT5_510Dir -PackageName "transformers" -ExpectedVersion "5.10.2")) { $_NeedT5Install = $true }
# Also reinstall when python deps were updated
if (-not $SkipPythonDeps) { $_NeedT5Install = $true }
@ -2433,9 +2500,44 @@ if ($script:UnslothVerbose) {
if ($tiktokenInstallExit -ne 0) {
substep "Could not install tiktoken into .venv_t5_550/ -- Qwen tokenizers may fail" "Yellow"
}
$ErrorActionPreference = $prevEAP_t5
step "transformers" "5.5.0 pre-installed"
# --- .venv_t5_510 (transformers 5.10.2) ---
substep "pre-installing transformers 5.10.2 for Gemma 4 Unified support..."
Assert-StudioOwnedOrAbsent -Path $VenvT5_510Dir -Label "transformers 5.10 sidecar venv"
if (Test-Path -LiteralPath $VenvT5_510Dir) { Remove-Item -LiteralPath $VenvT5_510Dir -Recurse -Force }
[System.IO.Directory]::CreateDirectory($VenvT5_510Dir) | Out-Null
Mark-StudioOwned -Path $VenvT5_510Dir
foreach ($pkg in @("transformers==5.10.2", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_510Dir --no-deps $pkg
$t5PkgExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_510Dir --no-deps $pkg | Out-String
$t5PkgExit = $LASTEXITCODE
}
if ($t5PkgExit -ne 0) {
Write-Host "[FAIL] Could not install $pkg into .venv_t5_510/" -ForegroundColor Red
Write-Host $output -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
exit 1
}
}
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_510Dir tiktoken
$tiktokenInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_510Dir tiktoken | Out-String
$tiktokenInstallExit = $LASTEXITCODE
}
if ($tiktokenInstallExit -ne 0) {
substep "Could not install tiktoken into .venv_t5_510/ -- Qwen tokenizers may fail" "Yellow"
}
$ErrorActionPreference = $prevEAP_t5
step "transformers" "5.10.2 pre-installed"
} # end $_NeedT5Install
# ==========================================================================

View file

@ -587,6 +587,7 @@ fi
VENV_DIR="$STUDIO_HOME/unsloth_studio"
VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530"
VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550"
VENV_T5_510_DIR="$STUDIO_HOME/.venv_t5_510"
[ -d "$REPO_ROOT/.venv" ] && rm -rf "$REPO_ROOT/.venv"
[ -d "$REPO_ROOT/.venv_overlay" ] && rm -rf "$REPO_ROOT/.venv_overlay"
@ -700,9 +701,9 @@ else
verbose_substep "python deps check: installed=$_PKG_NAME@${INSTALLED_VER:-unknown} latest=${LATEST_VER:-unknown}"
fi
# ── 6b. Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ──
# ── 6b. Pre-install transformers 5.x into .venv_t5_530/, .venv_t5_550/, and .venv_t5_510/ ──
# Models like GLM-4.7-Flash, Qwen3 MoE need transformers>=5.3.0.
# Gemma 4 models need transformers>=5.5.0.
# Gemma 4 models need transformers>=5.5.0; Gemma 4 Unified needs 5.10.x.
# Pre-install into separate directories to avoid runtime pip overhead.
# The training subprocess prepends the appropriate dir to sys.path.
#
@ -737,6 +738,21 @@ _assert_studio_owned_or_absent() {
exit 1
fi
}
_target_has_pkg_version() {
_thpv_dir="$1"
_thpv_pkg="$2"
_thpv_version="$3"
[ -d "$_thpv_dir" ] || return 1
_thpv_pkg_norm=$(printf '%s' "$_thpv_pkg" | tr '-' '_')
for _thpv_metadata in \
"$_thpv_dir"/"$_thpv_pkg_norm"-*.dist-info/METADATA \
"$_thpv_dir"/"$_thpv_pkg"-*.dist-info/METADATA
do
[ -f "$_thpv_metadata" ] || continue
grep -qx "Version: $_thpv_version" "$_thpv_metadata" && return 0
done
return 1
}
_NEED_T5_INSTALL=false
if [ -d "$STUDIO_HOME/.venv_t5" ]; then
# Legacy layout — migrate
@ -746,6 +762,10 @@ if [ -d "$STUDIO_HOME/.venv_t5" ]; then
fi
[ ! -d "$VENV_T5_530_DIR" ] && _NEED_T5_INSTALL=true
[ ! -d "$VENV_T5_550_DIR" ] && _NEED_T5_INSTALL=true
[ ! -d "$VENV_T5_510_DIR" ] && _NEED_T5_INSTALL=true
_target_has_pkg_version "$VENV_T5_530_DIR" "transformers" "5.3.0" || _NEED_T5_INSTALL=true
_target_has_pkg_version "$VENV_T5_550_DIR" "transformers" "5.5.0" || _NEED_T5_INSTALL=true
_target_has_pkg_version "$VENV_T5_510_DIR" "transformers" "5.10.2" || _NEED_T5_INSTALL=true
# Also reinstall when python deps were updated (packages may need rebuild)
[ "$_SKIP_PYTHON_DEPS" = false ] && _NEED_T5_INSTALL=true
@ -769,6 +789,16 @@ if [ "$_NEED_T5_INSTALL" = true ]; then
run_quiet "install hf_xet for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "hf_xet==1.4.2"
run_quiet "install tiktoken for t5_550" fast_install --target "$VENV_T5_550_DIR" "tiktoken"
step "transformers" "5.5.0 pre-installed"
_assert_studio_owned_or_absent "$VENV_T5_510_DIR" "transformers 5.10 sidecar venv"
[ -d "$VENV_T5_510_DIR" ] && rm -rf "$VENV_T5_510_DIR"
mkdir -p "$VENV_T5_510_DIR"
: > "$VENV_T5_510_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
run_quiet "install transformers 5.10.2" fast_install --target "$VENV_T5_510_DIR" --no-deps "transformers==5.10.2"
run_quiet "install huggingface_hub for t5_510" fast_install --target "$VENV_T5_510_DIR" --no-deps "huggingface_hub==1.8.0"
run_quiet "install hf_xet for t5_510" fast_install --target "$VENV_T5_510_DIR" --no-deps "hf_xet==1.4.2"
run_quiet "install tiktoken for t5_510" fast_install --target "$VENV_T5_510_DIR" "tiktoken"
step "transformers" "5.10.2 pre-installed"
fi
fi