# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Tests for transformers version detection with local checkpoint fallbacks.""" import json import pytest from pathlib import Path from unittest.mock import patch # --------------------------------------------------------------------------- # We need to be able to import the module under test. The studio backend # uses relative-style imports (``from utils.…``), so we add the backend # directory to *sys.path* if it is not already there. # --------------------------------------------------------------------------- import sys _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) # Stub the custom logger before importing the module under test so it # doesn't fail on the ``from loggers import get_logger`` line. import types as _types _loggers_stub = _types.ModuleType("loggers") _loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) sys.modules.setdefault("loggers", _loggers_stub) from utils.transformers_version import ( _resolve_base_model, _check_tokenizer_config_needs_v5, _check_config_needs_550, _tokenizer_class_cache, _config_needs_550_cache, needs_transformers_5, get_transformers_tier, ) # --------------------------------------------------------------------------- # _resolve_base_model — config.json fallback # --------------------------------------------------------------------------- class TestResolveBaseModel: """Tests for _resolve_base_model() local config fallbacks.""" def test_adapter_config_takes_priority(self, tmp_path: Path): """adapter_config.json should be preferred over config.json.""" adapter_cfg = {"base_model_name_or_path": "meta-llama/Llama-3-8B"} config_cfg = {"_name_or_path": "different/model"} (tmp_path / "adapter_config.json").write_text(json.dumps(adapter_cfg)) (tmp_path / "config.json").write_text(json.dumps(config_cfg)) result = _resolve_base_model(str(tmp_path)) assert result == "meta-llama/Llama-3-8B" def test_config_json_fallback_model_name(self, tmp_path: Path): """config.json model_name should resolve when no adapter_config.""" config_cfg = {"model_name": "Qwen/Qwen3.5-9B"} (tmp_path / "config.json").write_text(json.dumps(config_cfg)) result = _resolve_base_model(str(tmp_path)) assert result == "Qwen/Qwen3.5-9B" def test_config_json_fallback_name_or_path(self, tmp_path: Path): """config.json _name_or_path should resolve as secondary fallback.""" config_cfg = {"_name_or_path": "Qwen/Qwen3.5-9B"} (tmp_path / "config.json").write_text(json.dumps(config_cfg)) result = _resolve_base_model(str(tmp_path)) assert result == "Qwen/Qwen3.5-9B" def test_model_name_takes_priority_over_name_or_path(self, tmp_path: Path): """model_name should be preferred over _name_or_path.""" config_cfg = { "model_name": "Qwen/Qwen3.5-9B", "_name_or_path": "some/other-model", } (tmp_path / "config.json").write_text(json.dumps(config_cfg)) result = _resolve_base_model(str(tmp_path)) assert result == "Qwen/Qwen3.5-9B" def test_config_json_skips_self_referencing(self, tmp_path: Path): """config.json should be ignored if model_name == the checkpoint path.""" config_cfg = {"model_name": str(tmp_path)} (tmp_path / "config.json").write_text(json.dumps(config_cfg)) result = _resolve_base_model(str(tmp_path)) # Should fall through, not return the self-referencing path assert result == str(tmp_path) def test_no_config_files(self, tmp_path: Path): """Returns original name when no config files are present.""" result = _resolve_base_model(str(tmp_path)) assert result == str(tmp_path) def test_plain_hf_id_passthrough(self): """Plain HuggingFace model IDs pass through unchanged.""" result = _resolve_base_model("meta-llama/Llama-3-8B") assert result == "meta-llama/Llama-3-8B" # --------------------------------------------------------------------------- # _check_tokenizer_config_needs_v5 — local file check # --------------------------------------------------------------------------- class TestCheckTokenizerConfigNeedsV5: """Tests for local tokenizer_config.json fallback.""" def setup_method(self): _tokenizer_class_cache.clear() def test_local_tokenizer_config_v5(self, tmp_path: Path): """Local tokenizer_config.json with v5 tokenizer should return True.""" tc = {"tokenizer_class": "TokenizersBackend"} (tmp_path / "tokenizer_config.json").write_text(json.dumps(tc)) result = _check_tokenizer_config_needs_v5(str(tmp_path)) assert result is True def test_local_tokenizer_config_v4(self, tmp_path: Path): """Local tokenizer_config.json with standard tokenizer should return False.""" tc = {"tokenizer_class": "LlamaTokenizerFast"} (tmp_path / "tokenizer_config.json").write_text(json.dumps(tc)) result = _check_tokenizer_config_needs_v5(str(tmp_path)) assert result is False def test_local_file_skips_network(self, tmp_path: Path): """When local file exists, no network request should be made.""" tc = {"tokenizer_class": "LlamaTokenizerFast"} (tmp_path / "tokenizer_config.json").write_text(json.dumps(tc)) with patch("urllib.request.urlopen") as mock_urlopen: result = _check_tokenizer_config_needs_v5(str(tmp_path)) mock_urlopen.assert_not_called() assert result is False def test_result_is_cached(self, tmp_path: Path): """Subsequent calls should use the cache.""" tc = {"tokenizer_class": "TokenizersBackend"} (tmp_path / "tokenizer_config.json").write_text(json.dumps(tc)) key = str(tmp_path) _check_tokenizer_config_needs_v5(key) assert key in _tokenizer_class_cache assert _tokenizer_class_cache[key] is True # --------------------------------------------------------------------------- # needs_transformers_5 — integration-level # --------------------------------------------------------------------------- class TestNeedsTransformers5: """Integration tests for the top-level needs_transformers_5() function.""" def setup_method(self): _tokenizer_class_cache.clear() def test_qwen35_substring(self): assert needs_transformers_5("Qwen/Qwen3.5-9B") is True def test_qwen3_30b_a3b_substring(self): assert needs_transformers_5("Qwen/Qwen3-30B-A3B-Instruct-2507") is True def test_ministral_substring(self): assert needs_transformers_5("mistralai/Ministral-3-8B-Instruct-2512") is True def test_llama_does_not_need_v5(self): """Standard models should not trigger v5.""" # Patch network call to avoid real fetch with patch( "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False, ): assert needs_transformers_5("meta-llama/Llama-3-8B") is False def test_local_checkpoint_resolved_via_config(self, tmp_path: Path): """A local checkpoint with config.json pointing to Qwen3.5 should need v5.""" config_cfg = {"model_name": "Qwen/Qwen3.5-9B"} (tmp_path / "config.json").write_text(json.dumps(config_cfg)) # _resolve_base_model is called by ensure_transformers_version, # but needs_transformers_5 just does substring matching. # We test the full resolution chain here: resolved = _resolve_base_model(str(tmp_path)) assert needs_transformers_5(resolved) is True # --------------------------------------------------------------------------- # _check_config_needs_550 — config.json architecture/model_type check # --------------------------------------------------------------------------- class TestCheckConfigNeeds550: """Tests for _check_config_needs_550() local config.json checks.""" def setup_method(self): _config_needs_550_cache.clear() def test_gemma4_architecture(self, tmp_path: Path): """config.json with Gemma4ForConditionalGeneration should return True.""" cfg = { "architectures": ["Gemma4ForConditionalGeneration"], "model_type": "gemma4", } (tmp_path / "config.json").write_text(json.dumps(cfg)) assert _check_config_needs_550(str(tmp_path)) is True def test_gemma4_model_type_only(self, tmp_path: Path): """config.json with model_type=gemma4 (no architectures) should return True.""" cfg = {"model_type": "gemma4"} (tmp_path / "config.json").write_text(json.dumps(cfg)) assert _check_config_needs_550(str(tmp_path)) is True def test_llama_architecture(self, tmp_path: Path): """config.json with LlamaForCausalLM should return False.""" cfg = {"architectures": ["LlamaForCausalLM"], "model_type": "llama"} (tmp_path / "config.json").write_text(json.dumps(cfg)) assert _check_config_needs_550(str(tmp_path)) is False def test_no_config_json(self, tmp_path: Path): """Missing config.json should return False (fail-open).""" # Patch network call to avoid real fetch with patch("urllib.request.urlopen") as mock_urlopen: mock_urlopen.side_effect = Exception("no network") assert _check_config_needs_550(str(tmp_path)) is False def test_result_is_cached(self, tmp_path: Path): """Subsequent calls should use the cache.""" cfg = {"architectures": ["Gemma4ForConditionalGeneration"]} (tmp_path / "config.json").write_text(json.dumps(cfg)) key = str(tmp_path) _check_config_needs_550(key) assert key in _config_needs_550_cache assert _config_needs_550_cache[key] is True def test_local_file_skips_network(self, tmp_path: Path): """When local config.json exists, no network request should be made.""" cfg = {"architectures": ["LlamaForCausalLM"]} (tmp_path / "config.json").write_text(json.dumps(cfg)) with patch("urllib.request.urlopen") as mock_urlopen: _check_config_needs_550(str(tmp_path)) mock_urlopen.assert_not_called() # --------------------------------------------------------------------------- # get_transformers_tier — tier detection # --------------------------------------------------------------------------- class TestGetTransformersTier: """Tests for get_transformers_tier() tiered version detection.""" def setup_method(self): _tokenizer_class_cache.clear() _config_needs_550_cache.clear() def test_gemma4_substring_returns_550(self): assert get_transformers_tier("google/gemma-4-E2B-it") == "550" def test_gemma4_alt_substring_returns_550(self): assert get_transformers_tier("unsloth/gemma4-E4B-it") == "550" def test_gemma4_config_json_returns_550(self, tmp_path: Path): """Local checkpoint with Gemma4 architecture → 550.""" cfg = { "architectures": ["Gemma4ForConditionalGeneration"], "model_type": "gemma4", } (tmp_path / "config.json").write_text(json.dumps(cfg)) assert get_transformers_tier(str(tmp_path)) == "550" def test_qwen35_returns_530(self): with patch( "utils.transformers_version._check_config_needs_550", return_value = False, ): assert get_transformers_tier("Qwen/Qwen3.5-9B") == "530" def test_ministral_returns_530(self): with patch( "utils.transformers_version._check_config_needs_550", return_value = False, ): assert ( get_transformers_tier("mistralai/Ministral-3-8B-Instruct-2512") == "530" ) def test_llama_returns_default(self): with ( patch( "utils.transformers_version._check_config_needs_550", return_value = False, ), patch( "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False, ), ): assert get_transformers_tier("meta-llama/Llama-3-8B") == "default" def test_550_checked_before_530(self): """Ensure 5.5.0 is checked first — a model matching both should get 550.""" # This shouldn't happen in practice, but verifies priority assert get_transformers_tier("gemma-4-model") == "550" def test_needs_transformers_5_compat(self): """needs_transformers_5 should return True for both 530 and 550 models.""" assert needs_transformers_5("google/gemma-4-E2B-it") is True with patch( "utils.transformers_version._check_config_needs_550", return_value = False, ): assert needs_transformers_5("Qwen/Qwen3.5-9B") is True with ( patch( "utils.transformers_version._check_config_needs_550", return_value = False, ), patch( "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False, ), ): assert needs_transformers_5("meta-llama/Llama-3-8B") is False