sglang rust server egress message (#32342)
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
//! `SamplingParams` port), [`io_struct`] (the scheduler wire structs), [`types`]
|
//! `SamplingParams` port), [`io_struct`] (the scheduler wire structs), [`types`]
|
||||||
//! (the shared wire-shape adapters both directions use).
|
//! (the shared wire-shape adapters both directions use).
|
||||||
|
|
||||||
|
mod egress;
|
||||||
|
mod finish_reason;
|
||||||
mod io_struct;
|
mod io_struct;
|
||||||
mod request;
|
mod request;
|
||||||
mod sampling;
|
mod sampling;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
|||||||
|
//! The terminal finish reason: Python's `FinishReasonDict` — what
|
||||||
|
//! `BaseFinishReason.to_json()` (schedule_batch.py) puts on the egress wire, and
|
||||||
|
//! what the API echoes back as `meta_info.finish_reason`. Ingress has no
|
||||||
|
//! counterpart; it rides in the [`BatchHeader`](super::egress::BatchHeader) and on
|
||||||
|
//! each terminal [`ChunkEvent`](super::ChunkEvent).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The stop that ended a request — Python's `matched` key, typed
|
||||||
|
/// `Union[str, int, List[int]]`: a stop token id, a stop string
|
||||||
|
/// (`FINISH_MATCHED_STR` / `FINISHED_MATCHED_REGEX`), or a multi-token stop
|
||||||
|
/// sequence. Untagged because the three wire shapes are disjoint, so the shape
|
||||||
|
/// alone picks the arm.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Matched {
|
||||||
|
Token(i64),
|
||||||
|
Str(String),
|
||||||
|
Tokens(Vec<i64>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The finish reasons this build knows, keyed by the `type` tag Python writes.
|
||||||
|
/// Every field is optional so a reason missing one still classifies (and keeps its
|
||||||
|
/// tag) instead of falling through to [`FinishReason::Unknown`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum FinishKind {
|
||||||
|
/// `FINISH_MATCHED_TOKEN` / `FINISH_MATCHED_STR` / `FINISHED_MATCHED_REGEX` —
|
||||||
|
/// all three report `type: "stop"`, to match the OpenAI API's value.
|
||||||
|
Stop {
|
||||||
|
#[serde(default)]
|
||||||
|
matched: Option<Matched>,
|
||||||
|
},
|
||||||
|
/// `FINISH_LENGTH` — hit `max_new_tokens` (or the context limit).
|
||||||
|
Length {
|
||||||
|
#[serde(default)]
|
||||||
|
length: Option<u64>,
|
||||||
|
},
|
||||||
|
/// `FINISH_ABORT` — a scheduler-side termination. Boxed because it is by far
|
||||||
|
/// the widest variant and the rarest: unboxed it would set the size of every
|
||||||
|
/// [`ChunkEvent`], including the plain stop/length ones (see
|
||||||
|
/// `chunk_event_frame_stays_small`).
|
||||||
|
Abort(Box<AbortReason>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `FINISH_ABORT` payload. `status_code`/`err_type` are `None` for a plain
|
||||||
|
/// abort and set for a request error (e.g. over-context → 400); Python emits all
|
||||||
|
/// three keys, nulls included, so none of them is skipped on the way out.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AbortReason {
|
||||||
|
#[serde(default)]
|
||||||
|
pub message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status_code: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub err_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal finish reason exactly as `BaseFinishReason.to_json()`
|
||||||
|
/// (schedule_batch.py) puts it on the wire — Python's `FinishReasonDict`. It
|
||||||
|
/// round-trips: the API echoes it verbatim as `meta_info.finish_reason`, so the
|
||||||
|
/// serialized form must stay key-for-key what Python would have sent.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum FinishReason {
|
||||||
|
Known(FinishKind),
|
||||||
|
/// A `type` this build doesn't know, kept as its raw map and echoed unchanged.
|
||||||
|
/// This arm is why the outer enum is untagged: a finish reason added Python-side
|
||||||
|
/// must not fail the header decode, which rejects the whole frame — every
|
||||||
|
/// request in the batch, not just the one that carried it.
|
||||||
|
Unknown(serde_json::Map<String, serde_json::Value>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FinishKind> for FinishReason {
|
||||||
|
fn from(kind: FinishKind) -> Self {
|
||||||
|
FinishReason::Known(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FinishReason {
|
||||||
|
/// The stop this request matched, if it stopped on one. `None` for
|
||||||
|
/// length/abort and for an unknown type.
|
||||||
|
pub fn matched(&self) -> Option<&Matched> {
|
||||||
|
match self {
|
||||||
|
FinishReason::Known(FinishKind::Stop { matched }) => matched.as_ref(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Some((status, message))` when this is an abort carrying a `status_code` —
|
||||||
|
/// a scheduler-side request error the API surfaces as that HTTP status instead
|
||||||
|
/// of as a normal completion. A plain abort (no code) reads as `None`.
|
||||||
|
pub fn abort_status(&self) -> Option<(u16, &str)> {
|
||||||
|
match self {
|
||||||
|
FinishReason::Known(FinishKind::Abort(a)) => Some((
|
||||||
|
a.status_code?,
|
||||||
|
a.message.as_deref().unwrap_or("request aborted"),
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Parse a finish reason from the map Python puts on the wire, so these tests
|
||||||
|
/// exercise the real deserialization and not just the classifier.
|
||||||
|
fn fr(v: serde_json::Value) -> Option<FinishReason> {
|
||||||
|
Some(serde_json::from_value(v).expect("finish reason must parse"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify through an `Option`, the shape both API paths actually hold.
|
||||||
|
/// Owned message so a caller can classify a temporary inline.
|
||||||
|
fn abort_status(f: &Option<FinishReason>) -> Option<(u16, String)> {
|
||||||
|
let (code, message) = f.as_ref()?.abort_status()?;
|
||||||
|
Some((code, message.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The classifier both API paths use: a validation abort yields its
|
||||||
|
/// `(code, message)` (the streaming path turns this into an SSE error event
|
||||||
|
/// instead of a normal `Done` frame); anything else yields `None`.
|
||||||
|
#[test]
|
||||||
|
fn abort_status_extracts_code_and_message() {
|
||||||
|
let (code, msg) = abort_status(&fr(serde_json::json!({
|
||||||
|
"type": "abort", "message": "over the limit", "status_code": 400
|
||||||
|
})))
|
||||||
|
.expect("validation abort → (code, message)");
|
||||||
|
assert_eq!(code, 400);
|
||||||
|
assert_eq!(msg, "over the limit");
|
||||||
|
// Normal finish, bare abort (no status), and no finish → not an error.
|
||||||
|
assert!(abort_status(&fr(serde_json::json!({"type": "stop"}))).is_none());
|
||||||
|
assert!(abort_status(&fr(serde_json::json!({"type": "abort"}))).is_none());
|
||||||
|
assert!(abort_status(&None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A normal finish, a bare abort (no status), and no finish are not errors
|
||||||
|
/// (the unary path returns them as a 200 result frame).
|
||||||
|
#[test]
|
||||||
|
fn non_error_finishes_stay_ok() {
|
||||||
|
assert!(abort_status(&fr(serde_json::json!({"type": "stop", "matched": 5}))).is_none());
|
||||||
|
assert!(abort_status(&fr(serde_json::json!({"type": "length", "length": 8}))).is_none());
|
||||||
|
assert!(
|
||||||
|
abort_status(&fr(
|
||||||
|
serde_json::json!({"type": "abort", "message": "Aborted"})
|
||||||
|
))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(abort_status(&None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `matched` accessor reads only a stop, and reads every shape of one.
|
||||||
|
#[test]
|
||||||
|
fn matched_reads_stops_only() {
|
||||||
|
let m = |v| fr(v).unwrap().matched().cloned();
|
||||||
|
assert_eq!(
|
||||||
|
m(serde_json::json!({"type": "stop", "matched": 9})),
|
||||||
|
Some(Matched::Token(9))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m(serde_json::json!({"type": "stop", "matched": "</s>"})),
|
||||||
|
Some(Matched::Str("</s>".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m(serde_json::json!({"type": "stop", "matched": [9, 10]})),
|
||||||
|
Some(Matched::Tokens(vec![9, 10]))
|
||||||
|
);
|
||||||
|
// A stop with no `matched`, and the non-stop reasons.
|
||||||
|
assert_eq!(m(serde_json::json!({"type": "stop"})), None);
|
||||||
|
assert_eq!(m(serde_json::json!({"type": "length", "length": 8})), None);
|
||||||
|
assert_eq!(m(serde_json::json!({"type": "abort"})), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python's abort dict always carries `status_code`/`err_type`, null included,
|
||||||
|
/// and the reason is echoed verbatim into `meta_info.finish_reason` — so the
|
||||||
|
/// typed form must serialize back key-for-key, nulls and all. A dropped null
|
||||||
|
/// key is a silently changed response body.
|
||||||
|
#[test]
|
||||||
|
fn finish_reason_round_trips_python_shapes() {
|
||||||
|
for wire in [
|
||||||
|
serde_json::json!({"type": "stop", "matched": 9}),
|
||||||
|
serde_json::json!({"type": "stop", "matched": "</s>"}),
|
||||||
|
serde_json::json!({"type": "stop", "matched": [9, 10]}),
|
||||||
|
serde_json::json!({"type": "length", "length": 8}),
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "abort", "message": "Aborted",
|
||||||
|
"status_code": null, "err_type": null
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "abort", "message": "over the limit",
|
||||||
|
"status_code": 400, "err_type": "BadRequestError"
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let parsed: FinishReason = serde_json::from_value(wire.clone()).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(parsed, FinishReason::Known(_)),
|
||||||
|
"must classify, not fall back to Unknown: {wire}"
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `type` this build doesn't know must not fail the decode — the header holds
|
||||||
|
/// the whole batch, so a rejected frame drops every request in it, not just the
|
||||||
|
/// one carrying the new reason. It is kept verbatim and echoed unchanged.
|
||||||
|
#[test]
|
||||||
|
fn unknown_finish_type_is_preserved_not_rejected() {
|
||||||
|
let wire = serde_json::json!({"type": "tool_calls", "name": "search"});
|
||||||
|
let parsed: FinishReason = serde_json::from_value(wire.clone()).unwrap();
|
||||||
|
assert!(matches!(parsed, FinishReason::Unknown(_)));
|
||||||
|
assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
|
||||||
|
// Unknown reasons classify as neither an abort nor a matched stop.
|
||||||
|
assert!(parsed.abort_status().is_none());
|
||||||
|
assert!(parsed.matched().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user