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
768 lines
32 KiB
Python
768 lines
32 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 (
|
|
ModelConfig,
|
|
is_vision_model,
|
|
_is_vision_model_uncached,
|
|
_vision_detection_cache,
|
|
)
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _clear_vision_cache(tmp_path, monkeypatch):
|
|
"""Ensure every test starts with a fresh cache, from an empty working dir.
|
|
|
|
``is_vision_model`` calls ``is_local_path`` first: any relative model id that
|
|
happens to exist on disk (``Path(name).exists()``) is treated as a local
|
|
model, short-circuiting before the mocked detection internals run. The CI cwd
|
|
(``studio/backend``) and the HF cache can contain dirs whose names collide
|
|
with the synthetic remote ids used here (``org/my-vlm``, ``model-a``,
|
|
``broken/model`` ...), which made these tests fail with "called 0 times".
|
|
Running each test from a fresh empty ``tmp_path`` removes that collision
|
|
while leaving the real ``is_local_path`` logic intact (the local-GGUF tests
|
|
pass absolute ``tmp_path`` paths, unaffected by cwd).
|
|
"""
|
|
monkeypatch.chdir(tmp_path)
|
|
_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, local_files_only = False)
|
|
|
|
@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, False)] 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._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw):
|
|
"""When the raw-config reader is inconclusive (None), the transformers
|
|
5.x subprocess fires only on the first call; the second is cached."""
|
|
# First call: raw None -> 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, False)] 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_raw_config_primary_skips_subprocess(
|
|
self, mock_needs_t5, mock_subprocess, mock_raw_config
|
|
):
|
|
# The raw config.json read is the primary path; a definitive answer there never
|
|
# reaches the transformers-5.x subprocess or needs_transformers_5 routing.
|
|
assert is_vision_model("unsloth/gemma-4-E4B-it") is True
|
|
assert is_vision_model("unsloth/gemma-4-E4B-it") is True
|
|
|
|
mock_raw_config.assert_called_once_with(
|
|
"unsloth/gemma-4-E4B-it", hf_token = None, local_files_only = False
|
|
)
|
|
mock_subprocess.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Local GGUF capability path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLocalGgufVisionDetection:
|
|
@patch(
|
|
"utils.models.model_config._is_vision_model_subprocess",
|
|
side_effect = AssertionError("GGUF must not use Transformers vision detection"),
|
|
)
|
|
def test_qwen36_gguf_with_mmproj_skips_transformers(self, mock_subprocess, tmp_path):
|
|
model = tmp_path / "Qwen3.6-27B-UD-Q4_K_XL-MTP.gguf"
|
|
model.write_bytes(b"")
|
|
(tmp_path / "mmproj-F32.gguf").write_bytes(b"")
|
|
|
|
assert is_vision_model(str(model)) is True
|
|
mock_subprocess.assert_not_called()
|
|
|
|
@patch(
|
|
"utils.models.model_config._is_vision_model_subprocess",
|
|
side_effect = AssertionError("GGUF must not use Transformers vision detection"),
|
|
)
|
|
def test_direct_gguf_in_variant_subdir_finds_snapshot_mmproj(self, mock_subprocess, tmp_path):
|
|
variant_dir = tmp_path / "BF16"
|
|
variant_dir.mkdir()
|
|
model = variant_dir / "Qwen3.6-27B-UD-Q4_K_XL-MTP.gguf"
|
|
model.write_bytes(b"")
|
|
(tmp_path / "mmproj-F32.gguf").write_bytes(b"")
|
|
|
|
assert is_vision_model(str(model)) is True
|
|
mock_subprocess.assert_not_called()
|
|
|
|
@patch(
|
|
"utils.models.model_config._is_vision_model_subprocess",
|
|
side_effect = AssertionError("GGUF must not use Transformers vision detection"),
|
|
)
|
|
def test_qwen36_gguf_without_mmproj_skips_transformers(self, mock_subprocess, tmp_path):
|
|
model = tmp_path / "Qwen3.6-27B-UD-Q4_K_XL-MTP.gguf"
|
|
model.write_bytes(b"")
|
|
|
|
assert is_vision_model(str(model)) is False
|
|
mock_subprocess.assert_not_called()
|
|
|
|
def test_local_gguf_check_observes_mmproj_added_later(self, tmp_path):
|
|
model = tmp_path / "Qwen3.6-27B-UD-Q4_K_XL-MTP.gguf"
|
|
model.write_bytes(b"")
|
|
|
|
assert is_vision_model(str(model)) is False
|
|
(tmp_path / "mmproj-F32.gguf").write_bytes(b"")
|
|
assert is_vision_model(str(model)) is True
|
|
|
|
@patch(
|
|
"utils.models.model_config._is_vision_model_subprocess",
|
|
side_effect = AssertionError("GGUF must not use Transformers vision detection"),
|
|
)
|
|
def test_ui_selection_returns_local_gguf_config(self, mock_subprocess, tmp_path):
|
|
model = tmp_path / "Qwen3.6-27B-UD-Q4_K_XL-MTP.gguf"
|
|
model.write_bytes(b"")
|
|
mmproj = tmp_path / "mmproj-F32.gguf"
|
|
mmproj.write_bytes(b"")
|
|
|
|
config = ModelConfig.from_ui_selection(str(model), None)
|
|
|
|
assert config is not None
|
|
assert config.is_gguf is True
|
|
assert config.is_vision is True
|
|
assert config.gguf_mmproj_file == str(mmproj.resolve())
|
|
mock_subprocess.assert_not_called()
|
|
|
|
@patch(
|
|
"utils.models.model_config._is_vision_model_subprocess",
|
|
side_effect = AssertionError("GGUF must not use Transformers vision detection"),
|
|
)
|
|
def test_ui_selection_direct_gguf_in_variant_subdir_keeps_mmproj(
|
|
self, mock_subprocess, tmp_path
|
|
):
|
|
variant_dir = tmp_path / "BF16"
|
|
variant_dir.mkdir()
|
|
model = variant_dir / "Qwen3.6-27B-UD-Q4_K_XL-MTP.gguf"
|
|
model.write_bytes(b"")
|
|
mmproj = tmp_path / "mmproj-F32.gguf"
|
|
mmproj.write_bytes(b"")
|
|
|
|
config = ModelConfig.from_ui_selection(str(model), None)
|
|
|
|
assert config is not None
|
|
assert config.is_gguf is True
|
|
assert config.is_vision is True
|
|
assert config.gguf_mmproj_file == str(mmproj.resolve())
|
|
mock_subprocess.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw):
|
|
"""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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw):
|
|
"""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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw
|
|
):
|
|
"""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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw):
|
|
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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw
|
|
):
|
|
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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw
|
|
):
|
|
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.models.model_config._raw_config_has_vision_config", return_value = None)
|
|
@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, mock_raw):
|
|
"""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()
|
|
|
|
|
|
class TestVisionCacheLocalOnly:
|
|
"""local_files_only is in the cache key: an offline negative must not be reused by a
|
|
later online probe (else a VLM is routed through the text loader until restart)."""
|
|
|
|
def test_local_only_negative_does_not_poison_online(self, monkeypatch):
|
|
import utils.models.model_config as mc
|
|
|
|
mc._vision_detection_cache.clear()
|
|
monkeypatch.setattr(mc, "is_local_path", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(mc, "resolve_cached_repo_id_case", lambda n, *_a, **_k: n)
|
|
# Pin env-offline off so the key tracks the kwarg.
|
|
monkeypatch.setattr(mc, "_env_offline", lambda: False)
|
|
|
|
seen = []
|
|
|
|
def _probe(
|
|
name,
|
|
hf_token = None,
|
|
local_files_only = False,
|
|
):
|
|
seen.append(local_files_only)
|
|
# Offline can't fetch -> not a VLM; online reveals the VLM.
|
|
return False if local_files_only else True
|
|
|
|
monkeypatch.setattr(mc, "_is_vision_model_uncached", _probe)
|
|
|
|
# Offline probe caches False under a local-only key.
|
|
assert mc.is_vision_model("some/vlm", local_files_only = True) is False
|
|
# A later online probe must re-run (different key) and detect the VLM.
|
|
assert mc.is_vision_model("some/vlm", local_files_only = False) is True
|
|
assert seen == [True, False]
|
|
# The online positive is then cached for subsequent online callers.
|
|
assert mc.is_vision_model("some/vlm", local_files_only = False) is True
|
|
assert seen == [True, False]
|
|
mc._vision_detection_cache.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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):
|
|
# Derived from the transformers audio registry, so a superset of {csm, whisper}.
|
|
assert {"csm", "whisper"} <= _AUDIO_ONLY_MODEL_TYPES
|
|
|
|
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
|
|
|
|
|
|
class TestAudioDetectionCacheTokenAware:
|
|
"""The audio cache mirrors the vision cache: keyed by (model, token_fingerprint)
|
|
so an unauthenticated miss cannot poison a later authenticated lookup."""
|
|
|
|
def test_audio_cache_is_token_aware(self, monkeypatch):
|
|
import utils.models.model_config as mc
|
|
|
|
mc._audio_detection_cache.clear()
|
|
calls = []
|
|
|
|
def _fake(
|
|
name,
|
|
hf_token = None,
|
|
local_files_only = False,
|
|
):
|
|
calls.append(hf_token)
|
|
# Gated repo: only an authenticated probe can read the tokenizer.
|
|
return ("bicodec", True) if hf_token else (None, True)
|
|
|
|
monkeypatch.setattr(mc, "_detect_audio_from_tokenizer", _fake)
|
|
monkeypatch.setattr(mc, "is_local_path", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(mc, "resolve_cached_repo_id_case", lambda n, *_a, **_k: n)
|
|
|
|
# Unauthenticated miss caches None under (name, None)...
|
|
assert mc.detect_audio_type("private/spark") is None
|
|
# ...but the authenticated call uses a different key and is NOT poisoned.
|
|
assert mc.detect_audio_type("private/spark", hf_token = "hf_x") == "bicodec"
|
|
assert calls == [None, "hf_x"]
|
|
|
|
# Same (model, token) is served from cache (no third probe).
|
|
assert mc.detect_audio_type("private/spark", hf_token = "hf_x") == "bicodec"
|
|
assert calls == [None, "hf_x"]
|
|
mc._audio_detection_cache.clear()
|
|
|
|
def test_transient_none_is_not_cached_but_definitive_none_is(self, monkeypatch):
|
|
"""A transient probe failure (definitive=False) must retry; a clean
|
|
'not audio' read (definitive=True) caches so we don't re-probe."""
|
|
import utils.models.model_config as mc
|
|
|
|
mc._audio_detection_cache.clear()
|
|
monkeypatch.setattr(mc, "is_local_path", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(mc, "resolve_cached_repo_id_case", lambda n, *_a, **_k: n)
|
|
|
|
transient_calls = []
|
|
|
|
def _transient(
|
|
name,
|
|
hf_token = None,
|
|
local_files_only = False,
|
|
):
|
|
transient_calls.append(hf_token)
|
|
return (None, False) # network/5xx -- not cacheable
|
|
|
|
monkeypatch.setattr(mc, "_detect_audio_from_tokenizer", _transient)
|
|
assert mc.detect_audio_type("flaky/model") is None
|
|
assert mc.detect_audio_type("flaky/model") is None
|
|
# Re-probed both times: the transient None was never cached.
|
|
assert transient_calls == [None, None]
|
|
|
|
definitive_calls = []
|
|
|
|
def _definitive(
|
|
name,
|
|
hf_token = None,
|
|
local_files_only = False,
|
|
):
|
|
definitive_calls.append(hf_token)
|
|
return (None, True) # read the config, no audio tokens
|
|
|
|
monkeypatch.setattr(mc, "_detect_audio_from_tokenizer", _definitive)
|
|
assert mc.detect_audio_type("plain/text-model") is None
|
|
assert mc.detect_audio_type("plain/text-model") is None
|
|
# Probed once: the definitive None was cached.
|
|
assert definitive_calls == [None]
|
|
mc._audio_detection_cache.clear()
|
|
|
|
def test_local_only_negative_does_not_poison_online(self, monkeypatch):
|
|
"""An offline negative must not be reused by a later online probe (else an audio
|
|
model is routed through the text loader until restart)."""
|
|
import utils.models.model_config as mc
|
|
|
|
mc._audio_detection_cache.clear()
|
|
monkeypatch.setattr(mc, "is_local_path", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(mc, "resolve_cached_repo_id_case", lambda n, *_a, **_k: n)
|
|
# Pin env-offline off so the key tracks the kwarg.
|
|
monkeypatch.setattr(mc, "_env_offline", lambda: False)
|
|
|
|
seen = []
|
|
|
|
def _probe(
|
|
name,
|
|
hf_token = None,
|
|
local_files_only = False,
|
|
):
|
|
seen.append(local_files_only)
|
|
# Offline: nothing on disk -> not audio; online reveals the audio model.
|
|
return (None, True) if local_files_only else ("snac", True)
|
|
|
|
monkeypatch.setattr(mc, "_detect_audio_from_tokenizer", _probe)
|
|
|
|
# Offline probe caches None under a local-only key.
|
|
assert mc.detect_audio_type("some/audio-model", local_files_only = True) is None
|
|
# A later online probe must re-run (different key) and detect the audio model.
|
|
assert mc.detect_audio_type("some/audio-model", local_files_only = False) == "snac"
|
|
assert seen == [True, False]
|
|
# The online positive is then cached for subsequent online callers.
|
|
assert mc.detect_audio_type("some/audio-model", local_files_only = False) == "snac"
|
|
assert seen == [True, False]
|
|
mc._audio_detection_cache.clear()
|
|
|
|
def test_env_offline_negative_does_not_poison_online(self, monkeypatch):
|
|
"""An env-offline probe (default local_files_only=False) must cache under the
|
|
effective-offline key, so clearing the env var later doesn't leak a stale negative."""
|
|
import utils.models.model_config as mc
|
|
|
|
mc._audio_detection_cache.clear()
|
|
monkeypatch.setattr(mc, "is_local_path", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(mc, "resolve_cached_repo_id_case", lambda n, *_a, **_k: n)
|
|
|
|
env_offline = {"v": True}
|
|
monkeypatch.setattr(mc, "_env_offline", lambda: env_offline["v"])
|
|
|
|
seen = []
|
|
|
|
def _probe(
|
|
name,
|
|
hf_token = None,
|
|
local_files_only = False,
|
|
):
|
|
seen.append(local_files_only)
|
|
return (None, True) if local_files_only else ("snac", True)
|
|
|
|
monkeypatch.setattr(mc, "_detect_audio_from_tokenizer", _probe)
|
|
|
|
# Env offline + default kwarg -> probe runs offline; None cached under the offline key.
|
|
assert mc.detect_audio_type("some/audio-model") is None
|
|
assert seen == [True]
|
|
# Env var cleared: a fresh online probe must re-run (different key) and detect.
|
|
env_offline["v"] = False
|
|
assert mc.detect_audio_type("some/audio-model") == "snac"
|
|
assert seen == [True, False]
|
|
mc._audio_detection_cache.clear()
|
|
|
|
|
|
class TestEnvOfflineParsing:
|
|
"""_env_offline accepts the canonical truthy set (strip+lower, on/true/yes/1); it gates
|
|
the requests.get fallback and the cache keys, so 'on' or ' 1 ' must still count as offline."""
|
|
|
|
def test_truthy_values_recognized(self, monkeypatch):
|
|
import utils.models.model_config as mc
|
|
for var in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE"):
|
|
for val in ("1", "true", "TRUE", "yes", "Yes", "on", "ON", " 1 ", " on ", "\ttrue\n"):
|
|
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
|
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
|
|
monkeypatch.setenv(var, val)
|
|
assert mc._env_offline() is True, f"{var}={val!r} should be offline"
|
|
|
|
def test_falsy_values_not_offline(self, monkeypatch):
|
|
import utils.models.model_config as mc
|
|
|
|
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
|
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
|
|
assert mc._env_offline() is False
|
|
for val in ("", "0", "false", "no", "off", "2", "onn"):
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", val)
|
|
assert mc._env_offline() is False, f"HF_HUB_OFFLINE={val!r} should not be offline"
|