feat(cli): add extensible serve backend plugins (#34753)

This commit is contained in:
Mick
2026-08-14 13:57:59 +08:00
committed by GitHub
parent 463981922c
commit 46d84f4b48
7 changed files with 796 additions and 85 deletions
@@ -0,0 +1,249 @@
# SPDX-License-Identifier: Apache-2.0
import unittest
from unittest.mock import MagicMock, patch
from sglang.cli.serve import serve
from sglang.cli.serve_backends import (
SERVE_BACKEND_API_VERSION,
RegisteredServeBackend,
ServeBackend,
ServeBackendDetection,
ServeBackendRegistry,
ServeRequest,
)
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def _make_entry_point(name, factory, distribution=None):
entry_point = MagicMock()
entry_point.name = name
entry_point.value = f"fake_{name}:create_backend"
entry_point.load.return_value = factory
if distribution is None:
entry_point.dist = None
else:
entry_point.dist = MagicMock()
entry_point.dist.name = distribution
return entry_point
def _backend(*, detector=None, requires_model_path=True, api_version=None):
if api_version is None:
api_version = SERVE_BACKEND_API_VERSION
return ServeBackend(
api_version=api_version,
run=MagicMock(),
detect=detector,
requires_model_path=requires_model_path,
)
class TestServeBackendRegistry(unittest.TestCase):
@patch("sglang.cli.serve_backends.entry_points")
def test_listing_does_not_import_out_of_tree_backends(self, mock_entry_points):
factory = MagicMock(return_value=_backend())
entry_point = _make_entry_point("omni", factory)
mock_entry_points.return_value = [entry_point]
registry = ServeBackendRegistry({"llm": _backend()})
self.assertEqual(registry.available_names, ("llm", "omni"))
entry_point.load.assert_not_called()
factory.assert_not_called()
@patch("sglang.cli.serve_backends.entry_points")
def test_explicit_selection_only_loads_selected_backend(self, mock_entry_points):
omni_entry_point = _make_entry_point(
"omni", MagicMock(return_value=_backend()), "sglang-omni"
)
other_entry_point = _make_entry_point(
"other", MagicMock(return_value=_backend()), "other-project"
)
mock_entry_points.return_value = [omni_entry_point, other_entry_point]
registry = ServeBackendRegistry({"llm": _backend()})
registered = registry.get("omni")
self.assertEqual(registered.name, "omni")
self.assertEqual(registered.distribution, "sglang-omni")
omni_entry_point.load.assert_called_once_with()
other_entry_point.load.assert_not_called()
@patch("sglang.cli.serve_backends.entry_points")
def test_auto_detection_selects_one_unique_match(self, mock_entry_points):
matching = _backend(
detector=MagicMock(return_value=ServeBackendDetection.MATCH)
)
non_matching = _backend(
detector=MagicMock(return_value=ServeBackendDetection.NO_MATCH)
)
mock_entry_points.return_value = [
_make_entry_point("omni", lambda: matching),
_make_entry_point("speech", lambda: non_matching),
]
registry = ServeBackendRegistry({"llm": _backend()})
request = ServeRequest(argv=("--model-path", "model"), model_path="model")
self.assertEqual(registry.auto_detect(request).name, "omni")
@patch("sglang.cli.serve_backends.entry_points")
def test_auto_detection_rejects_ambiguous_matches(self, mock_entry_points):
mock_entry_points.return_value = [
_make_entry_point(
"omni",
lambda: _backend(
detector=MagicMock(return_value=ServeBackendDetection.MATCH)
),
),
_make_entry_point(
"speech",
lambda: _backend(
detector=MagicMock(return_value=ServeBackendDetection.MATCH)
),
),
]
registry = ServeBackendRegistry({"llm": _backend()})
request = ServeRequest(argv=("--model-path", "model"), model_path="model")
with self.assertRaisesRegex(RuntimeError, "Multiple serve backends matched"):
registry.auto_detect(request)
@patch("sglang.cli.serve_backends.entry_points")
def test_broken_optional_detector_does_not_block_llm_fallback(
self, mock_entry_points
):
broken_entry_point = _make_entry_point("broken", MagicMock())
broken_entry_point.load.side_effect = ImportError("optional dependency missing")
mock_entry_points.return_value = [broken_entry_point]
llm = _backend()
registry = ServeBackendRegistry({"llm": llm})
request = ServeRequest(argv=("--model-path", "model"), model_path="model")
with self.assertLogs("sglang.cli.serve_backends", level="WARNING"):
selected = registry.auto_detect(request)
self.assertIs(selected.backend, llm)
with self.assertRaisesRegex(RuntimeError, "Failed to load serve backend"):
registry.get("broken")
@patch("sglang.cli.serve_backends.entry_points")
def test_duplicate_provider_names_are_rejected_when_selected(
self, mock_entry_points
):
mock_entry_points.return_value = [
_make_entry_point("omni", lambda: _backend(), "provider-a"),
_make_entry_point("omni", lambda: _backend(), "provider-b"),
]
registry = ServeBackendRegistry({"llm": _backend()})
with self.assertRaisesRegex(RuntimeError, "provider-a, provider-b"):
registry.get("omni")
@patch("sglang.cli.serve_backends.entry_points")
def test_out_of_tree_backend_cannot_replace_builtin(self, mock_entry_points):
mock_entry_points.return_value = [
_make_entry_point("llm", lambda: _backend(), "bad-provider")
]
with self.assertRaisesRegex(RuntimeError, "cannot replace"):
ServeBackendRegistry({"llm": _backend()})
@patch("sglang.cli.serve_backends.entry_points")
def test_backend_api_version_is_validated(self, mock_entry_points):
mock_entry_points.return_value = [
_make_entry_point(
"future",
lambda: _backend(api_version=SERVE_BACKEND_API_VERSION + 1),
)
]
registry = ServeBackendRegistry({"llm": _backend()})
with self.assertRaisesRegex(RuntimeError, "uses API version"):
registry.get("future")
class TestServeBackendDispatch(unittest.TestCase):
@patch("sglang.cli.serve.kill_process_tree")
@patch("sglang.srt.plugins.load_plugins")
@patch("sglang.cli.serve._create_backend_registry")
def test_explicit_backend_receives_normalized_model_path(
self, mock_registry_factory, mock_load_plugins, mock_kill
):
backend = _backend()
registry = MagicMock()
registry.get.return_value = RegisteredServeBackend("omni", backend)
mock_registry_factory.return_value = registry
serve(
None,
["Example/Model", "--model-type", "omni", "--pipeline-parallel", "2"],
)
request = backend.run.call_args.args[0]
self.assertEqual(
request.argv,
("--model-path", "Example/Model", "--pipeline-parallel", "2"),
)
self.assertEqual(request.model_path, "Example/Model")
self.assertTrue(request.model_path_is_positional)
mock_load_plugins.assert_called_once_with()
mock_kill.assert_called_once()
@patch("sglang.cli.serve.kill_process_tree")
@patch("sglang.srt.plugins.load_plugins")
@patch("sglang.cli.serve._create_backend_registry")
def test_explicit_backend_can_support_config_only_requests(
self, mock_registry_factory, _mock_load_plugins, _mock_kill
):
backend = _backend(requires_model_path=False)
registry = MagicMock()
registry.get.return_value = RegisteredServeBackend("pipeline", backend)
mock_registry_factory.return_value = registry
serve(None, ["--model-type=pipeline", "--config", "pipeline.yaml"])
request = backend.run.call_args.args[0]
self.assertIsNone(request.model_path)
self.assertEqual(request.argv, ("--config", "pipeline.yaml"))
@patch("sglang.cli.serve.kill_process_tree")
@patch("sglang.srt.plugins.load_plugins")
@patch("sglang.cli.serve._create_backend_registry")
def test_auto_detection_uses_registry(
self, mock_registry_factory, _mock_load_plugins, _mock_kill
):
backend = _backend()
registry = MagicMock()
registry.auto_detect.return_value = RegisteredServeBackend("omni", backend)
mock_registry_factory.return_value = registry
serve(None, ["Example/Model"])
registry.auto_detect.assert_called_once()
backend.run.assert_called_once()
@patch("sglang.cli.serve.kill_process_tree")
@patch("sglang.srt.plugins.load_plugins")
@patch("sglang.cli.serve._create_backend_registry")
def test_targeted_help_is_forwarded_without_startup_or_model_path(
self, mock_registry_factory, mock_load_plugins, mock_kill
):
backend = _backend()
registry = MagicMock()
registry.get.return_value = RegisteredServeBackend("omni", backend)
mock_registry_factory.return_value = registry
serve(None, ["--model-type", "omni", "--help"])
request = backend.run.call_args.args[0]
self.assertEqual(request.argv, ("--help",))
self.assertIsNone(request.model_path)
mock_load_plugins.assert_not_called()
mock_kill.assert_not_called()
if __name__ == "__main__":
unittest.main()