Files
unslothai--unsloth/tests/python/test_fast_language_model_text_only.py
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

424 lines
15 KiB
Python

"""Text-only FastLanguageModel routing for vision-capable configs."""
import ast
import copy
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
LOADER_PATH = REPO_ROOT / "unsloth" / "models" / "loader.py"
VISION_PATH = REPO_ROOT / "unsloth" / "models" / "vision.py"
UTILS_PATH = REPO_ROOT / "unsloth" / "models" / "_utils.py"
def _source(path):
return path.read_text()
def _class_method(tree, class_name, method_name):
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == class_name:
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == method_name:
return item
raise AssertionError(f"{class_name}.{method_name} not found")
def _assigns_name(method, target_name, predicate):
"""True when the method contains `target_name = <value>` and predicate(value)."""
for node in ast.walk(method):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id == target_name:
if predicate(node.value):
return True
return False
def _calls_function(method, func_name):
"""True when the method calls `func_name(...)` (bare name, not attribute)."""
for node in ast.walk(method):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == func_name
):
return True
return False
def _names_in(node):
return {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}
def _param_default(method, name):
# Default-value AST node for a named parameter, or None.
args = method.args
params = list(args.args) + list(args.kwonlyargs)
defaults = list(args.defaults) + list(args.kw_defaults)
return dict(zip([p.arg for p in params][-len(defaults) :], defaults)).get(name)
def _load_text_only_namespace():
# Exec the _utils text-only helpers into one namespace (no unsloth import),
# in dependency order so cross-references resolve.
source = _source(UTILS_PATH)
import transformers
from packaging.version import Version
ns = {
"copy": copy,
"Version": Version,
"transformers_version": transformers.__version__,
}
funcs = {
node.name: ast.get_source_segment(source, node)
for node in ast.parse(source).body
if isinstance(node, ast.FunctionDef)
}
for name in (
"resolve_model_class",
"_is_family_text_decoder",
"_remap_text_only_skip_modules",
"_get_text_only_config",
"_get_text_only_key_mapping",
"_apply_text_only_key_mapping",
):
if name in funcs:
exec(funcs[name], ns)
return ns
def _load_text_only_helper():
return _load_text_only_namespace()["_get_text_only_config"]
def test_gemma3_vision_config_resolves_to_text_config():
transformers = pytest.importorskip("transformers")
helper = _load_text_only_helper()
config = transformers.Gemma3Config()
text_config = helper(config, "google/gemma-3-27b-it")
assert isinstance(text_config, transformers.Gemma3TextConfig)
assert text_config.model_type == "gemma3_text"
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
assert model_class.__name__ == "Gemma3ForCausalLM"
def test_text_only_helper_rejects_configs_without_text_submodel():
helper = _load_text_only_helper()
class VisionOnlyConfig:
vision_config = object()
with pytest.raises(ValueError, match = "Cannot load vision-only as text-only"):
helper(VisionOnlyConfig(), "vision-only")
def test_fast_language_model_forwards_text_only_to_fast_model():
source = _source(LOADER_PATH)
method = _class_method(ast.parse(source), "FastLanguageModel", "from_pretrained")
# text_only defaults False (opt-in); both FastModel delegations forward it.
text_only_default = _param_default(method, "text_only")
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
fast_model_calls = [
node
for node in ast.walk(method)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "from_pretrained"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "FastModel"
]
assert len(fast_model_calls) == 2
for call in fast_model_calls:
kw = [k for k in call.keywords if k.arg == "text_only"]
assert len(kw) == 1
assert isinstance(kw[0].value, ast.Name) and kw[0].value.id == "text_only"
def test_fast_model_text_only_does_not_override_explicit_auto_model():
# AST-based so formatting/refactors that keep the structure do not break it.
source = _source(LOADER_PATH)
method = _class_method(ast.parse(source), "FastModel", "from_pretrained")
text_only_default = _param_default(method, "text_only")
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
# load_text_only is text_only AND a check that the caller did not pass auto_model.
def _is_guarded_bool(value):
names = _names_in(value)
has_none_check = any(
isinstance(n, ast.Compare) and any(isinstance(op, (ast.Is, ast.IsNot)) for op in n.ops)
for n in ast.walk(value)
)
return "text_only" in names and "auto_model" in names and has_none_check
assert _assigns_name(method, "load_text_only", _is_guarded_bool)
assert _calls_function(method, "_get_text_only_config")
def _forwards_kwarg(node):
return any(
isinstance(n, ast.Call)
and any(
kw.arg == "text_only"
and isinstance(kw.value, ast.Name)
and kw.value.id == "load_text_only"
for kw in n.keywords
)
for n in ast.walk(node)
)
assert _forwards_kwarg(method)
# Falls back to the full model unless the family has its own text decoder.
assert _calls_function(method, "_is_family_text_decoder")
assert _assigns_name(
method,
"load_text_only",
lambda v: isinstance(v, ast.Constant) and v.value is False,
)
def test_fast_base_model_text_only_bypasses_vision_auto_model():
source = _source(VISION_PATH)
method = _class_method(ast.parse(source), "FastBaseModel", "from_pretrained")
text_only_default = _param_default(method, "text_only")
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
assert _assigns_name(
method,
"auto_model",
lambda v: isinstance(v, ast.Name) and v.id == "AutoModelForCausalLM",
)
# Text-only path: strip config, apply the family guard, inject the key remap.
assert _calls_function(method, "_get_text_only_config")
assert _calls_function(method, "_is_family_text_decoder")
assert _calls_function(method, "_apply_text_only_key_mapping")
def test_gemma3_text_only_model_class_resolves_and_has_no_vision_tower():
"""End-to-end: a tiny Gemma3 text-only model instantiates with text LM attrs and no vision tower."""
transformers = pytest.importorskip("transformers")
helper = _load_text_only_helper()
full_config = transformers.Gemma3Config()
text_config = helper(full_config, "google/gemma-3-27b-it")
# Shrink for cheap CPU instantiation.
text_config.num_hidden_layers = 1
text_config.hidden_size = 32
text_config.intermediate_size = 32
text_config.num_attention_heads = 2
text_config.num_key_value_heads = 1
text_config.head_dim = 16
text_config.vocab_size = 128
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
model = model_class(text_config)
assert hasattr(model, "lm_head"), "text-only Gemma3 model should expose lm_head"
# No vision tower / multimodal projector remains.
assert not hasattr(
model, "vision_tower"
), "text-only Gemma3 model should not have a vision_tower"
assert not hasattr(
model, "multi_modal_projector"
), "text-only Gemma3 model should not have a multi_modal_projector"
def test_helper_defined_once_in_utils_and_imported():
# _get_text_only_config defined only in _utils, imported by loader + vision.
def _defines(path):
return any(
isinstance(n, ast.FunctionDef) and n.name == "_get_text_only_config"
for n in ast.parse(_source(path)).body
)
def _imports(path):
return any(
isinstance(n, ast.ImportFrom)
and n.module == "_utils"
and any(a.name == "_get_text_only_config" for a in n.names)
for n in ast.walk(ast.parse(_source(path)))
)
assert _defines(UTILS_PATH)
assert not _defines(LOADER_PATH) and _imports(LOADER_PATH)
assert not _defines(VISION_PATH) and _imports(VISION_PATH)
def _load_util_func(name):
ns = _load_text_only_namespace()
if name not in ns:
raise AssertionError(f"{name} not found")
return ns[name]
def test_text_only_guard_predicate_across_vlm_families():
# Text-only taken only when the resolved class remaps VLM weights.
transformers = pytest.importorskip("transformers")
from transformers import AutoModelForCausalLM
resolve = _load_util_func("resolve_model_class")
is_family = _load_util_func("_is_family_text_decoder")
helper = _load_text_only_helper()
def takes_text_only(cfg):
text = helper(cfg, "x")
return resolve(AutoModelForCausalLM, text) is not None and is_family(
getattr(cfg, "model_type", ""), getattr(text, "model_type", "")
)
# Dedicated text decoder remaps language_model.* -> strip vision.
assert takes_text_only(transformers.Gemma3Config()) is True
# No text class (Qwen2-VL/Mllama) or a generic reused decoder that would
# load random weights (Llava/PaliGemma/Idefics3/InternVL) -> keep full model.
for name in [
"Qwen2VLConfig",
"Qwen2_5_VLConfig",
"MllamaConfig",
"LlavaConfig",
"PaliGemmaConfig",
"Idefics3Config",
"InternVLConfig",
]:
cfg_cls = getattr(transformers, name, None)
if cfg_cls is None:
continue
assert takes_text_only(cfg_cls()) is False, name
def test_text_only_helper_preserves_quantization_config():
# quantization_config must survive the strip so pre-quantized repos load. A
# sentinel object avoids a bitsandbytes dependency on transformers 4.51.3.
transformers = pytest.importorskip("transformers")
helper = _load_text_only_helper()
config = transformers.Gemma3Config()
sentinel = object()
config.quantization_config = sentinel
text_config = helper(config, "google/gemma-3-27b-it")
assert getattr(text_config, "quantization_config", None) is sentinel
# The parent's shared text sub-config must not be mutated.
assert getattr(config.get_text_config(), "quantization_config", None) is None
def test_text_only_key_mapping_targets_published_prefixes():
# Remap the published VLM decoder prefixes, applying only on transformers >=5
# (on 4.x base_model_prefix handles it and a mapping hurts).
transformers = pytest.importorskip("transformers")
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
mapping = get_key_mapping(transformers.Gemma3Config(), transformers.Gemma3TextConfig())
if int(transformers.__version__.split(".")[0]) < 5:
assert mapping is None
else:
assert isinstance(mapping, dict)
assert mapping.get(r"^language_model\.model\.") == "model." # gemma3
assert mapping.get(r"^model\.language_model\.") == "model." # gemma3n
assert mapping.get(r"^language_model\.lm_head\.") == "lm_head."
def test_gemma3_text_only_loads_real_language_weights_from_vlm_checkpoint(tmp_path):
# PR #5816: text-only loading of a Gemma 3 VLM checkpoint must load real
# language weights, not random ones. Fails on tf >=5 without the key_mapping fix.
transformers = pytest.importorskip("transformers")
torch = pytest.importorskip("torch")
import shutil
from safetensors.torch import load_file, save_file
get_text_config = _load_text_only_helper()
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
sentinel = 0.1234
text_cfg = transformers.Gemma3TextConfig(
hidden_size = 32,
intermediate_size = 64,
num_hidden_layers = 1,
num_attention_heads = 2,
num_key_value_heads = 1,
head_dim = 16,
vocab_size = 128,
max_position_embeddings = 128,
sliding_window = 64,
)
vision_cfg = transformers.SiglipVisionConfig(
hidden_size = 32,
intermediate_size = 64,
num_hidden_layers = 1,
num_attention_heads = 2,
image_size = 16,
patch_size = 8,
num_channels = 3,
)
full_config = transformers.Gemma3Config(
text_config = text_cfg.to_dict(),
vision_config = vision_cfg.to_dict(),
)
full_model = transformers.Gemma3ForConditionalGeneration(full_config)
state = full_model.state_dict()
text_q = [
k
for k in state
if "language_model" in k
and "vision" not in k
and k.endswith("layers.0.self_attn.q_proj.weight")
]
assert text_q, [k for k in state if "q_proj" in k][:5]
with torch.no_grad():
for k in text_q:
state[k].fill_(sentinel)
save_dir = tmp_path / "vlm"
full_model.save_pretrained(save_dir, safe_serialization = True)
# tf >=5 saves under an outer "model." prefix; strip it to reproduce the
# language_model.model.* layout the published Gemma 3 checkpoints use.
real_dir = tmp_path / "real"
real_dir.mkdir()
weights = {}
for f in save_dir.glob("*.safetensors"):
weights.update(load_file(str(f)))
for f in save_dir.glob("*.bin"):
weights.update(torch.load(f, map_location = "cpu", weights_only = True))
weights = {
(k[len("model.") :] if k.startswith("model.") else k): v.contiguous()
for k, v in weights.items()
}
for p in save_dir.iterdir():
if not p.name.endswith((".safetensors", ".bin", ".index.json")):
shutil.copy(p, real_dir / p.name)
save_file(weights, str(real_dir / "model.safetensors"))
text_config = get_text_config(full_config, "google/gemma-3-27b-it")
load_kwargs = {}
key_mapping = get_key_mapping(full_config, text_config)
if key_mapping is not None:
load_kwargs["key_mapping"] = key_mapping
model = transformers.AutoModelForCausalLM.from_pretrained(
real_dir,
config = text_config,
dtype = torch.float32,
local_files_only = True,
**load_kwargs,
)
loaded = model.state_dict()
q_key = [k for k in loaded if k.endswith("model.layers.0.self_attn.q_proj.weight")]
assert q_key, "text decoder q_proj weight missing from the loaded model"
assert float(loaded[q_key[0]].flatten()[0]) == pytest.approx(
sentinel
), "text weights were randomly initialized instead of loaded from the checkpoint"
assert not any(
"vision_tower" in n for n, _ in model.named_modules()
), "vision tower should be skipped on the text-only path"