config: the readback and the resolving view say what they are (#35027)

This commit is contained in:
Cheng Wan
2026-08-17 16:18:19 -07:00
committed by GitHub
parent cba3c5d5ac
commit c70c7d72a8
12 changed files with 775 additions and 97 deletions
+173 -50
View File
@@ -1,23 +1,28 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Single-shot `/server_info` introspection for newly-discovered workers.
//! Introspection for newly-discovered workers.
//!
//! Combines what used to be two separate round-trips (the worker
//! manager's `served_model_name` fetch and `KvEventIndex::add_worker`'s
//! `fetch_event_config`) into one HTTP request. The result is dispatched
//! by the manager: registry consumes `served_model_name`, the optional
//! `KvEventIndex` consumes the resolved `EventConfig`.
//! Two concurrent requests, because the worker answers two different
//! questions on two different endpoints: `/model_info` reports the identity
//! the worker currently serves under (a weight update moves it), while
//! `/server_info` reports its launch configuration — kv-event publisher and
//! disaggregation role. The result is dispatched by the manager: registry
//! consumes `served_model_name`, the optional `KvEventIndex` consumes the
//! resolved `EventConfig`.
//!
//! `served_model_name` is taken from `/model_info`, falling back to
//! `/server_info` for workers that predate the field there.
//!
//! # Failure semantics
//!
//! `fetch` is **infallible** — any error (network, non-2xx, JSON parse,
//! invalid worker URL) is logged at `warn!` and returns an empty
//! `ServerInfo` so the caller can register the worker with empty
//! `model_ids` and no kv-events attachment. Workers that need accuracy
//! around publisher availability use `kv_events::discovery::fetch_event_config`
//! directly (it returns `Result<Option<EventConfig>>`); the manager
//! intentionally doesn't.
//! invalid worker URL) is logged and yields `None` for whatever that request
//! was carrying, so the caller can register the worker with empty `model_ids`
//! and no kv-events attachment. The two requests fail independently. Workers
//! that need accuracy around publisher availability use
//! `kv_events::discovery::fetch_event_config` directly (it returns
//! `Result<Option<EventConfig>>`); the manager intentionally doesn't.
use std::time::Duration;
@@ -68,9 +73,9 @@ pub enum DisaggregationRole {
Decode,
}
/// Performs the single `/server_info` round-trip and projects the
/// response into both halves of `ServerInfo`. Cheap to clone — wraps a
/// `reqwest::Client` (which is internally `Arc`-backed).
/// Performs the two round-trips concurrently and projects the responses into
/// `ServerInfo`. Cheap to clone — wraps a `reqwest::Client` (which is
/// internally `Arc`-backed).
#[derive(Clone)]
pub struct WorkerIntrospector {
client: reqwest::Client,
@@ -95,9 +100,9 @@ impl WorkerIntrospector {
Self { client }
}
/// Fetch `/server_info` for the worker. Never returns an error:
/// any failure is logged at `warn!` and yields a default
/// `ServerInfo` with both halves `None`. Callers register the
/// Fetch `/model_info` and `/server_info` for the worker, concurrently.
/// Never returns an error: any failure is logged at `warn!` and yields
/// `None` for the fields that request carried. Callers register the
/// worker with empty model IDs and no event subscription on the
/// failure path; future re-discovery will retry.
///
@@ -106,24 +111,31 @@ impl WorkerIntrospector {
/// responses and JSON-parse errors short-circuit immediately —
/// the worker answered authoritatively, retrying won't help.
pub async fn fetch(&self, worker_url: &str) -> ServerInfo {
let server_info_url = format!("{}/server_info", worker_url.trim_end_matches('/'));
let parsed = match Self::fetch_with_retry(&self.client, &server_info_url, worker_url).await
{
Some(p) => p,
None => return ServerInfo::default(),
};
let base = worker_url.trim_end_matches('/');
let server_info_url = format!("{base}/server_info");
let model_info_url = format!("{base}/model_info");
let (parsed, model_info) = tokio::join!(
Self::fetch_with_retry::<ServerInfoBody>(&self.client, &server_info_url, worker_url),
Self::fetch_with_retry::<ModelInfoBody>(&self.client, &model_info_url, worker_url),
);
// A worker that answers one endpoint and not the other still gets
// registered with whatever did answer.
let parsed = parsed.unwrap_or_default();
let served_model_name = match parsed.served_model_name {
Some(name) if !name.is_empty() => Some(name),
Some(_) => {
warn!(
worker_url = %worker_url,
"introspect: /server_info has empty `served_model_name`; registering worker with empty model_ids"
);
None
}
None => None,
};
// `/model_info` is the effective identity; `/server_info` is the launch
// record, kept as the fallback for workers that predate the field
// there. An empty string is the same as absent on either.
let non_empty = |name: String| Some(name).filter(|name| !name.is_empty());
let served_model_name = model_info
.and_then(|body| body.served_model_name)
.and_then(non_empty)
.or_else(|| parsed.served_model_name.and_then(non_empty));
if served_model_name.is_none() {
warn!(
worker_url = %worker_url,
"introspect: no `served_model_name` on /model_info or /server_info; registering worker with empty model_ids"
);
}
// EAGLE-family speculative decoding ⇒ the worker hashes KV blocks over
// token bigrams; the router must mirror that on the selection side.
@@ -148,48 +160,53 @@ impl WorkerIntrospector {
}
}
/// Issue the `/server_info` GET with bounded retry on transient
/// errors. Returns `Some(body)` on success, `None` after exhausting
/// retries (the caller falls back to default `ServerInfo`).
async fn fetch_with_retry(
/// Issue one introspection GET with bounded retry on transient errors.
/// Returns `Some(body)` on success, `None` after exhausting retries or on
/// an authoritative answer (4xx, unparsable JSON). The caller decides what
/// a missing body costs — each endpoint carries different fields.
async fn fetch_with_retry<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
server_info_url: &str,
url: &str,
worker_url: &str,
) -> Option<ServerInfoBody> {
) -> Option<T> {
let mut delay = FETCH_BACKOFF_BASE;
for attempt in 1..=FETCH_MAX_ATTEMPTS {
match client.get(server_info_url).send().await {
match client.get(url).send().await {
Err(e) => {
warn!(
worker_url = %worker_url,
url = %url,
attempt,
error = %e,
"introspect: /server_info request failed; will retry"
"introspect: request failed; will retry"
);
}
Ok(resp) if resp.status().is_server_error() => {
warn!(
worker_url = %worker_url,
url = %url,
attempt,
status = %resp.status(),
"introspect: /server_info returned 5xx; will retry"
"introspect: returned 5xx; will retry"
);
}
Ok(resp) if !resp.status().is_success() => {
warn!(
worker_url = %worker_url,
url = %url,
status = %resp.status(),
"introspect: /server_info returned non-2xx; registering worker with empty model_ids"
"introspect: returned non-2xx; treating the endpoint as absent"
);
return None;
}
Ok(resp) => match resp.json::<ServerInfoBody>().await {
Ok(resp) => match resp.json::<T>().await {
Ok(body) => return Some(body),
Err(e) => {
warn!(
worker_url = %worker_url,
url = %url,
error = %e,
"introspect: /server_info JSON parse failed; registering worker with empty model_ids"
"introspect: JSON parse failed; treating the endpoint as absent"
);
return None;
}
@@ -202,8 +219,9 @@ impl WorkerIntrospector {
}
warn!(
worker_url = %worker_url,
url = %url,
attempts = FETCH_MAX_ATTEMPTS,
"introspect: /server_info failed after retries; registering worker with empty model_ids"
"introspect: failed after retries; treating the endpoint as absent"
);
None
}
@@ -306,11 +324,21 @@ pub(crate) fn resolve_event_config(
}
}
/// Projection of `/model_info` used by the introspector: the identity the
/// worker currently serves under, which a weight update moves.
#[derive(Debug, Default, Deserialize)]
struct ModelInfoBody {
#[serde(default)]
served_model_name: Option<String>,
}
/// Projection of `/server_info` used by the introspector. Every field is
/// `#[serde(default)]` so a worker that exposes only some of them still
/// deserialises; downstream callers handle `None` as "absent".
#[derive(Debug, Default, Deserialize)]
struct ServerInfoBody {
/// The launch record's value, and the fallback for a worker whose
/// `/model_info` predates the field.
#[serde(default)]
served_model_name: Option<String>,
#[serde(default)]
@@ -359,17 +387,36 @@ mod tests {
use tokio::net::TcpListener;
use tokio::sync::oneshot;
/// A worker that serves `/server_info` only: an SGLang that predates
/// `served_model_name` on `/model_info`.
async fn spawn_fake_worker(body: Value) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
spawn_fake_worker_with_model_info(body, None).await
}
async fn spawn_fake_worker_with_model_info(
server_info: Value,
model_info: Option<Value>,
) -> (String, oneshot::Sender<()>) {
let body = Arc::new(server_info);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
let mut app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move { Json((*body).clone()) }
}),
);
if let Some(model_info) = model_info {
let model_info = Arc::new(model_info);
app = app.route(
"/model_info",
get(move || {
let model_info = model_info.clone();
async move { Json((*model_info).clone()) }
}),
);
}
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
@@ -500,6 +547,82 @@ mod tests {
);
}
#[tokio::test]
async fn fetch_prefers_model_info_over_server_info_for_the_name() {
let (url, _shutdown) = spawn_fake_worker_with_model_info(
json!({"served_model_name": "at-launch"}),
Some(json!({"served_model_name": "after-weight-update"})),
)
.await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(
got.served_model_name.as_deref(),
Some("after-weight-update")
);
}
#[tokio::test]
async fn fetch_falls_back_to_server_info_when_model_info_lacks_the_name() {
let (url, _shutdown) = spawn_fake_worker_with_model_info(
json!({"served_model_name": "at-launch"}),
Some(json!({"model_path": "/models/m"})),
)
.await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(got.served_model_name.as_deref(), Some("at-launch"));
}
/// An empty name on either surface is the same as absent.
#[tokio::test]
async fn fetch_treats_an_empty_name_as_absent_on_both() {
let (url, _shutdown) = spawn_fake_worker_with_model_info(
json!({"served_model_name": "at-launch"}),
Some(json!({"served_model_name": ""})),
)
.await;
assert_eq!(
fast_introspector()
.fetch(&url)
.await
.served_model_name
.as_deref(),
Some("at-launch"),
);
let (url, _shutdown) =
spawn_fake_worker_with_model_info(json!({"served_model_name": ""}), None).await;
assert!(fast_introspector()
.fetch(&url)
.await
.served_model_name
.is_none());
}
#[tokio::test]
async fn fetch_keeps_the_name_when_server_info_is_absent() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/model_info",
get(|| async { Json(json!({"served_model_name": "m"})) }),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
let _shutdown = tx;
let got = fast_introspector()
.fetch(&format!("http://127.0.0.1:{port}"))
.await;
assert_eq!(got.served_model_name.as_deref(), Some("m"));
assert!(got.event_config.is_none());
assert!(got.disaggregation_role.is_none());
}
#[tokio::test]
async fn fetch_only_served_model_name_when_kv_events_absent() {
let (url, _shutdown) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
+22 -3
View File
@@ -1364,9 +1364,7 @@ class Engine(EngineScoreMixin, EngineBase):
)
return msgspec_to_builtins(
{
**self.tokenizer_manager.resolved_config_dict(
dataclasses.asdict(self.tokenizer_manager.server_args)
),
**dataclasses.asdict(self.tokenizer_manager.server_args),
**self._scheduler_init_result.scheduler_infos[0],
"startup_time": self.tokenizer_manager.startup_time,
"internal_states": internal_states,
@@ -1374,6 +1372,27 @@ class Engine(EngineScoreMixin, EngineBase):
}
)
def get_model_info(self):
"""What this engine is serving right now.
`get_server_info` answers with the record: the launch configuration,
parsers included -- `auto` resolves into the record before the config
is published. This surface adds what the control plane changed after
publication: the model a weight update swapped in, its load format, an
operator-set weight version. The HTTP and gRPC model-info endpoints
answer with the same fields.
"""
tm = self.tokenizer_manager
return {
"model_path": tm.model_path,
"served_model_name": tm.served_model_name,
"is_generation": tm.is_generation,
"weight_version": tm.config_value("weight_version"),
"load_format": tm.config_value("load_format"),
"reasoning_parser": tm.config_value("reasoning_parser"),
"tool_call_parser": tm.config_value("tool_call_parser"),
}
def init_weights_update_group(
self,
master_address: str,
+5 -3
View File
@@ -397,9 +397,13 @@ class RuntimeHandle:
model_config = self.tokenizer_manager.model_config
result = {
"model_path": self.tokenizer_manager.model_path,
"served_model_name": self.tokenizer_manager.served_model_name,
"tokenizer_path": self.tokenizer_manager.server_args.tokenizer_path,
"is_generation": self.tokenizer_manager.is_generation,
"weight_version": self.tokenizer_manager.config_value("weight_version"),
"load_format": self.tokenizer_manager.config_value("load_format"),
"reasoning_parser": self.tokenizer_manager.config_value("reasoning_parser"),
"tool_call_parser": self.tokenizer_manager.config_value("tool_call_parser"),
"model_type": getattr(model_config.hf_config, "model_type", None),
"architectures": getattr(model_config.hf_config, "architectures", None),
}
@@ -413,9 +417,7 @@ class RuntimeHandle:
return json.dumps(result, default=str)
def get_server_info(self) -> str:
result: Dict[str, Any] = self.tokenizer_manager.resolved_config_dict(
dataclasses.asdict(self.tokenizer_manager.server_args)
)
result: Dict[str, Any] = dataclasses.asdict(self.tokenizer_manager.server_args)
result.update(self.scheduler_info)
return json.dumps(msgspec_to_builtins(result), default=str)
+19 -4
View File
@@ -741,12 +741,22 @@ async def model_info():
model_config = _global_state.tokenizer_manager.model_config
result = {
"model_path": _global_state.tokenizer_manager.model_path,
# Manager-owned, and moved by a weight update alongside `model_path`:
# this is where a client reads the identity the server answers under.
"served_model_name": _global_state.tokenizer_manager.served_model_name,
"tokenizer_path": _global_state.tokenizer_manager.server_args.tokenizer_path,
"is_generation": _global_state.tokenizer_manager.is_generation,
"preferred_sampling_params": _global_state.tokenizer_manager.server_args.preferred_sampling_params,
"weight_version": _global_state.tokenizer_manager.config_value(
"weight_version"
),
"load_format": _global_state.tokenizer_manager.config_value("load_format"),
"reasoning_parser": _global_state.tokenizer_manager.config_value(
"reasoning_parser"
),
"tool_call_parser": _global_state.tokenizer_manager.config_value(
"tool_call_parser"
),
"has_image_understanding": model_config.is_image_understandable_model,
"has_audio_understanding": model_config.is_audio_understandable_model,
"model_type": getattr(model_config.hf_config, "model_type", None),
@@ -785,7 +795,14 @@ async def get_server_info():
@app.get("/server_info")
async def server_info():
"""Get the server information."""
"""The startup configuration, plus live scheduler state.
The `ServerArgs` fields here are the record: what the launcher was given,
with resolution written back into it. Fields the control plane changes
after publication -- the model a weight update swapped in, its load format,
an operator-set weight version -- are reported by `/model_info`, and the
HiCache mirror by `GET /hicache/storage-backend`.
"""
# Returns internal states per DP.
internal_states: List[Dict[Any, Any]] = (
await _global_state.tokenizer_manager.get_internal_state()
@@ -796,9 +813,7 @@ async def server_info():
# server_args.model_config is not serializable but should be excluded by asdict.
return msgspec_to_builtins(
{
**_global_state.tokenizer_manager.resolved_config_dict(
dataclasses.asdict(server_args)
),
**dataclasses.asdict(server_args),
**_global_state.scheduler_info,
"startup_time": _global_state.tokenizer_manager.startup_time,
"internal_states": internal_states,
+14 -8
View File
@@ -98,7 +98,11 @@ from typing_extensions import Literal
from sglang.srt.environ import envs
from sglang.srt.observability.func_timer import enable_func_timer
from sglang.srt.platforms import current_platform
from sglang.srt.runtime_context import get_parallel
from sglang.srt.runtime_context import (
configured_tp_size,
get_exec,
get_parallel,
)
from sglang.srt.utils.video_decoder import _BACKEND, VideoDecoderWrapper
if TYPE_CHECKING:
@@ -3578,10 +3582,12 @@ def bind_or_assign(target, source):
# TODO(hebiao064): Accelerate FA3 Spec Decode with topk > 1.
# TODO(hebiao064): Improve the acc rate for FA3 Spec Decode with topk == 1 and page_size > 1.
def is_no_spec_infer_or_topk_one(server_args):
return server_args.speculative_eagle_topk is None or (
server_args.speculative_eagle_topk == 1
and (server_args.page_size == 1 or server_args.page_size is None)
def is_no_spec_infer_or_topk_one(cfg):
"""``cfg`` is a resolving config view, not the published record: the
resolution pipeline is the only caller, and it asks mid-resolution."""
return cfg.speculative_eagle_topk is None or (
cfg.speculative_eagle_topk == 1
and (cfg.page_size == 1 or cfg.page_size is None)
)
@@ -3752,7 +3758,7 @@ def require_mlp_tp_gather(server_args: ServerArgs):
else:
return (
get_parallel().moe_dense_tp_size
> server_args.tp_size // get_parallel().dp_size
> configured_tp_size() // get_parallel().dp_size
)
else:
return False
@@ -3778,7 +3784,7 @@ def require_attn_tp_gather(server_args: ServerArgs):
or get_parallel().moe_dense_tp_size is not None
):
if get_parallel().enable_dp_attention:
return get_parallel().dp_size < server_args.tp_size
return get_parallel().dp_size < configured_tp_size()
else:
return True
else:
@@ -3797,7 +3803,7 @@ def require_mlp_sync(server_args: ServerArgs):
def get_cuda_graph_batch_size_alignment(server_args: ServerArgs) -> int:
alignment = 1
if server_args.enable_two_batch_overlap:
if get_exec().overlap.enable_two_batch_overlap:
alignment *= 2
if require_gathered_buffer(server_args):
alignment *= get_parallel().attn_tp_size
+16 -1
View File
@@ -70,10 +70,18 @@ async fn await_control_result(
/// `GET /get_model_info` (+ `/model_info` alias) — static model metadata from
/// `server_args` (no scheduler round-trip); `is_generation` always true.
///
/// Under `SGLANG_RUST_SERVER=1` this is the only `/model_info` a client can
/// reach — `launch_server` never mounts the Python app — so it answers the same
/// keys. It answers them from the launch blob, which is the whole of this
/// server's config knowledge: `server_args` is parsed once at boot and held
/// behind an `Arc`, and no route mounted here changes weights or parsers, so
/// the launch values are also the current ones.
async fn model_info(State(state): State<AppState>) -> Response {
let sa = &state.server_args;
let body = serde_json::json!({
"model_path": sa.model_path,
"served_model_name": sa.served_model_name,
"tokenizer_path": sa.tokenizer_path,
"is_generation": true,
// Python's `TokenizerManager` merges this into every request
@@ -81,7 +89,14 @@ async fn model_info(State(state): State<AppState>) -> Response {
// `RustServer.launch` REFUSES to start when it is set. It can therefore
// only be null here — echoing it keeps the field's shape.
"preferred_sampling_params": sa.preferred_sampling_params,
"weight_version": serde_json::Value::Null,
// Python answers this through `config_value`, so a control-plane write
// moves it there; here it is the launch value.
"weight_version": sa.weight_version,
"load_format": sa.load_format,
// `auto` never reaches the blob: `resolve_auto_parsers` writes the
// selected parser into `server_args` before the scheduler forks.
"reasoning_parser": sa.reasoning_parser,
"tool_call_parser": sa.tool_call_parser,
});
(
StatusCode::OK,
+12
View File
@@ -72,6 +72,18 @@ pub struct ServerArgs {
/// HF revision, used only when `tokenizer_path` is a repo id. `None` → main.
#[serde(default)]
pub revision: Option<String>,
/// Weight format selected by `--load-format`, reported by `/get_model_info`.
/// The blob carries the post-`__post_init__` value (`auto` is already
/// narrowed to `gguf` / `mistral` / `runai_streamer` / `remote` where the
/// checkpoint demands it). Not consumed for loading -- the scheduler owns
/// that; `None` only when the blob omits the key.
#[serde(default)]
pub load_format: Option<String>,
/// Operator-supplied weight version, reported by `/model_info`. Defaults to
/// `"default"` on the Python side, so it is present in every blob; `None`
/// only when the blob omits the key.
#[serde(default)]
pub weight_version: Option<String>,
/// HTTP bind address (see [`Self::bind`]).
#[serde(default = "default_host")]
pub host: String,
@@ -47,6 +47,10 @@ pub struct ServerInfo {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ModelInfo {
pub model_path: Option<String>,
/// The identity the worker currently serves under. A weight update moves
/// this (and `model_path`) on the worker's manager, so it is answered here
/// rather than by `/server_info`, which reports the launch record.
pub served_model_name: Option<String>,
pub tokenizer_path: Option<String>,
pub is_generation: Option<bool>,
pub model_type: Option<String>,
@@ -277,18 +281,50 @@ impl StepExecutor<LocalWorkerWorkflowData> for DiscoverMetadataStep {
ConnectionMode::Http => {
let mut labels = HashMap::new();
// Fetch from /server_info for server-related metadata
if let Ok(server_info) =
get_server_info(&config.url, config.api_key.as_deref()).await
{
if let Some(model_path) = server_info.model_path.filter(|s| !s.is_empty()) {
labels.insert("model_path".to_string(), model_path);
}
if let Some(served_model_name) =
server_info.served_model_name.filter(|s| !s.is_empty())
{
labels.insert("served_model_name".to_string(), served_model_name);
// /server_info reports the launch configuration; /model_info
// reports the model the worker is serving now. Both are read
// here, and a failure of one does not lose the other.
let server_info = get_server_info(&config.url, config.api_key.as_deref())
.await
.ok();
let model_info = get_model_info(&config.url, config.api_key.as_deref())
.await
.ok();
// Identity comes from /model_info, which a weight update moves;
// /server_info answers the launch record and is the fallback for
// workers that predate the fields there.
let present = |value: Option<&String>| value.filter(|s| !s.is_empty()).cloned();
let identity = [
(
"model_path",
present(model_info.as_ref().and_then(|m| m.model_path.as_ref())).or_else(
|| present(server_info.as_ref().and_then(|s| s.model_path.as_ref())),
),
),
(
"served_model_name",
present(
model_info
.as_ref()
.and_then(|m| m.served_model_name.as_ref()),
)
.or_else(|| {
present(
server_info
.as_ref()
.and_then(|s| s.served_model_name.as_ref()),
)
}),
),
];
for (key, value) in identity {
if let Some(value) = value {
labels.insert(key.to_string(), value);
}
}
if let Some(server_info) = server_info {
if let Some(tp_size) = server_info.tp_size {
labels.insert("tp_size".to_string(), tp_size.to_string());
}
@@ -303,9 +339,7 @@ impl StepExecutor<LocalWorkerWorkflowData> for DiscoverMetadataStep {
}
}
// Fetch from /model_info for model-related metadata
if let Ok(model_info) = get_model_info(&config.url, config.api_key.as_deref()).await
{
if let Some(model_info) = model_info {
if let Some(tokenizer_path) =
model_info.tokenizer_path.filter(|s| !s.is_empty())
{
@@ -0,0 +1,401 @@
"""Every serving surface can answer what is running, not only what was asked.
`/server_info` and its gRPC and in-process twins report the startup record,
parsers included -- the launcher resolves `auto` into the record before
publishing. What changes after publication -- the model a weight update
swapped in, its load format, an operator-set weight version -- is reported by
the model-info surface, and there is one per entry point: HTTP, gRPC and
`Engine`. Adding a field to one and forgetting the others leaves that entry
point's users with no way to see it, which no test notices because each
surface passes its own tests.
The required set has two halves. The derived half comes from the control-plane
writers: whatever a process writes with `override` after publication is exactly
what can differ from the record, and both the keyword and the `**`-expansion
shapes resolve statically here. The second half is a policy, not a derivation --
what any one surface reports effectively, all of them owe their users -- so a
field every surface drops at once leaves the set with it. Each surface must both
carry the key and take its value from the effective config: reading
`server_args.<field>` under the right key reports the startup value with a
straight face.
"""
import ast
import inspect
import pathlib
import re
import unittest
import sglang
from sglang.srt.entrypoints.engine import Engine
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=3, suite="base-a-test-cpu")
_PACKAGE_ROOT = pathlib.Path(sglang.__file__).resolve().parent
_RUST_MODEL_INFO = (
pathlib.Path(__file__).resolve().parents[4]
/ "rust/sglang-server/src/api_server/common.rs"
)
# The tokenizer process is the one whose control-plane writes a served request
# can observe; a field it overrides there is a post-launch fact.
_TOKENIZER_WRITERS = (
"srt/managers/tokenizer_manager.py",
"srt/managers/tokenizer_control_mixin.py",
"srt/entrypoints/http_server.py",
"srt/entrypoints/engine.py",
)
# The model path and the served name stay manager attributes: a weight update
# moves them on the manager rather than through `override`, so the writer
# derivation cannot see them. They are subtracted from the derived half and
# asserted directly instead -- an exemption whose premise ("every surface
# reports them") is checked, not assumed. It was not true when it was written:
# only `Engine` carried `served_model_name`, and the router had to read the
# launch record off `/server_info` to learn a name a weight update had moved.
_MANAGER_ATTRIBUTES = {"model_path", "served_model_name"}
def _hicache_status_fields() -> set:
"""The fields `GET /hicache/storage-backend` answers with.
The HiCache mirror is written post-publish and reported by its own
endpoint, so the model-info surfaces do not owe it. Taking the set from
that handler is what keeps the exemption honest: a field it stops
reporting falls back to them.
"""
tree = ast.parse((_PACKAGE_ROOT / "srt/entrypoints/http_server.py").read_text())
for fn in ast.walk(tree):
if (
not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef))
or fn.name != "hicache_storage_backend_status"
):
continue
fields = {
elt.value
for comp in ast.walk(fn)
if isinstance(comp, ast.DictComp)
for gen in comp.generators
for elt in getattr(gen.iter, "elts", [])
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
}
assert fields, "the HiCache status handler names no field"
return fields
raise AssertionError(
"no `hicache_storage_backend_status` handler: the HiCache fields have "
"no endpoint of their own and fall to the model-info surfaces"
)
def _expanded_write_keys(rel: str, tree: ast.AST, call: ast.Call, kw: ast.keyword):
"""The field names behind a `**` at a control-plane writer call.
Resolves a dict literal -- constant keys, or a key bound by an enclosing
literal `for` -- and a name bound to a dict literal in the enclosing
function, including constant-subscript stores onto it. A `**` that
forwards its own function's `**kwargs` names no field: its callers do.
Anything else raises, because a skipped expansion shrinks the required
set instead of failing.
"""
enclosing = None
for fn in ast.walk(tree):
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
fn.lineno <= call.lineno <= (fn.end_lineno or fn.lineno)
):
if enclosing is None or fn.lineno > enclosing.lineno:
enclosing = fn
def loop_bound(name: str) -> set:
"""The values a `for name, ... in (<literal tuples>)` around the call binds."""
values = set()
for node in ast.walk(tree):
if not isinstance(node, ast.For):
continue
target = node.target
names = (
[target]
if isinstance(target, ast.Name)
else list(getattr(target, "elts", []))
)
if not names or not isinstance(names[0], ast.Name) or names[0].id != name:
continue
if not (node.lineno <= call.lineno <= (node.end_lineno or node.lineno)):
continue
for item in getattr(node.iter, "elts", []):
first = (
item.elts[0] if isinstance(item, ast.Tuple) and item.elts else item
)
if isinstance(first, ast.Constant) and isinstance(first.value, str):
values.add(first.value)
return values
def dict_keys(node: ast.Dict) -> set:
keys = set()
for key in node.keys:
if isinstance(key, ast.Constant):
keys.add(key.value)
continue
assert isinstance(
key, ast.Name
), f"non-literal dict key in a writer expansion at {rel}:{call.lineno}"
bound = loop_bound(key.id)
assert bound, (
f"dict key {key.id!r} at {rel}:{call.lineno} is not bound by a "
"literal loop; extend the resolver"
)
keys |= bound
return keys
if isinstance(kw.value, ast.Dict):
return dict_keys(kw.value)
assert isinstance(
kw.value, ast.Name
), f"unresolvable writer expansion at {rel}:{call.lineno}"
assert (
enclosing is not None
), f"writer expansion outside any function at {rel}:{call.lineno}"
name = kw.value.id
if enclosing.args.kwarg is not None and enclosing.args.kwarg.arg == name:
return set()
keys = set()
found = False
for node in ast.walk(enclosing):
if not (isinstance(node, ast.Assign) and len(node.targets) == 1):
continue
target = node.targets[0]
if isinstance(target, ast.Name) and target.id == name:
assert isinstance(node.value, ast.Dict), (
f"writer expansion {name!r} at {rel}:{call.lineno} is assigned "
"something other than a dict literal; extend the resolver"
)
found = True
keys |= dict_keys(node.value)
elif (
isinstance(target, ast.Subscript)
and isinstance(target.value, ast.Name)
and target.value.id == name
and isinstance(target.slice, ast.Constant)
):
keys.add(target.slice.value)
assert found, (
f"writer expansion {name!r} at {rel}:{call.lineno} has no dict-literal "
"assignment in its function; extend the resolver"
)
return keys
def _overridden_fields() -> set:
"""Fields the tokenizer process writes after publication."""
fields = set()
for rel in _TOKENIZER_WRITERS:
tree = ast.parse((_PACKAGE_ROOT / rel).read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
name = (
node.func.attr
if isinstance(node.func, ast.Attribute)
else getattr(node.func, "id", "")
)
if name not in ("override", "record_config_updates"):
continue
for kw in node.keywords:
if kw.arg == "source":
# The provenance label, not a config field.
continue
if kw.arg:
fields.add(kw.arg)
else:
fields |= _expanded_write_keys(rel, tree, node, kw)
# Only the mirror's own fields earn the endpoint exemption: adding an
# unrelated field to that handler must not buy it a pass here.
hicache = {f for f in _hicache_status_fields() if f.startswith("hicache_")}
return fields - _MANAGER_ATTRIBUTES - hicache
def _effective_reads_in(source: str, func_name: str) -> set:
"""Fields the function reports *and* reads through the effective config.
A key whose value comes off the `ServerArgs` record does not count: that is
the startup value under a name that promises the running one.
"""
tree = ast.parse(source)
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if fn.name != func_name:
continue
reported = set()
for node in ast.walk(fn):
if not isinstance(node, ast.Dict):
continue
for key, value in zip(node.keys, node.values):
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
continue
for inner in ast.walk(value):
if (
isinstance(inner, ast.Call)
and isinstance(inner.func, ast.Attribute)
and inner.func.attr in ("config_value", "config_leaf")
and inner.args
and isinstance(inner.args[0], ast.Constant)
and inner.args[0].value == key.value
):
reported.add(key.value)
return reported
return set()
def _rust_model_info_keys() -> set:
"""The keys the rust server's `/model_info` handler answers with.
Scanned as text, from the handler's signature to the next item in the
file, with each line cut at its first `//`. A key is always left of its
value, so cutting inside a string can only drop keys, never invent one.
The signature must appear exactly once: a rust handler that moved or was
renamed would otherwise contribute an empty set and let the parity check
pass on nothing.
"""
source = _RUST_MODEL_INFO.read_text()
marker = "async fn model_info("
found = source.count(marker)
assert found == 1, (
f"{_RUST_MODEL_INFO.name} declares `{marker}` {found} times; the rust "
"/model_info surface is the one users reach under SGLANG_RUST_SERVER=1 "
"and is no longer being read"
)
body = source[source.index(marker) + len(marker) :]
following = re.search(r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s", body, re.M)
if following is not None:
body = body[: following.start()]
code = "\n".join(line.split("//")[0] for line in body.splitlines())
keys = set(re.findall(r'"([A-Za-z_][A-Za-z0-9_]*)"\s*:', code))
assert keys, "the rust /model_info handler names no field"
return keys
def _reported_keys_in(source: str, func_name: str) -> set:
"""Every string key the function's response dicts carry, whatever the value.
The manager-owned attributes are read off the manager, not through
`config_value`, so `_effective_reads_in` does not see them; this is how they
are checked.
"""
tree = ast.parse(source)
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if fn.name != func_name:
continue
return {
key.value
for node in ast.walk(fn)
if isinstance(node, ast.Dict)
for key in node.keys
if isinstance(key, ast.Constant) and isinstance(key.value, str)
}
return set()
def _model_info_sources() -> dict:
"""`(source, function name)` per Python model-info surface."""
return {
"http /model_info": (
(_PACKAGE_ROOT / "srt/entrypoints/http_server.py").read_text(),
"model_info",
),
"grpc get_model_info": (
(_PACKAGE_ROOT / "srt/entrypoints/grpc_bridge.py").read_text(),
"get_model_info",
),
"Engine.get_model_info": (
inspect.getsource(Engine.get_model_info).lstrip(),
"get_model_info",
),
}
def _model_info_surfaces() -> dict:
"""Each Python entry point's model-info surface, by what it reports
effectively."""
return {
name: _effective_reads_in(source, func_name)
for name, (source, func_name) in _model_info_sources().items()
}
class TestEffectiveStateSurfaces(CustomTestCase):
def test_each_entry_point_reports_the_post_launch_facts(self):
surfaces = _model_info_surfaces()
written = _overridden_fields()
# Both writer shapes are in reach of the scan: `load_format` is a
# literal keyword, the parsers arrive as `**{attr: ...}` under a key
# the enclosing loop binds.
self.assertLessEqual(
{"load_format", "reasoning_parser", "tool_call_parser"},
written,
"the derivation stopped finding the control-plane writers",
)
# What any surface reports effectively, all of them owe their users;
# what the control plane overrides, every surface owes regardless.
required = set().union(*surfaces.values()) | written
missing = {
name: sorted(required - reported)
for name, reported in surfaces.items()
if required - reported
}
self.assertEqual(
missing,
{},
"a serving surface cannot report what it is running: " f"{missing}",
)
def test_every_surface_reports_the_manager_owned_identity(self):
"""The identity a weight update moves is answered where it is read.
`model_path` and `served_model_name` live on the tokenizer manager, so
the writer derivation cannot reach them and they are subtracted from the
required set. This is the assertion that pays for that subtraction. A
surface that drops one sends its clients back to the launch record --
which is what the rust router had to read, under a name that had since
moved.
"""
surfaces = {
name: _reported_keys_in(source, func_name)
for name, (source, func_name) in _model_info_sources().items()
}
surfaces["rust /model_info"] = _rust_model_info_keys()
missing = {
name: sorted(_MANAGER_ATTRIBUTES - reported)
for name, reported in surfaces.items()
if _MANAGER_ATTRIBUTES - reported
}
self.assertEqual(
missing,
{},
f"a model-info surface does not say which model it serves: {missing}",
)
def test_the_rust_model_info_answers_the_same_keys(self):
"""`SGLANG_RUST_SERVER=1` swaps the whole HTTP server, not one handler.
The keys are owed there too, or the endpoint's contract depends on
which server the operator launched. The values are the launch record:
that process parses `server_args` once and mounts no route that can
change weights or parsers, so this is a key-set check and the handler
states which it reports.
"""
required = set().union(*_model_info_surfaces().values()) | _overridden_fields()
missing = sorted(required - _rust_model_info_keys())
self.assertEqual(
missing,
[],
"the rust /model_info answers a different contract than the Python "
f"one it replaces: {missing}",
)
if __name__ == "__main__":
unittest.main()
@@ -29,6 +29,7 @@ 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
@@ -36,6 +37,25 @@ 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; `_config_updates` is the log `record_config_updates`
appends to.
"""
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._config_updates = []
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,
@@ -48,31 +68,37 @@ def _call_server_info_with(
`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 = 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._config_updates = (
[("test", dict(config_updates))] if config_updates else []
)
tokenizer_manager.get_internal_state = _fake_internal_state
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):
@@ -243,16 +269,37 @@ class TestServerInfoKvEventsField(CustomTestCase):
class TestServerInfoControlPlaneUpdates(CustomTestCase):
"""Runtime control-plane updates live on the manager, not on ServerArgs."""
"""/server_info answers what was asked for, not what is in effect."""
def test_recorded_updates_win_over_the_startup_config(self):
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"], "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(
dataclasses.asdict(server_args)
)
self.assertEqual(overlaid["weight_version"], "v2")
finally:
reset_context()
class TestServerInfoExistingFieldsPreserved(CustomTestCase):
"""Regression guard: the new `kv_events` field is additive — none of
@@ -112,6 +112,12 @@ _CONFIGURED_SIZE_CALL_SITES = {
"the encode server's launch entry sizes its workers before it has "
"spawned any of them"
),
("srt/utils/common.py", "configured_tp_size"): (
"the require_*_tp_gather predicates compared the configured tp_size "
"when they read the record; the live property answers a different "
"question wherever the groups alias, so the configured accessor is the "
"mechanical substitution and the live one would be a semantic change"
),
("srt/model_loader/loader.py", "configured_moe_dp_size"): (
"the same dict already carries the live moe_dp_size under 'dp'; this entry "
"is the configured intent"
@@ -349,8 +349,6 @@ _EXPOSED = {
("speculative/standalone_worker_v2.py", "speculative_eagle_topk"),
("speculative/standalone_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/standalone_worker_v2.py", "speculative_num_steps"),
("utils/common.py", "page_size"),
("utils/common.py", "speculative_eagle_topk"),
("utils/common.py", "speculative_num_draft_tokens"),
("utils/common.py", "speculative_num_steps"),
("utils/cuda_vmm_transport_utils.py", "mm_feature_transport"),