feat(docker): support a multimodal projector via --mmproj

Vision-capable GGUFs need a separate projector (mmproj) that maps image
embeddings into the text model's space. Add `mmproj` and `mmproj_offload`
as config keys and CLI overrides, and pass them through to llama-server.

The projector path resolves under hf_home exactly like model_path, so it
is covered by the existing read-only mount. validate_model_path() now also
checks the projector, which means --change rejects a missing one *before*
it removes the running container.

--no-mmproj-offload is suppressed when no projector is configured, since
llama.cpp rejects the flag on its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-07-10 16:24:47 +02:00
commit 218c3fc791
9 changed files with 199 additions and 9 deletions

View file

@ -213,3 +213,34 @@ def test_container_lock_force_bypasses_busy(tmp_path, capsys):
entered = True
assert entered is True
assert "busy lock" in capsys.readouterr().err
# --- model / mmproj path validation ---------------------------------------
def test_validate_model_path_accepts_existing_model(tmp_path):
(tmp_path / "m.gguf").write_bytes(b"x")
cfg = make_cfg(hf_home=tmp_path, model_path="m.gguf")
actions.validate_model_path(cfg) # must not raise
def test_validate_model_path_rejects_missing_model(tmp_path):
cfg = make_cfg(hf_home=tmp_path, model_path="absent.gguf")
with pytest.raises(FileNotFoundError, match="model file not found"):
actions.validate_model_path(cfg)
def test_validate_model_path_rejects_missing_mmproj(tmp_path):
# The model exists but the configured projector does not: --change must fail
# here, before the running container is removed.
(tmp_path / "m.gguf").write_bytes(b"x")
cfg = make_cfg(hf_home=tmp_path, model_path="m.gguf", mmproj="absent-proj.gguf")
with pytest.raises(FileNotFoundError, match="mmproj file not found"):
actions.validate_model_path(cfg)
def test_validate_model_path_accepts_existing_mmproj(tmp_path):
(tmp_path / "m.gguf").write_bytes(b"x")
(tmp_path / "proj.gguf").write_bytes(b"x")
cfg = make_cfg(hf_home=tmp_path, model_path="m.gguf", mmproj="proj.gguf")
actions.validate_model_path(cfg) # must not raise

View file

@ -55,6 +55,10 @@ model_alias = alt_llm
[model.noname]
host_port = 9002
[model.vision]
container_name = test_vision
mmproj = qwen3/mmproj.gguf
[prompt.concise]
system_prompt = Be brief.
"""
@ -280,6 +284,57 @@ def test_stream_and_read_timeout_from_config(tmp_path):
assert prompt_cfg.connect_timeout == 5.0
def test_mmproj_empty_by_default(tmp_path):
cfg_path = _write_config(tmp_path)
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
assert server_cfg.mmproj == ""
assert server_cfg.mmproj_offload is True # llama.cpp offloads by default
def test_mmproj_from_profile(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path), "--profile", "vision"])
server_cfg, _ = resolve_effective_config(args)
assert server_cfg.mmproj == "qwen3/mmproj.gguf"
assert server_cfg.container_name == "test_vision"
def test_mmproj_cli_overrides_config(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(
[
"--start",
"--config",
str(cfg_path),
"--profile",
"vision",
"--mmproj",
"other/proj.gguf",
"--no-mmproj-offload",
]
)
server_cfg, _ = resolve_effective_config(args)
assert server_cfg.mmproj == "other/proj.gguf"
assert server_cfg.mmproj_offload is False
def test_mmproj_offload_false_from_config(tmp_path):
cfg = CONFIG_BASIC.replace(
"[model.vision]\ncontainer_name = test_vision",
"[model.vision]\ncontainer_name = test_vision\nmmproj_offload = false",
)
cfg_path = _write_config(tmp_path, cfg)
args = _parse(["--start", "--config", str(cfg_path), "--profile", "vision"])
server_cfg, _ = resolve_effective_config(args)
assert server_cfg.mmproj_offload is False
def test_blank_mmproj_via_cli_rejected(tmp_path):
cfg_path = _write_config(tmp_path)
with pytest.raises(SystemExit):
_parse(["--start", "--config", str(cfg_path), "--mmproj", " "])
def test_stream_and_read_timeout_cli_overrides_config(tmp_path):
cfg = CONFIG_BASIC.replace("poll_interval = 2", "poll_interval = 2\nread_timeout = 900")
cfg_path = _write_config(tmp_path, cfg)

View file

@ -178,6 +178,41 @@ def test_build_run_command_relative_model_path():
assert cmd[idx + 1] == "/hf_home/qwen3/default.gguf"
def test_build_run_command_no_mmproj_by_default():
cmd = build_run_command(make_cfg())
assert "--mmproj" not in cmd
assert "--no-mmproj-offload" not in cmd
def test_build_run_command_mmproj_relative_path_resolves_under_hf_home():
cfg = make_cfg(mmproj="qwen3/mmproj.gguf")
cmd = build_run_command(cfg)
idx = cmd.index("--mmproj")
assert cmd[idx + 1] == "/hf_home/qwen3/mmproj.gguf"
# offload is llama.cpp's default, so the opt-out flag must stay absent
assert "--no-mmproj-offload" not in cmd
def test_build_run_command_mmproj_absolute_path_passed_through():
cfg = make_cfg(mmproj="/abs/mmproj.gguf")
cmd = build_run_command(cfg)
idx = cmd.index("--mmproj")
assert cmd[idx + 1] == "/abs/mmproj.gguf"
def test_build_run_command_mmproj_offload_disabled():
cfg = make_cfg(mmproj="qwen3/mmproj.gguf", mmproj_offload=False)
cmd = build_run_command(cfg)
assert "--no-mmproj-offload" in cmd
def test_build_run_command_no_mmproj_offload_needs_mmproj():
# Without a projector the offload opt-out is meaningless and must not leak
# into the command line (llama.cpp would reject it).
cfg = make_cfg(mmproj="", mmproj_offload=False)
assert "--no-mmproj-offload" not in build_run_command(cfg)
def test_format_command_for_display_quotes_properly():
cmd = ["docker", "run", "--name", "has space"]
out = format_command_for_display(cmd)