* 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>
1309 lines
49 KiB
Python
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)
|