unsloth/studio/backend/utils/transformers_version.py
Leo Borcherding 040858c382
Studio: fix tier detection for models loaded via custom folder path (#6396)
* Studio: detect transformers 5.3.0 tier from config.json for local checkpoints

A local safetensors folder whose config.json did not match the Gemma4 (510/550)
architecture signals short-circuited get_transformers_tier() to "default"
(transformers 4.57.x), never reaching the name-substring check that routes
Qwen3.5 to the 5.3.0 sidecar. So a local Qwen3.5 checkpoint (model_type
"qwen3_5", needs transformers >= 5.2.0) loaded with 4.57.x and failed with
"does not support Qwen3.5". The same model as a remote HF id worked, because it
has no local config.json to trigger the short-circuit.

Detect the 5.3.0 tier from config.json (model_type "qwen3_5" / architecture
Qwen3_5ForCausalLM) in the local-config branch, mirroring the existing Gemma4
510/550 handling. This is a positive config signal, so it fixes local Qwen3.5
without weakening the directory-name false-positive guard (a llama checkpoint
under a "gemma-4-12b-*" parent still resolves to default).

Adds tests for the config-based 530 detection and local-folder tier resolution.

* Studio: suppress false warning when config.json parse fails for sidecar-tier models

* Studio: generalize local-checkpoint tier detection for all 5.3.0 families

Expands the config.json-based tier detection to cover all known 5.3.0-tier
model families (Qwen3 MoE, GLM-4.7-Flash, LFM2.5-VL) and adds a _name_or_path
fallback so renamed local checkpoints with unrecognised model_type values still
route correctly via the HF ID embedded in their config.json.

- Expand _TRANSFORMERS_530_ARCHITECTURES / _MODEL_TYPES with verified entries
  from Qwen3MoeForCausalLM, Glm4MoeLiteForCausalLM, Lfm2VlForConditionalGeneration,
  and Qwen3_5ForConditionalGeneration (confirmed from local Qwen3.5-2B config.json)
- Extract _tier_from_name() helper, deduplicating the fast-substring logic used
  by both the remote-path branch and the new config _name_or_path fallback
- In the local-config branch: after architecture checks, resolve the tier from
  cfg._name_or_path / cfg.model_name before returning "default", preserving the
  existing directory-name false-positive guard
- 79 tests passing

* Studio: match 510/550 style for 530 config sets (no inline comments)

* Studio: use _resolve_base_model instead of reinlining _name_or_path lookup

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

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

* Studio: recurse into get_transformers_tier for resolved base model (Gemini suggestion)

* Studio: use _tier_from_name in local-config fallback to avoid network probes

Using get_transformers_tier(resolved) on the _name_or_path fallback would
trigger up to 3 network fetches (config.json + tokenizer_config.json, 10s
each) for every ordinary checkpoint whose _name_or_path is a plain HF ID
like meta-llama/Llama-3-8B. The fallback's purpose is name-based detection
on the resolved HF ID, _tier_from_name covers all known cases without I/O.

* Studio: add _check_config_needs_530 to slow HF-ID fallback path

Private or renamed HF repos whose model IDs lack a 5.3 substring were
silently routed to the default tier. _check_config_needs_530 mirrors the
existing 510/550 pattern: fetches config.json once, caches the result, and
is called after the 550 check in the slow path. Includes 5 unit tests.

* Studio: guard _tier_from_name fallback against local-path false positives

When _name_or_path in config.json is an absolute path to the same checkpoint
passed as a relative path, the textual resolved != model_name check passes
and _tier_from_name would scan the directory path for substrings. Split the
fallback: local directories recurse into get_transformers_tier (config check,
no network I/O); HF Hub IDs use _tier_from_name (name-based, no network).

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

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

* Studio: separator-norm aliases, model_name/_name_or_path fallback, tests

- _norm_separators(): collapse _ . whitespace to - so underscore/dot model
  ID variants (Qwen3_5, Qwen3_Next) match the canonical substring list
- _tier_from_name(): apply norm to both name and each substring so aliases
  resolve without duplicating the substring lists
- _resolve_base_model(): try model_name then _name_or_path separately so a
  self-referential Unsloth model_name doesn't hide the useful HF ID in
  _name_or_path
- Gate get_base_model_from_lora on adapter_cfg_path.is_file() to avoid
  eagerly importing transformers before the sidecar venv is on sys.path
- 17 new tests covering _norm_separators, separator-insensitive
  _tier_from_name, and the model_name/_name_or_path fallback

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

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

* Studio: only pre-resolve LoRA adapters in activation callers

activate_transformers_for_subprocess and ensure_transformers_version were
pre-resolving all local checkpoints via _resolve_base_model before calling
get_transformers_tier. After the model_name/_name_or_path fix, a full
checkpoint with a private/offline _name_or_path and no tier substring would
resolve to that HF ID, which can't be probed, bypassing the local config.json
model_type check entirely. Gate pre-resolution on adapter_config.json so full
checkpoints go straight to get_transformers_tier, which reads config.json
directly. LoRA adapters still pre-resolve as before.

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

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

* Studio: fix Qwen3.5 MoE/Qwen3.6 tier detection and dot-version false positives

- Add Qwen3.5 MoE (qwen3_5_moe / Qwen3_5MoeForConditionalGeneration) and
  Qwen3-Next to the 5.3.0 config sets, so renamed local checkpoints route to
  the sidecar instead of default transformers
- Let a 510/550 name match override a 530 config match, so Qwen3.6 (which
  reuses qwen3_5 / qwen3_5_moe config ids) still routes to the 5.5.0 sidecar
- Stop normalizing version dots to hyphens so size names like Qwen3-5B and
  Qwen3-6B are not promoted to a 5.x sidecar; underscore aliases still match
- Skip name matching for resolved values that look like stale local paths

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

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

* Studio: close remaining codex P2s: adapter-only LoRA + 530-override path-hint guard

- adapter_model-only LoRA: add import-light _is_lora_adapter_dir/_has_adapter_weights
  and gate activation/export pre-resolve on them, so LoRA dirs with
  adapter_model*.safetensors but no adapter_config.json still resolve to their base
  model (via _resolve_base_model's new unsloth_<model>_<ts> directory-name parse)
  instead of tiering off the adapter folder.
- 530 override: only treat a resolved value as a name hint when it is a real Hub id;
  a stale/renamed local path in model_name/_name_or_path can no longer flip a correct
  530 config to 550. Current folder basename still allowed.

Added 7 regression tests; suite at 116 passing.

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

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

* Studio: address review feedback on tier detection

- Add Qwen3.5 text-tower model types (qwen3_5_text / qwen3_5_moe_text) to the
  5.3.0 config set so text-only configs with stripped architectures still route
  to the sidecar
- Apply the Qwen3.6 name override on the remote slow path too, so a renamed or
  private repo whose config reuses qwen3_5 ids but names Qwen3.6 in
  _name_or_path selects 5.5.0 instead of 5.3.0
- Treat an existing local path (or empty value) as a path, not a Hub id, in
  _looks_like_hf_id so a real local checkpoint folder is not name matched
- Guard _resolve_base_model against non-string config values and compare paths
  by realpath so relative or absolute self references resolve correctly
- Keep the LoRA adapter is_file check inside the OSError guard

* Studio: harden tier detection against malformed configs and bad paths

- _config_matches_tier no longer raises TypeError when a malformed config.json
  carries a non-string model_type (e.g. a list) or non-list architectures; it
  fails open to no-match
- guard the model_name-derived is_file/is_dir probes with _safe_is_file /
  _safe_is_dir so a pathological or over-long path (e.g. a Windows long path)
  fails open to the default tier instead of raising OSError

No routing changes for any valid model; purely defensive. Verified by a
cross-platform simulation (POSIX + NT path semantics) and a before/after tier
matrix that is unchanged for all previously supported models.

* Studio: trim verbose comments in tier detection

Shorten/remove over-long comments and docstrings, mainly on internal helpers,
without changing behavior. Verified code-only via comment_tools.py check; suite
unchanged at 128 passing.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-22 05:40:39 -07:00

1309 lines
49 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Automatic transformers version switching.
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. Dense NemotronH models (e.g. NVIDIA-Nemotron-3-Nano-4B) use
MLP layers that only transformers>=5.10 can parse natively, so they go on the
5.10 sidecar too. Everything else needs the default 4.57.x that ships with
Unsloth.
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
import json
import structlog
from loggers import get_logger
import os
import shutil
import subprocess
import sys
from pathlib import Path
from utils.native_path_leases import child_env_without_native_path_secret
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
def _env_offline() -> bool:
"""True if HF_HUB_OFFLINE or TRANSFORMERS_OFFLINE is set to a truthy value."""
return os.environ.get("HF_HUB_OFFLINE", "").lower() in (
"1",
"true",
"yes",
) or os.environ.get("TRANSFORMERS_OFFLINE", "").lower() in ("1", "true", "yes")
def _safe_is_file(p: Path) -> bool:
"""``p.is_file()`` returning False instead of raising on a bad path."""
try:
return p.is_file()
except (OSError, ValueError):
return False
def _safe_is_dir(p: Path) -> bool:
"""``p.is_dir()`` returning False instead of raising on a bad path."""
try:
return p.is_dir()
except (OSError, ValueError):
return False
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
# Lowercase substrings — any match in the lowered model name needs transformers 5.3.0.
TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = (
"ministral-3-", # Ministral-3-{3,8,14}B-{Instruct,Reasoning,Base}-2512
"glm-4.7-flash", # GLM-4.7-Flash
"qwen3-30b-a3b", # Qwen3-30B-A3B-Instruct-2507 and variants
"qwen3.5", # Qwen3.5 family (35B-A3B, etc.)
"qwen3-next", # Qwen3-Next and variants
"tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B
"lfm2.5-vl-450m", # LiquidAI/LFM2.5-VL-450M
)
# 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] = {
"Gemma4ForConditionalGeneration",
}
_TRANSFORMERS_550_MODEL_TYPES: set[str] = {
"gemma4",
}
# Architecture classes / model_type values that require transformers 5.3.0.
# Checked via config.json (local or HuggingFace).
_TRANSFORMERS_530_ARCHITECTURES: set[str] = {
"Qwen3_5ForCausalLM",
"Qwen3_5ForConditionalGeneration",
"Qwen3_5MoeForCausalLM",
"Qwen3_5MoeForConditionalGeneration",
"Qwen3MoeForCausalLM",
"Qwen3NextForCausalLM",
"Glm4MoeLiteForCausalLM",
"Lfm2VlForConditionalGeneration",
}
_TRANSFORMERS_530_MODEL_TYPES: set[str] = {
"qwen3_5",
"qwen3_5_text",
"qwen3_5_moe",
"qwen3_5_moe_text",
"qwen3_moe",
"qwen3_next",
"glm4_moe_lite",
"lfm2_vl",
}
# Tokenizer classes that only exist in transformers>=5.x.
_TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = {
"TokenizersBackend",
}
# Cache for dynamic tokenizer_config.json lookups (avoids repeated fetches).
_tokenizer_class_cache: dict[str, bool] = {}
# config.json cache keyed on (model_name, token-hash) so authed/unauthed reads stay separate.
_config_json_cache: dict[tuple[str, str | None], dict | None] = {}
_config_needs_510_cache: dict[str, bool] = {}
_config_needs_550_cache: dict[str, bool] = {}
_config_needs_530_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 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
# Tier precedence: higher rank wins in _higher_tier.
_TIER_RANK = {"default": 0, "530": 1, "550": 2, "510": 3}
def _higher_tier(a: str, b: str) -> str:
return a if _TIER_RANK.get(a, 0) >= _TIER_RANK.get(b, 0) else b
def activate_transformers_for_subprocess(model_name: str) -> None:
"""Activate the correct transformers version in a subprocess worker.
Call BEFORE any ML imports. Resolves LoRA adapters to their base model,
determines the required tier, prepends the appropriate ``.venv_t5_*`` dir to
``sys.path``, and propagates it via ``PYTHONPATH`` for child processes
(e.g. GGUF converter). Used by training, inference, and export workers.
"""
# Pre-resolve only LoRA adapters; full checkpoints go to get_transformers_tier
# so their local config.json drives the tier (avoids a fragile HF-id probe).
if _is_lora_adapter_dir(Path(model_name)):
resolved = _resolve_base_model(model_name)
else:
resolved = model_name
tier = get_transformers_tier(resolved)
if model_name != resolved and (Path(model_name) / "config.json").is_file():
# Gate on a real local config.json: a checkpoint carries config the base may not
# surface, but path names alone must not upgrade a plain adapter.
tier = _higher_tier(tier, get_transformers_tier(model_name))
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(
"Prepended transformers %s venv to sys.path from %s "
"(path only; the loaded version is confirmed later by "
"'Subprocess loaded transformers ...' on first import)",
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 {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(
"Prepended transformers %s venv to sys.path from %s "
"(path only; the loaded version is confirmed later by "
"'Subprocess loaded transformers ...' on first import)",
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":
if not _ensure_venv_t5_530_exists():
raise RuntimeError(
f"Cannot activate transformers 5.3.0: "
f".venv_t5_530 missing at {_VENV_T5_530_DIR}"
)
if _VENV_T5_530_DIR not in sys.path:
sys.path.insert(0, _VENV_T5_530_DIR)
logger.info(
"Prepended transformers %s venv to sys.path from %s "
"(path only; the loaded version is confirmed later by "
"'Subprocess loaded transformers ...' on first import)",
TRANSFORMERS_530_VERSION,
_VENV_T5_530_DIR,
)
_pp = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = _VENV_T5_530_DIR + (os.pathsep + _pp if _pp else "")
else:
logger.info("Using default transformers (4.57.x) for %s", model_name)
def _has_adapter_weights(path: Path) -> bool:
"""True if *path* holds LoRA adapter weight files (``adapter_model.*``)."""
try:
return any(path.glob("adapter_model*.safetensors")) or any(path.glob("adapter_model*.bin"))
except OSError:
return False
def _is_lora_adapter_dir(path: Path) -> bool:
"""True if *path* is a local LoRA dir (adapter_config.json or adapter_model-only
weights). Import-light so it can run during subprocess activation."""
try:
if not path.is_dir():
return False
return (path / "adapter_config.json").is_file() or _has_adapter_weights(path)
except OSError:
return False
def _is_same_path(value: str, local_path: Path) -> bool:
"""True if *value* resolves to *local_path* (relative/absolute/symlink)."""
if value == str(local_path):
return True
try:
return os.path.realpath(value) == os.path.realpath(str(local_path))
except OSError:
return False
def _resolve_base_model(model_name: str) -> str:
"""If *model_name* points to a LoRA adapter, return its base model.
Checks ``adapter_config.json`` locally first. Only calls the heavier
``get_base_model_from_lora`` for real local directories (avoids noisy
warnings for plain HF model IDs). Returns *model_name* unchanged if not a
LoRA adapter.
"""
# --- Fast local check ---------------------------------------------------
local_path = Path(model_name)
adapter_cfg_path = local_path / "adapter_config.json"
if _safe_is_file(adapter_cfg_path):
try:
with open(adapter_cfg_path) as f:
cfg = json.load(f)
base = cfg.get("base_model_name_or_path")
if base:
logger.info(
"Resolved LoRA adapter '%s' → base model '%s'",
model_name,
base,
)
return base
except Exception as exc:
logger.debug("Could not read %s: %s", adapter_cfg_path, exc)
# --- config.json fallback (works for both LoRA and full fine-tune) ------
config_json_path = local_path / "config.json"
if _safe_is_file(config_json_path):
try:
with open(config_json_path) as f:
cfg = json.load(f)
# Unsloth writes model_name, HF writes _name_or_path; skip a self-reference.
for _key in ("model_name", "_name_or_path"):
base = cfg.get(_key)
if isinstance(base, str) and base and not _is_same_path(base, local_path):
logger.info(
"Resolved checkpoint '%s' → base model '%s' (via config.json)",
model_name,
base,
)
return base
except Exception as exc:
logger.debug("Could not read %s: %s", config_json_path, exc)
# Gate the heavy resolver on adapter_config.json: importing utils.models pulls
# in transformers, which would pin the default into sys.modules before the
# sidecar venv is prepended during activation.
if _safe_is_file(adapter_cfg_path):
try:
from utils.models import get_base_model_from_lora
base = get_base_model_from_lora(model_name)
if base:
logger.info(
"Resolved LoRA adapter '%s' → base model '%s' "
"(via get_base_model_from_lora)",
model_name,
base,
)
return base
except Exception as exc:
logger.debug(
"get_base_model_from_lora failed for '%s': %s",
model_name,
exc,
)
# adapter_model-only LoRA: no config to resolve from, so use the
# unsloth_<model>_<timestamp> dir-name convention (pure string parse).
if local_path.name.startswith("unsloth_") and _has_adapter_weights(local_path):
parts = local_path.name.split("_")
if len(parts) >= 2: # unsloth_<model...>_<timestamp>
base = "unsloth/" + "_".join(parts[1:-1])
logger.info(
"Resolved adapter-only LoRA '%s' → base model '%s' (via directory name)",
model_name,
base,
)
return base
return model_name
def _is_canonical_repo_id(model_name: str) -> bool:
"""True for a canonical ``owner/repo`` Hub id (not a local or relative path)."""
return bool(
model_name
and model_name.count("/") == 1
and model_name[0] not in "/.~"
and "\\" not in model_name
)
def _adapter_base_from_hf_cache(model_name: str) -> str | None:
"""``base_model_name_or_path`` from a remote adapter's cached ``adapter_config.json``.
Stdlib path resolution of the HF hub cache (no ``huggingface_hub`` import); the newest
snapshot wins. Lets an offline cached LoRA still resolve its base.
"""
if not _is_canonical_repo_id(model_name):
return None
hub = (
os.environ.get("HF_HUB_CACHE")
or os.environ.get("HUGGINGFACE_HUB_CACHE")
or os.path.join(
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"), "hub"
)
)
repo_dir = Path(hub) / ("models--" + model_name.replace("/", "--"))
candidates = []
ref_main = repo_dir / "refs" / "main"
def _mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except OSError:
return 0.0
try:
if ref_main.is_file():
candidates.append(
repo_dir / "snapshots" / ref_main.read_text().strip() / "adapter_config.json"
)
candidates += sorted(
repo_dir.glob("snapshots/*/adapter_config.json"), key = _mtime, reverse = True
)
for cfg_path in candidates:
if cfg_path.is_file():
base = json.loads(cfg_path.read_text()).get("base_model_name_or_path")
return base or None
except Exception as exc:
logger.debug("HF cache adapter_config.json lookup failed for '%s': %s", model_name, exc)
return None
def _remote_lora_base(model_name: str, hf_token: str | None = None) -> str | None:
"""``base_model_name_or_path`` from a remote adapter's ``adapter_config.json``, or None.
Raw HTTP (no huggingface_hub / transformers import), so a remote LoRA's base is known
before any ML import. Offline (or on a transient failure) it reads the local hub cache,
since a cached adapter is still loadable; a definitive 404 returns None (the repo is not
a LoRA) rather than a stale cached base. Skipped for local/non-canonical ids.
"""
if not _is_canonical_repo_id(model_name):
return None
try:
from utils.paths import is_local_path
if is_local_path(model_name):
return None # an existing relative path is a local checkpoint, not a Hub repo
except Exception:
pass
if _env_offline():
return _adapter_base_from_hf_cache(model_name)
import urllib.error
import urllib.request
endpoint = (os.environ.get("HF_ENDPOINT") or "https://huggingface.co").rstrip("/")
url = f"{endpoint}/{model_name}/raw/main/adapter_config.json"
headers = {"User-Agent": "unsloth-studio"}
if hf_token:
headers["Authorization"] = f"Bearer {hf_token}"
try:
req = urllib.request.Request(url, headers = headers)
with urllib.request.urlopen(req, timeout = 10) as resp:
cfg = json.loads(resp.read().decode())
base = cfg.get("base_model_name_or_path")
if base:
logger.info("Resolved remote LoRA adapter '%s' → base model '%s'", model_name, base)
return base or None
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None # definitively not a LoRA; do not serve a stale cached base
logger.debug("adapter_config.json fetch failed for '%s': %s", model_name, exc)
return _adapter_base_from_hf_cache(model_name)
except Exception as exc:
logger.debug("No remote adapter_config.json for '%s': %s", model_name, exc)
return _adapter_base_from_hf_cache(model_name)
def _check_tokenizer_config_needs_v5(model_name: str) -> bool:
"""True if the model's tokenizer_class requires transformers 5.x.
Checks local tokenizer_config.json, else fetches from HuggingFace. Cached in
``_tokenizer_class_cache``. Returns False on any network/parse error
(fail-open to default version).
"""
if model_name in _tokenizer_class_cache:
return _tokenizer_class_cache[model_name]
# --- Check local tokenizer_config.json first ---------------------------
local_path = Path(model_name)
local_tc = local_path / "tokenizer_config.json"
if _safe_is_file(local_tc):
try:
with open(local_tc) as f:
data = json.load(f)
tokenizer_class = data.get("tokenizer_class", "")
result = tokenizer_class in _TRANSFORMERS_5_TOKENIZER_CLASSES
if result:
logger.info(
"Local check: %s uses tokenizer_class=%s (requires transformers 5.x)",
model_name,
tokenizer_class,
)
_tokenizer_class_cache[model_name] = result
return result
except Exception as exc:
logger.debug("Could not read %s: %s", local_tc, exc)
# Offline: skip the 10s urllib fetch (fail-open to lower tier).
if _env_offline():
_tokenizer_class_cache[model_name] = False
return False
# --- Fall back to fetching from HuggingFace ----------------------------
import urllib.request
url = f"https://huggingface.co/{model_name}/raw/main/tokenizer_config.json"
try:
req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-studio"})
with urllib.request.urlopen(req, timeout = 10) as resp:
data = json.loads(resp.read().decode())
tokenizer_class = data.get("tokenizer_class", "")
result = tokenizer_class in _TRANSFORMERS_5_TOKENIZER_CLASSES
if result:
logger.info(
"Dynamic check: %s uses tokenizer_class=%s (requires transformers 5.x)",
model_name,
tokenizer_class,
)
_tokenizer_class_cache[model_name] = result
return result
except Exception as exc:
logger.debug("Could not fetch tokenizer_config.json for '%s': %s", model_name, exc)
_tokenizer_class_cache[model_name] = False
return False
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _config_json_from_hf_cache(model_name: str) -> dict | None:
"""Parsed ``config.json`` from the local HF hub cache, or None.
Stdlib-only path resolution (no ``huggingface_hub`` import) so tier detection never
loads the default-env hub before a sidecar venv is activated.
"""
# Only a canonical ``owner/repo`` Hub id maps to a cache dir; reject local paths.
if not model_name or model_name.count("/") != 1 or model_name[0] in "/.~" or "\\" in model_name:
return None
hub = (
os.environ.get("HF_HUB_CACHE")
or os.environ.get("HUGGINGFACE_HUB_CACHE")
or os.path.join(
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"), "hub"
)
)
repo_dir = Path(hub) / ("models--" + model_name.replace("/", "--"))
candidates = []
ref_main = repo_dir / "refs" / "main"
try:
if ref_main.is_file():
candidates.append(repo_dir / "snapshots" / ref_main.read_text().strip() / "config.json")
# No refs/main (e.g. commit-pinned downloads): newest snapshot by mtime, not a stale
# lexicographically-first SHA, matching what the Hub cache would actually load.
candidates += sorted(
repo_dir.glob("snapshots/*/config.json"), key = _safe_mtime, reverse = True
)
for cfg_path in candidates:
if cfg_path.is_file():
with open(cfg_path) as f:
return json.load(f)
except Exception as exc:
logger.debug("HF cache config.json lookup failed for '%s': %s", model_name, exc)
return None
def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | None:
"""Return parsed ``config.json`` for *model_name*, checking local files first.
``hf_token`` authenticates the raw fetch so gated/private repos resolve. The
cache is keyed on the token so an unauthenticated miss never poisons a later
authenticated read. The HF hub cache is consulted only offline or after a failed
network fetch, so an online read never serves stale metadata.
"""
import hashlib
tok = hashlib.sha256(hf_token.encode()).hexdigest()[:16] if hf_token else None
cache_key = (model_name, tok)
if cache_key in _config_json_cache:
return _config_json_cache[cache_key]
local_cfg = Path(model_name) / "config.json"
if _safe_is_file(local_cfg):
try:
with open(local_cfg) as f:
cfg = json.load(f)
_config_json_cache[cache_key] = cfg
return cfg
except Exception as exc:
logger.debug("Could not read %s: %s", local_cfg, exc)
_config_json_cache[cache_key] = None
return None
if _env_offline():
# No network: a previously downloaded repo can still tier from the hub cache.
cfg = _config_json_from_hf_cache(model_name)
_config_json_cache[cache_key] = cfg
return cfg
import urllib.error
import urllib.request
url = f"https://huggingface.co/{model_name}/raw/main/config.json"
headers = {"User-Agent": "unsloth-studio"}
if hf_token:
headers["Authorization"] = f"Bearer {hf_token}"
try:
req = urllib.request.Request(url, headers = headers)
with urllib.request.urlopen(req, timeout = 10) as resp:
cfg = json.loads(resp.read().decode())
_config_json_cache[cache_key] = cfg
return cfg
except urllib.error.HTTPError as exc:
# 401/403/404 is a definitive access answer: never serve another caller's cached
# private metadata to an unauthenticated/wrong-token request.
if exc.code in (401, 403, 404):
logger.debug("config.json access denied for '%s': %s", model_name, exc)
return None
logger.debug("Could not fetch config.json for '%s': %s", model_name, exc)
return _config_json_from_hf_cache(model_name)
except Exception as exc:
logger.debug("Could not fetch config.json for '%s': %s", model_name, exc)
# Transient: serve the hub cache uncached so the next call retries the network.
return _config_json_from_hf_cache(model_name)
def _config_json_is_definitive(model_name: str) -> bool:
"""True if the last unauthenticated ``_load_config_json`` read was cached (definitive),
not a transient fallback (deliberately not stored, so callers re-check next call)."""
return (model_name, None) in _config_json_cache
def _config_matches_tier(cfg: dict, architectures: set[str], model_types: set[str]) -> bool:
# Defensive: a malformed config may carry non-string values (e.g. list model_type).
archs = cfg.get("architectures")
if isinstance(archs, (list, tuple)) and any(a in architectures for a in archs):
return True
mt = cfg.get("model_type")
return isinstance(mt, str) and mt in model_types
def _config_needs_550(cfg: dict) -> bool:
return _config_matches_tier(
cfg,
_TRANSFORMERS_550_ARCHITECTURES,
_TRANSFORMERS_550_MODEL_TYPES,
)
_NESTED_CONFIG_KEYS = ("llm_config", "text_config", "language_config", "thinker_config")
def _nemotron_h_needs_mlp_support(cfg: dict) -> bool:
"""True for a dense NemotronH config using MLP (``-``) layers.
transformers only gained ``-`` -> ``mlp`` in 5.10; 5.3/5.5 raise ``KeyError: '-'``.
Read from ``hybrid_override_pattern`` or ``layers_block_type``, recursing into nested
language configs (VL wrappers hold the dense LM under ``llm_config``/``text_config``).
"""
if not isinstance(cfg, dict):
return False
if cfg.get("model_type") == "nemotron_h":
pattern = cfg.get("hybrid_override_pattern")
if isinstance(pattern, str) and "-" in pattern:
return True
block_types = cfg.get("layers_block_type")
if isinstance(block_types, (list, tuple)) and "mlp" in block_types:
return True
return any(_nemotron_h_needs_mlp_support(cfg.get(key)) for key in _NESTED_CONFIG_KEYS)
def _config_needs_510(cfg: dict) -> bool:
if _config_matches_tier(
cfg,
_TRANSFORMERS_510_ARCHITECTURES,
_TRANSFORMERS_510_MODEL_TYPES,
):
return True
return _nemotron_h_needs_mlp_support(cfg)
def _config_needs_530(cfg: dict) -> bool:
return _config_matches_tier(
cfg,
_TRANSFORMERS_530_ARCHITECTURES,
_TRANSFORMERS_530_MODEL_TYPES,
)
def _check_config_needs_550(model_name: str) -> bool:
"""True if ``config.json`` needs transformers 5.5.0 (e.g. Gemma 4). Local first, else
fetched; cached only for a definitive read so a transient miss retries. False on error.
"""
if model_name in _config_needs_550_cache:
return _config_needs_550_cache[model_name]
cfg = _load_config_json(model_name)
result = bool(cfg) and _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"),
)
if _config_json_is_definitive(model_name):
_config_needs_550_cache[model_name] = result
return result
def _check_config_needs_530(model_name: str) -> bool:
"""Check ``config.json`` for 5.3.0-only architectures (Qwen3.5, Qwen3 MoE, GLM-4.7, LFM2.5-VL).
Used in the slow HF-ID path for private/renamed repos where name substrings
aren't reliable.
"""
if model_name in _config_needs_530_cache:
return _config_needs_530_cache[model_name]
cfg = _load_config_json(model_name)
if cfg is None:
_config_needs_530_cache[model_name] = False
return False
result = _config_needs_530(cfg)
if result:
logger.info(
"config.json check: %s needs transformers %s (architectures=%s, model_type=%s)",
model_name,
TRANSFORMERS_530_VERSION,
cfg.get("architectures", []),
cfg.get("model_type"),
)
_config_needs_530_cache[model_name] = result
return result
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)
result = bool(cfg) and _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"),
)
if _config_json_is_definitive(model_name):
_config_needs_510_cache[model_name] = result
return result
def _norm_separators(s: str) -> str:
"""Collapse ``_``/whitespace to ``-`` (underscore aliases) but keep ``.`` so a
version dot (``qwen3.5``) isn't conflated with a size separator (``Qwen3-5B``)."""
return "".join("-" if ch in "_ \t" else ch for ch in s)
def _looks_like_hf_id(value: str) -> bool:
"""True if *value* looks like a Hub id (``org/name``), not a local path. An
existing path is treated as a path, mirroring transformers' own resolution."""
if not value or not value.strip():
return False
if os.path.isabs(value) or value.startswith((".", "~")) or "\\" in value:
return False
if os.path.exists(value):
return False
return value.count("/") <= 1
def _tier_from_name(name: str) -> tuple[str, str] | None:
"""``(tier, reason)`` from name substrings (order 510 > 550 > 530), or ``None``.
Underscore aliases match (``Qwen3_5`` == ``Qwen3.5``); a dot-version substring
matches only the dot/underscore form, never a hyphen, so ``Qwen3-6B`` size names
aren't promoted.
"""
lowered = name.lower()
norm = _norm_separators(lowered)
dotted = lowered.replace("_", ".")
if "assistant" in lowered and ("gemma-4" in norm or "gemma4" in norm):
return "510", "gemma-4 assistant variant"
for substrings, tier in (
(TRANSFORMERS_510_MODEL_SUBSTRINGS, "510"),
(TRANSFORMERS_550_MODEL_SUBSTRINGS, "550"),
(TRANSFORMERS_5_MODEL_SUBSTRINGS, "530"),
):
for s in substrings:
if "." in s:
if s in lowered or s in dotted:
return tier, s
elif s in lowered or _norm_separators(s) in norm:
return tier, s
return None
def _higher_tier_name_override(name_hint: str | None) -> str | None:
"""510/550 tier if *name_hint* names a higher-tier model, else ``None``. Qwen3.6
reuses Qwen3.5 config ids but needs the 5.5 sidecar, so a name hint overrides 530."""
if not name_hint:
return None
hint = _tier_from_name(name_hint)
return hint[0] if hint is not None and hint[0] in ("510", "550") else None
def get_transformers_tier(model_name: str) -> str:
"""Return the transformers tier required for *model_name*.
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. For local paths, ``config.json`` is checked
before name heuristics to avoid false-positives from directory name fragments.
"""
# Local path: trust config.json. If its arch matches a known sidecar, return;
# else fall back to the HF id in the config (not the folder name) for renamed dirs.
local_cfg = Path(model_name) / "config.json"
if _safe_is_file(local_cfg):
cfg = _load_config_json(model_name)
if cfg is not None:
if _config_needs_510(cfg):
logger.info(
"Transformers tier 510 selected for %s (local config.json check)",
model_name,
)
return "510"
if _config_needs_550(cfg):
logger.info(
"Transformers tier 550 selected for %s (local config.json check)",
model_name,
)
return "550"
if _config_needs_530(cfg):
# Qwen3.6 reuses Qwen3.5 config ids but needs 5.5 by name. Only a real
# Hub id (or the folder basename) may override 530, so a stale local
# path in _name_or_path can't flip a correct 530 config to 550.
base = _resolve_base_model(model_name)
hint_src = (
base
if (base != model_name and _looks_like_hf_id(base))
else Path(model_name).name
)
override = _higher_tier_name_override(hint_src)
if override is not None:
logger.info(
"Transformers tier %s selected for %s (name overrides 530 config)",
override,
model_name,
)
return override
logger.info(
"Transformers tier 530 selected for %s (local config.json check)",
model_name,
)
return "530"
# Unknown arch: resolve the base id from config. A resolved local dir
# recurses (config check); a Hub id uses name rules only (no network).
resolved = _resolve_base_model(model_name)
if resolved != model_name:
if _safe_is_dir(Path(resolved)):
tier = get_transformers_tier(resolved)
if tier != "default":
logger.info(
"Transformers tier %s selected for %s (resolved local path: %s)",
tier,
model_name,
resolved,
)
return tier
elif _looks_like_hf_id(resolved):
result = _tier_from_name(resolved)
if result is not None:
tier, match = result
logger.info(
"Transformers tier %s selected for %s (resolved HF ID: %s, match: %s)",
tier,
model_name,
resolved,
match,
)
return tier
local_tc = Path(model_name) / "tokenizer_config.json"
if _safe_is_file(local_tc) and _check_tokenizer_config_needs_v5(model_name):
logger.info(
"Transformers tier 530 selected for %s (local tokenizer_config.json check)",
model_name,
)
return "530"
logger.info(
"Transformers tier default (4.57.x) selected for %s (local config.json no match)",
model_name,
)
return "default"
# --- Fast substring checks (no I/O) ------------------------------------
result = _tier_from_name(model_name)
if result is not None:
tier, match = result
logger.info(
"Transformers tier %s selected for %s (substring match: %s)",
tier,
model_name,
match,
)
return tier
# --- Slow config fallbacks (network for HF IDs) ------------------------
if _check_config_needs_510(model_name):
logger.info("Transformers tier 510 selected for %s (config.json check)", model_name)
return "510"
if _check_config_needs_550(model_name):
logger.info("Transformers tier 550 selected for %s (config.json check)", model_name)
return "550"
if _check_config_needs_530(model_name):
# Same Qwen3.6 caveat as the local path: honor a _name_or_path name hint
# before selecting 530.
remote_cfg = _load_config_json(model_name) or {}
base = remote_cfg.get("_name_or_path") or remote_cfg.get("model_name")
override = _higher_tier_name_override(
base if isinstance(base, str) and base != model_name else None
)
if override is not None:
logger.info(
"Transformers tier %s selected for %s (name overrides 530 config)",
override,
model_name,
)
return override
logger.info("Transformers tier 530 selected for %s (config.json check)", model_name)
return "530"
if _check_tokenizer_config_needs_v5(model_name):
logger.info(
"Transformers tier 530 selected for %s (tokenizer_config.json check)",
model_name,
)
return "530"
logger.info("Transformers tier default (4.57.x) selected for %s (no match)", model_name)
return "default"
def needs_transformers_5(model_name: str) -> bool:
"""Return True if *model_name* requires any transformers 5.x version.
Convenience wrapper around :func:`get_transformers_tier`.
"""
return get_transformers_tier(model_name) != "default"
# ---------------------------------------------------------------------------
# Version switching (in-process — used only by export)
# ---------------------------------------------------------------------------
def _get_in_memory_version() -> str | None:
"""Return the transformers version currently loaded in this process."""
tf = sys.modules.get("transformers")
if tf is not None:
return getattr(tf, "__version__", None)
return None
# All top-level prefixes that hold references to transformers internals.
_PURGE_PREFIXES = (
"transformers",
"huggingface_hub",
"unsloth",
"unsloth_zoo",
"peft",
"trl",
"accelerate",
"auto_gptq",
# NOTE: bitsandbytes is intentionally EXCLUDED -- it registers torch custom
# operators via torch.library.define() into torch's global registry, which
# survives module purge; re-importing after purge -> duplicate registration
# -> crash.
# Our own modules that import from transformers at module level.
"utils.models",
"core.training",
"core.inference",
"core.export",
)
def _purge_modules() -> int:
"""Remove all cached modules for transformers and its dependents.
Returns the number of modules purged.
"""
importlib.invalidate_caches()
to_remove = [
k
for k in list(sys.modules.keys())
if any(k == p or k.startswith(p + ".") for p in _PURGE_PREFIXES)
]
for key in to_remove:
del sys.modules[key]
return len(to_remove)
_VENV_T5_530_PACKAGES = (
f"transformers=={TRANSFORMERS_530_VERSION}",
"huggingface_hub==1.8.0",
"hf_xet==1.4.2",
"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",
"hf_xet==1.4.2",
"tiktoken",
)
# Backwards-compat alias
_VENV_T5_PACKAGES = _VENV_T5_550_PACKAGES
def _venv_dir_is_valid(venv_dir: str, packages: tuple[str, ...]) -> bool:
"""Return True if *venv_dir* has all *packages* at the correct versions."""
if not os.path.isdir(venv_dir) or not os.listdir(venv_dir):
return False
for pkg_spec in packages:
parts = pkg_spec.split("==")
pkg_name = parts[0]
pkg_version = parts[1] if len(parts) > 1 else None
pkg_name_norm = pkg_name.replace("-", "_")
# Directory must exist.
if not any(
(Path(venv_dir) / d).is_dir() for d in (pkg_name_norm, pkg_name_norm.replace("_", "-"))
):
return False
# Unpinned packages: existence is enough.
if pkg_version is None:
continue
# Check version via .dist-info metadata.
dist_info_found = False
for di in Path(venv_dir).glob(f"{pkg_name_norm}-*.dist-info"):
metadata = di / "METADATA"
if not metadata.is_file():
continue
for line in metadata.read_text(errors = "replace").splitlines():
if line.startswith("Version:"):
installed_ver = line.split(":", 1)[1].strip()
if installed_ver != pkg_version:
logger.warning(
"%s has %s==%s but need %s -- venv will be wiped and reinstalled",
venv_dir,
pkg_name,
installed_ver,
pkg_version,
)
return False
dist_info_found = True
break
if dist_info_found:
break
if not dist_info_found:
return False
return True
def _venv_t5_is_valid() -> bool:
"""Backwards-compat: check the Gemma 4 sidecar venv."""
return _venv_dir_is_valid(_VENV_T5_550_DIR, _VENV_T5_550_PACKAGES)
def _install_to_dir(pkg: str, target_dir: str) -> bool:
"""Install a single package into *target_dir*, preferring uv then pip."""
# Try uv first (faster) if on PATH -- do NOT install uv at runtime.
if shutil.which("uv"):
result = subprocess.run(
[
"uv",
"pip",
"install",
"--python",
sys.executable,
"--target",
target_dir,
"--no-deps",
"--upgrade",
pkg,
],
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
return True
logger.warning("uv install of %s failed, falling back to pip", pkg)
# Fallback to pip.
result = subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--target",
target_dir,
"--no-deps",
"--upgrade",
pkg,
],
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
logger.error("install failed:\n%s", result.stdout)
return False
return True
def _ensure_venv_dir(venv_dir: str, packages: tuple[str, ...], label: str) -> bool:
"""Ensure *venv_dir* exists with all *packages*. Install if missing."""
if _venv_dir_is_valid(venv_dir, packages):
return True
logger.warning("%s not found or incomplete at %s -- installing at runtime", label, venv_dir)
shutil.rmtree(venv_dir, ignore_errors = True)
os.makedirs(venv_dir, exist_ok = True)
total = len(packages)
for idx, pkg in enumerate(packages, start = 1):
logger.info("Installing %s (%d/%d) into %s ...", pkg, idx, total, venv_dir)
if not _install_to_dir(pkg, venv_dir):
return False
logger.info("Installed %s to %s", label, venv_dir)
return True
def _ensure_venv_t5_530_exists() -> bool:
"""Ensure .venv_t5_530/ exists with transformers 5.3.0."""
return _ensure_venv_dir(_VENV_T5_530_DIR, _VENV_T5_530_PACKAGES, "transformers 5.3.0")
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,
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 Gemma 4 5.5 sidecar venv exists."""
return _ensure_venv_t5_550_exists()
def _activate_venv(venv_dir: str, label: str) -> None:
"""Prepend *venv_dir* to sys.path, purge stale modules, reimport."""
if venv_dir not in sys.path:
sys.path.insert(0, venv_dir)
logger.info("Prepended %s to sys.path", venv_dir)
count = _purge_modules()
logger.info("Purged %d cached modules", count)
import transformers
logger.info("Loaded transformers %s (%s)", transformers.__version__, label)
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, _VENV_T5_510_DIR):
while d in sys.path:
sys.path.remove(d)
logger.info("Removed venv_t5 dirs from sys.path")
count = _purge_modules()
logger.info("Purged %d cached modules", count)
import transformers
logger.info("Reverted to transformers %s", transformers.__version__)
def ensure_transformers_version(model_name: str) -> None:
"""Ensure the correct ``transformers`` version is active for *model_name*.
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.
For custom-named LoRA adapters, the base model is resolved before checking
(from ``adapter_config.json`` or, for adapter_model-only LoRAs, the directory
name).
NOTE: Training and inference use subprocess isolation instead. Used only by
the export path (routes/export.py).
"""
# Only pre-resolve for LoRA adapter dirs; see activate_transformers_for_subprocess.
if _is_lora_adapter_dir(Path(model_name)):
resolved = _resolve_base_model(model_name)
else:
resolved = model_name
tier = get_transformers_tier(resolved)
if model_name != resolved and (Path(model_name) / "config.json").is_file():
# Gate on a real local config.json: a checkpoint carries config the base may not
# surface, but path names alone must not upgrade a plain adapter.
tier = _higher_tier(tier, get_transformers_tier(model_name))
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
elif tier == "530":
target_version = TRANSFORMERS_530_VERSION
venv_dir = _VENV_T5_530_DIR
ensure_fn = _ensure_venv_t5_530_exists
else:
target_version = TRANSFORMERS_DEFAULT_VERSION
venv_dir = None
ensure_fn = None
target_major = int(target_version.split(".")[0])
# Check what's actually loaded in memory
in_memory = _get_in_memory_version()
logger.info(
"Version check for '%s' (resolved: '%s'): need=%s, in_memory=%s",
model_name,
resolved,
target_version,
in_memory,
)
# --- Already correct? ---------------------------------------------------
if in_memory is not None:
if in_memory == target_version:
logger.info(
"transformers %s already loaded — correct for '%s'",
in_memory,
model_name,
)
return
# 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.
logger.info(
"transformers %s already loaded — correct for '%s'",
in_memory,
model_name,
)
return
# --- Switch version -----------------------------------------------------
if venv_dir is not None:
# First remove any other 5.x venv from sys.path.
_deactivate_5x()
if not ensure_fn():
raise RuntimeError(
f"Cannot activate transformers {target_version}: " f"venv missing at {venv_dir}"
)
logger.info("Activating transformers %s", target_version)
_activate_venv(venv_dir, f"transformers {target_version}")
else:
logger.info("Reverting to default transformers %s", TRANSFORMERS_DEFAULT_VERSION)
_deactivate_5x()
final = _get_in_memory_version()
logger.info("✓ transformers version is now %s", final)