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>
This commit is contained in:
parent
3158d16f9b
commit
24202feee6
14 changed files with 485 additions and 32 deletions
127
tests/test_actions.py
Normal file
127
tests/test_actions.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from llamacppctl import actions # noqa: E402
|
||||
from llamacppctl.http_ops import HttpError # noqa: E402
|
||||
from llamacppctl.schema import ChatReply, CheckResult # noqa: E402
|
||||
from tests.test_docker_ops import make_cfg # noqa: E402
|
||||
|
||||
|
||||
def _cfg(tmp_path):
|
||||
return make_cfg(lock_file=tmp_path / "test.lock")
|
||||
|
||||
|
||||
# --- dry-run must be a pure preview (no side effects) ---------------------
|
||||
|
||||
|
||||
def test_do_start_dry_run_does_not_start(tmp_path, monkeypatch, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
called = {"start": 0}
|
||||
monkeypatch.setattr(actions, "start_llama_container", lambda c: called.__setitem__("start", 1))
|
||||
rc = actions.do_start(cfg, SimpleNamespace(), SimpleNamespace(dry_run=True))
|
||||
assert rc == actions.EXIT_OK
|
||||
assert called["start"] == 0
|
||||
assert "docker run" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_do_change_dry_run_does_not_remove(tmp_path, monkeypatch):
|
||||
cfg = _cfg(tmp_path)
|
||||
called = {"remove": 0}
|
||||
monkeypatch.setattr(actions, "remove_container", lambda n: called.__setitem__("remove", 1))
|
||||
rc = actions.do_change(cfg, SimpleNamespace(), SimpleNamespace(dry_run=True))
|
||||
assert rc == actions.EXIT_OK
|
||||
assert called["remove"] == 0
|
||||
|
||||
|
||||
# --- --change must validate BEFORE removing the running container ----------
|
||||
|
||||
|
||||
def test_do_change_validates_before_remove(tmp_path, monkeypatch):
|
||||
cfg = _cfg(tmp_path)
|
||||
called = {"remove": 0, "start": 0}
|
||||
monkeypatch.setattr(actions, "remove_container", lambda n: called.__setitem__("remove", 1))
|
||||
monkeypatch.setattr(actions, "start_llama_container", lambda c: called.__setitem__("start", 1))
|
||||
|
||||
def missing(_cfg):
|
||||
raise FileNotFoundError("model file not found: /x")
|
||||
|
||||
monkeypatch.setattr(actions, "validate_model_path", missing)
|
||||
rc = actions.do_change(cfg, SimpleNamespace(), SimpleNamespace(dry_run=False, force=False))
|
||||
assert rc == actions.EXIT_MODEL_PATH_MISSING
|
||||
assert called["remove"] == 0 # container was NOT touched
|
||||
assert called["start"] == 0
|
||||
|
||||
|
||||
# --- do_chat -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_do_chat_prints_content(tmp_path, monkeypatch, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("Hallo", "stop"))
|
||||
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
||||
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=False))
|
||||
assert rc == actions.EXIT_OK
|
||||
assert capsys.readouterr().out.strip() == "Hallo"
|
||||
|
||||
|
||||
def test_do_chat_truncated_empty_warns_and_fails(tmp_path, monkeypatch, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("", "length"))
|
||||
prompt = SimpleNamespace(user_prompt="hi", max_tokens=128)
|
||||
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=False))
|
||||
assert rc == actions.EXIT_GENERAL
|
||||
err = capsys.readouterr().err
|
||||
assert "max_tokens=128" in err and "abgeschnitten" in err
|
||||
|
||||
|
||||
def test_do_chat_http_error_returns_not_ready(tmp_path, monkeypatch):
|
||||
cfg = _cfg(tmp_path)
|
||||
|
||||
def boom(c, p):
|
||||
raise HttpError("down")
|
||||
|
||||
monkeypatch.setattr(actions, "chat_completion_text", boom)
|
||||
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
||||
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=False))
|
||||
assert rc == actions.EXIT_HTTP_NOT_READY
|
||||
|
||||
|
||||
def test_do_chat_stream(tmp_path, monkeypatch, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
actions,
|
||||
"stream_chat",
|
||||
lambda c, p: iter([("content", "Hal"), ("content", "lo"), ("finish", "stop")]),
|
||||
)
|
||||
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
||||
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=True))
|
||||
assert rc == actions.EXIT_OK
|
||||
assert capsys.readouterr().out.strip() == "Hallo"
|
||||
|
||||
|
||||
# --- exit-code mapping & force stop --------------------------------------
|
||||
|
||||
|
||||
def test_check_exit_code():
|
||||
ok = CheckResult(True, True, True, True, True, "u", "")
|
||||
down = CheckResult(True, False, False, False, False, "u", "")
|
||||
missing = CheckResult(False, False, False, False, False, "u", "")
|
||||
assert actions.check_exit_code(ok) == actions.EXIT_OK
|
||||
assert actions.check_exit_code(down) == actions.EXIT_HTTP_NOT_READY
|
||||
assert actions.check_exit_code(missing) == actions.EXIT_HTTP_NOT_READY
|
||||
|
||||
|
||||
def test_do_stop_passes_force(tmp_path, monkeypatch):
|
||||
cfg = _cfg(tmp_path)
|
||||
seen = {}
|
||||
|
||||
def fake_stop(name, remove=True, force=False):
|
||||
seen["force"] = force
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(actions, "stop_container", fake_stop)
|
||||
actions.do_stop(cfg, SimpleNamespace(force=True))
|
||||
assert seen["force"] is True
|
||||
|
|
@ -206,6 +206,30 @@ def test_chat_params_from_config(tmp_path):
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,8 @@ def test_build_run_command_contains_key_flags():
|
|||
assert "--name" in cmd
|
||||
assert "test_llm" in cmd
|
||||
assert "-p" in cmd
|
||||
assert "8001:8000" in cmd
|
||||
# loopback-only by default (expose=False)
|
||||
assert "127.0.0.1:8001:8000" in cmd
|
||||
assert "--jinja" in cmd
|
||||
assert "--reasoning" in cmd
|
||||
assert "on" in cmd
|
||||
|
|
@ -125,6 +126,25 @@ def test_build_run_command_contains_key_flags():
|
|||
assert "--cont-batching" in cmd
|
||||
|
||||
|
||||
def test_build_run_command_expose_binds_all_interfaces():
|
||||
cfg = make_cfg(expose=True)
|
||||
cmd = build_run_command(cfg)
|
||||
assert "8001:8000" in cmd
|
||||
assert "127.0.0.1:8001:8000" not in cmd
|
||||
|
||||
|
||||
def test_build_run_command_api_key_passed_to_server():
|
||||
cfg = make_cfg(api_key="secret123")
|
||||
cmd = build_run_command(cfg)
|
||||
assert "--api-key" in cmd
|
||||
assert "secret123" in cmd
|
||||
|
||||
|
||||
def test_build_run_command_no_api_key_by_default():
|
||||
cfg = make_cfg()
|
||||
assert "--api-key" not in build_run_command(cfg)
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ def test_chat_completion_text_success():
|
|||
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"
|
||||
reply = chat_completion_text(cfg, prompt_cfg)
|
||||
assert reply.content == "ok"
|
||||
assert reply.finish_reason == ""
|
||||
|
||||
|
||||
def test_chat_completion_text_honors_chat_endpoint():
|
||||
|
|
@ -116,6 +118,53 @@ def test_chat_completion_text_malformed_response():
|
|||
pass
|
||||
|
||||
|
||||
def test_chat_completion_text_reports_finish_reason():
|
||||
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": [{"finish_reason": "length", "message": {"content": ""}}]
|
||||
}
|
||||
mock_post.return_value = mock_resp
|
||||
reply = chat_completion_text(cfg, prompt_cfg)
|
||||
assert reply.content == ""
|
||||
assert reply.finish_reason == "length"
|
||||
|
||||
|
||||
def test_api_key_sent_as_bearer():
|
||||
cfg = make_cfg(api_key="secret123")
|
||||
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": "x"}}]}
|
||||
mock_post.return_value = mock_resp
|
||||
chat_completion_text(cfg, prompt_cfg)
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer secret123"
|
||||
|
||||
|
||||
def test_stream_chat_yields_content_and_finish():
|
||||
cfg = make_cfg()
|
||||
prompt_cfg = PromptConfig(user_prompt="hi", stream=True)
|
||||
lines = [
|
||||
'data: {"choices":[{"delta":{"content":"Hal"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"lo"}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
|
||||
"data: [DONE]",
|
||||
]
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.iter_lines.return_value = iter(lines)
|
||||
with patch("requests.post", return_value=mock_resp):
|
||||
from llamacppctl.http_ops import stream_chat
|
||||
|
||||
events = list(stream_chat(cfg, prompt_cfg))
|
||||
contents = [v for k, v in events if k == "content"]
|
||||
finishes = [v for k, v in events if k == "finish"]
|
||||
assert "".join(contents) == "Hallo"
|
||||
assert finishes == ["stop"]
|
||||
|
||||
|
||||
def test_wait_until_ready_succeeds_immediately():
|
||||
cfg = make_cfg(timeout=5, poll_interval=0.01)
|
||||
probe = PromptConfig(user_prompt="ping", max_tokens=1)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue