unsloth/studio/backend/tests/test_vision_cache.py
Daniel Han 187144d4e7
Reduce and tighten code comments and docstrings repo-wide (#6095)
Trim and tighten code comments and docstrings across the repository. Comment-only: every changed file verified code-identical to main via AST/token comparison.
2026-06-08 23:09:51 -07:00

436 lines
18 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
"""Tests for is_vision_model() caching behaviour.
``_vision_detection_cache`` mirrors the ``_audio_detection_cache``
pattern used by ``detect_audio_type()``. These tests verify:
* Repeated calls for the same model hit the cache.
* Different models each trigger their own detection.
* Both True and False results are cached.
* The subprocess path (transformers 5.x models) is cached.
* Exceptions that fall back to False are cached.
"""
import sys
import types as _types
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# sys.path + logger stub — same pattern as the rest of the test suite
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
from utils.models.model_config import (
is_vision_model,
_is_vision_model_uncached,
_vision_detection_cache,
)
# Helpers
@pytest.fixture(autouse = True)
def _clear_vision_cache():
"""Ensure every test starts with a fresh cache."""
_vision_detection_cache.clear()
yield
_vision_detection_cache.clear()
# Cache hit / miss tests
class TestVisionCacheHitMiss:
"""Verify the cache prevents redundant detection calls."""
@patch("utils.models.model_config._is_vision_model_uncached", return_value = True)
def test_second_call_uses_cache(self, mock_uncached):
"""Two calls for the same model invoke the uncached fn once."""
assert is_vision_model("org/my-vlm") is True
assert is_vision_model("org/my-vlm") is True
mock_uncached.assert_called_once_with("org/my-vlm", None)
@patch("utils.models.model_config._is_vision_model_uncached", return_value = False)
def test_different_models_each_detected(self, mock_uncached):
"""Different model names should each trigger detection."""
is_vision_model("model-a")
is_vision_model("model-b")
assert mock_uncached.call_count == 2
@patch("utils.models.model_config._is_vision_model_uncached", return_value = True)
def test_cache_returns_correct_value(self, mock_uncached):
"""The cached value must match what _is_vision_model_uncached returned."""
first = is_vision_model("org/vlm")
second = is_vision_model("org/vlm")
assert first is True
assert second is True
class TestVisionCacheStoresFalse:
"""Non-VLM results (False) must also be cached to avoid re-detection."""
@patch("utils.models.model_config._is_vision_model_uncached", return_value = False)
def test_false_result_cached(self, mock_uncached):
assert is_vision_model("org/text-only") is False
assert is_vision_model("org/text-only") is False
mock_uncached.assert_called_once()
assert _vision_detection_cache[("org/text-only", None)] is False
# Subprocess path (transformers 5.x) caching
class TestVisionCacheSubprocessPath:
"""transformers 5.x models go through _is_vision_model_subprocess.
The cache should spawn the subprocess at most once per model per
process."""
@patch("utils.models.model_config._is_vision_model_subprocess", return_value = True)
@patch("utils.transformers_version.needs_transformers_5", return_value = True)
def test_subprocess_called_once_with_cache(self, mock_needs_t5, mock_subprocess):
"""Subprocess fires only on the first call; second is cached."""
# First call: uncached → subprocess
assert is_vision_model("unsloth/Qwen3.5-2B") is True
# Second call: cache hit, no subprocess
assert is_vision_model("unsloth/Qwen3.5-2B") is True
mock_subprocess.assert_called_once()
assert _vision_detection_cache[("unsloth/Qwen3.5-2B", None)] is True
@patch("utils.models.model_config._raw_config_has_vision_config", return_value = True)
@patch("utils.models.model_config._is_vision_model_subprocess", return_value = None)
@patch("utils.transformers_version.needs_transformers_5", return_value = True)
def test_subprocess_none_falls_back_to_raw_vision_config(
self, mock_needs_t5, mock_subprocess, mock_raw_config
):
assert is_vision_model("unsloth/gemma-4-E4B-it") is True
assert is_vision_model("unsloth/gemma-4-E4B-it") is True
mock_subprocess.assert_called_once()
mock_raw_config.assert_called_once_with("unsloth/gemma-4-E4B-it", hf_token = None)
# Exception handling — cache the False fallback
class TestVisionCacheOnException:
"""On exception, _is_vision_model_uncached distinguishes permanent
failures (cached as False) from transient ones (returned as None,
not cached, so the next call retries). Verify both contracts."""
@patch(
"utils.models.model_config.load_model_config",
side_effect = ValueError("bad config"),
)
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
def test_permanent_exception_result_cached(self, mock_needs_t5, mock_load_config):
"""A permanent failure (ValueError / RepositoryNotFoundError /
GatedRepoError / JSONDecodeError) is caught, returns False, and
that False is cached so subsequent calls don't retry. ValueError
stands in as the simplest cacheable exception type."""
# First call raises -> False; second is a cache hit.
assert is_vision_model("broken/model") is False
assert is_vision_model("broken/model") is False
mock_load_config.assert_called_once()
@patch(
"utils.models.model_config.load_model_config",
side_effect = OSError("network down"),
)
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
def test_transient_exception_not_cached(self, mock_needs_t5, mock_load_config):
"""A transient failure (OSError, timeouts) returns None from
_is_vision_model_uncached, surfaces as False, and is NOT cached
so the next call retries."""
# First call: OSError -> False, not cached; second call retries.
assert is_vision_model("broken/model") is False
assert is_vision_model("broken/model") is False
assert mock_load_config.call_count == 2
# Direct detection path (non-transformers-5 models) caching
class TestVisionCacheDirectPath:
"""Models that do NOT need transformers 5.x detect via
load_model_config directly. The cache must work the same way."""
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_direct_vlm_detection_cached(self, mock_load_config, mock_needs_t5):
"""A standard VLM detected via architecture suffix should be cached."""
cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist
cfg.model_type = "gemma3"
cfg.architectures = ["Gemma3ForConditionalGeneration"]
mock_load_config.return_value = cfg
assert is_vision_model("google/gemma-3-4b-it") is True
assert is_vision_model("google/gemma-3-4b-it") is True
# load_model_config should only be called once
mock_load_config.assert_called_once()
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_direct_non_vlm_detection_cached(self, mock_load_config, mock_needs_t5):
"""A standard text model (no VLM indicators) should cache False."""
cfg = MagicMock(spec = []) # spec=[] means no attributes at all
cfg.model_type = "llama"
cfg.architectures = ["LlamaForCausalLM"]
mock_load_config.return_value = cfg
# No VLM suffix, no vision_config, etc.
assert is_vision_model("meta-llama/Llama-3-8B") is False
assert is_vision_model("meta-llama/Llama-3-8B") is False
mock_load_config.assert_called_once()
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_vision_config_attr_detected_and_cached(self, mock_load_config, mock_needs_t5):
"""Models with vision_config (LLaVA, Qwen2-VL, etc.) should be cached as True."""
cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist
cfg.model_type = "qwen2_vl"
cfg.architectures = ["Qwen2VLForCausalLM"] # Doesn't match VLM suffixes
cfg.vision_config = {"hidden_size": 1024}
mock_load_config.return_value = cfg
assert is_vision_model("Qwen/Qwen2-VL-7B") is True
assert is_vision_model("Qwen/Qwen2-VL-7B") is True
mock_load_config.assert_called_once()
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_gemma4_model_type_detected_and_cached(self, mock_load_config, mock_needs_t5):
cfg = MagicMock(spec = [])
cfg.model_type = "gemma4"
cfg.architectures = ["Gemma4ForConditionalGeneration"]
mock_load_config.return_value = cfg
assert is_vision_model("google/gemma-4-E4B-it") is True
assert is_vision_model("google/gemma-4-E4B-it") is True
mock_load_config.assert_called_once()
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_gemma4_audio_subconfig_not_detected_as_vision(self, mock_load_config, mock_needs_t5):
cfg = MagicMock(spec = [])
cfg.model_type = "gemma4_audio"
cfg.architectures = ["Gemma4AudioModel"]
mock_load_config.return_value = cfg
assert is_vision_model("local/gemma4-audio-encoder") is False
assert is_vision_model("local/gemma4-audio-encoder") is False
mock_load_config.assert_called_once()
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_gemma4_text_subconfig_not_detected_as_vision(self, mock_load_config, mock_needs_t5):
cfg = MagicMock(spec = [])
cfg.model_type = "gemma4_text"
cfg.architectures = ["Gemma4ForCausalLM"]
mock_load_config.return_value = cfg
assert is_vision_model("local/gemma-4-text") is False
assert is_vision_model("local/gemma-4-text") is False
mock_load_config.assert_called_once()
@patch("utils.transformers_version.needs_transformers_5", return_value = False)
@patch("utils.models.model_config.load_model_config")
def test_audio_model_excluded_and_cached(self, mock_load_config, mock_needs_t5):
"""Audio-only models (csm, whisper) with ForConditionalGeneration
should be excluded from VLM detection and cached as False."""
cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist
cfg.model_type = "whisper"
cfg.architectures = ["WhisperForConditionalGeneration"]
mock_load_config.return_value = cfg
assert is_vision_model("openai/whisper-large-v3") is False
assert is_vision_model("openai/whisper-large-v3") is False
mock_load_config.assert_called_once()
# hf_token handling
class TestVisionCacheTokenHandling:
"""The cache is keyed on (model_name, hf_token). Different tokens
for the same model trigger separate detections for gated models."""
@patch("utils.models.model_config._is_vision_model_uncached", return_value = True)
def test_different_tokens_trigger_new_detection(self, mock_uncached):
"""Different tokens trigger separate detections for gated models
(e.g. unauthenticated probe → False, then authenticated
re-check)."""
assert is_vision_model("gated/model", hf_token = "token-a") is True
assert is_vision_model("gated/model", hf_token = "token-b") is True
assert mock_uncached.call_count == 2
@patch("utils.models.model_config._is_vision_model_uncached", return_value = True)
def test_same_token_uses_cache(self, mock_uncached):
"""Repeated calls with identical model + token should hit cache."""
assert is_vision_model("gated/model", hf_token = "token-a") is True
assert is_vision_model("gated/model", hf_token = "token-a") is True
mock_uncached.assert_called_once()
# ---------------------------------------------------------------------------
# Direct unit tests for _raw_config_has_vision_config
# ---------------------------------------------------------------------------
import json as _json
from utils.models.model_config import (
_AUDIO_ONLY_MODEL_TYPES,
_VISION_CHECK_INLINE_HELPERS,
_VISION_CHECK_SCRIPT,
_is_vlm,
_raw_config_has_vision_config,
)
def _write_config(tmp_path, config):
(tmp_path / "config.json").write_text(_json.dumps(config))
return tmp_path
class TestRawConfigVlmDetection:
"""Direct coverage of _raw_config_has_vision_config across the same
indicator set used by _is_vlm. The cache integration tests above mock
this function; these exercise its real implementation."""
def test_truthy_vision_config(self, tmp_path):
p = _write_config(tmp_path, {"vision_config": {"hidden_size": 1024}})
assert _raw_config_has_vision_config(str(p)) is True
def test_empty_vision_config_key(self, tmp_path):
p = _write_config(tmp_path, {"vision_config": {}})
assert _raw_config_has_vision_config(str(p)) is True
def test_arch_suffix_detection(self, tmp_path):
p = _write_config(
tmp_path,
{
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
},
)
assert _raw_config_has_vision_config(str(p)) is True
def test_img_processor_key(self, tmp_path):
p = _write_config(tmp_path, {"img_processor": {"image_size": 336}})
assert _raw_config_has_vision_config(str(p)) is True
def test_image_token_index_key(self, tmp_path):
p = _write_config(tmp_path, {"image_token_index": 32000})
assert _raw_config_has_vision_config(str(p)) is True
def test_known_vlm_model_type(self, tmp_path):
p = _write_config(tmp_path, {"model_type": "gemma4"})
assert _raw_config_has_vision_config(str(p)) is True
def test_plain_text_model_returns_false(self, tmp_path):
p = _write_config(
tmp_path,
{"model_type": "llama", "architectures": ["LlamaForCausalLM"]},
)
assert _raw_config_has_vision_config(str(p)) is False
def test_missing_config_returns_none(self, tmp_path):
assert _raw_config_has_vision_config(str(tmp_path)) is None
# ---------------------------------------------------------------------------
# Self-contained subprocess script (no parent backend imports)
# ---------------------------------------------------------------------------
class TestSubprocessScript:
def test_does_not_import_parent_module(self):
assert "from utils.models.model_config" not in _VISION_CHECK_SCRIPT
def test_inline_is_vlm_executes_correctly(self):
ns: dict = {}
exec(_VISION_CHECK_INLINE_HELPERS, ns)
inline_is_vlm = ns["_is_vlm"]
class _C:
def __init__(self, **kw):
for k, v in kw.items():
setattr(self, k, v)
assert (
inline_is_vlm(
_C(
model_type = "gemma4",
architectures = ["Gemma4ForConditionalGeneration"],
)
)
is True
)
assert (
inline_is_vlm(_C(model_type = "gemma4_text", architectures = ["Gemma4ForCausalLM"]))
is False
)
assert inline_is_vlm(_C(model_type = "llama", architectures = ["LlamaForCausalLM"])) is False
# ---------------------------------------------------------------------------
# Audio-only model exclusion must apply across every detection path
# ---------------------------------------------------------------------------
class TestVlmAudioExclusion:
"""The {csm, whisper} guard previously lived only in the direct caller
branch. These tests assert it now applies inside _is_vlm, the raw
fallback, and the inlined subprocess helper too."""
def test_audio_only_set_canonical(self):
assert _AUDIO_ONLY_MODEL_TYPES == {"csm", "whisper"}
def test_is_vlm_excludes_whisper(self):
cfg = MagicMock(spec = [])
cfg.model_type = "whisper"
cfg.architectures = ["WhisperForConditionalGeneration"]
assert _is_vlm(cfg) is False
def test_raw_fallback_excludes_whisper(self, tmp_path):
p = _write_config(
tmp_path,
{
"architectures": ["WhisperForConditionalGeneration"],
"model_type": "whisper",
},
)
assert _raw_config_has_vision_config(str(p)) is False
def test_inline_subprocess_helper_excludes_whisper(self):
ns: dict = {}
exec(_VISION_CHECK_INLINE_HELPERS, ns)
cfg = MagicMock(spec = [])
cfg.model_type = "whisper"
cfg.architectures = ["WhisperForConditionalGeneration"]
assert ns["_is_vlm"](cfg) is False
@patch("utils.models.model_config._is_vision_model_subprocess", return_value = None)
@patch("utils.transformers_version.needs_transformers_5", return_value = True)
def test_t5_subprocess_none_falls_back_through_raw_for_whisper(
self, mock_needs_t5, mock_subprocess, tmp_path
):
_write_config(
tmp_path,
{
"architectures": ["WhisperForConditionalGeneration"],
"model_type": "whisper",
},
)
assert is_vision_model(str(tmp_path)) is False