llamacppctl/tests/test_config.py

260 lines
8.5 KiB
Python
Raw Normal View History

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_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