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:
parent
489af21fd2
commit
218c3fc791
9 changed files with 199 additions and 9 deletions
|
|
@ -88,6 +88,24 @@ host_port = 8003
|
||||||
gpu_device = 1
|
gpu_device = 1
|
||||||
ctx_size = 131072
|
ctx_size = 131072
|
||||||
|
|
||||||
|
# Vision profile: `mmproj` points at the multimodal projector (vision encoder)
|
||||||
|
# GGUF. Like `model_path` it is resolved relative to `hf_home` unless absolute.
|
||||||
|
# The projector MUST match the base model's vision tower -- a projector built for
|
||||||
|
# a different base will not load. A missing projector aborts with exit code 3
|
||||||
|
# (for --change: before the running container is removed).
|
||||||
|
#
|
||||||
|
# llama.cpp offloads the projector to the GPU by default; set mmproj_offload to
|
||||||
|
# false to keep it on the CPU when VRAM is tight. Images are then sent to
|
||||||
|
# /v1/chat/completions as an image_url content part -- `llamacppctl --chat`
|
||||||
|
# itself only sends text.
|
||||||
|
[model.vision]
|
||||||
|
model_path = qwen3/Qwen3-35B-A3B-Q4_K_M.gguf
|
||||||
|
mmproj = qwen3/mmproj.gguf
|
||||||
|
mmproj_offload = true
|
||||||
|
container_name = llama_cpp_vision
|
||||||
|
host_port = 8004
|
||||||
|
gpu_device = 1
|
||||||
|
|
||||||
[prompt.concise]
|
[prompt.concise]
|
||||||
system_prompt = Du antwortest kurz, präzise und technisch.
|
system_prompt = Du antwortest kurz, präzise und technisch.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,23 @@ def check_exit_code(result: CheckResult) -> int:
|
||||||
return EXIT_HTTP_NOT_READY
|
return EXIT_HTTP_NOT_READY
|
||||||
|
|
||||||
|
|
||||||
|
def _host_path(cfg: ServerConfig, path_str: str) -> Path:
|
||||||
|
path = Path(path_str)
|
||||||
|
return path if path.is_absolute() else cfg.hf_home / path_str
|
||||||
|
|
||||||
|
|
||||||
def validate_model_path(cfg: ServerConfig) -> None:
|
def validate_model_path(cfg: ServerConfig) -> None:
|
||||||
model_path = Path(cfg.model_path)
|
"""Check that the model file — and the vision projector, when one is
|
||||||
if model_path.is_absolute():
|
configured — exist on the host before the container is (re)created."""
|
||||||
target = model_path
|
target = _host_path(cfg, cfg.model_path)
|
||||||
else:
|
|
||||||
target = cfg.hf_home / cfg.model_path
|
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise FileNotFoundError(f"model file not found: {target}")
|
raise FileNotFoundError(f"model file not found: {target}")
|
||||||
|
|
||||||
|
if cfg.mmproj:
|
||||||
|
projector = _host_path(cfg, cfg.mmproj)
|
||||||
|
if not projector.exists():
|
||||||
|
raise FileNotFoundError(f"mmproj file not found: {projector}")
|
||||||
|
|
||||||
|
|
||||||
def print_effective_config(cfg: ServerConfig, prompt_cfg: PromptConfig) -> None:
|
def print_effective_config(cfg: ServerConfig, prompt_cfg: PromptConfig) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
p.add_argument("--image", help="Docker image (e.g. ghcr.io/ggml-org/llama.cpp:server-cuda)")
|
p.add_argument("--image", help="Docker image (e.g. ghcr.io/ggml-org/llama.cpp:server-cuda)")
|
||||||
p.add_argument("--hf-home", help="Host path mounted read-only as model storage")
|
p.add_argument("--hf-home", help="Host path mounted read-only as model storage")
|
||||||
p.add_argument("--model-path", help="Model path, relative to --hf-home unless absolute")
|
p.add_argument("--model-path", help="Model path, relative to --hf-home unless absolute")
|
||||||
|
p.add_argument(
|
||||||
|
"--mmproj",
|
||||||
|
help="Vision projector (mmproj) GGUF, relative to --hf-home unless absolute. "
|
||||||
|
"Must match the base model's vision tower; enables image input",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--no-mmproj-offload",
|
||||||
|
dest="mmproj_offload",
|
||||||
|
action="store_false",
|
||||||
|
default=None,
|
||||||
|
help="Keep the vision projector on the CPU instead of offloading it to the GPU",
|
||||||
|
)
|
||||||
p.add_argument("--container-name", help="Docker container name (must be unique per config)")
|
p.add_argument("--container-name", help="Docker container name (must be unique per config)")
|
||||||
p.add_argument("--host-port", type=int, help="Host port to publish")
|
p.add_argument("--host-port", type=int, help="Host port to publish")
|
||||||
p.add_argument("--container-port", type=int, help="Container-internal port")
|
p.add_argument("--container-port", type=int, help="Container-internal port")
|
||||||
|
|
@ -185,3 +197,6 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||||
|
|
||||||
if args.container_name is not None and not args.container_name.strip():
|
if args.container_name is not None and not args.container_name.strip():
|
||||||
parser.error("--container-name must not be empty")
|
parser.error("--container-name must not be empty")
|
||||||
|
|
||||||
|
if args.mmproj is not None and not args.mmproj.strip():
|
||||||
|
parser.error("--mmproj must not be empty")
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ def builtin_defaults() -> dict:
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"expose": "false", # publish only on loopback unless true
|
"expose": "false", # publish only on loopback unless true
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
|
"mmproj": "", # vision projector GGUF; empty => text-only server
|
||||||
|
"mmproj_offload": "true",
|
||||||
"health_endpoint": "/health",
|
"health_endpoint": "/health",
|
||||||
"models_endpoint": "/v1/models",
|
"models_endpoint": "/v1/models",
|
||||||
"chat_endpoint": "/v1/chat/completions",
|
"chat_endpoint": "/v1/chat/completions",
|
||||||
|
|
@ -137,6 +139,8 @@ def _apply_cli_overrides(merged: dict, args) -> None:
|
||||||
"lock_file": getattr(args, "lock_file", None),
|
"lock_file": getattr(args, "lock_file", None),
|
||||||
"expose": getattr(args, "expose", None),
|
"expose": getattr(args, "expose", None),
|
||||||
"api_key": getattr(args, "api_key", None),
|
"api_key": getattr(args, "api_key", None),
|
||||||
|
"mmproj": getattr(args, "mmproj", None),
|
||||||
|
"mmproj_offload": getattr(args, "mmproj_offload", None),
|
||||||
}
|
}
|
||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|
@ -193,6 +197,8 @@ def build_server_config(merged: dict) -> ServerConfig:
|
||||||
host=str(merged["host"]),
|
host=str(merged["host"]),
|
||||||
expose=_to_bool(merged.get("expose", "false"), "expose"),
|
expose=_to_bool(merged.get("expose", "false"), "expose"),
|
||||||
api_key=str(merged.get("api_key", "")),
|
api_key=str(merged.get("api_key", "")),
|
||||||
|
mmproj=str(merged.get("mmproj") or "").strip(),
|
||||||
|
mmproj_offload=_to_bool(merged.get("mmproj_offload", "true"), "mmproj_offload"),
|
||||||
health_endpoint=str(merged["health_endpoint"]),
|
health_endpoint=str(merged["health_endpoint"]),
|
||||||
models_endpoint=str(merged["models_endpoint"]),
|
models_endpoint=str(merged["models_endpoint"]),
|
||||||
chat_endpoint=str(merged["chat_endpoint"]),
|
chat_endpoint=str(merged["chat_endpoint"]),
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,21 @@ def container_logs(name: str, tail: int = 100) -> str:
|
||||||
tail_logs = container_logs
|
tail_logs = container_logs
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_hf_path_in_container(path_str: str) -> str:
|
||||||
|
"""Map a host-side model/projector path to its path inside the container.
|
||||||
|
Absolute paths are passed through; relative ones resolve under the
|
||||||
|
read-only /hf_home mount."""
|
||||||
|
if Path(path_str).is_absolute():
|
||||||
|
return path_str
|
||||||
|
return f"/hf_home/{path_str}"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_path_in_container(cfg: ServerConfig) -> str:
|
def _resolve_model_path_in_container(cfg: ServerConfig) -> str:
|
||||||
model_path = Path(cfg.model_path)
|
return _resolve_hf_path_in_container(cfg.model_path)
|
||||||
if model_path.is_absolute():
|
|
||||||
return str(model_path)
|
|
||||||
return f"/hf_home/{cfg.model_path}"
|
def _resolve_mmproj_path_in_container(cfg: ServerConfig) -> str:
|
||||||
|
return _resolve_hf_path_in_container(cfg.mmproj)
|
||||||
|
|
||||||
|
|
||||||
def _port_publish(cfg: ServerConfig) -> str:
|
def _port_publish(cfg: ServerConfig) -> str:
|
||||||
|
|
@ -181,6 +191,10 @@ def build_run_command(cfg: ServerConfig) -> list:
|
||||||
cmd.append("--kv-unified")
|
cmd.append("--kv-unified")
|
||||||
if cfg.cont_batching:
|
if cfg.cont_batching:
|
||||||
cmd.append("--cont-batching")
|
cmd.append("--cont-batching")
|
||||||
|
if cfg.mmproj:
|
||||||
|
cmd += ["--mmproj", _resolve_mmproj_path_in_container(cfg)]
|
||||||
|
if not cfg.mmproj_offload:
|
||||||
|
cmd.append("--no-mmproj-offload")
|
||||||
if cfg.api_key:
|
if cfg.api_key:
|
||||||
cmd += ["--api-key", cfg.api_key]
|
cmd += ["--api-key", cfg.api_key]
|
||||||
cmd.extend(cfg.extra_args)
|
cmd.extend(cfg.extra_args)
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@ class ServerConfig:
|
||||||
# as a Bearer token on every request.
|
# as a Bearer token on every request.
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
|
|
||||||
|
# Optional multimodal projector (vision encoder) GGUF. Resolved relative to
|
||||||
|
# hf_home unless absolute, like model_path. Must match the base model's
|
||||||
|
# vision tower. Empty => text-only server.
|
||||||
|
mmproj: str = ""
|
||||||
|
# llama.cpp offloads the projector to the GPU by default; set False to keep
|
||||||
|
# it on the CPU when VRAM is tight (emits --no-mmproj-offload).
|
||||||
|
mmproj_offload: bool = True
|
||||||
|
|
||||||
extra_args: list = field(default_factory=list)
|
extra_args: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,3 +213,34 @@ def test_container_lock_force_bypasses_busy(tmp_path, capsys):
|
||||||
entered = True
|
entered = True
|
||||||
assert entered is True
|
assert entered is True
|
||||||
assert "busy lock" in capsys.readouterr().err
|
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
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ model_alias = alt_llm
|
||||||
[model.noname]
|
[model.noname]
|
||||||
host_port = 9002
|
host_port = 9002
|
||||||
|
|
||||||
|
[model.vision]
|
||||||
|
container_name = test_vision
|
||||||
|
mmproj = qwen3/mmproj.gguf
|
||||||
|
|
||||||
[prompt.concise]
|
[prompt.concise]
|
||||||
system_prompt = Be brief.
|
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
|
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):
|
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 = CONFIG_BASIC.replace("poll_interval = 2", "poll_interval = 2\nread_timeout = 900")
|
||||||
cfg_path = _write_config(tmp_path, cfg)
|
cfg_path = _write_config(tmp_path, cfg)
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,41 @@ def test_build_run_command_relative_model_path():
|
||||||
assert cmd[idx + 1] == "/hf_home/qwen3/default.gguf"
|
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():
|
def test_format_command_for_display_quotes_properly():
|
||||||
cmd = ["docker", "run", "--name", "has space"]
|
cmd = ["docker", "run", "--name", "has space"]
|
||||||
out = format_command_for_display(cmd)
|
out = format_command_for_display(cmd)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue