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>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
from llamacppctl.config import ConfigError, resolve_effective_config, builtin_defaults # noqa: E402
|
|
from llamacppctl.cli import build_parser, validate_args # noqa: E402
|
|
|
|
|
|
CONFIG_BASIC = """
|
|
[default]
|
|
image = ghcr.io/ggml-org/llama.cpp:server-cuda
|
|
hf_home = /srv/models
|
|
model_path = qwen3/default.gguf
|
|
container_name = test_default
|
|
host_port = 8001
|
|
container_port = 8000
|
|
model_alias = default_llm
|
|
gpu_device = 0
|
|
restart_policy = unless-stopped
|
|
ctx_size = 262144
|
|
n_predict = 16384
|
|
temp = 0.65
|
|
top_p = 0.80
|
|
top_k = 20
|
|
min_p = 0.01
|
|
repeat_penalty = 1.05
|
|
main_gpu = 0
|
|
ngl = 999
|
|
fa = true
|
|
kv_unified = true
|
|
jinja = true
|
|
reasoning = on
|
|
no_context_shift = true
|
|
cache_type_k = q4_0
|
|
cache_type_v = q4_0
|
|
batch_size = 1024
|
|
ubatch_size = 512
|
|
parallel = 1
|
|
cont_batching = true
|
|
host = 0.0.0.0
|
|
health_endpoint = /health
|
|
models_endpoint = /v1/models
|
|
chat_endpoint = /v1/chat/completions
|
|
timeout = 300
|
|
poll_interval = 2
|
|
|
|
[model.alt]
|
|
container_name = test_alt
|
|
host_port = 9001
|
|
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.
|
|
"""
|
|
|
|
|
|
def _write_config(tmp_path: Path, content: str = CONFIG_BASIC) -> Path:
|
|
p = tmp_path / "llama.cpp.config"
|
|
p.write_text(content, encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def _parse(argv):
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
validate_args(args, parser)
|
|
args._resolved_system_prompt = None
|
|
args._resolved_user_prompt = None
|
|
return args
|
|
|
|
|
|
def test_default_profile_resolves(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(["--start", "--config", str(cfg_path)])
|
|
server_cfg, prompt_cfg = resolve_effective_config(args)
|
|
assert server_cfg.container_name == "test_default"
|
|
assert server_cfg.host_port == 8001
|
|
assert server_cfg.jinja is True
|
|
assert server_cfg.reasoning == "on"
|
|
|
|
|
|
def test_model_profile_overrides_default(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(["--start", "--config", str(cfg_path), "--profile", "alt"])
|
|
server_cfg, _ = resolve_effective_config(args)
|
|
assert server_cfg.container_name == "test_alt"
|
|
assert server_cfg.host_port == 9001
|
|
assert server_cfg.model_alias == "alt_llm"
|
|
# inherited from [default]
|
|
assert server_cfg.image == "ghcr.io/ggml-org/llama.cpp:server-cuda"
|
|
|
|
|
|
def test_missing_profile_raises(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(["--start", "--config", str(cfg_path), "--profile", "does-not-exist"])
|
|
with pytest.raises(ConfigError):
|
|
resolve_effective_config(args)
|
|
|
|
|
|
def test_prompt_profile_provides_fallback_system_prompt(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(
|
|
["--start", "--config", str(cfg_path), "--system-prompt-profile", "concise"]
|
|
)
|
|
_, prompt_cfg = resolve_effective_config(args)
|
|
assert prompt_cfg.system_prompt == "Be brief."
|
|
|
|
|
|
def test_explicit_system_overrides_prompt_profile(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(
|
|
[
|
|
"--start",
|
|
"--config",
|
|
str(cfg_path),
|
|
"--system-prompt-profile",
|
|
"concise",
|
|
"--system",
|
|
"Explicit wins.",
|
|
]
|
|
)
|
|
args._resolved_system_prompt = "Explicit wins."
|
|
_, prompt_cfg = resolve_effective_config(args)
|
|
assert prompt_cfg.system_prompt == "Explicit wins."
|
|
|
|
|
|
def test_missing_container_name_raises(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(["--start", "--config", str(cfg_path), "--profile", "noname"])
|
|
# [model.noname] has no container_name and [default] provides one,
|
|
# so this should actually succeed by inheriting test_default.
|
|
server_cfg, _ = resolve_effective_config(args)
|
|
assert server_cfg.container_name == "test_default"
|
|
|
|
|
|
def test_empty_container_name_via_cli_rejected_by_argparse(tmp_path):
|
|
# cli.validate_args() already rejects a blank --container-name before
|
|
# config resolution is ever reached (defense at the CLI layer).
|
|
cfg_path = _write_config(tmp_path)
|
|
with pytest.raises(SystemExit):
|
|
_parse(["--start", "--config", str(cfg_path), "--container-name", " "])
|
|
|
|
|
|
def test_empty_container_name_from_config_raises(tmp_path):
|
|
# container_name can only end up empty via the config file itself
|
|
# (CLI-level blanks are already rejected by validate_args above).
|
|
bad_config = CONFIG_BASIC.replace("container_name = test_default", "container_name =")
|
|
cfg_path = _write_config(tmp_path, bad_config)
|
|
args = _parse(["--start", "--config", str(cfg_path)])
|
|
with pytest.raises(ConfigError):
|
|
resolve_effective_config(args)
|
|
|
|
|
|
def test_cli_override_wins_over_config(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(
|
|
["--start", "--config", str(cfg_path), "--host-port", "12345"]
|
|
)
|
|
server_cfg, _ = resolve_effective_config(args)
|
|
assert server_cfg.host_port == 12345
|
|
|
|
|
|
def test_invalid_boolean_raises(tmp_path):
|
|
bad_config = CONFIG_BASIC.replace("jinja = true", "jinja = maybe")
|
|
cfg_path = _write_config(tmp_path, bad_config)
|
|
args = _parse(["--start", "--config", str(cfg_path)])
|
|
with pytest.raises(ConfigError):
|
|
resolve_effective_config(args)
|
|
|
|
|
|
def test_lock_file_derived_from_container_name(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(["--start", "--config", str(cfg_path)])
|
|
server_cfg, _ = resolve_effective_config(args)
|
|
assert str(server_cfg.lock_file) == "/tmp/llamacppctl.test_default.lock"
|
|
|
|
|
|
def test_hf_home_expands_env_var(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LLAMACPPCTL_TEST_HOME", "/data/models")
|
|
cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ${LLAMACPPCTL_TEST_HOME}/sub")
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
|
assert str(server_cfg.hf_home) == "/data/models/sub"
|
|
|
|
|
|
def test_hf_home_expands_tilde(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HOME", "/home/tester")
|
|
cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ~/models")
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
|
assert str(server_cfg.hf_home) == "/home/tester/models"
|
|
|
|
|
|
def test_builtin_defaults_has_container_name():
|
|
defaults = builtin_defaults()
|
|
assert defaults["container_name"] == "va_llm"
|
|
|
|
|
|
def test_chat_params_default_and_defer_temperature(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
args = _parse(["--start", "--config", str(cfg_path)])
|
|
_, prompt_cfg = resolve_effective_config(args)
|
|
assert prompt_cfg.max_tokens == 2048 # built-in default
|
|
assert prompt_cfg.temperature is None # empty chat_temperature => server temp
|
|
|
|
|
|
def test_chat_params_from_config(tmp_path):
|
|
cfg = CONFIG_BASIC.replace(
|
|
"poll_interval = 2",
|
|
"poll_interval = 2\nmax_tokens = 32768\nchat_temperature = 0.6",
|
|
)
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
args = _parse(["--start", "--config", str(cfg_path)])
|
|
_, prompt_cfg = resolve_effective_config(args)
|
|
assert prompt_cfg.max_tokens == 32768
|
|
assert prompt_cfg.temperature == 0.6
|
|
|
|
|
|
def test_expose_and_api_key_default_and_config(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
|
assert server_cfg.expose is False # loopback-only default
|
|
assert server_cfg.api_key == ""
|
|
|
|
cfg = CONFIG_BASIC.replace(
|
|
"host = 0.0.0.0", "host = 0.0.0.0\nexpose = true\napi_key = s3cr3t"
|
|
)
|
|
cfg_path2 = _write_config(tmp_path, cfg)
|
|
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path2)]))
|
|
assert server_cfg.expose is True
|
|
assert server_cfg.api_key == "s3cr3t"
|
|
|
|
|
|
def test_expose_cli_overrides_config(tmp_path):
|
|
cfg = CONFIG_BASIC.replace("host = 0.0.0.0", "host = 0.0.0.0\nexpose = true")
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
server_cfg, _ = resolve_effective_config(
|
|
_parse(["--start", "--config", str(cfg_path), "--no-expose"])
|
|
)
|
|
assert server_cfg.expose is False
|
|
|
|
|
|
def test_chat_params_cli_overrides_config(tmp_path):
|
|
cfg = CONFIG_BASIC.replace(
|
|
"poll_interval = 2",
|
|
"poll_interval = 2\nmax_tokens = 32768\nchat_temperature = 0.6",
|
|
)
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
args = _parse(
|
|
["--start", "--config", str(cfg_path), "--max-tokens", "512", "--chat-temp", "0.1"]
|
|
)
|
|
_, prompt_cfg = resolve_effective_config(args)
|
|
assert prompt_cfg.max_tokens == 512
|
|
assert prompt_cfg.temperature == 0.1
|
|
|
|
|
|
def test_stream_and_timeouts_default(tmp_path):
|
|
cfg_path = _write_config(tmp_path)
|
|
_, prompt_cfg = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
|
assert prompt_cfg.stream is False
|
|
assert prompt_cfg.read_timeout == 600.0 # generous default for reasoning models
|
|
assert prompt_cfg.connect_timeout == 10.0
|
|
|
|
|
|
def test_stream_and_read_timeout_from_config(tmp_path):
|
|
cfg = CONFIG_BASIC.replace(
|
|
"poll_interval = 2",
|
|
"poll_interval = 2\nstream = true\nread_timeout = 900\nconnect_timeout = 5",
|
|
)
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
_, prompt_cfg = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
|
assert prompt_cfg.stream is True
|
|
assert prompt_cfg.read_timeout == 900.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):
|
|
cfg = CONFIG_BASIC.replace("poll_interval = 2", "poll_interval = 2\nread_timeout = 900")
|
|
cfg_path = _write_config(tmp_path, cfg)
|
|
# --stream forces streaming on; --read-timeout overrides the config value.
|
|
args = _parse(["--chat", "-p", "hi", "--config", str(cfg_path), "--stream", "--read-timeout", "42"])
|
|
_, prompt_cfg = resolve_effective_config(args)
|
|
assert prompt_cfg.stream is True
|
|
assert prompt_cfg.read_timeout == 42.0
|