[Rust Frontend][CI] Unblock more end-to-end test cases (#47735)
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jul 7, 2026 at 16:27 UTC
e55cc59e52791fe7620f6808e409edcf68740b6b
14 files changed
+105
-40
.buildkite/test-amd.yaml
+12
-2
@@ -1977,7 +1977,10 @@ steps:
1977
- tests/utils.py
1978
- tests/benchmarks/test_serve_cli.py
1979
- tests/entrypoints/openai/chat_completion/test_chat_completion.py
1980
+ - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py
1981
- tests/entrypoints/openai/completion/test_shutdown.py
1982
+ - tests/entrypoints/openai/test_return_token_ids.py
1983
+ - tests/entrypoints/openai/test_uds.py
1984
- tests/v1/sample/test_logprobs_e2e.py
1985
- vllm/platforms/rocm.py
1986
commands:
@@ -1985,7 +1988,10 @@ steps:
1988
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
1989
- pytest -v -s benchmarks/test_serve_cli.py -k "not insecure and not (test_bench_serve and not test_bench_serve_chat)"
1990
- pytest -v -s entrypoints/openai/chat_completion/test_chat_completion.py -k "not test_invalid_json_schema and not test_invalid_regex"
1991
+ - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not multiple"
1992
- pytest -v -s entrypoints/openai/completion/test_shutdown.py -k "not engine_failure and not test_abort_timeout_exits_quickly"
1993
+ - pytest -v -s entrypoints/openai/test_return_token_ids.py -k "not test_comparison"
1994
+ - pytest -v -s entrypoints/openai/test_uds.py
1995
- pytest -v -s v1/sample/test_logprobs_e2e.py -k "test_prompt_logprobs_e2e_server"
1996
1997
- label: Rust Frontend Serve Admin Coverage # TBD
@@ -2000,16 +2006,20 @@ steps:
2006
- vllm/entrypoints/serve/
2007
- vllm/v1/engine/
2008
- tests/utils.py
2003
- - tests/entrypoints/serve/disagg/test_serving_tokens.py
2009
+ - tests/entrypoints/serve/dev/rpc/test_collective_rpc.py
2010
+ - tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py
2011
- tests/entrypoints/serve/instrumentator/test_basic.py
2012
- tests/entrypoints/serve/instrumentator/test_metrics.py
2013
+ - tests/entrypoints/serve/tokenize/test_tokenization.py
2014
- vllm/platforms/rocm.py
2015
commands:
2016
- export VLLM_USE_RUST_FRONTEND=1
2017
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
2018
+ - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc/test_collective_rpc.py
2019
- pytest -v -s entrypoints/serve/instrumentator/test_basic.py -k "not show_version and not server_load"
2011
- - pytest -v -s entrypoints/serve/disagg/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow"
2020
+ - pytest -v -s entrypoints/scale_out/token_in_token_out/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow"
2021
- pytest -v -s entrypoints/serve/instrumentator/test_metrics.py -k "text and not show and not run_batch and not test_metrics_counts and not test_metrics_exist"
2022
+ - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info and not test_tokenize_chat"
2023
2024
- label: Rust Frontend Core Correctness # TBD
2025
timeout_in_minutes: 180
.buildkite/test_areas/rust_frontend.yaml
+15
-8
@@ -15,24 +15,26 @@ steps:
15
- tests/utils.py
16
- tests/benchmarks/test_serve_cli.py
17
- tests/entrypoints/openai/chat_completion/test_chat_completion.py
18
- # - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py
18
+ - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py
19
20
# - tests/entrypoints/openai/completion/test_prompt_validation.py
21
- tests/entrypoints/openai/completion/test_shutdown.py
22
- # - tests/entrypoints/openai/test_return_token_ids.py
23
- # - tests/entrypoints/openai/test_uds.py
22
+ - tests/entrypoints/openai/test_return_token_ids.py
23
+ - tests/entrypoints/openai/test_uds.py
24
- tests/v1/sample/test_logprobs_e2e.py
25
commands:
26
- export VLLM_USE_RUST_FRONTEND=1
27
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
28
- pytest -v -s benchmarks/test_serve_cli.py -k "not insecure and not (test_bench_serve and not test_bench_serve_chat)"
29
- pytest -v -s entrypoints/openai/chat_completion/test_chat_completion.py -k "not test_invalid_json_schema and not test_invalid_regex"
30
- # - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not invalid"
30
+ - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not multiple"
31
32
# - pytest -v -s entrypoints/openai/completion/test_prompt_validation.py -k "not prompt_embeds"
33
- pytest -v -s entrypoints/openai/completion/test_shutdown.py -k "not engine_failure and not test_abort_timeout_exits_quickly"
34
- # - pytest -v -s entrypoints/openai/test_return_token_ids.py
35
- # - pytest -v -s entrypoints/openai/test_uds.py
34
+ # test_comparison streams differently: Rust emits a separate first (prompt_token_ids) chunk and
35
+ # finish chunk without logprobs, while the test reads `logprobs.tokens` on every chunk.
36
+ - pytest -v -s entrypoints/openai/test_return_token_ids.py -k "not test_comparison"
37
+ - pytest -v -s entrypoints/openai/test_uds.py
38
- pytest -v -s v1/sample/test_logprobs_e2e.py -k "test_prompt_logprobs_e2e_server"
39
40
- label: Rust Frontend Serve/Admin Coverage
@@ -45,19 +47,24 @@ steps:
47
- vllm/entrypoints/serve/
48
- vllm/v1/engine/
49
- tests/utils.py
48
- # - tests/entrypoints/serve/dev/rpc/test_collective_rpc.py
50
+ - tests/entrypoints/serve/dev/rpc/test_collective_rpc.py
51
- tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py
52
- tests/entrypoints/serve/instrumentator/test_basic.py
53
- tests/entrypoints/serve/instrumentator/test_metrics.py
54
# - tests/entrypoints/serve/dev/test_sleep.py
55
+ - tests/entrypoints/serve/tokenize/test_tokenization.py
56
commands:
57
- export VLLM_USE_RUST_FRONTEND=1
58
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
56
- # - pytest -v -s entrypoints/serve/dev/rpc/test_collective_rpc.py
59
+ - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc/test_collective_rpc.py
60
+ # server_load can be flaky under the Rust frontend; keep it excluded for now.
61
- pytest -v -s entrypoints/serve/instrumentator/test_basic.py -k "not show_version and not server_load"
62
+ # test_generate_logprobs expects Python-style top_logprobs truncation (dedup sampled + cap at max(k, 1)).
63
- pytest -v -s entrypoints/scale_out/token_in_token_out/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow"
64
- pytest -v -s entrypoints/serve/instrumentator/test_metrics.py -k "text and not show and not run_batch and not test_metrics_counts and not test_metrics_exist"
65
# - pytest -v -s entrypoints/serve/dev/test_sleep.py
66
+ # /tokenizer_info is not implemented in the Rust frontend (the CLI flag is accepted as a no-op).
67
+ - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info and not test_tokenize_chat"
68
69
- label: Rust Frontend Core Correctness
70
timeout_in_minutes: 30
rust/src/cmd/src/cli/unsupported.rs
+6
-2
@@ -456,13 +456,17 @@ pub struct ServerUnsupportedArgs {
456
457
/// Enable the `/tokenizer_info` endpoint. May expose chat
458
/// templates and other tokenizer configuration.
459
+ ///
460
+ /// Accepted as a no-op: the Rust frontend serves `/tokenize` and
461
+ /// `/detokenize`, but does not implement `/tokenizer_info` yet.
462
#[arg(
463
long,
464
visible_alias = "no-enable-tokenizer-info-endpoint",
465
default_missing_value = "true",
463
- num_args = 0..=1
466
+ num_args = 0..=1,
467
+ hide = true
468
)]
465
- pub enable_tokenizer_info_endpoint: Option<Unsupported>,
469
+ pub enable_tokenizer_info_endpoint: Option<Noop>,
470
471
/// If set to True, log model outputs (generations).
472
/// Requires `--enable-log-requests`. As with `--enable-log-requests`,
rust/src/server/src/routes/inference/generate/types.rs
+3
-3
@@ -29,7 +29,9 @@ pub struct GenerateRequest {
29
impl Normalizable for GenerateRequest {}
30
31
/// Mirrors the Python vLLM `GenerateResponseChoice` class.
32
-#[serde_with::skip_serializing_none]
32
+///
33
+/// Do not skip serializing `None` fields here: non-streaming response types
34
+/// should serialize `None` as explicit `null`.
35
#[derive(Debug, Clone, Serialize)]
36
pub(super) struct GenerateResponseChoice {
37
pub index: u32,
@@ -58,7 +60,6 @@ pub(super) struct GenerateStreamResponse {
60
}
61
62
/// Mirrors the Python vLLM `GenerateResponse` class.
61
-#[serde_with::skip_serializing_none]
63
#[derive(Debug, Clone, Serialize)]
64
pub(super) struct GenerateResponse {
65
pub request_id: String,
@@ -68,7 +69,6 @@ pub(super) struct GenerateResponse {
69
}
70
71
/// Mirrors the Python vLLM `Logprob` class used in prompt-logprobs payloads.
71
-#[serde_with::skip_serializing_none]
72
#[derive(Debug, Clone, Serialize)]
73
pub(super) struct GenerateLogprob {
74
pub logprob: f32,
rust/src/server/src/routes/openai/chat_completions.rs
+1
-1
@@ -211,7 +211,7 @@ async fn collect_chat_completion(
211
Some(prefix) => Some(format!("{prefix}{}", message.text())),
212
None => Some(message.text()).filter(|t| !t.is_empty()),
213
},
214
- tool_calls: Some(tool_calls).filter(|calls| !calls.is_empty()),
214
+ tool_calls,
215
reasoning: if include_reasoning { reasoning } else { None },
216
},
217
logprobs,
rust/src/server/src/routes/openai/chat_completions/convert.rs
+1
-1
@@ -830,7 +830,7 @@ mod tests {
830
let message = ChatCompletionMessage {
831
role: AssistantRole,
832
content: Some("answer".to_string()),
833
- tool_calls: None,
833
+ tool_calls: Vec::new(),
834
reasoning: Some("inner".to_string()),
835
};
836
let message_json = serde_json::to_value(message).expect("message serializes");
rust/src/server/src/routes/openai/chat_completions/types.rs
+5
-4
@@ -331,7 +331,9 @@ impl Normalizable for ChatCompletionRequest {
331
}
332
333
/// Mirrors the Python vLLM `ChatCompletionResponse` class.
334
-#[serde_with::skip_serializing_none]
334
+///
335
+/// Do not skip serializing `None` fields here: non-streaming response types
336
+/// should serialize `None` as explicit `null`.
337
#[derive(Debug, Clone, Serialize)]
338
pub(super) struct ChatCompletionResponse {
339
pub id: String,
@@ -347,7 +349,6 @@ pub(super) struct ChatCompletionResponse {
349
}
350
351
/// Mirrors the Python vLLM `ChatCompletionResponseChoice` class.
350
-#[serde_with::skip_serializing_none]
352
#[derive(Debug, Clone, Serialize)]
353
pub(super) struct ChatCompletionChoice {
354
pub index: u32,
@@ -370,12 +371,12 @@ impl fmt::Display for AssistantRole {
371
}
372
373
/// Mirrors the Python vLLM response `ChatMessage` class.
373
-#[serde_with::skip_serializing_none]
374
#[derive(Debug, Clone, Serialize)]
375
pub(super) struct ChatCompletionMessage {
376
pub role: AssistantRole,
377
pub content: Option<String>,
378
- pub tool_calls: Option<Vec<ToolCall>>,
378
+ #[serde(skip_serializing_if = "Vec::is_empty")]
379
+ pub tool_calls: Vec<ToolCall>,
380
pub reasoning: Option<String>,
381
}
382
rust/src/server/src/routes/openai/completions/types.rs
+3
-2
@@ -196,7 +196,9 @@ impl Normalizable for CompletionRequest {
196
}
197
198
/// Mirrors the Python vLLM `CompletionResponse` class.
199
-#[serde_with::skip_serializing_none]
199
+///
200
+/// Do not skip serializing `None` fields here: non-streaming response types
201
+/// should serialize `None` as explicit `null`.
202
#[derive(Debug, Clone, Serialize)]
203
pub(super) struct CompletionResponse {
204
pub id: String,
@@ -210,7 +212,6 @@ pub(super) struct CompletionResponse {
212
}
213
214
/// Mirrors the Python vLLM `CompletionResponseChoice` class.
213
-#[serde_with::skip_serializing_none]
215
#[derive(Debug, Clone, Serialize)]
216
pub(super) struct CompletionChoice {
217
pub index: u32,
rust/src/server/src/routes/openai/utils/types.rs
+3
-5
@@ -311,7 +311,9 @@ pub enum MessageContent {
311
// ============================================================================
312
313
/// Mirrors the Python vLLM `UsageInfo` class.
314
-#[serde_with::skip_serializing_none]
314
+///
315
+/// Do not skip serializing `None` fields here: non-streaming response types
316
+/// should serialize `None` as explicit `null`.
317
#[derive(Debug, Clone, Serialize)]
318
pub struct Usage {
319
pub prompt_tokens: usize,
@@ -402,14 +404,12 @@ pub struct LogProbs {
404
}
405
406
/// Mirrors the Python vLLM `ChatCompletionLogProbs` class.
405
-#[serde_with::skip_serializing_none]
407
#[derive(Debug, Clone, Serialize)]
408
pub struct ChatLogProbs {
409
pub content: Option<Vec<ChatLogProbsContent>>,
410
}
411
412
/// Mirrors the Python vLLM `ChatCompletionLogProbsContent` class.
412
-#[serde_with::skip_serializing_none]
413
#[derive(Debug, Clone, Serialize)]
414
pub struct ChatLogProbsContent {
415
pub token: String,
@@ -419,7 +419,6 @@ pub struct ChatLogProbsContent {
419
}
420
421
/// Mirrors the Python vLLM `ChatCompletionLogProb` class.
422
-#[serde_with::skip_serializing_none]
422
#[derive(Debug, Clone, Serialize)]
423
pub struct TopLogProb {
424
pub token: String,
@@ -436,7 +435,6 @@ pub struct ErrorResponse {
435
pub error: ErrorDetail,
436
}
437
439
-#[serde_with::skip_serializing_none]
438
#[derive(Debug, Clone, Deserialize, Serialize)]
439
pub struct ErrorDetail {
440
pub message: String,
rust/src/server/src/routes/tests.rs
+49
-8
@@ -2191,6 +2191,28 @@ async fn non_stream_chat_returns_json_response() {
2191
assert_eq!(json["usage"]["prompt_tokens"], 22);
2192
assert_eq!(json["usage"]["completion_tokens"], 3);
2193
assert_eq!(json["usage"]["total_tokens"], 25);
2194
+
2195
+ // Unset optional fields are serialized as explicit `null` on
2196
+ // non-streaming responses...
2197
+ let response_object = json.as_object().expect("response object");
2198
+ let choice = json["choices"][0].as_object().expect("choice object");
2199
+ let message = choice["message"].as_object().expect("message object");
2200
+ for (object, key) in [
2201
+ (response_object, "system_fingerprint"),
2202
+ (response_object, "prompt_token_ids"),
2203
+ (response_object, "kv_transfer_params"),
2204
+ (choice, "logprobs"),
2205
+ (choice, "stop_reason"),
2206
+ (choice, "token_ids"),
2207
+ (message, "reasoning"),
2208
+ ] {
2209
+ assert!(
2210
+ object.contains_key(key) && object[key].is_null(),
2211
+ "expected explicit null `{key}`: {json}"
2212
+ );
2213
+ }
2214
+ // ...except `tool_calls`, which Python pops from the payload when empty.
2215
+ assert!(!message.contains_key("tool_calls"), "{json}");
2216
}
2217
2218
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -2956,6 +2978,25 @@ async fn non_stream_completions_return_json_response() {
2978
assert_eq!(json["choices"][0]["text"], "hi");
2979
assert_eq!(json["choices"][0]["finish_reason"], "stop");
2980
assert_eq!(json["usage"]["completion_tokens"], 3);
2981
+
2982
+ // Unset optional fields are serialized as explicit `null` on
2983
+ // non-streaming responses.
2984
+ let response_object = json.as_object().expect("response object");
2985
+ let choice = json["choices"][0].as_object().expect("choice object");
2986
+ for (object, key) in [
2987
+ (response_object, "system_fingerprint"),
2988
+ (response_object, "kv_transfer_params"),
2989
+ (choice, "logprobs"),
2990
+ (choice, "stop_reason"),
2991
+ (choice, "prompt_logprobs"),
2992
+ (choice, "token_ids"),
2993
+ (choice, "prompt_token_ids"),
2994
+ ] {
2995
+ assert!(
2996
+ object.contains_key(key) && object[key].is_null(),
2997
+ "expected explicit null `{key}`: {json}"
2998
+ );
2999
+ }
3000
}
3001
3002
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -4371,10 +4412,10 @@ async fn include_reasoning_false_suppresses_reasoning_in_non_stream_chat() {
4412
let json: serde_json::Value = serde_json::from_str(&text).expect("decode json");
4413
4414
assert_eq!(json["choices"][0]["message"]["content"], "answer");
4415
+ // Suppressed fields are serialized as explicit `null` on non-streaming
4416
+ // responses.
4417
assert!(
4375
- json["choices"][0]["message"]
4376
- .as_object()
4377
- .is_some_and(|message| !message.contains_key("reasoning")),
4418
+ json["choices"][0]["message"]["reasoning"].is_null(),
4419
"{text}"
4420
);
4421
}
@@ -4476,14 +4517,14 @@ async fn include_reasoning_false_suppresses_non_stream_output_metadata() {
4517
let choice = json["choices"][0].as_object().expect("choice object");
4518
4519
assert_eq!(json["choices"][0]["message"]["content"], "answer");
4520
+ // Suppressed fields are serialized as explicit `null` on non-streaming
4521
+ // responses.
4522
assert!(
4480
- json["choices"][0]["message"]
4481
- .as_object()
4482
- .is_some_and(|message| !message.contains_key("reasoning")),
4523
+ json["choices"][0]["message"]["reasoning"].is_null(),
4524
"{text}"
4525
);
4485
- assert!(!choice.contains_key("logprobs"), "{text}");
4486
- assert!(!choice.contains_key("token_ids"), "{text}");
4526
+ assert!(choice["logprobs"].is_null(), "{text}");
4527
+ assert!(choice["token_ids"].is_null(), "{text}");
4528
assert!(json["prompt_token_ids"].is_array(), "{text}");
4529
}
4530
rust/src/server/src/routes/tokenize/types.rs
+2
-1
@@ -102,12 +102,13 @@ pub struct DetokenizeRequest {
102
pub tokens: Vec<u32>,
103
}
104
105
+/// Do not skip serializing `None` fields here: non-streaming response types
106
+/// should serialize `None` as explicit `null`.
107
#[derive(Debug, Clone, Serialize)]
108
pub struct TokenizeResponse {
109
pub count: usize,
110
pub max_model_len: u32,
111
pub tokens: Vec<u32>,
110
- #[serde(skip_serializing_if = "Option::is_none")]
112
pub token_strs: Option<Vec<String>>,
113
}
114
rust/src/text/src/lower/token_ids.rs
+1
-1
@@ -10,7 +10,7 @@ pub enum TokenIdsError {
10
#[error("allowed_token_ids should not be empty")]
11
EmptyAllowedTokenIds,
12
#[error(
13
- "token_id(s) {token_ids:?} in {parameter} contain out-of-vocab token ids. \
13
+ "token_id(s) {token_ids:?} in {parameter} are out of vocabulary. \
14
Vocabulary size: {vocab_size}"
15
)]
16
OutOfVocab {
tests/entrypoints/openai/test_uds.py
+2
-1
@@ -40,4 +40,5 @@ async def test_show_version(server: RemoteOpenAIServer):
40
response = client.get(server.url_for("version"))
41
response.raise_for_status()
42
43
- assert response.json() == {"version": VLLM_VERSION}
43
+ # Tolerate additive fields (e.g. the Rust frontend reports its own version).
44
+ assert response.json()["version"] == VLLM_VERSION
tests/entrypoints/serve/instrumentator/test_basic.py
+2
-1
@@ -83,7 +83,8 @@ async def test_show_version(server: RemoteOpenAIServer):
83
response = requests.get(server.url_for("version"))
84
response.raise_for_status()
85
86
- assert response.json() == {"version": VLLM_VERSION}
86
+ # Tolerate additive fields (e.g. the Rust frontend reports its own version).
87
+ assert response.json()["version"] == VLLM_VERSION
88
89
90
@pytest.mark.asyncio