[Frontend] Add /abort_requests to the RLHF dev API router (#47173)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

aoshen02 committed Jul 12, 2026 at 14:21 UTC 83762b77b07e97c77f986e4bf5a9474952e47bb3
8 files changed +69 -12
docs/serving/online_serving/README.md
+1
@@ -170,6 +170,7 @@ For further details on Weight Transfer, please refer to [this page](../../traini
170 - `/pause` - Pause generation (causes denial of service)
171 - `/resume` - Resume generation
172 - `/is_paused` - Check if generation is paused
173 +- `/abort_requests` - Abort in-flight requests (all in-flight, or the given `request_ids`) without pausing the scheduler
174 - `/init_weight_transfer_engine` - Initialize weight transfer engine for RLHF
175 - `/start_weight_update` - Prepares the inference engine for a weight update.
176 - `/update_weights` - Update model weights (can alter model behavior)
docs/training/async_rl.md
+1
@@ -42,6 +42,7 @@ When using the vLLM HTTP server, the same functionality is available via:
42
43 - `POST /pause?mode=keep` - Pause generation
44 - `POST /resume` - Resume generation
45 +- `POST /abort_requests` - Abort in-flight requests without pausing the scheduler (send `{}` to abort all, or `{"request_ids": [...]}`)
46
47 !!! note "Data Parallelism"
48 When using data parallelism with vLLM's **internal load balancer** (i.e. `data_parallel_backend="ray"`), pause and resume are handled automatically across all DP ranks -- a single call is sufficient. When using an **external load balancer** (i.e. multiple independent vLLM instances behind a proxy), you must send pause and resume requests to **every** engine instance individually before and after the weight update.
docs/usage/security.md
+1
@@ -191,6 +191,7 @@ The following endpoints **do not require authentication** even when `--api-key`
191 - `/pause` - Pause generation (causes denial of service)
192 - `/resume` - Resume generation
193 - `/is_paused` - Check if generation is paused
194 +- `/abort_requests` - Abort in-flight requests (causes loss of in-flight work)
195 - `/scale_elastic_ep` - Trigger scaling operations
196 - `/is_scaling_elastic_ep` - Check if scaling is in progress
197 - `/init_weight_transfer_engine` - Initialize weight transfer engine for RLHF
rust/src/llm/src/inflight.rs
+7
@@ -64,6 +64,13 @@ impl InflightRequests {
64 .collect()
65 }
66
67 + /// Collect the internal engine ids of every in-flight request. Used to
68 + /// abort all outstanding requests when no external ids are given.
69 + pub(crate) fn all_internal_ids(&self) -> Vec<String> {
70 + let map = self.map.lock();
71 + map.values().flat_map(|internal_ids| internal_ids.keys()).cloned().collect()
72 + }
73 +
74 #[cfg(test)]
75 fn is_empty(&self) -> bool {
76 self.map.lock().is_empty()
rust/src/llm/src/lib.rs
+6 -1
@@ -122,7 +122,12 @@ impl Llm {
122 /// tracking entries themselves are removed when the corresponding output
123 /// streams are dropped, not here.
124 pub async fn abort(&self, external_ids: &[String]) -> Result<()> {
125 - let internal_ids = self.inflight.resolve(external_ids);
125 + // Empty `external_ids` means abort every in-flight request.
126 + let internal_ids = if external_ids.is_empty() {
127 + self.inflight.all_internal_ids()
128 + } else {
129 + self.inflight.resolve(external_ids)
130 + };
131 if internal_ids.is_empty() {
132 return Ok(());
133 }
rust/src/server/src/routes/abort_requests.rs
+2 -6
@@ -20,12 +20,8 @@ pub async fn abort_requests(
20 body: Result<Json<AbortRequestsRequest>, JsonRejection>,
21 ) -> Result<StatusCode, ApiError> {
22 let Json(body) = body.map_err(|error| ApiError::json_parse_error(error.body_text()))?;
23 - let request_ids = body.request_ids.ok_or_else(|| {
24 - ApiError::invalid_request(
25 - "Missing 'request_ids' in request body".to_string(),
26 - Some("request_ids"),
27 - )
28 - })?;
23 + // Empty/missing `request_ids` aborts all in-flight requests.
24 + let request_ids = body.request_ids.unwrap_or_default();
25
26 state
27 .chat
rust/src/server/src/routes/tests.rs
+6 -5
@@ -5276,10 +5276,12 @@ async fn abort_requests_route_returns_ok_for_well_formed_body() {
5276
5277 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5278 #[serial]
5279 -async fn abort_requests_route_rejects_missing_request_ids() {
5279 +async fn abort_requests_route_aborts_all_when_request_ids_missing() {
5280 let (app, engine_task) =
5281 test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await;
5282
5283 + // Missing `request_ids` means "abort all in-flight requests"; with no
5284 + // in-flight requests this is a no-op that still succeeds.
5285 let response = app
5286 .clone()
5287 .call(
@@ -5293,11 +5295,10 @@ async fn abort_requests_route_rejects_missing_request_ids() {
5295 .await
5296 .expect("call app");
5297
5296 - assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5298 + let status = response.status();
5299 let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
5298 - let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
5299 - assert_eq!(json["error"]["type"], "invalid_request_error");
5300 - assert_eq!(json["error"]["param"], "request_ids");
5300 + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
5301 + assert!(body.is_empty());
5302 engine_task.abort_and_join().await;
5303 }
5304
vllm/entrypoints/serve/dev/rlhf/api_router.py
+45
@@ -91,6 +91,51 @@ async def resume_generation(raw_request: Request) -> JSONResponse:
91 )
92
93
94 +@router.post("/abort_requests")
95 +async def abort_requests(raw_request: Request) -> JSONResponse:
96 + """Abort in-flight requests without pausing the scheduler.
97 +
98 + Empty/missing ``request_ids`` aborts all in-flight requests.
99 + """
100 +
101 + engine = engine_client(raw_request)
102 +
103 + try:
104 + body = await raw_request.json()
105 + except json.JSONDecodeError as e:
106 + raise HTTPException(status_code=400, detail="Invalid JSON format") from e # noqa: B904
107 +
108 + request_ids = body.get("request_ids")
109 +
110 + try:
111 + if request_ids:
112 + # Body ids are external (user-supplied) request ids.
113 + await engine.abort(request_ids)
114 + else:
115 + # The dev RL server runs AsyncLLM; abort everything it is tracking.
116 + # request_states is keyed by internal ids; parent_requests holds
117 + # parallel-sampling parents. Abort both as internal ids.
118 + from vllm.v1.engine.async_llm import AsyncLLM
119 +
120 + assert isinstance(engine, AsyncLLM)
121 + op = engine.output_processor
122 + request_ids = [
123 + *op.request_states.keys(),
124 + *op.parent_requests.keys(),
125 + ]
126 + await engine.abort(request_ids, internal=True)
127 + return JSONResponse(
128 + content={"status": "aborted", "aborted": len(request_ids)},
129 + status_code=HTTPStatus.OK.value,
130 + )
131 + except Exception as err: # pragma: no cover - defensive
132 + logger.exception("Failed to abort requests")
133 + return JSONResponse(
134 + content={"error": f"Failed to abort requests: {err}"},
135 + status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
136 + )
137 +
138 +
139 @router.get("/is_paused")
140 async def is_paused(raw_request: Request) -> JSONResponse:
141 """Return the current pause status."""