Initial commit: llamacppctl – llama.cpp Docker server control CLI
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>
This commit is contained in:
commit
3158d16f9b
32 changed files with 3912 additions and 0 deletions
220
tests/test_config.py
Normal file
220
tests/test_config.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue