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
424 lines
15 KiB
Python
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"
|