Steuert einen llama.cpp-Server als Docker-Container: --start/--check/--stop/
--change/--chat, INI-Konfiguration (builtin defaults -> [default] ->
[model.<profile>] -> CLI), SSRF-gehärtete Prompt-Eingabe (Datei/HTTPS-URL),
File-Locking für --start/--change und ein OpenAI-kompatibler HTTP-Layer.
Enthält u. a.:
- Env-Var-Expansion in hf_home (hf_home = ${HF_HOME})
- konfigurierbares Chat-Antwortbudget (max_tokens/chat_temperature,
CLI: --max-tokens/--chat-temp); temperature defer an Server-Default
- DNS-Pinning gegen DNS-Rebinding bei URL-Quellen
- dry-run als nebenwirkungsfreie Vorschau (kein Lock/Removal/Modell-Check)
- 98 Tests (pytest)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
6.8 KiB
Python
220 lines
6.8 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
|
|
|
|
[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_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_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
|