2026-07-06 16:36:27 +02:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
test: close coverage gaps + opt-in integration smoke
Unit-Tests (kein Docker nötig), 130 Tests gesamt:
- config: ${ENV}- und ~-Expansion in hf_home
- prompt_io: _pin_dns (DNS-Rebinding wird abgewiesen, andere Hosts unberührt,
Resolver wird wiederhergestellt)
- actions: do_check (running/healthy, missing), do_start non-dry-run
(Happy-Path + Readiness-Fehler mit --logs), _container_lock Force-Bypass
- cli: --max-tokens <= 0 abgelehnt, --expose/--no-expose
scripts/smoke.sh: opt-in End-to-End-Test gegen echten Docker + llama.cpp-Server
(--api-key-Round-Trip inkl. 401/200, start/check/chat/stream/stop, eigener
Container/Port, Cleanup-Trap).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:08:10 +02:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 16:36:27 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat: harden and extend the CLI (security, UX, robustness, tests)
Sieben Verbesserungen; die Dateien überschneiden sich thematisch, daher ein
Commit (jeder Commit bleibt grün: 115 Tests).
- #1 --check ist scriptbar: Exit 0 wenn Container läuft und erreichbar,
sonst 5 (check_exit_code / CheckResult).
- #2 --force implementiert: Bypass eines belegten Locks mit Warnung
(_container_lock) und stop_container(force=…) schluckt Inkonsistenzen.
- #3 stille Trunkierung behoben: chat_completion_text liefert ChatReply
(content + finish_reason); bei finish_reason=length Hinweis auf stderr,
--chat gibt Exit 1 bei leerem Content zurück.
- #4 keine vermeidbare Downtime: --change validiert den Modellpfad VOR dem
Entfernen des laufenden Containers.
- #5 Netzwerk dicht: Port-Publish standardmäßig nur auf 127.0.0.1
(--expose/expose für alle Interfaces), optionaler --api-key/api_key
(Server --api-key + Bearer-Token auf allen Requests).
- #6 --stream: Chat-Reply token-weise via SSE auf stdout (stream_chat).
- #7 tests/test_actions.py: Orchestrierungs-Ebene (dry-run-Nebenwirkungen,
Lock, validate-before-remove, chat/stream/exit-codes).
Doku aktualisiert (Manpage, README, llama.cpp.config.example).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:57:47 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 16:36:27 +02:00
|
|
|
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
|