mirror of https://github.com/razor-ai/soup.git
313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""Tests for SGLang backend — detection, runtime creation, serve --backend flag, FastAPI app."""
|
|
|
|
from unittest.mock import MagicMock
|
|
from unittest.mock import patch as mock_patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _has_fastapi():
|
|
try:
|
|
import fastapi # noqa: F401
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
# ─── SGLang Detection Tests ──────────────────────────────────────────────
|
|
|
|
|
|
class TestSGLangDetection:
|
|
"""Test SGLang availability detection."""
|
|
|
|
def test_check_sglang_available_when_installed(self):
|
|
"""check_sglang_available should return True when sglang is importable."""
|
|
mock_sglang = MagicMock()
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sglang}):
|
|
from soup_cli.utils.sglang import check_sglang_available
|
|
|
|
assert check_sglang_available() is True
|
|
|
|
def test_check_sglang_available_when_not_installed(self):
|
|
"""check_sglang_available should return False when sglang import fails."""
|
|
from soup_cli.utils.sglang import check_sglang_available
|
|
|
|
# Just verify the function exists and is callable
|
|
assert callable(check_sglang_available)
|
|
|
|
def test_get_sglang_version_when_installed(self):
|
|
"""get_sglang_version should return version string."""
|
|
mock_sglang = MagicMock()
|
|
mock_sglang.__version__ = "0.3.0"
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sglang}):
|
|
from soup_cli.utils.sglang import get_sglang_version
|
|
|
|
version = get_sglang_version()
|
|
assert version == "0.3.0"
|
|
|
|
def test_get_sglang_version_when_not_installed(self):
|
|
"""get_sglang_version should return 'not installed' when unavailable."""
|
|
from soup_cli.utils.sglang import get_sglang_version
|
|
|
|
# If sglang is not actually installed, should return "not installed"
|
|
try:
|
|
import sglang # noqa: F401
|
|
except ImportError:
|
|
assert get_sglang_version() == "not installed"
|
|
|
|
|
|
# ─── SGLang Runtime Creation Tests ───────────────────────────────────────
|
|
|
|
|
|
class TestSGLangRuntimeCreation:
|
|
"""Test SGLang runtime creation."""
|
|
|
|
def test_create_sglang_runtime_full_model(self):
|
|
"""create_sglang_runtime should create runtime for a full model."""
|
|
mock_runtime = MagicMock()
|
|
mock_sgl = MagicMock()
|
|
mock_sgl.Runtime.return_value = mock_runtime
|
|
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sgl}):
|
|
from soup_cli.utils.sglang import create_sglang_runtime
|
|
|
|
runtime, model_name = create_sglang_runtime(
|
|
model_path="/path/to/model",
|
|
)
|
|
|
|
mock_sgl.Runtime.assert_called_once()
|
|
call_kwargs = mock_sgl.Runtime.call_args[1]
|
|
assert call_kwargs["model_path"] == "/path/to/model"
|
|
assert call_kwargs["trust_remote_code"] is True
|
|
assert model_name == "/path/to/model"
|
|
|
|
def test_create_sglang_runtime_with_adapter(self):
|
|
"""create_sglang_runtime should pass lora_paths for adapter."""
|
|
mock_runtime = MagicMock()
|
|
mock_sgl = MagicMock()
|
|
mock_sgl.Runtime.return_value = mock_runtime
|
|
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sgl}):
|
|
from soup_cli.utils.sglang import create_sglang_runtime
|
|
|
|
runtime, model_name = create_sglang_runtime(
|
|
model_path="/path/to/adapter",
|
|
base_model="meta-llama/Llama-3.1-8B",
|
|
is_adapter=True,
|
|
)
|
|
|
|
call_kwargs = mock_sgl.Runtime.call_args[1]
|
|
assert call_kwargs["model_path"] == "meta-llama/Llama-3.1-8B"
|
|
assert call_kwargs["lora_paths"] == ["/path/to/adapter"]
|
|
assert model_name == "meta-llama/Llama-3.1-8B"
|
|
|
|
def test_create_sglang_runtime_tensor_parallel(self):
|
|
"""create_sglang_runtime should pass tp_size."""
|
|
mock_sgl = MagicMock()
|
|
mock_sgl.Runtime.return_value = MagicMock()
|
|
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sgl}):
|
|
from soup_cli.utils.sglang import create_sglang_runtime
|
|
|
|
create_sglang_runtime(
|
|
model_path="/model",
|
|
tensor_parallel_size=4,
|
|
)
|
|
|
|
call_kwargs = mock_sgl.Runtime.call_args[1]
|
|
assert call_kwargs["tp_size"] == 4
|
|
|
|
|
|
# ─── SGLang FastAPI App Tests ────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not _has_fastapi(),
|
|
reason="fastapi not installed",
|
|
)
|
|
class TestSGLangApp:
|
|
"""Test SGLang FastAPI app creation (requires fastapi)."""
|
|
|
|
def test_create_sglang_app_returns_fastapi(self):
|
|
"""create_sglang_app should return a FastAPI application."""
|
|
mock_runtime = MagicMock()
|
|
|
|
from soup_cli.utils.sglang import create_sglang_app
|
|
|
|
app = create_sglang_app(
|
|
runtime=mock_runtime,
|
|
runtime_model_name="test-model",
|
|
model_name="test",
|
|
)
|
|
|
|
assert app is not None
|
|
|
|
def test_sglang_app_has_health_endpoint(self):
|
|
"""SGLang app should have /health endpoint."""
|
|
mock_runtime = MagicMock()
|
|
|
|
from soup_cli.utils.sglang import create_sglang_app
|
|
|
|
app = create_sglang_app(
|
|
runtime=mock_runtime,
|
|
runtime_model_name="test-model",
|
|
model_name="test",
|
|
)
|
|
|
|
routes = [route.path for route in app.routes]
|
|
assert "/health" in routes
|
|
|
|
def test_sglang_app_has_models_endpoint(self):
|
|
"""SGLang app should have /v1/models endpoint."""
|
|
mock_runtime = MagicMock()
|
|
|
|
from soup_cli.utils.sglang import create_sglang_app
|
|
|
|
app = create_sglang_app(
|
|
runtime=mock_runtime,
|
|
runtime_model_name="test-model",
|
|
model_name="test",
|
|
)
|
|
|
|
routes = [route.path for route in app.routes]
|
|
assert "/v1/models" in routes
|
|
|
|
def test_sglang_app_has_chat_completions_endpoint(self):
|
|
"""SGLang app should have /v1/chat/completions endpoint."""
|
|
mock_runtime = MagicMock()
|
|
|
|
from soup_cli.utils.sglang import create_sglang_app
|
|
|
|
app = create_sglang_app(
|
|
runtime=mock_runtime,
|
|
runtime_model_name="test-model",
|
|
model_name="test",
|
|
)
|
|
|
|
routes = [route.path for route in app.routes]
|
|
assert "/v1/chat/completions" in routes
|
|
|
|
|
|
# ─── Serve Command SGLang Tests ──────────────────────────────────────────
|
|
|
|
|
|
class TestServeSGLangCommand:
|
|
"""Test serve command with --backend sglang."""
|
|
|
|
def test_serve_sglang_backend_in_help(self):
|
|
"""soup serve --help should mention sglang backend."""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["serve", "--help"])
|
|
assert "sglang" in result.output
|
|
|
|
def test_serve_sglang_not_installed_shows_error(self, tmp_path):
|
|
"""serve --backend sglang should show install hint when sglang missing."""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
# Create a dummy model path
|
|
model_dir = tmp_path / "model"
|
|
model_dir.mkdir()
|
|
|
|
runner = CliRunner()
|
|
with mock_patch(
|
|
"soup_cli.utils.sglang.check_sglang_available", return_value=False
|
|
):
|
|
result = runner.invoke(app, [
|
|
"serve",
|
|
"--model", str(model_dir),
|
|
"--backend", "sglang",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
@pytest.mark.skipif(not _has_fastapi(), reason="fastapi not installed")
|
|
def test_serve_unknown_backend_shows_error(self, tmp_path):
|
|
"""serve --backend invalid should show error listing valid backends."""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
model_dir = tmp_path / "model"
|
|
model_dir.mkdir()
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"serve",
|
|
"--model", str(model_dir),
|
|
"--backend", "invalid_backend",
|
|
])
|
|
assert result.exit_code != 0
|
|
assert "sglang" in result.output # Should list valid backends
|
|
|
|
|
|
# ─── Doctor SGLang Detection Tests ───────────────────────────────────────
|
|
|
|
|
|
class TestDoctorSGLang:
|
|
"""Test that soup doctor checks for SGLang."""
|
|
|
|
def test_doctor_includes_sglang(self):
|
|
"""DEPS list should include sglang."""
|
|
from soup_cli.commands.doctor import DEPS
|
|
|
|
pkg_names = [dep[1] for dep in DEPS]
|
|
assert "sglang" in pkg_names
|
|
|
|
def test_doctor_includes_librosa(self):
|
|
"""DEPS list should include librosa."""
|
|
from soup_cli.commands.doctor import DEPS
|
|
|
|
pkg_names = [dep[1] for dep in DEPS]
|
|
assert "librosa" in pkg_names
|
|
|
|
|
|
# ─── SGLang SSRF Validation Tests ────────────────────────────────────────
|
|
|
|
|
|
class TestSGLangSSRF:
|
|
"""Test SSRF protection in SGLang runtime creation."""
|
|
|
|
def test_create_runtime_blocks_http_model_path(self):
|
|
"""HTTP URLs for model_path should be rejected."""
|
|
mock_sgl = MagicMock()
|
|
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sgl}):
|
|
from soup_cli.utils.sglang import create_sglang_runtime
|
|
|
|
with pytest.raises(ValueError, match="not a URL"):
|
|
create_sglang_runtime(
|
|
model_path="http://evil.com/model",
|
|
)
|
|
|
|
def test_create_runtime_blocks_http_base_model(self):
|
|
"""HTTP URLs for base_model should be rejected."""
|
|
mock_sgl = MagicMock()
|
|
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sgl}):
|
|
from soup_cli.utils.sglang import create_sglang_runtime
|
|
|
|
with pytest.raises(ValueError, match="not a URL"):
|
|
create_sglang_runtime(
|
|
model_path="/path/to/adapter",
|
|
base_model="https://evil.com/model",
|
|
is_adapter=True,
|
|
)
|
|
|
|
def test_create_runtime_allows_hf_model_id(self):
|
|
"""HuggingFace model IDs should be allowed."""
|
|
mock_sgl = MagicMock()
|
|
mock_sgl.Runtime.return_value = MagicMock()
|
|
|
|
with mock_patch.dict("sys.modules", {"sglang": mock_sgl}):
|
|
from soup_cli.utils.sglang import create_sglang_runtime
|
|
|
|
runtime, name = create_sglang_runtime(
|
|
model_path="meta-llama/Llama-3.1-8B",
|
|
)
|
|
assert name == "meta-llama/Llama-3.1-8B"
|