[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