Remove internal sd-server management, proxy to external sd-server
- Remove ProcessManager and process.py - Add get_sd_server_url() config (env/config/default) - Update routes to proxy to external sd-server URL - Remove model switching (handled by external sd-server) - Update CLI serve command - Update tests for new architecture Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+47
-119
@@ -2,110 +2,51 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tensors.server import create_app
|
||||
from tensors.server.models import ServerConfig
|
||||
from tensors.server.process import ProcessManager
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pm() -> ProcessManager:
|
||||
return ProcessManager()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api() -> TestClient:
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def _get_pm(api: TestClient) -> ProcessManager:
|
||||
return api.app.state.pm # type: ignore[no-any-return, attr-defined]
|
||||
"""Create test client with mock sd-server URL."""
|
||||
return TestClient(create_app(sd_server_url="http://mock-sd-server:1234"))
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_not_running(self, api: TestClient) -> None:
|
||||
r = api.get("/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["running"] is False
|
||||
@respx.mock
|
||||
def test_status_when_backend_reachable(self) -> None:
|
||||
"""Test status endpoint when sd-server is reachable."""
|
||||
respx.get("http://mock-sd-server:1234/").mock(return_value=httpx.Response(200))
|
||||
|
||||
def test_running(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.pid = 999
|
||||
pm.proc = mock_proc
|
||||
pm.config = ServerConfig(model="/m.safetensors")
|
||||
r = api.get("/status")
|
||||
data = r.json()
|
||||
assert data["running"] is True
|
||||
assert data["pid"] == 999
|
||||
with TestClient(create_app(sd_server_url="http://mock-sd-server:1234")) as client:
|
||||
r = client.get("/status")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["sd_server_url"] == "http://mock-sd-server:1234"
|
||||
|
||||
def test_exited(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
pm.proc = mock_proc
|
||||
r = api.get("/status")
|
||||
data = r.json()
|
||||
assert data["running"] is False
|
||||
assert data["exit_code"] == 1
|
||||
@respx.mock
|
||||
def test_status_when_backend_unreachable(self) -> None:
|
||||
"""Test status endpoint when sd-server is not reachable."""
|
||||
respx.get("http://mock-sd-server:1234/").mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
|
||||
class TestReload:
|
||||
@patch.object(ProcessManager, "wait_ready", new_callable=AsyncMock, return_value=True)
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_reload_swaps_model(self, mock_popen: MagicMock, mock_ready: AsyncMock, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
pm.config = ServerConfig(model="/old.gguf", port=5555, args=["--fa"])
|
||||
mock_popen.return_value.pid = 42
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
r = api.post("/reload", json={"model": "/new.gguf"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["model"] == "/new.gguf"
|
||||
assert data["pid"] == 42
|
||||
# Verify new config preserved port and args from previous config
|
||||
assert pm.config is not None
|
||||
assert pm.config.port == 5555
|
||||
assert pm.config.args == ["--fa"]
|
||||
assert pm.config.model == "/new.gguf"
|
||||
|
||||
@patch.object(ProcessManager, "wait_ready", new_callable=AsyncMock, return_value=False)
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_reload_fails_when_not_ready(self, mock_popen: MagicMock, mock_ready: AsyncMock, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
pm.config = ServerConfig(model="/old.gguf")
|
||||
mock_popen.return_value.pid = 43
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
r = api.post("/reload", json={"model": "/bad.gguf"})
|
||||
assert r.status_code == 503
|
||||
assert "failed" in r.json()["error"]
|
||||
|
||||
def test_reload_requires_model(self, api: TestClient) -> None:
|
||||
r = api.post("/reload", json={})
|
||||
assert r.status_code == 422
|
||||
with TestClient(create_app(sd_server_url="http://mock-sd-server:1234")) as client:
|
||||
r = client.get("/status")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "error"
|
||||
assert "Connection refused" in data["error"]
|
||||
|
||||
|
||||
class TestProxy:
|
||||
def test_proxy_503_when_not_running(self, api: TestClient) -> None:
|
||||
r = api.get("/v1/models")
|
||||
assert r.status_code == 503
|
||||
assert "not running" in r.json()["error"]
|
||||
|
||||
def test_proxy_forwards_request(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.pid = 100
|
||||
pm.proc = mock_proc
|
||||
pm.config = ServerConfig(model="/m.gguf", port=1234)
|
||||
|
||||
"""Test proxy forwards GET requests to backend."""
|
||||
upstream_response = httpx.Response(
|
||||
200,
|
||||
json={"data": [{"id": "model-1"}]},
|
||||
@@ -114,6 +55,7 @@ class TestProxy:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = upstream_response
|
||||
api.app.state.client = mock_client # type: ignore[attr-defined]
|
||||
api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined]
|
||||
|
||||
r = api.get("/v1/models")
|
||||
assert r.status_code == 200
|
||||
@@ -121,52 +63,38 @@ class TestProxy:
|
||||
mock_client.request.assert_called_once()
|
||||
|
||||
def test_proxy_forwards_post_with_body(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.pid = 100
|
||||
pm.proc = mock_proc
|
||||
pm.config = ServerConfig(model="/m.gguf", port=1234)
|
||||
|
||||
"""Test proxy forwards POST requests with body."""
|
||||
upstream_response = httpx.Response(200, json={"ok": True})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = upstream_response
|
||||
api.app.state.client = mock_client # type: ignore[attr-defined]
|
||||
api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined]
|
||||
|
||||
r = api.post("/v1/chat/completions", json={"prompt": "hello"})
|
||||
r = api.post("/sdapi/v1/txt2img", json={"prompt": "hello"})
|
||||
assert r.status_code == 200
|
||||
mock_client.request.assert_called_once()
|
||||
|
||||
def test_proxy_503_on_connect_error(self, api: TestClient) -> None:
|
||||
"""Test proxy returns 503 when backend is unreachable."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.side_effect = httpx.ConnectError("Connection refused")
|
||||
api.app.state.client = mock_client # type: ignore[attr-defined]
|
||||
api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined]
|
||||
|
||||
class TestProcessManager:
|
||||
def test_status_not_running(self, pm: ProcessManager) -> None:
|
||||
assert pm.status() == {"running": False}
|
||||
r = api.get("/v1/models")
|
||||
assert r.status_code == 503
|
||||
assert "Cannot connect" in r.json()["error"]
|
||||
|
||||
def test_build_cmd(self, pm: ProcessManager) -> None:
|
||||
pm.config = ServerConfig(model="/m.gguf", port=1234, args=["--fa"])
|
||||
cmd = pm.build_cmd()
|
||||
assert "/m.gguf" in cmd
|
||||
assert "--fa" in cmd
|
||||
assert "1234" in cmd
|
||||
def test_proxy_504_on_timeout(self, api: TestClient) -> None:
|
||||
"""Test proxy returns 504 on timeout."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.side_effect = httpx.TimeoutException("Timeout")
|
||||
api.app.state.client = mock_client # type: ignore[attr-defined]
|
||||
api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined]
|
||||
|
||||
def test_build_cmd_no_config(self, pm: ProcessManager) -> None:
|
||||
with pytest.raises(RuntimeError, match="No config"):
|
||||
pm.build_cmd()
|
||||
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_start_and_stop(self, mock_popen: MagicMock, pm: ProcessManager) -> None:
|
||||
mock_popen.return_value.pid = 77
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
mock_popen.return_value.wait.return_value = 0
|
||||
pm.start(ServerConfig(model="/m.gguf"))
|
||||
assert pm.proc is not None
|
||||
assert pm.stop() is True
|
||||
assert pm.proc is None
|
||||
|
||||
def test_server_config_defaults(self) -> None:
|
||||
cfg = ServerConfig(model="/m.gguf")
|
||||
assert cfg.port == 1234
|
||||
assert cfg.args == []
|
||||
r = api.get("/v1/models")
|
||||
assert r.status_code == 504
|
||||
assert "Timeout" in r.json()["error"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user