add the rust server ingress request validation and api server common types (#32873)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
//! Common control-plane endpoints — `/server_info`, `/get_model_info`
|
||||
//! (+ `/model_info` alias), plus the control-request submission path
|
||||
//! (`await_control_result`, on the shared `submit`). Data-plane endpoints (incl. `/health*`,
|
||||
//! which round-trips a generate probe) live in the sibling `native_api` and
|
||||
//! `openai` modules; the shared `AppState` lives in the parent
|
||||
//! `api_server` module.
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use super::AppState;
|
||||
use super::guard::AbortGuard;
|
||||
use super::submit::submit;
|
||||
use crate::message::{ControlRequest, EgressItem, GetInternalStateReq, RequestKind};
|
||||
use crate::runtime::ServerArgs;
|
||||
|
||||
/// The routes this module owns, mounted by `api_server::serve`.
|
||||
pub(super) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Control-plane: reuses the ingress FSM (no tokenization), returns one
|
||||
// non-streamed JSON result. Adding one = a route line + its struct tag.
|
||||
.route("/server_info", get(server_info))
|
||||
// Static config, no scheduler round-trip. `/get_model_info` (+ `/model_info`
|
||||
// alias).
|
||||
.route("/get_model_info", get(model_info))
|
||||
.route("/model_info", get(model_info))
|
||||
}
|
||||
|
||||
/// Submit a control request through the ingress FSM (no tokenization) and await the
|
||||
/// scheduler's single msgpack result (a `structs.asdict` named map). Returns the
|
||||
/// raw bytes, or an error `Response` to return as-is.
|
||||
async fn await_control_result(
|
||||
state: &AppState,
|
||||
control: ControlRequest,
|
||||
) -> Result<bytes::Bytes, Response> {
|
||||
let (rid, mut rx) = submit(state, RequestKind::Control(Box::new(control)), false).await?;
|
||||
// Control requests register a detok entry like any other, and only
|
||||
// `handle_result` removes it — so a request that never produces one (a stalled
|
||||
// scheduler, a client that hangs up mid-await) leaves the entry behind. A
|
||||
// monitor polling `/server_info` then leaks one `DetokState` per poll, forever.
|
||||
// The guard deregisters on drop; it is disarmed below when the result lands.
|
||||
let mut guard = AbortGuard::new(state.senders.clone(), rid.clone());
|
||||
let received = rx.recv().await;
|
||||
if received.is_some() {
|
||||
guard.disarm(&rid); // completed normally — nothing to abort
|
||||
}
|
||||
match received {
|
||||
Some(EgressItem::Control(bytes)) => Ok(bytes),
|
||||
Some(EgressItem::Error(e)) => {
|
||||
let code =
|
||||
StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
Err((code, e.to_string()).into_response())
|
||||
}
|
||||
// A control request never receives generation frames.
|
||||
Some(EgressItem::Frame(_)) | Some(EgressItem::Done(_)) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected generation output for control request",
|
||||
)
|
||||
.into_response()),
|
||||
None => Err((StatusCode::from_u16(499).unwrap(), "request aborted").into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /get_model_info` (+ `/model_info` alias) — static model metadata from
|
||||
/// `server_args` (no scheduler round-trip); `is_generation` always true.
|
||||
async fn model_info(State(state): State<AppState>) -> Response {
|
||||
let sa = &state.server_args;
|
||||
let body = serde_json::json!({
|
||||
"model_path": sa.model_path,
|
||||
"tokenizer_path": sa.tokenizer_path,
|
||||
"is_generation": true,
|
||||
// Python's `TokenizerManager` merges this into every request
|
||||
// (`{**preferred, **client}`); this server has no equivalent yet, so
|
||||
// `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,
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/json")],
|
||||
serde_json::to_vec(&body).unwrap_or_default(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `GET /server_info` — surface only an allowlist ([`INTERNAL_STATE_ALLOWLIST`] +
|
||||
/// curated [`ServerArgs`] accessors), never the raw server-args dump (embeds
|
||||
/// `api_key`/`admin_api_key`; see [`shape_server_info`]).
|
||||
///
|
||||
/// TODO(server_info): Python also includes `kv_events`; add once plumbed.
|
||||
async fn server_info(State(state): State<AppState>) -> Response {
|
||||
let bytes = match await_control_result(
|
||||
&state,
|
||||
ControlRequest::GetInternalStateReq(GetInternalStateReq::new(
|
||||
crate::ids::Rid::new().to_string(),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
match shape_server_info(&bytes, &state.server_args) {
|
||||
Ok(json) => (StatusCode::OK, [("content-type", "application/json")], json).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "server_info: shaping failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"bad server_info response",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime-metric keys `get_internal_state` adds atop the server-args dump. We copy
|
||||
/// ONLY these out of `internal_state` (an allowlist), so the co-mingled
|
||||
/// `api_key`/`admin_api_key` can never reach the response.
|
||||
const INTERNAL_STATE_ALLOWLIST: &[&str] = &[
|
||||
"last_gen_throughput",
|
||||
"memory_usage",
|
||||
"effective_max_running_requests_per_dp",
|
||||
"avg_spec_accept_length",
|
||||
"step_time_dict",
|
||||
];
|
||||
|
||||
fn shape_server_info(msgpack: &[u8], server_args: &ServerArgs) -> Result<Vec<u8>, String> {
|
||||
// GetInternalStateReqOutput asdict → `{ "internal_state": { server-args dump +
|
||||
// metrics }, ... }`. Pull that inner map out (it is NOT safe to expose whole).
|
||||
let mut obj: serde_json::Map<String, serde_json::Value> =
|
||||
rmp_serde::from_slice(msgpack).map_err(|e| e.to_string())?;
|
||||
let internal = match obj.remove("internal_state") {
|
||||
Some(serde_json::Value::Object(m)) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
|
||||
// Copy only the allowlisted runtime metrics — never the raw server-args dump.
|
||||
let mut state_out = serde_json::Map::new();
|
||||
for &k in INTERNAL_STATE_ALLOWLIST {
|
||||
match internal.get(k) {
|
||||
Some(v) if !v.is_null() => {
|
||||
state_out.insert(k.to_string(), v.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level non-secret config from typed accessors (structurally can't surface
|
||||
// a key field, unlike the raw dump).
|
||||
let response = serde_json::json!({
|
||||
"model_path": server_args.model_path,
|
||||
"served_model_name": server_args.served_model_name,
|
||||
"tokenizer_path": server_args.tokenizer_path,
|
||||
"max_context_length": server_args.model_config.context_len,
|
||||
"max_total_num_tokens": server_args.max_total_num_tokens,
|
||||
"version": server_args.version,
|
||||
"internal_states": [serde_json::Value::Object(state_out)],
|
||||
});
|
||||
serde_json::to_vec(&response).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The scheduler's `internal_state` embeds the full server-args dump (incl.
|
||||
/// `api_key`/`admin_api_key`). `/server_info` must surface only the allowlisted
|
||||
/// runtime metrics + curated config — never the secrets — and must not re-nest
|
||||
/// the dump under `internal_states[].internal_state`.
|
||||
#[test]
|
||||
fn shape_server_info_excludes_secrets_and_dump() {
|
||||
// GetInternalStateReqOutput.asdict → { "internal_state": { …dump+metrics… } }.
|
||||
let internal = rmpv::Value::Map(vec![
|
||||
(
|
||||
rmpv::Value::from("api_key"),
|
||||
rmpv::Value::from("secret-token"),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("admin_api_key"),
|
||||
rmpv::Value::from("admin-token"),
|
||||
),
|
||||
(rmpv::Value::from("model_path"), rmpv::Value::from("/m")),
|
||||
(
|
||||
rmpv::Value::from("last_gen_throughput"),
|
||||
rmpv::Value::from(1.5),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("effective_max_running_requests_per_dp"),
|
||||
rmpv::Value::from(32),
|
||||
),
|
||||
]);
|
||||
let outer = rmpv::Value::Map(vec![(rmpv::Value::from("internal_state"), internal)]);
|
||||
let mut msgpack = Vec::new();
|
||||
rmpv::encode::write_value(&mut msgpack, &outer).unwrap();
|
||||
|
||||
let sa =
|
||||
ServerArgs::from_json(r#"{"model_path": "/m", "api_key": "secret-token"}"#).unwrap();
|
||||
let out = shape_server_info(&msgpack, &sa).unwrap();
|
||||
let text = String::from_utf8(out.clone()).unwrap();
|
||||
// No secret leaks anywhere in the serialized response.
|
||||
assert!(!text.contains("secret-token"), "api_key leaked: {text}");
|
||||
assert!(
|
||||
!text.contains("admin-token"),
|
||||
"admin_api_key leaked: {text}"
|
||||
);
|
||||
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
// Allowlisted metric surfaced; the whole dump did not.
|
||||
let state0 = &v["internal_states"][0];
|
||||
assert_eq!(state0["last_gen_throughput"], 1.5);
|
||||
assert_eq!(state0["effective_max_running_requests_per_dp"], 32);
|
||||
assert!(
|
||||
state0.get("internal_state").is_none(),
|
||||
"must not re-nest the dump under internal_state"
|
||||
);
|
||||
assert!(state0.get("api_key").is_none());
|
||||
// Curated top-level config comes from typed accessors, not the dump.
|
||||
assert_eq!(v["model_path"], "/m");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! HTTP access logging — one INFO line per request, content-matching the
|
||||
//! Python server's uvicorn access log. Gated exactly like uvicorn's
|
||||
//! (`--log-level-http warning` turns it off, see
|
||||
//! `ServerArgs::http_access_log_enabled`); when disabled the middleware is not
|
||||
//! installed at all — zero cost.
|
||||
|
||||
use axum::{Router, response::Response};
|
||||
|
||||
use crate::runtime::ServerArgs;
|
||||
|
||||
/// Install the access-log middleware when `server_args` enables it; identity
|
||||
/// otherwise (the layer is never installed, so disabled stays zero-cost).
|
||||
pub(super) fn apply(app: Router, server_args: &ServerArgs) -> Router {
|
||||
if server_args.http_access_log_enabled() {
|
||||
app.layer(axum::middleware::from_fn(access_log))
|
||||
} else {
|
||||
app
|
||||
}
|
||||
}
|
||||
|
||||
/// Access log — one INFO line per request, content-matching the Python server's
|
||||
/// uvicorn access log (`127.0.0.1:54232 - "GET /model_info HTTP/1.1" 200 OK`).
|
||||
/// Logged when the response head is ready; for SSE that's stream start, same as
|
||||
/// uvicorn.
|
||||
async fn access_log(
|
||||
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
let version = req.version();
|
||||
let res = next.run(req).await;
|
||||
let status = res.status();
|
||||
tracing::info!(
|
||||
"{peer} - \"{method} {uri} {version:?}\" {} {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("")
|
||||
);
|
||||
res
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! OpenAI-compatible endpoints: `/v1/completions`, `/v1/chat/completions`, and
|
||||
//! `/v1/models`. Each runs the same tokenize→generate→detok pipeline as
|
||||
//! `/generate` and shapes the neutral [`ChunkEvent`] delta into OpenAI types
|
||||
//! (`dynamo-protocols`), with chat-template rendering (`dynamo-renderer`) and
|
||||
//! reasoning / tool-call parsing (`dynamo-parsers`).
|
||||
//!
|
||||
//! Mounted on the shared [`AppState`](super::AppState) by the parent
|
||||
//! `api_server` module; the submit machinery and control plane live there.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// The routes this module owns, mounted by `api_server::serve`.
|
||||
pub(super) fn routes() -> Router<AppState> {
|
||||
// `/v1/models` is OpenAI-compatible; completions/chat land here too.
|
||||
Router::new().route("/v1/models", get(available_models))
|
||||
}
|
||||
|
||||
/// `GET /v1/models` — OpenAI-compatible model list. Served from `server_args`;
|
||||
/// no scheduler round-trip. Mirrors `http_server.available_models`.
|
||||
///
|
||||
/// TODO(v1/models): when `--enable-lora`, append a `ModelCard` per loaded LoRA
|
||||
/// adapter (`id=lora_name, root=lora_path, parent=served_model_name,
|
||||
/// max_model_len=None`). Adapters load/unload at runtime, so that part needs a
|
||||
/// control-request query to the scheduler's LoRA registry.
|
||||
async fn available_models(State(state): State<AppState>) -> Response {
|
||||
let created = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let name = &state.server_args.served_model_name;
|
||||
let base = serde_json::json!({
|
||||
"id": name,
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": "sglang",
|
||||
"root": name,
|
||||
"parent": serde_json::Value::Null,
|
||||
"max_model_len": state.server_args.model_config.context_len,
|
||||
});
|
||||
let list = serde_json::json!({ "object": "list", "data": [base] });
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/json")],
|
||||
serde_json::to_vec(&list).unwrap_or_default(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
//! TokenizerManager — ingress side.
|
||||
//!
|
||||
//! [`Ingress`] is a single-consumer stage draining one inbox fed by both the API
|
||||
//! server (fresh requests) and the Tokenizer pool (returned requests). It owns
|
||||
//! the request while driving the ingress FSM and hands it off by *moving* it to
|
||||
//! the next stage; nothing here is shared, so no locks.
|
||||
//!
|
||||
//! Edges driven here (from the design table):
|
||||
//! Received → Validating
|
||||
//! Validating → Normalizing (generate: sampling-param normalize/verify)
|
||||
//! Validating → PreSendValidating (control: no tokenize, no sampling params)
|
||||
//! Normalizing → {Encoding | Tokenizing | PreSendValidating} (by ValidationOutcome)
|
||||
//! Tokenizing → PreSendValidating (on TokenizeDone, when the request returns)
|
||||
//! PreSendValidating → Queued (checks needing the tokenized length)
|
||||
//! Queued → ring (handed to the scheduler)
|
||||
//!
|
||||
//! The egress edges (Streaming/Finalizing/Completed) are driven on the egress
|
||||
//! side (see `egress` + `detokenizer`).
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::fsm::{Event, RequestState, ValidationOutcome};
|
||||
|
||||
use crate::message::{
|
||||
AbortReq, ControlRequest, DetokMsg, EgressItem, GenerateRequest, IngressMsg, Request,
|
||||
RequestKind,
|
||||
};
|
||||
use crate::ring::IngressProducer;
|
||||
use crate::runtime::{Runnable, ServerArgs};
|
||||
use crate::tokenizer_manager::{AbortSource, Senders, TmEvent};
|
||||
|
||||
/// Ingress FSM dispatcher stage. Owns its inbox + downstream handles, so the
|
||||
/// runtime spawns it as a [`Runnable`] rather than calling a free `run_*` fn
|
||||
/// with positional arguments.
|
||||
pub struct Ingress {
|
||||
rx: flume::Receiver<TmEvent>,
|
||||
/// Unbounded abort lane (see [`Senders::abort`]). Selected against `rx` so an
|
||||
/// abort is handled promptly even while the bounded inbox is saturated.
|
||||
abort_rx: flume::Receiver<AbortSource>,
|
||||
senders: Senders,
|
||||
ingress: IngressProducer,
|
||||
limits: Limits,
|
||||
shutdown: flume::Receiver<()>,
|
||||
}
|
||||
|
||||
/// Longest client-supplied rid accepted. It keys the detok table and travels on
|
||||
/// every chunk, so its length is a recurring cost; Python mints 32-byte uuid hex.
|
||||
const MAX_RID_LEN: usize = 128;
|
||||
|
||||
/// What ingress admits, resolved once at boot from the scheduler's `server_args`.
|
||||
/// A struct rather than more positional `new` arguments — these grew from two to
|
||||
/// six, and every one of them is a `u64`/`bool` that would be trivial to swap at
|
||||
/// a call site.
|
||||
///
|
||||
/// NOT `Default`-able on purpose. `vocab_size` and `context_len` are mandatory,
|
||||
/// and their zero value is the most restrictive setting there is — a derived
|
||||
/// `Default` would silently build limits that reject every request rather than
|
||||
/// failing loudly. Tests construct these explicitly (see `test_limits`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Limits {
|
||||
/// Token-ids-in mode: a generate request must arrive already tokenized.
|
||||
pub skip_tokenizer_init: bool,
|
||||
/// `model_config.vocab_size`; bounds client-supplied token ids. Mandatory —
|
||||
/// [`ServerArgs::validate_mandatory`](crate::runtime::ServerArgs) rejects a
|
||||
/// boot without it, so ingress can check unconditionally.
|
||||
pub vocab_size: u64,
|
||||
/// `model_config.context_len`, the ceiling for input + `max_new_tokens`.
|
||||
/// Mandatory, as above.
|
||||
pub context_len: u64,
|
||||
/// Output slots reserved on top of the input (eagle draft tokens).
|
||||
pub num_reserved_tokens: u64,
|
||||
/// Clamp `max_new_tokens` to what fits instead of rejecting the request.
|
||||
pub allow_auto_truncate: bool,
|
||||
/// Whether the server can produce hidden states at all.
|
||||
pub enable_return_hidden_states: bool,
|
||||
}
|
||||
|
||||
impl TryFrom<&ServerArgs> for Limits {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(sa: &ServerArgs) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
skip_tokenizer_init: sa.skip_tokenizer_init,
|
||||
vocab_size: sa
|
||||
.model_config
|
||||
.vocab_size
|
||||
.ok_or_else(|| Error::Validation("vocab_size missing".into()))?,
|
||||
context_len: sa
|
||||
.model_config
|
||||
.context_len
|
||||
.ok_or_else(|| Error::Validation("context_len missing".into()))?,
|
||||
num_reserved_tokens: sa.num_reserved_tokens,
|
||||
allow_auto_truncate: sa.allow_auto_truncate,
|
||||
enable_return_hidden_states: sa.enable_return_hidden_states,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Ingress {
|
||||
pub fn new(
|
||||
rx: flume::Receiver<TmEvent>,
|
||||
abort_rx: flume::Receiver<AbortSource>,
|
||||
senders: Senders,
|
||||
ingress: IngressProducer,
|
||||
limits: Limits,
|
||||
shutdown: flume::Receiver<()>,
|
||||
) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
abort_rx,
|
||||
senders,
|
||||
ingress,
|
||||
limits,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which lane produced the next item.
|
||||
enum Lane {
|
||||
Abort(AbortSource),
|
||||
Event(TmEvent),
|
||||
}
|
||||
|
||||
impl Runnable for Ingress {
|
||||
fn run(self) {
|
||||
loop {
|
||||
// Select, not a drain-then-block: an abort arriving while the inbox is
|
||||
// idle must still be handled at once.
|
||||
let next = flume::Selector::new()
|
||||
.recv(&self.abort_rx, |r| r.ok().map(Lane::Abort))
|
||||
.recv(&self.rx, |r| r.ok().map(Lane::Event))
|
||||
.recv(&self.shutdown, |_| None)
|
||||
.wait();
|
||||
match next {
|
||||
Some(Lane::Abort(rid)) => self.on_abort(rid),
|
||||
// A fresh request and one returning from the tokenizer pool.
|
||||
Some(Lane::Event(TmEvent::Ingress(req) | TmEvent::Tokenized(req))) => {
|
||||
self.drive(req)
|
||||
}
|
||||
None => {
|
||||
// Shutdown, or the inbox closed. Drain whatever is still queued
|
||||
// on the abort lane first: those requests are in flight on the
|
||||
// scheduler, and the selector may report the closed inbox before
|
||||
// it ever looks at a pending abort.
|
||||
while let Ok(source) = self.abort_rx.try_recv() {
|
||||
self.on_abort(source);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ingress {
|
||||
/// Reject a request: → `Failed`, notify the client, deregister (unconditional
|
||||
/// — a no-op when nothing was registered).
|
||||
/// `registered` says whether this request ever reached `register_detok`. It
|
||||
/// must: `Deregister`'s handler is a bare `table.remove(&rid)`, so a
|
||||
/// request rejected BEFORE registering would evict whatever entry currently
|
||||
/// holds that key — a concurrent request's sink — leaving that client with no
|
||||
/// terminal frame and a hung connection. Python cannot hit this because it
|
||||
/// validates before `rid_to_state[obj.rid] = state`.
|
||||
fn fail(&self, req: &mut Request, err: Error, registered: bool) {
|
||||
// Log only server faults (500); 4xx/499/503 are expected and would spam.
|
||||
if err.http_status() == 500 {
|
||||
tracing::error!(rid = %req.rid, error = %err, "ingress rejected request");
|
||||
}
|
||||
let _ = req.state.apply(Event::Error(err.clone()));
|
||||
let _ = req.sink.try_send(EgressItem::Error(err)); // client may be gone
|
||||
if registered {
|
||||
let _ = self.senders.detok_for(&req.rid).send(DetokMsg::Deregister {
|
||||
rid: req.rid.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a request through its ingress states until it terminates (failed or
|
||||
/// pushed to the ring) or is handed to the tokenizer pool (re-entering as a
|
||||
/// `Tokenized` event). Each arm acts and advances the FSM; the loop
|
||||
/// re-dispatches. The arms are the design table's states, `Failed` the single
|
||||
/// reject path.
|
||||
fn drive(&self, mut req: Request) {
|
||||
// Flipped once `register_detok` succeeds; `fail` must not deregister before
|
||||
// that (see `fail`). A pool return re-enters `drive` already registered.
|
||||
let mut registered = !matches!(req.state, RequestState::Received);
|
||||
loop {
|
||||
match req.state.clone() {
|
||||
// Validate, then register the sink before the request leaves Rust.
|
||||
// Failures move to `Failed` and fall through to the reject arm.
|
||||
RequestState::Received => {
|
||||
if let Err(e) = validate(&mut req, &self.limits) {
|
||||
let _ = req.state.apply(Event::Error(e)); // → Failed
|
||||
continue;
|
||||
}
|
||||
if !self.register_detok(&req) {
|
||||
let _ = req
|
||||
.state
|
||||
.apply(Event::Error(Error::Internal("detok shard gone".into())));
|
||||
continue;
|
||||
}
|
||||
registered = true;
|
||||
// `validate` advanced Received → Validating; keep driving.
|
||||
}
|
||||
// Control skips normalization (no sampling params) straight to the
|
||||
// pre-send checks; generate goes to Normalizing.
|
||||
RequestState::Validating => match &req.kind {
|
||||
RequestKind::Control(_) => {
|
||||
let _ = req
|
||||
.state
|
||||
.apply(Event::Validated(ValidationOutcome::AlreadyTokenized));
|
||||
}
|
||||
RequestKind::Generate(_) => {
|
||||
let _ = req.state.apply(Event::NeedsNormalize);
|
||||
}
|
||||
},
|
||||
// Normalize + verify sampling params (off the scheduler loop), then
|
||||
// pick the branch; a bad param becomes `Failed`.
|
||||
RequestState::Normalizing => {
|
||||
let outcome = {
|
||||
let RequestKind::Generate(g) = &mut req.kind else {
|
||||
// Unreachable (control never reaches here); reject so a
|
||||
// bug can't leak/hang a registered request.
|
||||
self.fail(
|
||||
&mut req,
|
||||
Error::Internal("non-generate request in Normalizing".into()),
|
||||
registered,
|
||||
);
|
||||
return;
|
||||
};
|
||||
match g
|
||||
.sampling_params
|
||||
.normalize(self.limits.skip_tokenizer_init, self.limits.vocab_size)
|
||||
{
|
||||
Err(e) => Err(e),
|
||||
// Client ids skip the pool; text goes to the tokenizer.
|
||||
Ok(()) if g.already_tokenized() => {
|
||||
Ok(ValidationOutcome::AlreadyTokenized)
|
||||
}
|
||||
Ok(()) => Ok(ValidationOutcome::NeedsTokenize),
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Err(e) => {
|
||||
let _ = req.state.apply(Event::Error(e)); // → Failed
|
||||
}
|
||||
Ok(o) => {
|
||||
// AlreadyTokenized → Queued, NeedsTokenize → Tokenizing.
|
||||
let _ = req.state.apply(Event::Validated(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hand off to the tokenizer pool; it returns the request as a
|
||||
// `Tokenized` event (PreSendValidating, or Failed on error).
|
||||
// Doesn't loop.
|
||||
RequestState::Tokenizing => {
|
||||
if let Err(err) = self.senders.tok.send(req) {
|
||||
// Pool gone (workers exited); flume hands the request back.
|
||||
let mut req = err.into_inner();
|
||||
// Past `Received`, so registration happened.
|
||||
self.fail(
|
||||
&mut req,
|
||||
Error::Internal("tokenizer pool gone".into()),
|
||||
true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The checks that need the final `input_ids`: every branch
|
||||
// converges here (client ids arrive directly, text arrives from
|
||||
// the tokenizer pool), so they run once per request regardless of
|
||||
// how it was tokenized. `validate` runs too early — at `Received`
|
||||
// a text request has no ids yet.
|
||||
RequestState::PreSendValidating => {
|
||||
if let RequestKind::Generate(g) = &mut req.kind
|
||||
&& let Err(e) = check_total_tokens(g, &self.limits)
|
||||
{
|
||||
let _ = req.state.apply(Event::Error(e)); // → Failed
|
||||
continue;
|
||||
}
|
||||
let _ = req.state.apply(Event::PreSendValidated); // → Queued
|
||||
}
|
||||
// Push the wire message (control frame or generate payload) to the ring.
|
||||
RequestState::Queued => {
|
||||
// `matches!` reads the discriminant without holding a borrow,
|
||||
// so `req` can be moved into the push below.
|
||||
if matches!(req.kind, RequestKind::Generate(_)) {
|
||||
self.push_to_ring(req);
|
||||
} else {
|
||||
self.push_control_to_ring(req);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The single reject path for every post-register failure.
|
||||
RequestState::Failed(e) => {
|
||||
self.fail(&mut req, e, registered);
|
||||
return;
|
||||
}
|
||||
// Unreachable (egress states never reach here). Reject via `fail`/
|
||||
// return (not apply + continue, which would spin on a terminal state).
|
||||
other => {
|
||||
self.fail(
|
||||
&mut req,
|
||||
Error::Internal(format!("unexpected ingress state: {other:?}")),
|
||||
registered,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the egress sink with the owning detok shard (by id) so the response
|
||||
/// has a home. Carries the per-request detok flags — `return_text_in_logprobs`
|
||||
/// (decode logprob text on this shard) and `no_stop_trim` (keep the matched
|
||||
/// stop in the output) — so the shard needs no back-reference to the request.
|
||||
/// Returns `false` if the shard is gone.
|
||||
fn register_detok(&self, req: &Request) -> bool {
|
||||
let (decode_logprob_text, no_stop_trim) = match &req.kind {
|
||||
RequestKind::Generate(g) => (
|
||||
g.return_text_in_logprobs.unwrap_or(false),
|
||||
g.sampling_params.no_stop_trim,
|
||||
),
|
||||
RequestKind::Control(_) => (false, false),
|
||||
};
|
||||
self.senders
|
||||
.detok_for(&req.rid)
|
||||
.send(DetokMsg::Register {
|
||||
rid: req.rid.clone(),
|
||||
sink: req.sink.clone(),
|
||||
decode_logprob_text,
|
||||
no_stop_trim,
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Push a bare control request (`[tag, rid, nil]`) onto the ingress ring. The
|
||||
/// scheduler dispatches it (e.g. `GetInternalStateReq`) and replies via the
|
||||
/// egress ring as a single `Result`.
|
||||
fn push_control_to_ring(&self, mut req: Request) {
|
||||
let encode = match &req.kind {
|
||||
RequestKind::Control(control) => control.encode(),
|
||||
_ => Err(Error::Internal(
|
||||
"non-control request reached push_control_to_ring".into(),
|
||||
)),
|
||||
};
|
||||
let header = match encode {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.fail(&mut req, e, true); // on the push path: registered
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Control requests carry no tensor cell — empty `ids`.
|
||||
if !self.ingress.try_push(IngressMsg {
|
||||
header,
|
||||
ids: Bytes::new(),
|
||||
}) {
|
||||
self.fail(&mut req, Error::QueueFull, true); // registered
|
||||
}
|
||||
}
|
||||
|
||||
/// Client disconnected (or a detok terminal): deregister the sink, then push an
|
||||
/// `AbortReq(rid)` so the scheduler stops generating for it.
|
||||
///
|
||||
/// A failed push is logged, not retried: the scheduler keeps generating and the
|
||||
/// chunks arrive for a rid no longer in the detok table, where they are dropped.
|
||||
/// That wastes GPU work until the request finishes on its own, but it cannot be
|
||||
/// misdelivered — the rid is unique to this request for the process's lifetime
|
||||
/// ([`Rid::from_client`]), so no later request can ever answer to it.
|
||||
fn on_abort(&self, source: AbortSource) {
|
||||
let rid = source.rid().clone();
|
||||
let _ = self
|
||||
.senders
|
||||
.detok_for(&rid)
|
||||
.send(DetokMsg::Deregister { rid: rid.clone() });
|
||||
|
||||
// The ring is BOUNDED and drops pushes under exactly the load this matters
|
||||
// for, so report the miss rather than assuming the scheduler was told.
|
||||
match ControlRequest::AbortReq(AbortReq::new(rid.as_str().to_string(), false)).encode() {
|
||||
Ok(header) => {
|
||||
if !self.ingress.try_push(IngressMsg {
|
||||
header,
|
||||
ids: Bytes::new(),
|
||||
}) {
|
||||
tracing::error!(
|
||||
rid = %rid,
|
||||
"abort dropped: ingress ring full; the scheduler keeps generating \
|
||||
for this request until it finishes on its own"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(rid = %rid, error = %e, "abort encode failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the tokenized request to its `TokenizedGenerateReqInput` wire and
|
||||
/// push it onto the ingress ring for the scheduler. On backpressure, fail it.
|
||||
fn push_to_ring(&self, mut req: Request) {
|
||||
// Only generate requests reach here (control uses `push_control_to_ring`).
|
||||
// Validate + serialize while borrowing `g` immutably; the resulting `Bytes`
|
||||
// own their data, so the borrow ends before any `fail(&mut req)`.
|
||||
let serialized = match &req.kind {
|
||||
RequestKind::Generate(g) if g.already_tokenized() => g
|
||||
.encode_header()
|
||||
.map(|header| (header, g.encode_data_buf())),
|
||||
RequestKind::Generate(_) => Err(Error::Tokenize("empty input_ids".into())),
|
||||
_ => Err(Error::Internal(
|
||||
"non-generate request reached push_to_ring".into(),
|
||||
)),
|
||||
};
|
||||
let (header, ids) = match serialized {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
self.fail(&mut req, e, true); // on the push path: registered
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !self.ingress.try_push(IngressMsg { header, ids }) {
|
||||
self.fail(&mut req, Error::QueueFull, true); // registered
|
||||
}
|
||||
// On success the scheduler owns the request (egress arrives by rid); we
|
||||
// drop our `Request` here — the detok shard holds the sink.
|
||||
}
|
||||
}
|
||||
|
||||
/// `Received → Validating` + admissibility check. Under `skip_tokenizer_init` a
|
||||
/// generate request must already carry token ids (no tokenizer to byte-encode
|
||||
/// text); control requests carry none and are exempt.
|
||||
fn validate(req: &mut Request, limits: &Limits) -> Result<(), Error> {
|
||||
let (skip_tokenizer_init, vocab_size) = (limits.skip_tokenizer_init, limits.vocab_size);
|
||||
let _ = req
|
||||
.state
|
||||
.apply(Event::Validated(ValidationOutcome::NeedsTokenize));
|
||||
|
||||
// The rid is the request's identity everywhere downstream: it keys the detok
|
||||
// table, and it rides on EVERY chunk of EVERY decode step. An unbounded
|
||||
// client-supplied rid is therefore a per-step cost, not a one-off. Python's is
|
||||
// a 32-byte uuid hex, so this is generous.
|
||||
// Measured on the CLIENT-facing form: the uniquifier `Rid::from_client` appends
|
||||
// is this server's own overhead, and charging the client for bytes it did not
|
||||
// send would reject a rid exactly at the documented limit.
|
||||
let client_rid_len = req.rid.client_facing().len();
|
||||
if client_rid_len > MAX_RID_LEN {
|
||||
return Err(Error::Validation(format!(
|
||||
"rid is {client_rid_len} bytes, over the {MAX_RID_LEN}-byte limit"
|
||||
)));
|
||||
}
|
||||
if skip_tokenizer_init
|
||||
&& matches!(&req.kind, RequestKind::Generate(g) if !g.already_tokenized())
|
||||
{
|
||||
// `Validation` (400), not `Tokenize` (500): the client sent a request this
|
||||
// server cannot serve, which is their error to fix — Python 400s it too.
|
||||
return Err(Error::Validation(
|
||||
"skip_tokenizer_init is set: request must provide input_ids".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Client-supplied token ids must be in-vocabulary: an out-of-range id
|
||||
// reaches the embedding lookup and kills the scheduler process, so 400
|
||||
// here instead — mirroring the Python `TokenizerManager` validation.
|
||||
if let RequestKind::Generate(g) = &req.kind {
|
||||
if let Some(ids) = &g.input_ids {
|
||||
for &id in ids {
|
||||
if id < 0 || id as u64 >= vocab_size {
|
||||
return Err(Error::Validation(format!(
|
||||
"input_ids contains out-of-vocabulary token id {id}; \
|
||||
valid range is [0, {vocab_size})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ids) = &g.token_ids_logprob {
|
||||
for &id in ids {
|
||||
if id < 0 || id as u64 >= vocab_size {
|
||||
return Err(Error::Validation(format!(
|
||||
"token_ids_logprob contains out-of-vocabulary token id \
|
||||
{id}; valid range is [0, {vocab_size})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The scheduler only computes hidden states when launched for it, so without
|
||||
// this the request would 200 with `meta_info.hidden_states` silently absent
|
||||
// (Python `TokenizerManager._validate_one_request`).
|
||||
if !limits.enable_return_hidden_states
|
||||
&& matches!(&req.kind, RequestKind::Generate(g) if g.return_hidden_states)
|
||||
{
|
||||
return Err(Error::Validation(
|
||||
"The server is not configured to return the hidden states. \
|
||||
Please set `--enable-return-hidden-states` to enable this feature."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The context-window checks that need the tokenized length, mirroring Python
|
||||
/// `TokenizerManager._validate_one_request`: the input alone must fit, and then
|
||||
/// input + `max_new_tokens` must fit. Without them the scheduler silently clamps
|
||||
/// and the client gets a 200 with a truncated completion instead of an actionable
|
||||
/// 400.
|
||||
///
|
||||
/// Under `allow_auto_truncate` both clamp instead of rejecting — the launch flag
|
||||
/// opted into that.
|
||||
fn check_total_tokens(g: &mut GenerateRequest, limits: &Limits) -> Result<(), Error> {
|
||||
let max_req_len = limits.context_len;
|
||||
// Python counts the reserved slots as part of the input, so a request can be
|
||||
// rejected for them even when the prompt alone fits.
|
||||
let input_len =
|
||||
g.input_ids.as_ref().map_or(0, |ids| ids.len()) as u64 + limits.num_reserved_tokens;
|
||||
|
||||
// Input length first, and unconditionally: `max_new_tokens: null` means "no
|
||||
// cap", which must not disable this. Python's comparison is `>=` — a prompt
|
||||
// that exactly fills the window leaves no room to generate.
|
||||
if input_len >= max_req_len {
|
||||
if !limits.allow_auto_truncate {
|
||||
return Err(Error::Validation(format!(
|
||||
"The input ({input_len} tokens) is longer than the model's context \
|
||||
length ({max_req_len} tokens)."
|
||||
)));
|
||||
}
|
||||
if let Some(ids) = &mut g.input_ids {
|
||||
ids.truncate(max_req_len as usize);
|
||||
}
|
||||
}
|
||||
let input_len =
|
||||
g.input_ids.as_ref().map_or(0, |ids| ids.len()) as u64 + limits.num_reserved_tokens;
|
||||
|
||||
let Some(max_new_tokens) = g.sampling_params.max_new_tokens else {
|
||||
return Ok(()); // no cap requested → nothing to add to the input length
|
||||
};
|
||||
let total = input_len.saturating_add(max_new_tokens.max(0) as u64);
|
||||
if total <= max_req_len {
|
||||
return Ok(());
|
||||
}
|
||||
if !limits.allow_auto_truncate {
|
||||
return Err(Error::Validation(format!(
|
||||
"Requested token count exceeds the model's maximum context length of \
|
||||
{max_req_len} tokens. You requested a total of {total} tokens: {input_len} \
|
||||
tokens from the input messages and {max_new_tokens} tokens for the \
|
||||
completion. Please reduce the number of tokens in the input messages or \
|
||||
the completion to fit within the limit."
|
||||
)));
|
||||
}
|
||||
let clamped = max_req_len.saturating_sub(input_len) as i64;
|
||||
// Re-check what the clamp can break. `verify` already ran (in Normalizing), so
|
||||
// lowering `max_new_tokens` here can leave `min_new_tokens > max_new_tokens` —
|
||||
// and `is_normalized: true` stops the scheduler from re-verifying, so nothing
|
||||
// downstream would catch it. Python validates before it verifies; we can't
|
||||
// reorder the FSM, so we re-assert the one invariant the clamp can violate.
|
||||
if g.sampling_params.min_new_tokens > clamped {
|
||||
return Err(Error::Validation(format!(
|
||||
"min_new_tokens must be in [0, max_new_tokens({clamped})], got {}",
|
||||
g.sampling_params.min_new_tokens
|
||||
)));
|
||||
}
|
||||
g.sampling_params.max_new_tokens = Some(clamped);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user