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
322 lines
12 KiB
Python
322 lines
12 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 :func:`utils.models.model_config.detect_mmproj_file` (#5347)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import struct
|
|
|
|
from utils.models.model_config import (
|
|
_detect_family_token,
|
|
detect_mmproj_file,
|
|
mmproj_matches_model_family,
|
|
)
|
|
|
|
|
|
_GGUF_MAGIC = 0x46554747
|
|
|
|
|
|
def _gguf_with_general(path: Path, fields: dict) -> Path:
|
|
"""Write a minimal GGUF with only ``general.*`` string KVs."""
|
|
body = b""
|
|
for k, v in fields.items():
|
|
kb = k.encode("utf-8")
|
|
vb = v.encode("utf-8")
|
|
body += struct.pack("<Q", len(kb)) + kb
|
|
body += struct.pack("<I", 8) # STRING vtype
|
|
body += struct.pack("<Q", len(vb)) + vb
|
|
header = struct.pack("<IIQQ", _GGUF_MAGIC, 3, 0, len(fields))
|
|
path.parent.mkdir(parents = True, exist_ok = True)
|
|
path.write_bytes(header + body)
|
|
return path
|
|
|
|
|
|
def _touch(path: Path) -> Path:
|
|
path.parent.mkdir(parents = True, exist_ok = True)
|
|
path.write_bytes(b"")
|
|
return path
|
|
|
|
|
|
def test_returns_none_when_no_mmproj(tmp_path: Path):
|
|
model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
|
|
assert detect_mmproj_file(str(model)) is None
|
|
|
|
|
|
def test_single_matching_family_mmproj_picked(tmp_path: Path):
|
|
"""Single same-family projector is returned (historical behaviour)."""
|
|
model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
|
|
mmproj = _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
|
|
assert detect_mmproj_file(str(model)) == str(mmproj.resolve())
|
|
|
|
|
|
def test_hf_style_unprefixed_mmproj_still_works(tmp_path: Path):
|
|
"""HF convention: weight + ``mmproj-F16.gguf`` sibling."""
|
|
model = _touch(tmp_path / "model.gguf")
|
|
mmproj = _touch(tmp_path / "mmproj-F16.gguf")
|
|
assert detect_mmproj_file(str(model)) == str(mmproj.resolve())
|
|
|
|
|
|
def test_blocks_single_cross_family_projector(tmp_path: Path):
|
|
"""#5347 core: Qwen weight + lone Gemma mmproj returns None."""
|
|
model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
|
|
_touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
|
|
assert detect_mmproj_file(str(model)) is None
|
|
|
|
|
|
def test_picks_matching_family_among_mixed_candidates(tmp_path: Path):
|
|
"""Mixed Qwen + Gemma projectors: pick Qwen, drop Gemma."""
|
|
model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
|
|
qwen_mm = _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
|
|
_touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
|
|
assert detect_mmproj_file(str(model)) == str(qwen_mm.resolve())
|
|
|
|
|
|
def test_prefers_longest_prefix_within_same_family(tmp_path: Path):
|
|
"""Same family, different sizes: longest shared stem prefix wins."""
|
|
model = _touch(tmp_path / "Qwen3.5-35B-A3B-UD-Q4_K_L.gguf")
|
|
_touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
|
|
big_mm = _touch(tmp_path / "Qwen3.5-35B-A3B-BF16-mmproj.gguf")
|
|
assert detect_mmproj_file(str(model)) == str(big_mm.resolve())
|
|
|
|
|
|
def test_unrecognised_family_does_not_break_detection(tmp_path: Path):
|
|
"""Unknown model family must not return None on a sole candidate."""
|
|
model = _touch(tmp_path / "MyCustomBrand-7B-Q4_K_M.gguf")
|
|
mmproj = _touch(tmp_path / "MyCustomBrand-7B-BF16-mmproj.gguf")
|
|
assert detect_mmproj_file(str(model)) == str(mmproj.resolve())
|
|
|
|
|
|
def test_directory_path_returns_first_candidate(tmp_path: Path):
|
|
"""Directory path: no model stem to compare; legacy first-candidate."""
|
|
_touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
|
|
_touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
|
|
result = detect_mmproj_file(str(tmp_path))
|
|
assert result is not None
|
|
assert "mmproj" in Path(result).name.lower()
|
|
|
|
|
|
def test_search_root_walk_still_works(tmp_path: Path):
|
|
"""Snapshot layout: weight in quant subdir, mmproj at snapshot root."""
|
|
snapshot = tmp_path / "snapshot"
|
|
weight = _touch(snapshot / "BF16" / "Qwen3.5-9B-BF16.gguf")
|
|
mmproj = _touch(snapshot / "Qwen3.5-9B-BF16-mmproj.gguf")
|
|
result = detect_mmproj_file(str(weight), search_root = str(snapshot))
|
|
assert result == str(mmproj.resolve())
|
|
|
|
|
|
# -- Family token detection: word-bounded matching ----------------------
|
|
|
|
|
|
def test_family_token_phi_does_not_match_sapphire():
|
|
"""``phi`` substring inside ``sapphire`` must not tag Phi."""
|
|
assert _detect_family_token("sapphire-7b-q4_k_m.gguf") is None
|
|
|
|
|
|
def test_family_token_yi_does_not_match_tinyish_names():
|
|
"""``yi`` must not cross letter boundaries (``yip``)."""
|
|
assert _detect_family_token("yip-7b.gguf") is None
|
|
assert _detect_family_token("yi-vl-6b.gguf") == "yi"
|
|
|
|
|
|
def test_family_token_mimo_does_not_match_mimosa():
|
|
"""``mimo`` must not tag ``mimosa``."""
|
|
assert _detect_family_token("mimosa-rosa-7b.gguf") is None
|
|
assert _detect_family_token("MiMo-VL-7B-RL-BF16.gguf") == "mimo"
|
|
|
|
|
|
def test_family_token_mistral_does_not_match_ministral():
|
|
"""Pin Mistral-derivative tagging."""
|
|
assert _detect_family_token("Ministral-3-8B-Instruct-2512-BF16.gguf") == "ministral"
|
|
assert _detect_family_token("Mistral-7B-Instruct-v0.3.gguf") == "mistral"
|
|
assert _detect_family_token("Magistral-Small-2506-BF16.gguf") == "magistral"
|
|
assert _detect_family_token("Devstral-Small-2-24B-Instruct-2512-BF16.gguf") == "devstral"
|
|
|
|
|
|
def test_family_token_picks_leftmost_when_multiple_present():
|
|
"""Leftmost family token wins, not tuple order."""
|
|
assert _detect_family_token("llama-phi-merge.gguf") == "llama"
|
|
assert _detect_family_token("phi-llama-merge.gguf") == "phi"
|
|
assert _detect_family_token("llama3-3b-instruct.gguf") == "llama"
|
|
|
|
|
|
def test_family_token_new_families_recognised():
|
|
"""Catalogue-audit additions tag correctly."""
|
|
assert _detect_family_token("NVIDIA-Nemotron-3-Nano-Omni-30B.gguf") == "nemotron"
|
|
assert _detect_family_token("Kimi-K2.6-BF16.gguf") == "kimi"
|
|
assert _detect_family_token("Nanonets-OCR-s-BF16.gguf") == "nanonets"
|
|
assert _detect_family_token("Cosmos-Reason1-7B-BF16.gguf") == "cosmos"
|
|
assert _detect_family_token("Apriel-1.5-15b-Thinker-BF16.gguf") == "apriel"
|
|
assert _detect_family_token("LFM2.5-VL-1.6B-BF16.gguf") == "lfm"
|
|
|
|
|
|
# -- Cross-family rejection with the expanded token list ----------------
|
|
|
|
|
|
def test_blocks_cross_family_for_new_token_pair(tmp_path: Path):
|
|
"""Nemotron weight + lone Gemma projector returns None."""
|
|
model = _touch(tmp_path / "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-MXFP4_MOE.gguf")
|
|
_touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
|
|
assert detect_mmproj_file(str(model)) is None
|
|
|
|
|
|
def test_picks_devstral_mmproj_in_mixed_dir(tmp_path: Path):
|
|
"""Devstral weight + Devstral mmproj + a Qwen mmproj: pick Devstral."""
|
|
model = _touch(tmp_path / "Devstral-Small-2-24B-Instruct-2512-BF16.gguf")
|
|
dev_mm = _touch(tmp_path / "Devstral-Small-2-mmproj-bf16.gguf")
|
|
_touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
|
|
assert detect_mmproj_file(str(model)) == str(dev_mm.resolve())
|
|
|
|
|
|
# -- Launcher-level family guard ----------------------------------------
|
|
|
|
|
|
def test_mmproj_family_guard_blocks_cross_family():
|
|
assert (
|
|
mmproj_matches_model_family(
|
|
"/models/Qwen3.5-9B-Q4_K_M.gguf",
|
|
"/models/gemma-4-26B-A4B-it.mmproj-q8_0.gguf",
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_mmproj_family_guard_allows_same_family():
|
|
assert (
|
|
mmproj_matches_model_family(
|
|
"/models/Qwen3.5-9B-Q4_K_M.gguf",
|
|
"/models/Qwen3.5-9B-BF16-mmproj.gguf",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_mmproj_family_guard_allows_generic_hf_mmproj():
|
|
"""No family token on the projector: wildcard."""
|
|
assert (
|
|
mmproj_matches_model_family(
|
|
"/models/Qwen3.5-9B-Q4_K_M.gguf",
|
|
"/models/mmproj-F16.gguf",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_mmproj_family_guard_allows_unrecognised_model_family():
|
|
"""No family token on the model: wildcard."""
|
|
assert (
|
|
mmproj_matches_model_family(
|
|
"/models/Apriel-1.5-15b-Thinker-BF16.gguf",
|
|
"/models/mmproj-F16.gguf",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# -- Metadata-primary pairing in detect_mmproj_file ---------------------
|
|
|
|
|
|
def test_metadata_url_match_picked_over_filename_lookalike(tmp_path: Path):
|
|
"""URL match beats a longer-prefix sibling."""
|
|
weight = _gguf_with_general(
|
|
tmp_path / "Qwen3.5-9B-Q4_K_M.gguf",
|
|
{
|
|
"general.architecture": "qwen2vl",
|
|
"general.type": "model",
|
|
"general.basename": "Qwen3.5",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
# Closer filename prefix, wrong upstream.
|
|
_gguf_with_general(
|
|
tmp_path / "Qwen3.5-9B-mmproj-bf16.gguf",
|
|
{
|
|
"general.architecture": "clip",
|
|
"general.type": "mmproj",
|
|
"general.basename": "Qwen3.5",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-1.5B",
|
|
},
|
|
)
|
|
# Matching upstream.
|
|
correct = _gguf_with_general(
|
|
tmp_path / "mmproj-BF16.gguf",
|
|
{
|
|
"general.architecture": "clip",
|
|
"general.type": "mmproj",
|
|
"general.basename": "Qwen3.5",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
assert detect_mmproj_file(str(weight)) == str(correct.resolve())
|
|
|
|
|
|
def test_metadata_url_mismatch_dropped(tmp_path: Path):
|
|
"""Filenames match family but metadata disagrees: returns None."""
|
|
weight = _gguf_with_general(
|
|
tmp_path / "qwen-9b.gguf",
|
|
{
|
|
"general.architecture": "qwen2vl",
|
|
"general.type": "model",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
_gguf_with_general(
|
|
tmp_path / "qwen-9b-mmproj.gguf",
|
|
{
|
|
"general.architecture": "clip",
|
|
"general.type": "mmproj",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/google/gemma-3-9B",
|
|
},
|
|
)
|
|
assert detect_mmproj_file(str(weight)) is None
|
|
|
|
|
|
def test_metadata_identifies_mmproj_without_filename_hint(tmp_path: Path):
|
|
"""Projector named ``vision-projector.gguf`` discovered via header."""
|
|
weight = _gguf_with_general(
|
|
tmp_path / "Qwen3.5-9B.gguf",
|
|
{
|
|
"general.architecture": "qwen2vl",
|
|
"general.type": "model",
|
|
"general.basename": "Qwen3.5",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
projector = _gguf_with_general(
|
|
tmp_path / "vision-projector.gguf",
|
|
{
|
|
"general.architecture": "clip",
|
|
"general.type": "mmproj",
|
|
"general.basename": "Qwen3.5",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
assert detect_mmproj_file(str(weight)) == str(projector.resolve())
|
|
|
|
|
|
def test_metadata_score_outranks_filename_prefix(tmp_path: Path):
|
|
"""Score 100 (URL match) beats score 0 (long filename prefix)."""
|
|
weight = _gguf_with_general(
|
|
tmp_path / "Qwen3.5-9B-Q4_K_M.gguf",
|
|
{
|
|
"general.architecture": "qwen2vl",
|
|
"general.type": "model",
|
|
"general.basename": "Qwen3.5",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
# Headerless: long shared stem, score 0.
|
|
_touch(tmp_path / "Qwen3.5-9B-Q4_K_M-mmproj.gguf")
|
|
# Headered: generic name, score 100.
|
|
correct = _gguf_with_general(
|
|
tmp_path / "mmproj-BF16.gguf",
|
|
{
|
|
"general.architecture": "clip",
|
|
"general.type": "mmproj",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
},
|
|
)
|
|
assert detect_mmproj_file(str(weight)) == str(correct.resolve())
|