Files
sglang/test/registered/unit/entrypoints/test_server_info.py
T

589 lines
23 KiB
Python

"""Endpoint-level tests for `/server_info`.
`/server_info` is the introspection surface that external consumers
(SGLang's own deprecated `/get_server_info` alias, monitoring tools,
KV-aware routers) scrape to learn about the running server's
configuration. New `/server_info` behaviours should add their test
classes to this file as the surface grows.
Current coverage:
* `TestServerInfoKvEventsField` — the `kv_events` publisher descriptor
surfaced by `_build_kv_events_block`. Covers the full input matrix
end-to-end (happy path / disabled / malformed JSON / inproc endpoint /
port edge cases / missing-or-non-positive page_size) because the
helper has no separate test target; the handler is its only caller.
* `TestServerInfoExistingFieldsPreserved` — regression guard that no
field existing consumers depend on is silently dropped: every
`ServerArgs` dataclass field, `internal_states`, `version`, and the
pre-existing flat `kv_events_config` string all remain visible.
"""
import asyncio
import dataclasses
import json
import unittest
from types import SimpleNamespace
from sglang.srt.entrypoints import http_server
from sglang.srt.lora.lora_registry import LoRARef
from sglang.srt.managers.tokenizer_manager import TokenizerManager
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def _stub_tokenizer_manager(
server_args: ServerArgs, get_internal_state=None
) -> TokenizerManager:
"""A manager carrying the state `/server_info` and its writers read.
`__new__` skips `__init__`, which would open the ZMQ sockets and start
the handle loop.
"""
tokenizer_manager = TokenizerManager.__new__(TokenizerManager)
tokenizer_manager.server_args = server_args
tokenizer_manager.model_path = server_args.model_path
tokenizer_manager.served_model_name = server_args.served_model_name
tokenizer_manager.startup_time = None
tokenizer_manager.get_internal_state = get_internal_state
return tokenizer_manager
def _call_server_info_with(
server_args: ServerArgs,
internal_states: list[dict] | None = None,
config_updates: dict | None = None,
) -> dict:
"""Invoke `http_server.server_info()` against a stub global state.
Bypasses the FastAPI HTTP layer (no TestClient): the handler is an
`async def` that reads module-level `_global_state`, so wiring a
`SimpleNamespace` stub via `set_global_state` and awaiting the
coroutine directly is enough to exercise the handler logic without
booting a model server.
`config_updates` are applied the way production applies them -- through
`record_config_updates`, in a process that has published -- rather than
planted on the stub. The endpoint answers the record and does not read that
log, so what the real writer buys is that an assertion on the record is not
satisfied for free by an update that never landed.
"""
async def _fake_internal_state():
return internal_states or [{"max_req_input_len": 1024}]
tokenizer_manager = _stub_tokenizer_manager(server_args, _fake_internal_state)
stub_state = SimpleNamespace(
tokenizer_manager=tokenizer_manager,
scheduler_info={"max_req_input_len": 1024},
)
prior_state = http_server.get_global_state()
http_server.set_global_state(stub_state)
published = False
if config_updates:
# The writer runs in a published process, as it does in production.
publish(server_args, role="tokenizer")
published = True
tokenizer_manager.record_config_updates("test", **config_updates)
try:
return asyncio.run(http_server.server_info())
finally:
# Restore so a later test in the same process isn't surprised.
http_server._global_state = prior_state
if published:
reset_context()
class TestServerInfoKvEventsField(CustomTestCase):
"""The new `kv_events` field is wired correctly across the full
`_build_kv_events_block` input matrix.
"""
# ----- happy path --------------------------------------------------
def test_kv_events_key_present_when_publishing_enabled(self):
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://*:5557", "topic": "kv"}'
),
load_publish_endpoint="auto",
page_size=64,
dp_size=2,
)
info = _call_server_info_with(args)
self.assertIn("kv_events", info)
self.assertEqual(
info["kv_events"],
{
"publisher": "zmq",
"endpoint_host": "*",
"endpoint_port_base": 5557,
"topic": "kv",
"block_size": 64,
"dp_size": 2,
# Load range packed immediately after the KV range: 5557 + dp_size.
"load_endpoint_port_base": 5559,
"load_topic": "load",
},
)
def test_load_port_skips_an_overlapping_replay_range(self):
# Conventional replay = kv + 1: the load range must be advertised
# past the replay ROUTER range (5558 + dp_size), matching where
# SchedulerLoadPublisher actually binds — both sides resolve it via
# resolve_load_pub_range.
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://*:5557", '
'"replay_endpoint": "tcp://*:5558"}'
),
load_publish_endpoint="auto",
page_size=64,
dp_size=2,
)
info = _call_server_info_with(args)
self.assertEqual(info["kv_events"]["load_endpoint_port_base"], 5560)
self.assertEqual(info["kv_events"]["load_topic"], "load")
def test_explicit_load_publish_endpoint_moves_the_advertised_base(self):
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "zmq", "endpoint": "tcp://*:5557"}',
load_publish_endpoint="tcp://*:7000",
page_size=64,
dp_size=2,
)
info = _call_server_info_with(args)
self.assertEqual(info["kv_events"]["load_endpoint_port_base"], 7000)
self.assertEqual(info["kv_events"]["load_topic"], "load")
def test_load_keys_omitted_for_connect_style_kv_endpoint(self):
# A concrete host is connected to rather than bound: the KV-events
# descriptor is still valid (KV events work connect-style), but no
# load range can be bound there, so the load keys must be omitted
# rather than advertising a port nothing listens on.
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://10.0.0.5:5557"}'
),
load_publish_endpoint="auto",
page_size=64,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertIsNotNone(info["kv_events"])
self.assertEqual(info["kv_events"]["endpoint_host"], "10.0.0.5")
self.assertNotIn("load_endpoint_port_base", info["kv_events"])
self.assertNotIn("load_topic", info["kv_events"])
def test_ipv6_wildcard_endpoint_advertises_bracketed_host_and_load_keys(self):
# "[::]" is a bind-all wildcard: the descriptor must keep the
# brackets (consumers splice tcp://{host}:{port}) and the load
# range resolves right after the KV range.
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "zmq", "endpoint": "tcp://[::]:5557"}',
load_publish_endpoint="auto",
page_size=64,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertEqual(info["kv_events"]["endpoint_host"], "[::]")
self.assertEqual(info["kv_events"]["endpoint_port_base"], 5557)
self.assertEqual(info["kv_events"]["load_endpoint_port_base"], 5558)
def test_concrete_ipv6_endpoint_advertises_kv_but_not_load(self):
# A concrete IPv6 host works connect-style for KV events (advertised,
# brackets kept) but is not bindable for the load range — and "::"
# appearing inside the address must not be mistaken for a wildcard.
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://[2001:db8::5]:5557"}'
),
load_publish_endpoint="auto",
page_size=64,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertEqual(info["kv_events"]["endpoint_host"], "[2001:db8::5]")
self.assertNotIn("load_endpoint_port_base", info["kv_events"])
def test_load_keys_omitted_when_explicitly_off(self):
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "zmq", "endpoint": "tcp://*:5557"}',
load_publish_endpoint="off",
page_size=64,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertIsNotNone(info["kv_events"])
self.assertNotIn("load_endpoint_port_base", info["kv_events"])
self.assertNotIn("load_topic", info["kv_events"])
def test_load_keys_omitted_when_no_load_range_fits(self):
# kv base 65535 leaves no u16 room for a load range: the kv_events
# descriptor must still be served, with only the load keys omitted,
# so routers fall back to their in-flight counter for load.
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "zmq", "endpoint": "tcp://*:65535"}',
load_publish_endpoint="auto",
page_size=64,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertIsNotNone(info["kv_events"])
self.assertEqual(info["kv_events"]["endpoint_port_base"], 65535)
self.assertNotIn("load_endpoint_port_base", info["kv_events"])
self.assertNotIn("load_topic", info["kv_events"])
def test_kv_events_descriptor_carries_specific_host_and_topic(self):
# No --load-publish-endpoint: KV descriptor served, load keys absent.
# Upgrade safety rests on this default silence, so pin it here.
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://0.0.0.0:7777", "topic": "kv"}'
),
page_size=128,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertIsNotNone(info["kv_events"])
self.assertEqual(info["kv_events"]["endpoint_host"], "0.0.0.0")
self.assertEqual(info["kv_events"]["endpoint_port_base"], 7777)
self.assertEqual(info["kv_events"]["topic"], "kv")
self.assertEqual(info["kv_events"]["block_size"], 128)
self.assertEqual(info["kv_events"]["dp_size"], 1)
self.assertNotIn("load_endpoint_port_base", info["kv_events"])
self.assertNotIn("load_topic", info["kv_events"])
# ----- disabled / unconfigured -------------------------------------
def test_kv_events_is_null_when_no_publisher_configured(self):
args = ServerArgs(model_path="dummy") # no --kv-events-config
info = _call_server_info_with(args)
# The key must still be present so consumers can detect
# "publishing disabled" via a single shape check.
self.assertIn("kv_events", info)
self.assertIsNone(info["kv_events"])
def test_kv_events_is_null_when_publisher_explicitly_null(self):
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "null"}',
page_size=64,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
# ----- malformed config --------------------------------------------
def test_kv_events_is_null_for_malformed_json(self):
# Not JSON — the publisher would have failed at server startup,
# but /server_info must keep working.
args = ServerArgs(
model_path="dummy",
kv_events_config="not-json",
page_size=64,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
# ----- unreachable endpoints ---------------------------------------
def test_kv_events_is_null_for_inproc_endpoint(self):
# `inproc://` is not reachable across process boundaries, so the
# descriptor must hide it from external routers.
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "inproc://cache", "topic": ""}'
),
page_size=64,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
def test_kv_events_is_null_when_endpoint_missing_port(self):
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://0.0.0.0", "topic": ""}'
),
page_size=64,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
def test_kv_events_is_null_when_port_not_integer(self):
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://0.0.0.0:abc", "topic": ""}'
),
page_size=64,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
def test_kv_events_is_null_for_port_out_of_range(self):
# TCP ports are 1..65535; values outside the range can't bind, so
# the descriptor refuses to advertise them rather than handing
# subscribers a non-dialable address.
for bad_port in (0, -1, 65536, 1_000_000):
with self.subTest(port=bad_port):
args = ServerArgs(
model_path="dummy",
kv_events_config=(
f'{{"publisher": "zmq", "endpoint": "tcp://0.0.0.0:{bad_port}", "topic": ""}}'
),
page_size=64,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
# ----- bad scheduler context ---------------------------------------
def test_kv_events_is_null_when_page_size_missing_or_non_positive(self):
# Without a real positive `page_size` the descriptor's
# `block_size` would be a misleading placeholder; subscribers
# would hash prompts at the wrong granularity and miss every
# cache entry. Refuse to advertise instead.
good_cfg = '{"publisher": "zmq", "endpoint": "tcp://*:5557", "topic": ""}'
for bad_page_size in (None, 0, -1):
with self.subTest(page_size=bad_page_size):
args = ServerArgs(
model_path="dummy",
kv_events_config=good_cfg,
page_size=bad_page_size,
)
info = _call_server_info_with(args)
self.assertIsNone(info["kv_events"])
class TestServerInfoControlPlaneUpdates(CustomTestCase):
"""/server_info answers what was asked for, not what is in effect."""
def test_the_readback_reports_the_record_not_the_control_plane(self):
# A runtime weight-version change is reported by /model_info and its
# gRPC / Engine twins; this endpoint reports what the operator supplied.
server_args = ServerArgs(model_path="dummy", weight_version="v1")
payload = _call_server_info_with(
server_args, config_updates={"weight_version": "v2"}
)
self.assertEqual(payload["weight_version"], "v1")
self.assertEqual(server_args.weight_version, "v1")
def test_the_recorded_update_reaches_the_readback_overlay(self):
"""The update the case above records is one `/server_info` could see.
`resolved_config_dict` is the overlay the endpoint dropped; an update
that never reached it would satisfy the assertion above for free.
"""
server_args = ServerArgs(model_path="dummy", weight_version="v1")
tokenizer_manager = _stub_tokenizer_manager(server_args)
publish(server_args, role="tokenizer")
try:
tokenizer_manager.record_config_updates("test", weight_version="v2")
self.assertEqual(tokenizer_manager.config_value("weight_version"), "v2")
overlaid = tokenizer_manager.resolved_config_dict(
server_args.resolved_dict()
)
self.assertEqual(overlaid["weight_version"], "v2")
finally:
reset_context()
class TestServerInfoExistingFieldsPreserved(CustomTestCase):
"""Regression guard: the new `kv_events` field is additive — none of
the fields existing consumers depend on may be silently dropped.
Existing `/server_info` consumers in the wild include:
* SGLang's own deprecated `/get_server_info` (forwards to the
same handler).
* External monitoring tools that scrape the full ServerArgs.
* KV-aware routers reading `kv_events_config`, `page_size`,
`dp_size` directly to derive subscription info (this is the
path the new `kv_events` block enriches but does not replace).
"""
def test_every_server_args_field_appears_in_response(self):
# `server_args.resolved_dict()` is spread into the response; asserting
# every dataclass field surfaces is the strongest backward-compat
# guarantee that's still implementation-agnostic.
args = ServerArgs(model_path="dummy")
info = _call_server_info_with(args)
for field in dataclasses.fields(ServerArgs):
self.assertIn(
field.name,
info,
f"existing ServerArgs field '{field.name}' missing from "
f"/server_info response — kv_events patch must not "
f"shadow or drop ServerArgs fields",
)
def test_internal_states_and_version_keys_preserved(self):
# These two top-level keys predate the kv_events patch and are
# named individually (not spread from a dataclass), so a stray
# edit could remove them without breaking syntax. Lock them down.
args = ServerArgs(model_path="dummy")
info = _call_server_info_with(args)
self.assertIn("internal_states", info)
self.assertIn("version", info)
def test_kv_events_config_raw_field_still_surfaced(self):
# The new structured `kv_events` block sits alongside the
# pre-existing flat `kv_events_config` field (the raw CLI string
# already on ServerArgs). Both must remain visible so
# consumers that hand-parse the raw config keep working.
raw_cfg = '{"publisher": "zmq", "endpoint": "tcp://*:5557", "topic": ""}'
args = ServerArgs(
model_path="dummy",
kv_events_config=raw_cfg,
page_size=64,
dp_size=1,
)
info = _call_server_info_with(args)
self.assertIn("kv_events_config", info)
self.assertEqual(info["kv_events_config"], raw_cfg)
# And the new structured block is separately present:
self.assertIn("kv_events", info)
self.assertIsNotNone(info["kv_events"])
def test_lora_refs_are_json_serializable_dicts(self):
lora_ref = LoRARef(
lora_id="lora-id",
lora_name="adapter",
lora_path="/tmp/adapter",
pinned=True,
)
args = ServerArgs(model_path="dummy")
args.lora_paths = [lora_ref]
info = _call_server_info_with(
args,
internal_states=[{"lora_paths": [lora_ref]}],
)
expected = {
"lora_id": "lora-id",
"lora_name": "adapter",
"lora_path": "/tmp/adapter",
"pinned": True,
}
self.assertEqual(info["lora_paths"], [expected])
self.assertEqual(info["internal_states"][0]["lora_paths"], [expected])
json.dumps(info)
class TestLoadPublishEndpointValidation(CustomTestCase):
"""--load-publish-endpoint fails fast at the entrypoint, not silently in a
scheduler subprocess log."""
def test_requires_kv_events_config(self):
args = ServerArgs(model_path="dummy", load_publish_endpoint="tcp://*:6000")
with self.assertRaisesRegex(ValueError, "kv-events"):
args.check_load_publish_args()
def test_rejects_non_bindable_endpoint(self):
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "zmq", "endpoint": "tcp://*:5557"}',
load_publish_endpoint="tcp://10.0.0.5:6000",
)
with self.assertRaisesRegex(ValueError, "bindable"):
args.check_load_publish_args()
def test_rejects_endpoint_overlapping_the_kv_range(self):
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "zmq", "endpoint": "tcp://*:5557"}',
dp_size=4,
load_publish_endpoint="tcp://*:5558",
)
with self.assertRaisesRegex(ValueError, "overlaps"):
args.check_load_publish_args()
def test_rejects_null_publisher(self):
# publisher='null' disables KV events, so there is nothing to advertise
# through — accepting the opt-in would silently do nothing.
args = ServerArgs(
model_path="dummy",
kv_events_config='{"publisher": "null"}',
load_publish_endpoint="auto",
)
with self.assertRaisesRegex(ValueError, "null"):
args.check_load_publish_args()
def test_rejects_unparseable_kv_events_config(self):
args = ServerArgs(
model_path="dummy",
kv_events_config="{not json",
load_publish_endpoint="auto",
)
with self.assertRaisesRegex(ValueError, "not parseable"):
args.check_load_publish_args()
def test_off_and_valid_endpoint_pass(self):
for endpoint in (None, "off", "OFF", "auto", "tcp://*:6000"):
with self.subTest(endpoint=endpoint):
args = ServerArgs(
model_path="dummy",
kv_events_config=(
'{"publisher": "zmq", "endpoint": "tcp://*:5557"}'
),
load_publish_endpoint=endpoint,
)
args.check_load_publish_args() # must not raise
if __name__ == "__main__":
unittest.main()