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
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from llamacppctl.prompt_io import InputPolicy # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy() -> InputPolicy:
|
||||
return InputPolicy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def small_policy() -> InputPolicy:
|
||||
return InputPolicy(max_input_bytes=16)
|
||||
145
tests/test_cli.py
Normal file
145
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from llamacppctl.cli import build_parser, validate_args # noqa: E402
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
validate_args(args, parser)
|
||||
return args
|
||||
|
||||
|
||||
def parse_error(argv):
|
||||
parser = build_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
args = parser.parse_args(argv)
|
||||
validate_args(args, parser)
|
||||
|
||||
|
||||
def test_requires_one_action():
|
||||
parse_error([])
|
||||
|
||||
|
||||
def test_actions_are_mutually_exclusive():
|
||||
parse_error(["--start", "--stop"])
|
||||
|
||||
|
||||
def test_start_is_valid_minimal():
|
||||
args = parse(["--start"])
|
||||
assert args.start is True
|
||||
|
||||
|
||||
def test_chat_requires_prompt_source():
|
||||
parse_error(["--chat"])
|
||||
|
||||
|
||||
def test_chat_with_prompt_is_valid():
|
||||
args = parse(["--chat", "-p", "hello"])
|
||||
assert args.chat is True
|
||||
assert args.prompt == "hello"
|
||||
|
||||
|
||||
def test_stop_rejects_prompt_sources():
|
||||
parse_error(["--stop", "-p", "hello"])
|
||||
|
||||
|
||||
def test_stop_rejects_system_sources():
|
||||
parse_error(["--stop", "-s", "system text"])
|
||||
|
||||
|
||||
def test_check_with_system_but_no_prompt_rejected():
|
||||
parse_error(["--check", "-s", "system text"])
|
||||
|
||||
|
||||
def test_check_with_system_and_prompt_ok():
|
||||
args = parse(["--check", "-s", "system text", "-p", "hello"])
|
||||
assert args.check is True
|
||||
|
||||
|
||||
def test_mutually_exclusive_system_sources():
|
||||
parse_error(["--start", "-s", "a", "--system-file", "b.txt"])
|
||||
|
||||
|
||||
def test_mutually_exclusive_prompt_sources():
|
||||
parse_error(["--start", "-p", "a", "--prompt-url", "https://example.com/p"])
|
||||
|
||||
|
||||
def test_max_input_bytes_must_be_positive():
|
||||
parse_error(["--start", "--max-input-bytes", "0"])
|
||||
|
||||
|
||||
def test_negative_max_input_bytes_rejected():
|
||||
parse_error(["--start", "--max-input-bytes", "-5"])
|
||||
|
||||
|
||||
def test_connect_timeout_must_be_positive():
|
||||
parse_error(["--start", "--connect-timeout", "0"])
|
||||
|
||||
|
||||
def test_read_timeout_must_be_positive():
|
||||
parse_error(["--start", "--read-timeout", "-1"])
|
||||
|
||||
|
||||
def test_allow_private_url_conflicts_with_allowlist():
|
||||
parse_error(["--start", "--allow-private-url", "--url-allow-host", "example.com"])
|
||||
|
||||
|
||||
def test_host_port_out_of_range_rejected():
|
||||
parse_error(["--start", "--host-port", "70000"])
|
||||
|
||||
|
||||
def test_host_port_zero_rejected():
|
||||
parse_error(["--start", "--host-port", "0"])
|
||||
|
||||
|
||||
def test_container_port_out_of_range_rejected():
|
||||
parse_error(["--start", "--container-port", "-1"])
|
||||
|
||||
|
||||
def test_container_name_empty_string_rejected():
|
||||
parse_error(["--start", "--container-name", " "])
|
||||
|
||||
|
||||
def test_container_name_valid():
|
||||
args = parse(["--start", "--container-name", "my_llm"])
|
||||
assert args.container_name == "my_llm"
|
||||
|
||||
|
||||
def test_boolean_flag_pairs_default_none():
|
||||
args = parse(["--start"])
|
||||
assert args.jinja is None
|
||||
assert args.fa is None
|
||||
assert args.kv_unified is None
|
||||
assert args.cont_batching is None
|
||||
assert args.no_context_shift is None
|
||||
|
||||
|
||||
def test_boolean_flag_pairs_explicit_true_false():
|
||||
args = parse(["--start", "--jinja", "--no-fa"])
|
||||
assert args.jinja is True
|
||||
assert args.fa is False
|
||||
|
||||
|
||||
def test_dry_run_flag():
|
||||
args = parse(["--start", "--dry-run"])
|
||||
assert args.dry_run is True
|
||||
|
||||
|
||||
def test_print_effective_config_flag():
|
||||
args = parse(["--start", "--print-effective-config"])
|
||||
assert args.print_effective_config is True
|
||||
|
||||
|
||||
def test_reasoning_choice_validated():
|
||||
parse_error(["--start", "--reasoning", "maybe"])
|
||||
|
||||
|
||||
def test_reasoning_choice_valid():
|
||||
args = parse(["--start", "--reasoning", "off"])
|
||||
assert args.reasoning == "off"
|
||||
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
|
||||
154
tests/test_docker_ops.py
Normal file
154
tests/test_docker_ops.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from llamacppctl.docker_ops import ( # noqa: E402
|
||||
build_run_command,
|
||||
container_exists,
|
||||
container_running,
|
||||
docker_available,
|
||||
format_command_for_display,
|
||||
inspect_container,
|
||||
)
|
||||
from llamacppctl.schema import ServerConfig # noqa: E402
|
||||
|
||||
|
||||
def make_cfg(**overrides) -> ServerConfig:
|
||||
base = dict(
|
||||
image="ghcr.io/ggml-org/llama.cpp:server-cuda",
|
||||
hf_home=Path("/srv/models"),
|
||||
model_path="qwen3/default.gguf",
|
||||
container_name="test_llm",
|
||||
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,
|
||||
batch_size=1024,
|
||||
ubatch_size=512,
|
||||
parallel=1,
|
||||
cache_type_k="q4_0",
|
||||
cache_type_v="q4_0",
|
||||
reasoning="on",
|
||||
jinja=True,
|
||||
fa=True,
|
||||
kv_unified=True,
|
||||
cont_batching=True,
|
||||
no_context_shift=True,
|
||||
host="0.0.0.0",
|
||||
health_endpoint="/health",
|
||||
models_endpoint="/v1/models",
|
||||
chat_endpoint="/v1/chat/completions",
|
||||
timeout=300,
|
||||
poll_interval=2.0,
|
||||
lock_file=Path("/tmp/llamacppctl.test_llm.lock"),
|
||||
)
|
||||
base.update(overrides)
|
||||
return ServerConfig(**base)
|
||||
|
||||
|
||||
def test_docker_available_true():
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert docker_available() is True
|
||||
|
||||
|
||||
def test_docker_available_false_when_missing():
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
assert docker_available() is False
|
||||
|
||||
|
||||
def test_container_exists_true():
|
||||
with patch("llamacppctl.docker_ops._run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="test_llm\nother\n")
|
||||
assert container_exists("test_llm") is True
|
||||
|
||||
|
||||
def test_container_exists_false():
|
||||
with patch("llamacppctl.docker_ops._run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="other\n")
|
||||
assert container_exists("test_llm") is False
|
||||
|
||||
|
||||
def test_container_running_true():
|
||||
with patch("llamacppctl.docker_ops._run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="test_llm\n")
|
||||
assert container_running("test_llm") is True
|
||||
|
||||
|
||||
def test_inspect_container_missing():
|
||||
with patch("llamacppctl.docker_ops.container_exists", return_value=False):
|
||||
info = inspect_container("ghost")
|
||||
assert info.status == "missing"
|
||||
assert info.health == "none"
|
||||
|
||||
|
||||
def test_inspect_container_running():
|
||||
fake_json = (
|
||||
'[{"State": {"Status": "running", "Health": {"Status": "healthy"}}}]'
|
||||
)
|
||||
with patch("llamacppctl.docker_ops.container_exists", return_value=True), patch(
|
||||
"llamacppctl.docker_ops._run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout=fake_json)
|
||||
info = inspect_container("test_llm")
|
||||
assert info.status == "running"
|
||||
assert info.health == "healthy"
|
||||
|
||||
|
||||
def test_build_run_command_contains_key_flags():
|
||||
cfg = make_cfg()
|
||||
cmd = build_run_command(cfg)
|
||||
assert cmd[0:2] == ["docker", "run"]
|
||||
assert "--gpus" in cmd
|
||||
assert "device=0" in cmd
|
||||
assert "--name" in cmd
|
||||
assert "test_llm" in cmd
|
||||
assert "-p" in cmd
|
||||
assert "8001:8000" in cmd
|
||||
assert "--jinja" in cmd
|
||||
assert "--reasoning" in cmd
|
||||
assert "on" in cmd
|
||||
assert "--no-context-shift" in cmd
|
||||
assert "--kv-unified" in cmd
|
||||
assert "--cont-batching" in cmd
|
||||
|
||||
|
||||
def test_build_run_command_respects_disabled_flags():
|
||||
cfg = make_cfg(jinja=False, kv_unified=False, cont_batching=False, no_context_shift=False, fa=False)
|
||||
cmd = build_run_command(cfg)
|
||||
assert "--jinja" not in cmd
|
||||
assert "--kv-unified" not in cmd
|
||||
assert "--cont-batching" not in cmd
|
||||
assert "--no-context-shift" not in cmd
|
||||
|
||||
|
||||
def test_build_run_command_absolute_model_path():
|
||||
cfg = make_cfg(model_path="/abs/path/model.gguf")
|
||||
cmd = build_run_command(cfg)
|
||||
idx = cmd.index("-m")
|
||||
assert cmd[idx + 1] == "/abs/path/model.gguf"
|
||||
|
||||
|
||||
def test_build_run_command_relative_model_path():
|
||||
cfg = make_cfg(model_path="qwen3/default.gguf")
|
||||
cmd = build_run_command(cfg)
|
||||
idx = cmd.index("-m")
|
||||
assert cmd[idx + 1] == "/hf_home/qwen3/default.gguf"
|
||||
|
||||
|
||||
def test_format_command_for_display_quotes_properly():
|
||||
cmd = ["docker", "run", "--name", "has space"]
|
||||
out = format_command_for_display(cmd)
|
||||
assert "'has space'" in out
|
||||
132
tests/test_http_ops.py
Normal file
132
tests/test_http_ops.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
import requests # noqa: E402
|
||||
|
||||
from llamacppctl.http_ops import ( # noqa: E402
|
||||
HttpError,
|
||||
base_url,
|
||||
chat_completion,
|
||||
chat_completion_text,
|
||||
check_health,
|
||||
check_models,
|
||||
wait_until_ready,
|
||||
)
|
||||
from llamacppctl.schema import PromptConfig, ServerConfig # noqa: E402
|
||||
from tests.test_docker_ops import make_cfg # noqa: E402
|
||||
|
||||
|
||||
def test_base_url():
|
||||
cfg = make_cfg(host_port=8001)
|
||||
assert base_url(cfg) == "http://127.0.0.1:8001/v1"
|
||||
|
||||
|
||||
def test_check_health_ok():
|
||||
cfg = make_cfg()
|
||||
with patch("requests.get") as mock_get:
|
||||
mock_get.return_value = MagicMock(ok=True)
|
||||
assert check_health(cfg) is True
|
||||
|
||||
|
||||
def test_check_health_exception_returns_false():
|
||||
cfg = make_cfg()
|
||||
with patch("requests.get", side_effect=requests.RequestException("boom")):
|
||||
assert check_health(cfg) is False
|
||||
|
||||
|
||||
def test_check_models_ok():
|
||||
cfg = make_cfg()
|
||||
with patch("requests.get") as mock_get:
|
||||
mock_get.return_value = MagicMock(ok=True)
|
||||
assert check_models(cfg) is True
|
||||
|
||||
|
||||
def test_chat_completion_posts_expected_payload():
|
||||
cfg = make_cfg()
|
||||
prompt_cfg = PromptConfig(system_prompt="sys", user_prompt="hi", max_tokens=32, temperature=0.1)
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
chat_completion(cfg, prompt_cfg)
|
||||
_, kwargs = mock_post.call_args
|
||||
payload = kwargs["json"]
|
||||
assert payload["messages"][0] == {"role": "system", "content": "sys"}
|
||||
assert payload["messages"][1] == {"role": "user", "content": "hi"}
|
||||
assert payload["max_tokens"] == 32
|
||||
|
||||
|
||||
def test_chat_completion_text_success():
|
||||
cfg = make_cfg()
|
||||
prompt_cfg = PromptConfig(user_prompt="hi")
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.json.return_value = {"choices": [{"message": {"content": "ok"}}]}
|
||||
mock_post.return_value = mock_resp
|
||||
assert chat_completion_text(cfg, prompt_cfg) == "ok"
|
||||
|
||||
|
||||
def test_chat_completion_text_honors_chat_endpoint():
|
||||
cfg = make_cfg(host_port=8001, chat_endpoint="/custom/chat")
|
||||
prompt_cfg = PromptConfig(user_prompt="hi")
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.json.return_value = {"choices": [{"message": {"content": "ok"}}]}
|
||||
mock_post.return_value = mock_resp
|
||||
chat_completion_text(cfg, prompt_cfg)
|
||||
args, _ = mock_post.call_args
|
||||
assert args[0] == "http://127.0.0.1:8001/custom/chat"
|
||||
|
||||
|
||||
def test_chat_completion_text_http_error():
|
||||
cfg = make_cfg()
|
||||
prompt_cfg = PromptConfig(user_prompt="hi")
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=500, text="server error")
|
||||
try:
|
||||
chat_completion_text(cfg, prompt_cfg)
|
||||
assert False, "expected HttpError"
|
||||
except HttpError:
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_completion_text_transport_error():
|
||||
cfg = make_cfg()
|
||||
prompt_cfg = PromptConfig(user_prompt="hi")
|
||||
with patch("requests.post", side_effect=requests.RequestException("down")):
|
||||
try:
|
||||
chat_completion_text(cfg, prompt_cfg)
|
||||
assert False, "expected HttpError"
|
||||
except HttpError:
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_completion_text_malformed_response():
|
||||
cfg = make_cfg()
|
||||
prompt_cfg = PromptConfig(user_prompt="hi")
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.json.return_value = {"unexpected": True}
|
||||
mock_post.return_value = mock_resp
|
||||
try:
|
||||
chat_completion_text(cfg, prompt_cfg)
|
||||
assert False, "expected HttpError"
|
||||
except HttpError:
|
||||
pass
|
||||
|
||||
|
||||
def test_wait_until_ready_succeeds_immediately():
|
||||
cfg = make_cfg(timeout=5, poll_interval=0.01)
|
||||
probe = PromptConfig(user_prompt="ping", max_tokens=1)
|
||||
with patch("llamacppctl.http_ops.chat_completion") as mock_chat:
|
||||
mock_chat.return_value = MagicMock(status_code=200)
|
||||
assert wait_until_ready(cfg, probe) is True
|
||||
|
||||
|
||||
def test_wait_until_ready_times_out():
|
||||
cfg = make_cfg(timeout=0, poll_interval=0.01)
|
||||
probe = PromptConfig(user_prompt="ping", max_tokens=1)
|
||||
with patch("llamacppctl.http_ops.chat_completion") as mock_chat:
|
||||
mock_chat.return_value = MagicMock(status_code=500)
|
||||
assert wait_until_ready(cfg, probe) is False
|
||||
31
tests/test_lock_ops.py
Normal file
31
tests/test_lock_ops.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from llamacppctl.lock_ops import FileLock, LockError # noqa: E402
|
||||
|
||||
|
||||
def test_lock_acquire_and_release(tmp_path):
|
||||
lock_path = tmp_path / "test.lock"
|
||||
with FileLock(lock_path):
|
||||
assert lock_path.exists()
|
||||
# released cleanly, can acquire again
|
||||
with FileLock(lock_path):
|
||||
pass
|
||||
|
||||
|
||||
def test_lock_busy_raises(tmp_path):
|
||||
lock_path = tmp_path / "busy.lock"
|
||||
with FileLock(lock_path):
|
||||
with pytest.raises(LockError):
|
||||
with FileLock(lock_path):
|
||||
pass
|
||||
|
||||
|
||||
def test_lock_creates_parent_dirs(tmp_path):
|
||||
lock_path = tmp_path / "nested" / "dir" / "test.lock"
|
||||
with FileLock(lock_path):
|
||||
assert lock_path.exists()
|
||||
81
tests/test_prompt_files.py
Normal file
81
tests/test_prompt_files.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from llamacppctl.prompt_io import PromptSourceError, load_text_file
|
||||
|
||||
|
||||
def test_load_utf8_file(tmp_path, policy):
|
||||
p = tmp_path / "sys.txt"
|
||||
p.write_text("Du bist präzise.", encoding="utf-8")
|
||||
assert load_text_file(str(p), policy) == "Du bist präzise."
|
||||
|
||||
|
||||
def test_strip_bom(tmp_path, policy):
|
||||
p = tmp_path / "bom.txt"
|
||||
p.write_bytes(b"\xef\xbb\xbfhello")
|
||||
assert load_text_file(str(p), policy) == "hello"
|
||||
|
||||
|
||||
def test_normalize_crlf(tmp_path, policy):
|
||||
p = tmp_path / "crlf.txt"
|
||||
p.write_bytes(b"line1\r\nline2\r\n")
|
||||
assert load_text_file(str(p), policy) == "line1\nline2\n"
|
||||
|
||||
|
||||
def test_reject_missing_file(tmp_path, policy):
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_file(str(tmp_path / "nope.txt"), policy)
|
||||
|
||||
|
||||
def test_reject_directory(tmp_path, policy):
|
||||
d = tmp_path / "adir"
|
||||
d.mkdir()
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_file(str(d), policy)
|
||||
|
||||
|
||||
def test_reject_binary_file(tmp_path, policy):
|
||||
p = tmp_path / "bin.dat"
|
||||
p.write_bytes(b"\x00\x01\x02binary")
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_file(str(p), policy)
|
||||
|
||||
|
||||
def test_reject_too_large_file(tmp_path, small_policy):
|
||||
p = tmp_path / "big.txt"
|
||||
p.write_text("x" * 100, encoding="utf-8")
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_file(str(p), small_policy)
|
||||
|
||||
|
||||
def test_reject_invalid_utf8(tmp_path, policy):
|
||||
p = tmp_path / "invalid.txt"
|
||||
p.write_bytes(b"\xff\xfe\xfa\xfb\x80\x81")
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_file(str(p), policy)
|
||||
|
||||
|
||||
def test_reject_symlink_when_disabled(tmp_path, policy):
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("hi", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
os.symlink(target, link)
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
strict_policy = replace(policy, allow_symlinks=False)
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_file(str(link), strict_policy)
|
||||
|
||||
|
||||
def test_allow_symlink_when_enabled(tmp_path, policy):
|
||||
target = tmp_path / "target2.txt"
|
||||
target.write_text("hi again", encoding="utf-8")
|
||||
link = tmp_path / "link2.txt"
|
||||
os.symlink(target, link)
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
permissive_policy = replace(policy, allow_symlinks=True)
|
||||
assert load_text_file(str(link), permissive_policy) == "hi again"
|
||||
28
tests/test_prompt_sources.py
Normal file
28
tests/test_prompt_sources.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from llamacppctl.prompt_io import PromptSource, PromptSourceError, resolve_source
|
||||
|
||||
|
||||
def test_resolve_literal_source():
|
||||
src = resolve_source("hello", None, None)
|
||||
assert src == PromptSource("literal", "hello")
|
||||
|
||||
|
||||
def test_resolve_file_source():
|
||||
src = resolve_source(None, "/tmp/foo.txt", None)
|
||||
assert src == PromptSource("file", "/tmp/foo.txt")
|
||||
|
||||
|
||||
def test_resolve_url_source():
|
||||
src = resolve_source(None, None, "https://example.org/x.txt")
|
||||
assert src == PromptSource("url", "https://example.org/x.txt")
|
||||
|
||||
|
||||
def test_resolve_none():
|
||||
assert resolve_source(None, None, None) is None
|
||||
|
||||
|
||||
def test_resolve_rejects_multiple_sources():
|
||||
try:
|
||||
resolve_source("a", "b", None)
|
||||
assert False, "expected PromptSourceError"
|
||||
except PromptSourceError:
|
||||
pass
|
||||
203
tests/test_prompt_urls.py
Normal file
203
tests/test_prompt_urls.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import socket
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from llamacppctl.prompt_io import (
|
||||
PromptSourceError,
|
||||
load_text_url,
|
||||
validate_url_target,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, headers=None, chunks=None, text=""):
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self._chunks = chunks if chunks is not None else [text.encode("utf-8")]
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
def iter_content(self, chunk_size=8192):
|
||||
for c in self._chunks:
|
||||
yield c
|
||||
|
||||
|
||||
def _fake_getaddrinfo_factory(mapping):
|
||||
def fake_getaddrinfo(host, port, *args, **kwargs):
|
||||
ips = mapping.get(host, [])
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0)) for ip in ips]
|
||||
|
||||
return fake_getaddrinfo
|
||||
|
||||
|
||||
def test_reject_http_without_opt_in(policy):
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("http://example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_reject_localhost(policy):
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("https://localhost/x.txt", policy)
|
||||
|
||||
|
||||
def test_reject_ip_literal_without_opt_in(policy):
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("https://93.184.216.34/x.txt", policy)
|
||||
|
||||
|
||||
def test_allow_ip_literal_with_opt_in(monkeypatch, policy):
|
||||
strict_policy = replace(policy, allow_ip_host=True)
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"93.184.216.34": ["93.184.216.34"]})
|
||||
)
|
||||
validate_url_target("https://93.184.216.34/x.txt", strict_policy)
|
||||
|
||||
|
||||
def test_reject_private_ip_resolution(monkeypatch, policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"internal.example.org": ["10.0.0.5"]})
|
||||
)
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("https://internal.example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_reject_metadata_ip(monkeypatch, policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"evil.example.org": ["169.254.169.254"]})
|
||||
)
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("https://evil.example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_reject_embedded_credentials(policy):
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("https://user:pass@example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_allowlist_blocks_other_hosts(monkeypatch, policy):
|
||||
strict_policy = replace(policy, url_allow_hosts=["good.example.org"])
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
"getaddrinfo",
|
||||
_fake_getaddrinfo_factory({"bad.example.org": ["93.184.216.34"]}),
|
||||
)
|
||||
with pytest.raises(PromptSourceError):
|
||||
validate_url_target("https://bad.example.org/x.txt", strict_policy)
|
||||
|
||||
|
||||
def test_accept_https_text_plain(monkeypatch, policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return FakeResponse(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "text/plain", "Content-Length": "5"},
|
||||
text="hello",
|
||||
)
|
||||
|
||||
import llamacppctl.prompt_io as prompt_io
|
||||
|
||||
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
|
||||
text = load_text_url("https://example.org/x.txt", policy)
|
||||
assert text == "hello"
|
||||
|
||||
|
||||
def test_reject_disallowed_content_type(monkeypatch, policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return FakeResponse(status_code=200, headers={"Content-Type": "text/html"}, text="<h1>hi</h1>")
|
||||
|
||||
import llamacppctl.prompt_io as prompt_io
|
||||
|
||||
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_url("https://example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_reject_large_content_length(monkeypatch, small_policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return FakeResponse(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "text/plain", "Content-Length": "1000"},
|
||||
text="x" * 1000,
|
||||
)
|
||||
|
||||
import llamacppctl.prompt_io as prompt_io
|
||||
|
||||
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_url("https://example.org/x.txt", small_policy)
|
||||
|
||||
|
||||
def test_reject_large_streamed_response(monkeypatch, small_policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return FakeResponse(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
chunks=[b"x" * 10, b"x" * 10, b"x" * 10],
|
||||
)
|
||||
|
||||
import llamacppctl.prompt_io as prompt_io
|
||||
|
||||
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_url("https://example.org/x.txt", small_policy)
|
||||
|
||||
|
||||
def test_reject_redirect_by_default(monkeypatch, policy):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return FakeResponse(status_code=302, headers={"Location": "https://example.org/other.txt"})
|
||||
|
||||
import llamacppctl.prompt_io as prompt_io
|
||||
|
||||
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_url("https://example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_revalidate_redirect_target(monkeypatch, policy):
|
||||
strict_policy = replace(policy, follow_redirects=True)
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
"getaddrinfo",
|
||||
_fake_getaddrinfo_factory(
|
||||
{"example.org": ["93.184.216.34"], "internal.example.org": ["10.0.0.9"]}
|
||||
),
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
calls["n"] += 1
|
||||
if "internal" not in url:
|
||||
return FakeResponse(
|
||||
status_code=302, headers={"Location": "https://internal.example.org/secret.txt"}
|
||||
)
|
||||
return FakeResponse(status_code=200, headers={"Content-Type": "text/plain"}, text="leak")
|
||||
|
||||
import llamacppctl.prompt_io as prompt_io
|
||||
|
||||
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
|
||||
with pytest.raises(PromptSourceError):
|
||||
load_text_url("https://example.org/x.txt", strict_policy)
|
||||
assert calls["n"] == 1 # must fail validation before the second request
|
||||
Loading…
Add table
Add a link
Reference in a new issue