* Studio: fall back to text-only when llama.cpp is too old for a model's vision projector Loading a GGUF vision model starts llama-server with --mmproj <projector>. When the installed llama.cpp prebuilt predates the model's projector format, llama-server aborts at startup with 'clip.cpp: Unknown projector type' (exit -6), and the whole load failed even though the base GGUF is a fine text/tools chat model. Seen with gemma-4 on a 3-day-old prebuilt (build b9496). load_model now retries the launch once without --mmproj when the captured startup output indicates a projector-format incompatibility. The retry runs the model text-only, marks the session non-vision (is_vision False, mmproj audio dropped) so the status/capabilities the frontend reads stay consistent, and warns the user to update llama.cpp. Detection is generic, not model-specific, and conservative: OOM, bad GGUF, port-bind, missing-file and other failures keep their existing handling and never retry. If the text-only retry also fails, it errors out with the real reason. Adds _is_projector_incompatibility and _strip_mmproj_args (unit-tested with the real gemma-4 abort plus negatives) and extracts _start_llama_process so both the initial start and the retry share one spawn path and each logs its argv. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reformat mmproj fallback files to match main (ruff line-length 100 + kwarg spacing) --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
187 lines
6.5 KiB
Python
187 lines
6.5 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 the llama-server mmproj text-only fallback.
|
|
|
|
A GGUF vision model is launched with ``--mmproj <projector>``. When the
|
|
installed llama.cpp prebuilt is older than the model's projector format,
|
|
llama-server aborts at startup with ``clip.cpp:NNNN: Unknown projector
|
|
type`` (exit -6). load_model now retries once WITHOUT ``--mmproj`` so the
|
|
base model still loads text-only, warns the user to update llama.cpp, and
|
|
marks the session non-vision. These tests pin the two decision helpers:
|
|
``_is_projector_incompatibility`` (when to retry) and ``_strip_mmproj_args``
|
|
(how the retry argv is built). Unrelated failures must NOT trigger a retry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Match the stubbing pattern in sibling tests so the module imports in a
|
|
# lightweight env without fastapi.
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
_structlog_stub = _types.ModuleType("structlog")
|
|
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("structlog")
|
|
sys.modules.setdefault("structlog", _structlog_stub)
|
|
if not hasattr(sys.modules["structlog"], "get_logger"):
|
|
sys.modules["structlog"].get_logger = _structlog_stub.get_logger
|
|
|
|
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
|
|
|
|
_detect = LlamaCppBackend._is_projector_incompatibility
|
|
_strip = LlamaCppBackend._strip_mmproj_args
|
|
|
|
# Real abort captured loading gemma-4 on a 3-day-old prebuilt (build b9496).
|
|
_GEMMA4_OLD_LLAMACPP_OUT = (
|
|
"srv load_model: loading model 'gemma-4-E2B-it-UD-Q4_K_XL.gguf'\n"
|
|
"/build_work/src/llama.cpp-b9496/tools/mtmd/clip.cpp:4391: "
|
|
"Unknown projector type\n"
|
|
"libggml-base.so.0(ggml_abort+0x152)\n"
|
|
"libmtmd.so.0(clip_n_mmproj_embd)\n"
|
|
)
|
|
# Unrelated failures that must keep their own handling (no projector retry).
|
|
_OOM_OUT = (
|
|
"ggml_backend_cuda_buffer_type_alloc_buffer: allocating 12000.00 MiB on "
|
|
"device 0: cudaMalloc failed: out of memory"
|
|
)
|
|
_BAD_ARCH_OUT = "llama_model_load: error loading model: unknown model architecture: 'qwen_image'"
|
|
_PORT_OUT = "srv start: failed to bind: address already in use"
|
|
_MISSING_OUT = "error: failed to open GGUF file: no such file or directory"
|
|
# A healthy startup log that merely mentions the projector must not match.
|
|
_HEALTHY_VISION_OUT = (
|
|
"Using mmproj for vision: /cache/mmproj-F16.gguf\n"
|
|
"clip_model_loader: loaded meta data with 20 key-value pairs\n"
|
|
"srv update_slots: all slots are idle"
|
|
)
|
|
|
|
|
|
class TestProjectorIncompatibilityDetector:
|
|
def test_gemma4_on_old_llamacpp_triggers_retry(self):
|
|
# Headline case: a 3-day-old llama.cpp aborts on Gemma-4's projector.
|
|
assert _detect(_GEMMA4_OLD_LLAMACPP_OUT) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"out",
|
|
[
|
|
"clip.cpp:4391: Unknown projector type",
|
|
"error: unsupported projector type for this model",
|
|
"llama_mmproj: unsupported mmproj file version",
|
|
"clip.cpp: projector type 'gemma4' is not supported",
|
|
],
|
|
)
|
|
def test_projector_format_errors_match(self, out):
|
|
assert _detect(out) is True
|
|
|
|
def test_case_insensitive(self):
|
|
assert _detect("UNKNOWN PROJECTOR TYPE") is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"out",
|
|
[
|
|
_OOM_OUT,
|
|
_BAD_ARCH_OUT,
|
|
_PORT_OUT,
|
|
_MISSING_OUT,
|
|
_HEALTHY_VISION_OUT,
|
|
"",
|
|
# bare multimodal words without a failure term must not match
|
|
"loading clip model",
|
|
"mmproj file resolved from cache",
|
|
],
|
|
)
|
|
def test_unrelated_failures_do_not_retry(self, out):
|
|
assert _detect(out) is False
|
|
|
|
|
|
# A realistic vision launch argv (mirrors the live "Starting llama-server"
|
|
# command), projector pair at the end.
|
|
_VISION_CMD = [
|
|
"/home/u/.unsloth/llama.cpp/build/bin/llama-server",
|
|
"-m",
|
|
"/cache/gemma-4-E2B-it-UD-Q4_K_XL.gguf",
|
|
"--port",
|
|
"55473",
|
|
"-c",
|
|
"131072",
|
|
"--parallel",
|
|
"1",
|
|
"--flash-attn",
|
|
"on",
|
|
"--no-context-shift",
|
|
"-ngl",
|
|
"-1",
|
|
"--threads",
|
|
"-1",
|
|
"--jinja",
|
|
"--spec-default",
|
|
"--mmproj",
|
|
"/cache/mmproj-F16.gguf",
|
|
]
|
|
|
|
|
|
class TestStripMmprojArgs:
|
|
def test_removes_mmproj_pair(self):
|
|
stripped = _strip(_VISION_CMD)
|
|
assert "--mmproj" not in stripped
|
|
assert "/cache/mmproj-F16.gguf" not in stripped
|
|
|
|
def test_preserves_every_text_flag(self):
|
|
stripped = _strip(_VISION_CMD)
|
|
for flag in (
|
|
"-m",
|
|
"/cache/gemma-4-E2B-it-UD-Q4_K_XL.gguf",
|
|
"--port",
|
|
"55473",
|
|
"-c",
|
|
"131072",
|
|
"-ngl",
|
|
"-1",
|
|
"--jinja",
|
|
"--spec-default",
|
|
"--flash-attn",
|
|
"on",
|
|
):
|
|
assert flag in stripped
|
|
# Exactly the two projector tokens are dropped.
|
|
assert len(stripped) == len(_VISION_CMD) - 2
|
|
|
|
def test_strips_mmproj_in_the_middle(self):
|
|
cmd = ["llama-server", "--mmproj", "/p/mm.gguf", "-c", "4096", "--jinja"]
|
|
assert _strip(cmd) == ["llama-server", "-c", "4096", "--jinja"]
|
|
|
|
def test_noop_when_no_mmproj(self):
|
|
cmd = ["llama-server", "-m", "/p/model.gguf", "-c", "4096", "--jinja"]
|
|
assert _strip(cmd) == cmd
|
|
|
|
def test_returns_new_list(self):
|
|
cmd = ["llama-server", "--mmproj", "/p/mm.gguf"]
|
|
out = _strip(cmd)
|
|
assert out is not cmd
|
|
assert cmd[-1] == "/p/mm.gguf" # input untouched
|
|
|
|
|
|
class TestRetryContract:
|
|
"""The two helpers compose into the load_model retry decision."""
|
|
|
|
def test_gemma4_failure_yields_valid_text_only_command(self):
|
|
# Old-llama.cpp projector abort -> retry, and the retry argv is a
|
|
# valid text-only launch (model + context kept, projector gone).
|
|
assert _detect(_GEMMA4_OLD_LLAMACPP_OUT) is True
|
|
retry_cmd = _strip(_VISION_CMD)
|
|
assert "--mmproj" not in retry_cmd
|
|
assert "-m" in retry_cmd and "--jinja" in retry_cmd
|
|
|
|
def test_oom_does_not_retry_text_only(self):
|
|
# An OOM with --mmproj present must NOT be treated as a projector
|
|
# problem: load_model errors out instead of dropping vision.
|
|
assert _detect(_OOM_OUT) is False
|