feat(frontend): session id plumbing into requests (#48048)
Signed-off-by: Karen Chung <karenc@nvidia.com>
Karen Chung committed
Aug 3, 2026 at 15:18 UTC
f57123aa2dbc1badb1865b18c724b308f90dceda
39 files changed
+438
-4
rust/proto/inference.proto
+2
@@ -45,6 +45,8 @@ message GenerateRequest {
45
uint32 truncate_prompt_tokens = 11;
46
47
int32 priority = 12;
48
+
49
+ optional string session_id = 13;
50
}
51
52
message RandomSampling {
rust/src/chat/src/lib.rs
+1
@@ -168,6 +168,7 @@ impl ChatRequestProcessor {
168
cache_salt: request.cache_salt,
169
add_special_tokens: request.add_special_tokens,
170
data_parallel_rank: request.data_parallel_rank,
171
+ session_id: request.session_id,
172
reasoning_parser_kwargs,
173
lora_request: request.lora_request,
174
arrival_time: Some(arrival_time),
rust/src/chat/src/request.rs
+4
@@ -491,6 +491,9 @@ pub struct ChatRequest {
491
/// Override data parallel rank.
492
#[serde(default)]
493
pub data_parallel_rank: Option<u32>,
494
+ /// Stable session identity shared by related requests.
495
+ #[serde(default)]
496
+ pub session_id: Option<String>,
497
/// LoRA adapter selected for this request.
498
#[serde(default)]
499
pub lora_request: Option<LoraRequest>,
@@ -514,6 +517,7 @@ impl ChatRequest {
517
cache_salt: None,
518
add_special_tokens: false,
519
data_parallel_rank: None,
520
+ session_id: None,
521
lora_request: None,
522
}
523
}
rust/src/engine-core-client/src/protocol/request.rs
+6
-1
@@ -124,6 +124,9 @@ pub struct EngineCoreRequest {
124
/// standard `request_finished` hook.
125
#[serde(default)]
126
pub abort_immediately: bool,
127
+ /// Stable session identity shared by related requests.
128
+ #[serde(default)]
129
+ pub session_id: Option<String>,
130
}
131
132
impl EngineCoreRequest {
@@ -175,6 +178,7 @@ mod tests {
178
}),
179
arrival_time: 1234.5,
180
client_index: 7,
181
+ session_id: Some("session-1".to_string()),
182
..EngineCoreRequest::default()
183
};
184
@@ -185,12 +189,13 @@ mod tests {
189
other => panic!("expected array, got {other:?}"),
190
};
191
188
- assert_eq!(array.len(), 20);
192
+ assert_eq!(array.len(), 21);
193
assert_eq!(array[0], Value::from("req-1"));
194
assert_eq!(array[2], Value::Nil);
195
assert_eq!(array[4], Value::Nil);
196
assert_eq!(array[10], Value::Nil);
197
assert_eq!(array[11], Value::from(7));
198
+ assert_eq!(array[20], Value::from("session-1"));
199
}
200
201
#[test]
rust/src/engine-core-client/src/tests/client.rs
+1
@@ -162,6 +162,7 @@ fn sample_request_with_id(request_id: &str) -> EngineCoreRequest {
162
..EngineCoreSamplingParams::for_test()
163
}),
164
arrival_time: 42.5,
165
+ session_id: Some("session-1".to_string()),
166
..EngineCoreRequest::default()
167
}
168
}
rust/src/engine-core-client/src/tests/python_compat.py
+2
@@ -75,6 +75,7 @@ class EngineCoreRequest(
75
reasoning_ended: bool | None = None
76
reasoning_parser_kwargs: dict[str, object] | None = None
77
abort_immediately: bool = False
78
+ session_id: str | None = None
79
80
81
class EngineCoreOutput(
@@ -137,6 +138,7 @@ request = EngineCoreRequest(
138
pooling_params=None,
139
arrival_time=42.5,
140
client_index=0,
141
+ session_id="session-1",
142
)
143
144
# All defaults -> empty map. Regression guard for the sparse-map decode.
rust/src/llm/examples/external_engine_smoke.rs
+1
@@ -59,6 +59,7 @@ fn build_request(request_id: String, max_tokens: u32) -> GenerateRequest {
59
trace_headers: None,
60
priority: 0,
61
data_parallel_rank: None,
62
+ session_id: None,
63
reasoning_parser_kwargs: None,
64
lora_request: None,
65
}
rust/src/llm/src/request.rs
+6
@@ -46,6 +46,8 @@ pub struct GenerateRequest {
46
pub priority: i32,
47
/// Optional data-parallel rank override for routing this request.
48
pub data_parallel_rank: Option<u32>,
49
+ /// Stable session identity shared by related requests.
50
+ pub session_id: Option<String>,
51
/// Optional reasoning-parser kwargs forwarded to engine-side structured
52
/// output logic.
53
pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
@@ -76,6 +78,7 @@ impl GenerateRequest {
78
trace_headers,
79
priority,
80
data_parallel_rank,
81
+ session_id,
82
reasoning_parser_kwargs,
83
lora_request,
84
} = self;
@@ -105,6 +108,7 @@ impl GenerateRequest {
108
priority,
109
trace_headers,
110
resumable: false,
111
+ session_id,
112
external_req_id: Some(external_request_id),
113
// Rust parser doesn't expose this information, leave it unset and let the
114
// reasoning logic in engine-sided structured output manager handle it.
@@ -150,6 +154,7 @@ mod tests {
154
)])),
155
priority: 3,
156
data_parallel_rank: Some(2),
157
+ session_id: Some("session-1".to_string()),
158
reasoning_parser_kwargs: Some(ReasoningParserKwargs {
159
chat_template_kwargs: [(
160
"chat_template_kwargs".to_string(),
@@ -177,6 +182,7 @@ mod tests {
182
assert_eq!(request.arrival_time, 42.5);
183
assert_eq!(request.cache_salt.as_deref(), Some("salt"));
184
assert_eq!(request.data_parallel_rank, Some(2));
185
+ assert_eq!(request.session_id.as_deref(), Some("session-1"));
186
assert_eq!(
187
request.trace_headers,
188
Some(BTreeMap::from([(
rust/src/llm/tests/generate.rs
+1
@@ -188,6 +188,7 @@ fn sample_generate_request(request_id: &str, max_tokens: u32) -> GenerateRequest
188
trace_headers: None,
189
priority: 0,
190
data_parallel_rank: None,
191
+ session_id: None,
192
reasoning_parser_kwargs: None,
193
lora_request: None,
194
}
rust/src/server/src/grpc/convert.rs
+2
@@ -53,6 +53,7 @@ pub fn to_text_request(
53
} else {
54
req.request_id
55
};
56
+ let session_id = req.session_id.filter(|s| !s.is_empty());
57
58
let sampling = req.sampling.as_ref();
59
let decoding = req.decoding.as_ref();
@@ -100,6 +101,7 @@ pub fn to_text_request(
101
cache_salt: kv.map(|k| &k.cache_salt).filter(|s| !s.is_empty()).cloned(),
102
add_special_tokens: true,
103
data_parallel_rank: None,
104
+ session_id,
105
reasoning_parser_kwargs: None,
106
lora_request: None,
107
arrival_time: None,
rust/src/server/src/routes/inference/generate/convert.rs
+1
@@ -75,6 +75,7 @@ pub(super) fn prepare_generate_request(
75
cache_salt: request.cache_salt,
76
add_special_tokens: false,
77
data_parallel_rank: ctx.data_parallel_rank,
78
+ session_id: ctx.session_id,
79
reasoning_parser_kwargs: None,
80
lora_request: lora_resolution.lora_request.clone(),
81
arrival_time: None,
rust/src/server/src/routes/openai/chat_completions/convert.rs
+50
@@ -18,6 +18,7 @@ use crate::routes::openai::utils::types::{
18
};
19
use crate::utils::{
20
ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
21
+ resolve_session_id,
22
};
23
24
/// Lowered chat request plus the public response metadata carried by every SSE
@@ -123,6 +124,11 @@ pub(super) fn prepare_chat_request(
124
request.response_format.as_ref(),
125
&request.structured_outputs,
126
)?;
127
+ let session_id = resolve_session_id(
128
+ &ctx,
129
+ request.session_id.as_deref(),
130
+ request.vllm_xargs.as_ref(),
131
+ );
132
133
let chat_request = ChatRequest {
134
request_id: request_id.clone(),
@@ -177,6 +183,7 @@ pub(super) fn prepare_chat_request(
183
cache_salt: request.cache_salt,
184
add_special_tokens: request.add_special_tokens,
185
data_parallel_rank: ctx.data_parallel_rank,
186
+ session_id,
187
lora_request: lora_resolution.lora_request.clone(),
188
};
189
@@ -1241,6 +1248,49 @@ mod tests {
1248
assert_eq!(prepared.chat_request.data_parallel_rank, Some(7));
1249
}
1250
1251
+ #[test]
1252
+ fn prepare_chat_request_threads_header_session_id() {
1253
+ let mut headers = HeaderMap::new();
1254
+ headers.insert("X-Session-ID", "header-session".parse().unwrap());
1255
+ let prepared = prepare_chat_request(
1256
+ base_request(),
1257
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
1258
+ request_context(&headers, None),
1259
+ )
1260
+ .expect("request is valid");
1261
+ assert_eq!(
1262
+ prepared.chat_request.session_id.as_deref(),
1263
+ Some("header-session")
1264
+ );
1265
+ }
1266
+
1267
+ #[test]
1268
+ fn prepare_chat_request_ignores_correlation_header() {
1269
+ let mut headers = HeaderMap::new();
1270
+ headers.insert("X-Correlation-ID", "correlation-header".parse().unwrap());
1271
+ let prepared = prepare_chat_request(
1272
+ base_request(),
1273
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
1274
+ request_context(&headers, None),
1275
+ )
1276
+ .expect("request is valid");
1277
+ assert_eq!(prepared.chat_request.session_id, None);
1278
+ }
1279
+
1280
+ #[test]
1281
+ fn prepare_chat_request_ignores_empty_session_header_without_fallback() {
1282
+ let mut headers = HeaderMap::new();
1283
+ headers.insert("X-Session-ID", "".parse().unwrap());
1284
+ headers.insert("X-Correlation-ID", "correlation-header".parse().unwrap());
1285
+ let prepared = prepare_chat_request(
1286
+ base_request(),
1287
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
1288
+ request_context(&headers, None),
1289
+ )
1290
+ .expect("request is valid");
1291
+ assert_eq!(prepared.chat_request.session_id, None);
1292
+ }
1293
+
1294
#[test]
1295
fn prepare_chat_request_leaves_data_parallel_rank_none_when_absent() {
1296
let prepared = prepare_chat_request(
rust/src/server/src/routes/openai/chat_completions/types.rs
+4
@@ -222,6 +222,9 @@ pub struct ChatCompletionRequest {
222
/// External request ID used for response correlation.
223
pub request_id: Option<String>,
224
225
+ /// Stable session identity shared by related requests.
226
+ pub session_id: Option<String>,
227
+
228
/// Tokens represented as strings of the form 'token_id:{token_id}' in
229
/// logprobs
230
pub return_tokens_as_token_ids: Option<bool>,
@@ -301,6 +304,7 @@ impl Default for ChatCompletionRequest {
304
structured_outputs: None,
305
priority: None,
306
request_id: None,
307
+ session_id: None,
308
return_tokens_as_token_ids: None,
309
return_token_ids: None,
310
cache_salt: None,
rust/src/server/src/routes/openai/completions/convert.rs
+77
@@ -12,6 +12,7 @@ use crate::routes::openai::completions::validate;
12
use crate::routes::openai::utils::structured_outputs::convert_from_response_format_value;
13
use crate::utils::{
14
ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
15
+ resolve_session_id,
16
};
17
18
/// Lowered completion request plus the public response metadata carried by
@@ -103,6 +104,11 @@ pub(super) fn prepare_completion_request(
104
105
let structured_outputs =
106
convert_from_response_format_value(&request.response_format, &request.structured_outputs)?;
107
+ let session_id = resolve_session_id(
108
+ &ctx,
109
+ request.session_id.as_deref(),
110
+ request.vllm_xargs.as_ref(),
111
+ );
112
113
let text_request = TextRequest {
114
request_id: request_id.clone(),
@@ -147,6 +153,7 @@ pub(super) fn prepare_completion_request(
153
cache_salt: request.cache_salt,
154
add_special_tokens: request.add_special_tokens,
155
data_parallel_rank: ctx.data_parallel_rank,
156
+ session_id,
157
reasoning_parser_kwargs: None,
158
lora_request: lora_resolution.lora_request.clone(),
159
arrival_time: None,
@@ -586,6 +593,76 @@ mod tests {
593
assert_eq!(prepared.text_request.data_parallel_rank, Some(3));
594
}
595
596
+ #[test]
597
+ fn prepare_completion_request_threads_body_session_id() {
598
+ let request: CompletionRequest = serde_json::from_value(json!({
599
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
600
+ "prompt": "hello",
601
+ "stream": false,
602
+ "session_id": "body-session",
603
+ "vllm_xargs": {"session_id": "xargs-session"},
604
+ }))
605
+ .expect("parse request");
606
+
607
+ let mut headers = HeaderMap::new();
608
+ headers.insert("X-Session-ID", "header-session".parse().unwrap());
609
+ let prepared = prepare_completion_request(
610
+ request,
611
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
612
+ request_context(&headers, None),
613
+ &test_tokenizer(),
614
+ )
615
+ .expect("prepare");
616
+ assert_eq!(
617
+ prepared.text_request.session_id.as_deref(),
618
+ Some("body-session")
619
+ );
620
+ }
621
+
622
+ #[test]
623
+ fn prepare_completion_request_uses_vllm_xargs_session_id_fallback() {
624
+ let request: CompletionRequest = serde_json::from_value(json!({
625
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
626
+ "prompt": "hello",
627
+ "stream": false,
628
+ "vllm_xargs": {"session_id": "xargs-session"},
629
+ }))
630
+ .expect("parse request");
631
+
632
+ let prepared = prepare_completion_request(
633
+ request,
634
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
635
+ ResolvedRequestContext::default(),
636
+ &test_tokenizer(),
637
+ )
638
+ .expect("prepare");
639
+ assert_eq!(
640
+ prepared.text_request.session_id.as_deref(),
641
+ Some("xargs-session")
642
+ );
643
+ }
644
+
645
+ #[test]
646
+ fn prepare_completion_request_ignores_empty_and_non_string_session_id_values() {
647
+ let request: CompletionRequest = serde_json::from_value(json!({
648
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
649
+ "prompt": "hello",
650
+ "stream": false,
651
+ "session_id": "",
652
+ "vllm_xargs": {"session_id": 7},
653
+ }))
654
+ .expect("parse request");
655
+
656
+ let prepared = prepare_completion_request(
657
+ request,
658
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
659
+ ResolvedRequestContext::default(),
660
+ &test_tokenizer(),
661
+ )
662
+ .expect("prepare");
663
+ assert_eq!(prepared.text_request.session_id, None);
664
+ }
665
+
666
#[test]
667
fn prepare_completion_request_leaves_data_parallel_rank_none_when_absent() {
668
let request: CompletionRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/completions/types.rs
+3
@@ -164,6 +164,9 @@ pub struct CompletionRequest {
164
/// External request ID used for response correlation.
165
pub request_id: Option<String>,
166
167
+ /// Stable session identity shared by related requests.
168
+ pub session_id: Option<String>,
169
+
170
/// Tokens represented as strings of the form 'token_id:{token_id}' in
171
/// logprobs
172
pub return_tokens_as_token_ids: Option<bool>,
rust/src/server/src/routes/tokenize/types.rs
+1
@@ -95,6 +95,7 @@ impl TokenizeChatRequest {
95
cache_salt: None,
96
add_special_tokens: self.add_special_tokens,
97
data_parallel_rank: None,
98
+ session_id: None,
99
lora_request: None,
100
})
101
}
rust/src/server/src/utils.rs
+27
-2
@@ -15,6 +15,7 @@ use crate::error::ApiError;
15
pub struct ResolvedRequestContext {
16
pub request_id: String,
17
pub data_parallel_rank: Option<u32>,
18
+ pub session_id: Option<String>,
19
}
20
21
/// Return the current Unix timestamp in seconds for OpenAI response objects.
@@ -66,6 +67,24 @@ pub fn merge_ec_transfer_params(
67
xargs
68
}
69
70
+pub fn resolve_session_id(
71
+ ctx: &ResolvedRequestContext,
72
+ request_session_id: Option<&str>,
73
+ xargs: Option<&HashMap<String, Value>>,
74
+) -> Option<String> {
75
+ request_session_id
76
+ .filter(|value| !value.is_empty())
77
+ .map(str::to_owned)
78
+ .or_else(|| ctx.session_id.clone())
79
+ .or_else(|| {
80
+ xargs
81
+ .and_then(|map| map.get("session_id"))
82
+ .and_then(Value::as_str)
83
+ .filter(|value| !value.is_empty())
84
+ .map(str::to_owned)
85
+ })
86
+}
87
+
88
/// Convert OpenAI-style `logit_bias` with string token-ID keys into the
89
/// internal `HashMap<u32, f32>` representation, validating that every key
90
/// parses as a `u32`.
@@ -91,8 +110,8 @@ pub fn convert_logit_bias(
110
.transpose()
111
}
112
94
-/// Extract common request metadata from HTTP headers: the external request ID
95
-/// and the optional data-parallel rank used for engine routing.
113
+/// Extract common request metadata from HTTP headers: the external request ID,
114
+/// session ID, and the optional data-parallel rank used for engine routing.
115
pub fn resolve_request_context(
116
headers: &HeaderMap,
117
request_id: Option<&str>,
@@ -106,10 +125,16 @@ pub fn resolve_request_context(
125
// Extract request id from header.
126
let request_id_header = headers.get("X-Request-Id").and_then(|value| value.to_str().ok());
127
let request_id = resolve_base_request_id(request_id_header, request_id);
128
+ let session_id = headers
129
+ .get("X-Session-ID")
130
+ .and_then(|value| value.to_str().ok())
131
+ .filter(|value| !value.is_empty())
132
+ .map(str::to_owned);
133
134
ResolvedRequestContext {
135
request_id,
136
data_parallel_rank,
137
+ session_id,
138
}
139
}
140
rust/src/text/src/lower.rs
+1
@@ -59,6 +59,7 @@ pub fn lower_text_request(
59
cache_salt: request.cache_salt.clone(),
60
priority: request.priority,
61
data_parallel_rank: request.data_parallel_rank,
62
+ session_id: request.session_id.clone(),
63
reasoning_parser_kwargs: request.reasoning_parser_kwargs.clone(),
64
lora_request: request.lora_request.clone(),
65
arrival_time: request.arrival_time,
rust/src/text/src/request.rs
+4
@@ -183,6 +183,9 @@ pub struct TextRequest {
183
/// Override data parallel rank.
184
#[serde(default)]
185
pub data_parallel_rank: Option<u32>,
186
+ /// Stable session identity shared by related requests.
187
+ #[serde(default)]
188
+ pub session_id: Option<String>,
189
/// Optional reasoning-parser kwargs forwarded to engine-side structured
190
/// output logic.
191
#[serde(default)]
@@ -212,6 +215,7 @@ impl TextRequest {
215
cache_salt: None,
216
add_special_tokens: false,
217
data_parallel_rank: None,
218
+ session_id: None,
219
reasoning_parser_kwargs: None,
220
lora_request: None,
221
arrival_time: None,
tests/entrypoints/openai/test_session_id.py
new
+97
@@ -0,0 +1,97 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+from starlette.requests import Request
6
+
7
+from vllm.entrypoints.generate.base.serving import GenerateBaseServing
8
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
9
+from vllm.entrypoints.openai.completion.protocol import CompletionRequest
10
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
11
+
12
+
13
+def _raw_request(headers: dict[str, str]) -> Request:
14
+ return Request(
15
+ {
16
+ "type": "http",
17
+ "method": "POST",
18
+ "path": "/v1/chat/completions",
19
+ "headers": [
20
+ (key.lower().encode("latin-1"), value.encode("latin-1"))
21
+ for key, value in headers.items()
22
+ ],
23
+ }
24
+ )
25
+
26
+
27
+@pytest.mark.parametrize(
28
+ "openai_request",
29
+ [
30
+ ChatCompletionRequest(
31
+ model="test-model",
32
+ messages=[{"role": "user", "content": "hi"}],
33
+ ),
34
+ CompletionRequest(model="test-model", prompt="hi"),
35
+ ResponsesRequest(model="test-model", input="hi"),
36
+ ],
37
+)
38
+def test_get_session_id_accepts_body_field(openai_request):
39
+ openai_request.session_id = "body-session"
40
+
41
+ session_id = GenerateBaseServing._get_session_id(
42
+ openai_request,
43
+ _raw_request({"X-Session-ID": "header-session"}),
44
+ )
45
+
46
+ assert session_id == "body-session"
47
+
48
+
49
+def test_get_session_id_accepts_session_header():
50
+ request = CompletionRequest(model="test-model", prompt="hi")
51
+
52
+ session_id = GenerateBaseServing._get_session_id(
53
+ request,
54
+ _raw_request({"X-Session-ID": "header-session"}),
55
+ )
56
+
57
+ assert session_id == "header-session"
58
+
59
+
60
+def test_get_session_id_ignores_correlation_header():
61
+ request = CompletionRequest(
62
+ model="test-model",
63
+ prompt="hi",
64
+ vllm_xargs={"session_id": "xargs-session"},
65
+ )
66
+
67
+ session_id = GenerateBaseServing._get_session_id(
68
+ request,
69
+ _raw_request({"X-Correlation-ID": "correlation-session"}),
70
+ )
71
+
72
+ assert session_id == "xargs-session"
73
+
74
+
75
+def test_get_session_id_keeps_vllm_xargs_as_compatibility_fallback():
76
+ request = CompletionRequest(
77
+ model="test-model",
78
+ prompt="hi",
79
+ vllm_xargs={"session_id": "xargs-session"},
80
+ )
81
+
82
+ session_id = GenerateBaseServing._get_session_id(request, None)
83
+
84
+ assert session_id == "xargs-session"
85
+
86
+
87
+def test_get_session_id_ignores_empty_and_non_string_values():
88
+ request = CompletionRequest(
89
+ model="test-model",
90
+ prompt="hi",
91
+ session_id="",
92
+ vllm_xargs={"session_id": 7},
93
+ )
94
+
95
+ session_id = GenerateBaseServing._get_session_id(request, None)
96
+
97
+ assert session_id is None
tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py
+26
@@ -203,6 +203,32 @@ async def test_serve_tokens_skips_mm_cache_for_remote_engine_execution():
203
)
204
205
206
+@pytest.mark.asyncio
207
+async def test_serve_tokens_threads_session_id_header_to_engine():
208
+ engine = _mock_engine()
209
+
210
+ async def mock_generate(*args, **kwargs):
211
+ yield _make_request_output(
212
+ "req-1", token_ids=[10], finish_reason="stop", finished=True
213
+ )
214
+
215
+ engine.generate = MagicMock(side_effect=mock_generate)
216
+ serving = _build_serving_tokens(engine)
217
+
218
+ request = GenerateRequest(
219
+ token_ids=[1, 2, 3],
220
+ sampling_params=SamplingParams(max_tokens=1),
221
+ model=MODEL_NAME,
222
+ stream=False,
223
+ )
224
+ raw_request = MagicMock()
225
+ raw_request.headers = {"X-Session-ID": "header-session"}
226
+
227
+ await serving.serve_tokens(request, raw_request)
228
+
229
+ assert engine.generate.call_args.kwargs["session_id"] == "header-session"
230
+
231
+
232
@pytest.mark.asyncio
233
async def test_stream_basic():
234
"""Streaming returns SSE chunks with correct token_ids and ends with [DONE]."""
tests/v1/engine/test_parallel_sampling.py
+16
@@ -1,6 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from copy import copy
5
+
6
from vllm import SamplingParams
7
from vllm.outputs import CompletionOutput
8
from vllm.sampling_params import RequestOutputKind
@@ -68,6 +70,20 @@ def test_parent_request_to_output_final_only() -> None:
70
)
71
72
73
+def test_parallel_sampling_child_requests_preserve_session_id() -> None:
74
+ request = make_request(SamplingParams(n=2))
75
+ request.session_id = "session-1"
76
+ parent_request = ParentRequest(request)
77
+
78
+ for idx in range(parent_request.n):
79
+ request_id, child_params = parent_request.get_child_info(idx)
80
+ child_request = request if idx == parent_request.n - 1 else copy(request)
81
+ child_request.request_id = request_id
82
+ child_request.sampling_params = child_params
83
+
84
+ assert child_request.session_id == "session-1"
85
+
86
+
87
def make_request(sampling_params: SamplingParams) -> EngineCoreRequest:
88
return EngineCoreRequest(
89
request_id="parent_id",
tests/v1/test_request.py
+23
-1
@@ -1,6 +1,9 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from vllm.v1.request import RequestStatus
3
+
4
+from vllm import SamplingParams
5
+from vllm.v1.engine import EngineCoreRequest
6
+from vllm.v1.request import Request, RequestStatus
7
8
9
def test_request_status_fmt_str():
@@ -18,3 +21,22 @@ def test_request_status_fmt_str():
21
assert f"{RequestStatus.FINISHED_LENGTH_CAPPED}" == "FINISHED_LENGTH_CAPPED"
22
assert f"{RequestStatus.FINISHED_ABORTED}" == "FINISHED_ABORTED"
23
assert f"{RequestStatus.FINISHED_IGNORED}" == "FINISHED_IGNORED"
24
+
25
+
26
+def test_request_copies_session_id_from_engine_core_request():
27
+ engine_request = EngineCoreRequest(
28
+ request_id="request-1",
29
+ prompt_token_ids=[1, 2, 3],
30
+ mm_features=None,
31
+ sampling_params=SamplingParams(max_tokens=1),
32
+ pooling_params=None,
33
+ arrival_time=0.0,
34
+ lora_request=None,
35
+ cache_salt=None,
36
+ data_parallel_rank=None,
37
+ session_id="session-1",
38
+ )
39
+
40
+ request = Request.from_engine_core_request(engine_request, block_hasher=None)
41
+
42
+ assert request.session_id == "session-1"
vllm/engine/protocol.py
+1
@@ -78,6 +78,7 @@ class EngineClient(ABC):
78
trace_headers: Mapping[str, str] | None = None,
79
priority: int = 0,
80
data_parallel_rank: int | None = None,
81
+ session_id: str | None = None,
82
reasoning_ended: bool | None = None,
83
reasoning_parser_kwargs: dict[str, Any] | None = None,
84
) -> AsyncGenerator[RequestOutput, None]:
vllm/entrypoints/generate/base/serving.py
+24
@@ -41,6 +41,7 @@ logger = init_logger(__name__)
41
42
RequestT = TypeVar("RequestT", bound=AnyRequest)
43
_T = TypeVar("_T")
44
+SESSION_ID_HEADER = "X-Session-ID"
45
46
47
def build_per_request_timing_metrics(
@@ -215,6 +216,29 @@ class GenerateBaseServing(BaseServing, BeamSearchOnlineMixin):
216
except ValueError:
217
return None
218
219
+ @staticmethod
220
+ def _get_session_id_from_headers(raw_request: Request | None) -> str | None:
221
+ if raw_request is None:
222
+ return None
223
+ if value := raw_request.headers.get(SESSION_ID_HEADER):
224
+ return value
225
+ return None
226
+
227
+ @staticmethod
228
+ def _get_session_id(
229
+ request: ChatCompletionRequest | CompletionRequest | ResponsesRequest,
230
+ raw_request: Request | None,
231
+ ) -> str | None:
232
+ if request.session_id:
233
+ return request.session_id
234
+ if value := GenerateBaseServing._get_session_id_from_headers(raw_request):
235
+ return value
236
+ if request.vllm_xargs:
237
+ session_id = request.vllm_xargs.get("session_id")
238
+ if isinstance(session_id, str) and session_id:
239
+ return session_id
240
+ return None
241
+
242
async def _with_kv_transfer_rejection_cleanup(
243
self,
244
awaitable: Awaitable[_T],
vllm/entrypoints/generate/beam_search/online.py
+2
@@ -32,6 +32,7 @@ class BeamSearchOnlineMixin(ABC):
32
params: BeamSearchParams,
33
lora_request: LoRARequest | None = None,
34
trace_headers: Mapping[str, str] | None = None,
35
+ session_id: str | None = None,
36
) -> AsyncGenerator[RequestOutput, None]:
37
beam_width = params.beam_width
38
max_tokens = params.max_tokens
@@ -90,6 +91,7 @@ class BeamSearchOnlineMixin(ABC):
91
request_id_item,
92
lora_request=lora_request_item,
93
trace_headers=trace_headers,
94
+ session_id=session_id,
95
)
96
)
97
)
vllm/entrypoints/openai/chat_completion/batch_serving.py
+2
@@ -174,6 +174,7 @@ class OpenAIServingChatBatch(OpenAIServingChat):
174
if raw_request is None
175
else await self._get_trace_headers(raw_request.headers)
176
)
177
+ session_id = self._get_session_id(single_request, raw_request)
178
generators.append(
179
self.engine_client.generate(
180
engine_prompt,
@@ -183,6 +184,7 @@ class OpenAIServingChatBatch(OpenAIServingChat):
184
trace_headers=trace_headers,
185
priority=request.priority,
186
data_parallel_rank=data_parallel_rank,
187
+ session_id=session_id,
188
reasoning_ended=None,
189
)
190
)
vllm/entrypoints/openai/chat_completion/protocol.py
+8
@@ -394,6 +394,14 @@ class ChatCompletionRequest(OpenAIBaseModel):
394
"through out the inference process and return in response."
395
),
396
)
397
+ session_id: str | None = Field(
398
+ default=None,
399
+ description=(
400
+ "Stable session identity shared by related requests. Unlike "
401
+ "request_id, this value is expected to remain stable across "
402
+ "multiple requests in the same conversation or agent session."
403
+ ),
404
+ )
405
406
return_tokens_as_token_ids: bool | None = Field(
407
default=None,
vllm/entrypoints/openai/chat_completion/serving.py
+3
@@ -318,6 +318,7 @@ class OpenAIServingChat(GenerateBaseServing):
318
if raw_request is None
319
else await self._get_trace_headers(raw_request.headers)
320
)
321
+ session_id = self._get_session_id(request, raw_request)
322
323
if isinstance(sampling_params, BeamSearchParams):
324
generator = self.beam_search(
@@ -326,6 +327,7 @@ class OpenAIServingChat(GenerateBaseServing):
327
params=sampling_params,
328
lora_request=lora_request,
329
trace_headers=trace_headers,
330
+ session_id=session_id,
331
)
332
else:
333
if not request.include_reasoning:
@@ -348,6 +350,7 @@ class OpenAIServingChat(GenerateBaseServing):
350
trace_headers=trace_headers,
351
priority=request.priority,
352
data_parallel_rank=data_parallel_rank,
353
+ session_id=session_id,
354
reasoning_ended=reasoning_ended,
355
reasoning_parser_kwargs={
356
"chat_template_kwargs": chat_template_kwargs,
vllm/entrypoints/openai/completion/protocol.py
+8
@@ -147,6 +147,14 @@ class CompletionRequest(OpenAIBaseModel):
147
"through out the inference process and return in response."
148
),
149
)
150
+ session_id: str | None = Field(
151
+ default=None,
152
+ description=(
153
+ "Stable session identity shared by related requests. Unlike "
154
+ "request_id, this value is expected to remain stable across "
155
+ "multiple requests in the same conversation or agent session."
156
+ ),
157
+ )
158
159
return_tokens_as_token_ids: bool | None = Field(
160
default=None,
vllm/entrypoints/openai/completion/serving.py
+3
@@ -194,6 +194,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
194
if raw_request is None
195
else await self._get_trace_headers(raw_request.headers)
196
)
197
+ session_id = self._get_session_id(request, raw_request)
198
199
if isinstance(sampling_params, BeamSearchParams):
200
generator = self.beam_search(
@@ -202,6 +203,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
203
params=sampling_params,
204
lora_request=lora_request,
205
trace_headers=trace_headers,
206
+ session_id=session_id,
207
)
208
else:
209
generator = self.engine_client.generate(
@@ -212,6 +214,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
214
trace_headers=trace_headers,
215
priority=request.priority,
216
data_parallel_rank=data_parallel_rank,
217
+ session_id=session_id,
218
)
219
220
generators.append(generator)
vllm/entrypoints/openai/responses/protocol.py
+8
@@ -220,6 +220,14 @@ class ResponsesRequest(OpenAIBaseModel):
220
"through out the inference process and return in response."
221
),
222
)
223
+ session_id: str | None = Field(
224
+ default=None,
225
+ description=(
226
+ "Stable session identity shared by related requests. Unlike "
227
+ "request_id, this value is expected to remain stable across "
228
+ "multiple requests in the same conversation or agent session."
229
+ ),
230
+ )
231
media_io_kwargs: dict[str, dict[str, Any]] | None = Field(
232
default=None,
233
description=(
vllm/entrypoints/openai/responses/serving.py
+4
@@ -451,6 +451,7 @@ class OpenAIServingResponses(GenerateBaseServing):
451
if raw_request is None
452
else await self._get_trace_headers(raw_request.headers)
453
)
454
+ session_id = self._get_session_id(request, raw_request)
455
456
chat_template_kwargs = self._effective_chat_template_kwargs(request)
457
response_parser = self._make_response_parser(
@@ -516,6 +517,7 @@ class OpenAIServingResponses(GenerateBaseServing):
517
lora_request=lora_request,
518
priority=request.priority,
519
trace_headers=trace_headers,
520
+ session_id=session_id,
521
reasoning_parser_kwargs=reasoning_parser_kwargs
522
if self.parser and self.parser.reasoning_parser_cls is not None
523
else None,
@@ -669,6 +671,7 @@ class OpenAIServingResponses(GenerateBaseServing):
671
lora_request: LoRARequest | None = None,
672
priority: int = 0,
673
trace_headers: Mapping[str, str] | None = None,
674
+ session_id: str | None = None,
675
reasoning_parser_kwargs: dict[str, Any] | None = None,
676
):
677
max_model_len = self.model_config.max_model_len
@@ -693,6 +696,7 @@ class OpenAIServingResponses(GenerateBaseServing):
696
lora_request=lora_request,
697
trace_headers=trace_headers,
698
priority=priority,
699
+ session_id=session_id,
700
reasoning_parser_kwargs=reasoning_parser_kwargs,
701
)
702
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+2
@@ -215,6 +215,7 @@ class ServingTokens(GenerateBaseServing):
215
216
# Extract data_parallel_rank from header (router can inject it)
217
data_parallel_rank = self._get_data_parallel_rank(raw_request)
218
+ session_id = self._get_session_id_from_headers(raw_request)
219
220
result_generator = self.engine_client.generate(
221
engine_input,
@@ -224,6 +225,7 @@ class ServingTokens(GenerateBaseServing):
225
trace_headers=trace_headers,
226
priority=request.priority,
227
data_parallel_rank=data_parallel_rank,
228
+ session_id=session_id,
229
)
230
231
assert result_generator is not None
vllm/v1/engine/__init__.py
+2
@@ -145,6 +145,8 @@ class EngineCoreRequest(
145
# KV-transfer request is rejected on the D node before engine admission.
146
abort_immediately: bool = False
147
148
+ session_id: str | None = None
149
+
150
@property
151
def params(self) -> SamplingParams | PoolingParams:
152
"""Return the processed params (sampling or pooling)."""
vllm/v1/engine/async_llm.py
+8
@@ -294,6 +294,7 @@ class AsyncLLM(EngineClient):
294
trace_headers: Mapping[str, str] | None = None,
295
priority: int = 0,
296
data_parallel_rank: int | None = None,
297
+ session_id: str | None = None,
298
prompt_text: str | None = None,
299
reasoning_ended: bool | None = None,
300
reasoning_parser_kwargs: dict[str, Any] | None = None,
@@ -331,6 +332,7 @@ class AsyncLLM(EngineClient):
332
trace_headers,
333
priority,
334
data_parallel_rank,
335
+ session_id,
336
)
337
338
# Convert Input --> Request.
@@ -362,6 +364,7 @@ class AsyncLLM(EngineClient):
364
trace_headers=trace_headers,
365
priority=priority,
366
data_parallel_rank=data_parallel_rank,
367
+ session_id=session_id,
368
)
369
else:
370
# Raw prompts require tokenization and possibly multimodal
@@ -377,6 +380,7 @@ class AsyncLLM(EngineClient):
380
trace_headers=trace_headers,
381
priority=priority,
382
data_parallel_rank=data_parallel_rank,
383
+ session_id=session_id,
384
)
385
prompt_text, _, _ = extract_prompt_components(self.model_config, prompt)
386
@@ -445,6 +449,7 @@ class AsyncLLM(EngineClient):
449
trace_headers: Mapping[str, str] | None = None,
450
priority: int = 0,
451
data_parallel_rank: int | None = None,
452
+ session_id: str | None = None,
453
) -> RequestOutputCollector:
454
self._validate_streaming_input_sampling_params(sampling_params)
455
@@ -456,6 +461,7 @@ class AsyncLLM(EngineClient):
461
trace_headers=trace_headers,
462
priority=priority,
463
data_parallel_rank=data_parallel_rank,
464
+ session_id=session_id,
465
)
466
467
if not sampling_params.skip_clone:
@@ -556,6 +562,7 @@ class AsyncLLM(EngineClient):
562
trace_headers: Mapping[str, str] | None = None,
563
priority: int = 0,
564
data_parallel_rank: int | None = None,
565
+ session_id: str | None = None,
566
reasoning_ended: bool | None = None,
567
reasoning_parser_kwargs: dict[str, Any] | None = None,
568
) -> AsyncGenerator[RequestOutput, None]:
@@ -585,6 +592,7 @@ class AsyncLLM(EngineClient):
592
trace_headers=trace_headers,
593
priority=priority,
594
data_parallel_rank=data_parallel_rank,
595
+ session_id=session_id,
596
prompt_text=prompt_text,
597
reasoning_ended=reasoning_ended,
598
reasoning_parser_kwargs=reasoning_parser_kwargs,
vllm/v1/engine/input_processor.py
+2
@@ -261,6 +261,7 @@ class InputProcessor:
261
priority: int = 0,
262
data_parallel_rank: int | None = None,
263
resumable: bool = False,
264
+ session_id: str | None = None,
265
) -> EngineCoreRequest:
266
self._validate_params(params, supported_tasks)
267
self._validate_lora(lora_request)
@@ -391,6 +392,7 @@ class InputProcessor:
392
data_parallel_rank=data_parallel_rank,
393
trace_headers=trace_headers,
394
resumable=resumable,
395
+ session_id=session_id,
396
)
397
398
def _validate_prompt_len(
vllm/v1/engine/llm_engine.py
+2
@@ -225,6 +225,7 @@ class LLMEngine:
225
tokenization_kwargs: dict[str, Any] | None = None,
226
trace_headers: Mapping[str, str] | None = None,
227
priority: int = 0,
228
+ session_id: str | None = None,
229
prompt_text: str | None = None,
230
) -> str:
231
# Validate the request_id type.
@@ -257,6 +258,7 @@ class LLMEngine:
258
tokenization_kwargs=tokenization_kwargs,
259
trace_headers=trace_headers,
260
priority=priority,
261
+ session_id=session_id,
262
)
263
prompt_text, _, _ = extract_prompt_components(self.model_config, prompt)
264
vllm/v1/request.py
+3
@@ -74,6 +74,7 @@ class Request:
74
trace_headers: Mapping[str, str] | None = None,
75
block_hasher: Callable[["Request"], list["BlockHash"]] | None = None,
76
resumable: bool = False,
77
+ session_id: str | None = None,
78
reasoning_ended: bool | None = None,
79
reasoning_parser_kwargs: dict[str, Any] | None = None,
80
abort_immediately: bool = False,
@@ -183,6 +184,7 @@ class Request:
184
self.all_token_ids = ConstantList(self._all_token_ids)
185
# trace_headers
186
self.trace_headers = trace_headers
187
+ self.session_id = session_id
188
189
# True if this request is scheduled as a non-final prefill chunk.
190
self.is_prefill_chunk = False
@@ -241,6 +243,7 @@ class Request:
243
trace_headers=request.trace_headers,
244
block_hasher=block_hasher,
245
resumable=request.resumable,
246
+ session_id=request.session_id,
247
reasoning_ended=request.reasoning_ended,
248
reasoning_parser_kwargs=request.reasoning_parser_kwargs,
249
abort_immediately=request.abort_immediately,