unsloth/studio/backend/tests/test_detect_mmproj_file.py
Tai An 63c6750532
fix(studio/mmproj): block cross-family projectors in flat local GGUF dirs (#5347) (#5350)
* fix(studio/mmproj): block cross-family projectors in flat local GGUF dirs (#5347)

When a flat local GGUF directory holds several unrelated models with their
own mmproj siblings, detect_mmproj_file() returned the first projector it
walked into. For the layout reported in #5347 (Qwen weights + a Gemma
mmproj in the same dir) that meant llama-server was launched with
--mmproj pointing at the Gemma projector, which fails to load and surfaces
as a confusing crash.

Disambiguation rules:
- Drop candidates whose family token (qwen/gemma/llama/mistral/phi/...)
  disagrees with the model's family. Candidates with no recognised
  family token (e.g. the HF-convention 'mmproj-F16.gguf') are kept.
- Among same-family candidates, prefer the one whose stem shares the
  longest prefix with the model (Qwen3.5-9B mmproj beats Qwen3.5-35B
  mmproj for a Qwen3.5-9B model).
- If every candidate is dropped, return None — better than attaching
  a wrong projector and getting a server-launch failure.

Tests cover the cross-family block, multi-candidate prefix tie-break,
HF-convention 'mmproj-F16.gguf', unrecognised families, and the
existing search_root walk.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio/mmproj: word-bounded family match, expanded token list, launcher guard

Tighten the family-token detector to match only on word boundaries so
substring collisions stop tagging false families: phi no longer matches
sapphire, yi no longer matches yip, mimo no longer matches mimosa, and
mistral does not bleed into ministral/magistral/devstral. Pick the token
whose first occurrence is leftmost in the filename rather than the first
hit in tuple order, so merge models disambiguate predictably (llama-phi
tags llama; phi-llama tags phi).

Expand _MODEL_FAMILY_TOKENS with the families an audit of the unsloth
HF org turned up that the previous list missed: devstral, ministral,
magistral (Mistral-derivative naming), nemotron, kimi, nanonets, cosmos,
mimo, apriel, lfm. Without these, a flat local GGUF directory containing
one of these weights plus an unrelated renamed projector still hit the
original #5347 failure.

Add mmproj_matches_model_family() and call it at the llama-server launch
site in core/inference/llama_cpp.py. detect_mmproj_file already drops
cross-family candidates at discovery time, but mmproj_path can also reach
the launcher via config injection or future overrides; this guard keeps
those paths from silently loading a known-wrong projector.

Tests: 12 new cases covering substring rejection, leftmost-position
selection, new family tokens, a new flat-dir Nemotron + Gemma rejection
case, and the launcher-level guard. All 21 detect_mmproj_file tests and
the existing 106 llama_cpp tests pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio/mmproj: pair via GGUF general.* metadata, not just filenames

Real Unsloth vision GGUFs carry rich identity metadata that has been
ignored by the discovery path. Every projector under the unsloth org
has general.type='mmproj' plus general.base_model.0.repo_url pointing
at the same upstream HF repo as its weight, and the equivalent
basename, base_model.0.name, and base_model.0.organization fields. A
flat-dir mismatch is therefore decidable from the headers alone, no
matter how the user has renamed the files.

Add utils/models/gguf_metadata.py with read_gguf_general_metadata():
a fast (~30 ms) header walk that pulls only the general.* string
fields and skips everything else, cached by (resolved path, mtime_ns,
size). Mirrors the parser shape already used by
LlamaCppBackend._read_gguf_metadata so the format handling is
consistent.

is_mmproj_by_metadata() returns True/False/None from general.type,
and pairing_score() returns 100 for an exact base_model URL match,
80 for basename plus organization match, 60 for basename only, -1
for definitive metadata disagreement, and 0 when neither side has
enough metadata to decide.

Rewire detect_mmproj_file() to a two-stage selector:
  1. Detect projectors via metadata (general.type) when present, else
     fall back to the filename substring heuristic. This recovers
     headerless projectors AND projectors whose name does not contain
     'mmproj' but whose header advertises one.
  2. Score each candidate against the weight via pairing_score. Drop
     candidates with score -1 (definitive metadata disagreement). For
     candidates with score 0 (no usable metadata) fall back to the
     existing filename family-token check, dropping recognised-family
     mismatches. Pick the survivor with the highest (score,
     longest_prefix, -len(stem)) tuple, so a metadata URL match
     always wins over a filename-prefix match.

Tests: 16 new cases. tests/test_gguf_metadata.py covers the parser
(missing file, non-GGUF, string extraction, walking past arrays and
uint32s, cache invalidation by mtime/size) and the score helpers.
tests/test_detect_mmproj_file.py adds end-to-end cases that synthesise
real on-disk GGUF headers: URL match wins over a longer-prefix
sibling, URL mismatch returns None even when filenames match, a
projector named 'vision-projector.gguf' is still discovered via
general.type, and a 100-score header match outranks a near-perfect
filename prefix on a headerless candidate.

All 75 tests across detect_mmproj_file, gguf_metadata, llama_cpp
load progress, cached gguf routes, trained model scan, and vision
cache pass.

* studio/mmproj: shorten comments and docstrings across the #5347 changes

Trim verbose explanations to one-line statements of intent. The
behaviour is unchanged: 161 tests across detect_mmproj_file,
gguf_metadata, llama_cpp_load_progress (+ matrix), llama_server_args,
llama_cpp_cache_aware_disk_check, trained_model_scan, and vision_cache
all pass.

* studio/mmproj: shorten remaining detect_mmproj_file body comments

Trim the docstring and the dir-walking block comments inside
detect_mmproj_file to one-liners. Behaviour unchanged; 44 mmproj +
gguf_metadata + llama_cpp_load_progress tests pass.

* studio/mmproj: cap gguf_metadata cache below ceiling on every insert

The eviction branch popped exactly one entry when len >= max, so the
cache size could only converge to the cap when entries were added
slowly enough for natural growth. After a sandbox sim that reduced
the cap mid-run, len stayed above the cap because each insert popped
one and added one. Switch to a while loop so we evict until len is
strictly below the cap before inserting. Steady-state behaviour at
the default 4096 ceiling is unchanged.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-14 20:31:20 -07:00

326 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: 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())