[Rust Frontend] Correct `--reasoning-parser` semantics (#46359)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jun 23, 2026 at 15:38 UTC
25bc3be49cc7ce8e7b185ca7da11b2fc9778832c
7 files changed
+232
-43
.buildkite/test_areas/rust_frontend.yaml
+1
-1
@@ -26,7 +26,7 @@ steps:
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
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"
31
32
# - pytest -v -s entrypoints/openai/completion/test_prompt_validation.py -k "not prompt_embeds"
rust/Cargo.lock
+1
@@ -5750,6 +5750,7 @@ dependencies = [
5750
"tracing",
5751
"tracing-subscriber",
5752
"uuid",
5753
+ "vllm-chat",
5754
"vllm-engine-core-client",
5755
"vllm-managed-engine",
5756
"vllm-server",
rust/src/cmd/Cargo.toml
+1
@@ -29,6 +29,7 @@ tokio-util.workspace = true
29
tracing.workspace = true
30
tracing-subscriber.workspace = true
31
uuid.workspace = true
32
+vllm-chat.workspace = true
33
vllm-engine-core-client.workspace = true
34
vllm-managed-engine.workspace = true
35
vllm-server.workspace = true
rust/src/cmd/src/cli.rs
+27
-3
@@ -19,6 +19,7 @@ use serde_json::Value;
19
use serde_with::{DefaultOnNull, OneOrMany, serde_as};
20
use thiserror_ext::AsReport as _;
21
use uuid::Uuid;
22
+use vllm_chat::ReasoningParserFactory;
23
use vllm_engine_core_client::TransportMode;
24
use vllm_managed_engine::ManagedEngineConfig;
25
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
@@ -91,7 +92,12 @@ pub enum Command {
92
#[serde(transparent)]
93
pub struct JsonStringList(pub Vec<String>);
94
94
-/// Runtime arguments shared by the external-engine and managed-engine paths.
95
+/// Runtime arguments shared by both paths of the Rust frontend:
96
+///
97
+/// - External-engine mode: Python-supervised bootstrap, `vllm serve` -> `vllm-rs frontend`.
98
+/// Arguments are deserialized from a single JSON object and defaults follow `serde` attrs.
99
+/// - Managed-engine mode: Rust-managed Python engine, `vllm-rs serve`.
100
+/// Arguments are parsed from CLI flags and defaults follow `clap` attrs.
101
#[serde_as]
102
#[derive(Educe, Clone, Args, PartialEq, Eq, Deserialize)]
103
#[educe(Debug)]
@@ -114,12 +120,12 @@ pub struct SharedRuntimeArgs {
120
/// Select the tool call parser depending on the model that you're using.
121
/// Use `auto` to infer from the model or `none` to disable parsing.
122
#[arg(long, default_value_t)]
117
- #[serde(default)]
123
+ #[serde(default = "default_py_bootstrap_parser_selection")]
124
pub tool_call_parser: ParserSelection,
125
/// Select the reasoning parser depending on the model that you're using.
126
/// Use `auto` to infer from the model or `none` to disable parsing.
127
#[arg(long, default_value_t)]
122
- #[serde(default)]
128
+ #[serde(default = "default_py_bootstrap_parser_selection")]
129
pub reasoning_parser: ParserSelection,
130
/// Select the chat renderer implementation.
131
#[arg(long = "tokenizer-mode", default_value_t)]
@@ -402,6 +408,10 @@ fn default_cors_wildcard() -> JsonStringList {
408
JsonStringList(vec!["*".to_string()])
409
}
410
411
+fn default_py_bootstrap_parser_selection() -> ParserSelection {
412
+ ParserSelection::None
413
+}
414
+
415
fn parse_json<T: DeserializeOwned>(value: &str) -> Result<T, String> {
416
serde_json::from_str(value).map_err(|e| format!("invalid JSON object: {}", e.as_report()))
417
}
@@ -526,10 +536,14 @@ impl ServeArgs {
536
/// Build the managed Python-engine spawn configuration with the given
537
/// handshake port.
538
pub fn to_managed_engine_config(&self, handshake_port: u16) -> ManagedEngineConfig {
539
+ let reasoning_parser =
540
+ effective_engine_reasoning_parser(&self.runtime.reasoning_parser, &self.runtime.model);
541
+
542
self.managed_engine.clone().into_config(
543
self.runtime.model.clone(),
544
self.runtime.max_model_len,
545
self.runtime.max_logprobs,
546
+ reasoning_parser.as_deref(),
547
self.runtime.language_model_only,
548
self.runtime.disable_log_stats,
549
self.runtime.shutdown_timeout,
@@ -538,6 +552,16 @@ impl ServeArgs {
552
}
553
}
554
555
+fn effective_engine_reasoning_parser(selection: &ParserSelection, model: &str) -> Option<String> {
556
+ match selection {
557
+ ParserSelection::Auto => ReasoningParserFactory::global()
558
+ .resolve_name_for_model(model)
559
+ .map(str::to_string),
560
+ ParserSelection::None => None,
561
+ ParserSelection::Explicit(name) => Some(name.clone()),
562
+ }
563
+}
564
+
565
/// Allocate fresh IPC endpoints for one managed frontend instance.
566
fn frontend_ipc_addresses() -> (String, String) {
567
let preferred_base_path = std::env::var_os("VLLM_RPC_BASE_PATH")
rust/src/cmd/src/cli/tests.rs
+191
-39
@@ -100,10 +100,13 @@ fn serve_args_auto_forward_python_flags_without_separator() {
100
let Command::Serve(args) = cli.command else {
101
panic!("expected serve args");
102
};
103
- assert_eq!(
104
- args.managed_engine.python_args,
105
- vec!["--quantization", "awq"]
106
- );
103
+ expect![[r#"
104
+ [
105
+ "--quantization",
106
+ "awq",
107
+ ]
108
+ "#]]
109
+ .assert_debug_eq(&args.managed_engine.python_args);
110
}
111
112
#[test]
@@ -114,7 +117,12 @@ fn serve_args_auto_forward_enable_lora_to_python() {
117
let Command::Serve(args) = cli.command else {
118
panic!("expected serve args");
119
};
117
- assert_eq!(args.managed_engine.python_args, vec!["--enable-lora"]);
120
+ expect![[r#"
121
+ [
122
+ "--enable-lora",
123
+ ]
124
+ "#]]
125
+ .assert_debug_eq(&args.managed_engine.python_args);
126
}
127
128
#[test]
@@ -134,7 +142,15 @@ fn serve_args_forward_shutdown_timeout_to_managed_engine() {
142
assert_eq!(args.runtime.shutdown_timeout, 60);
143
144
let config = args.to_managed_engine_config(5555);
137
- assert_eq!(config.python_args, vec!["--shutdown-timeout", "60"]);
145
+ expect![[r#"
146
+ [
147
+ "--reasoning-parser",
148
+ "qwen3",
149
+ "--shutdown-timeout",
150
+ "60",
151
+ ]
152
+ "#]]
153
+ .assert_debug_eq(&config.python_args);
154
}
155
156
#[test]
@@ -148,7 +164,14 @@ fn serve_args_forward_disable_log_stats_to_managed_engine() {
164
assert!(args.runtime.disable_log_stats);
165
166
let config = args.to_managed_engine_config(5555);
151
- assert_eq!(config.python_args, vec!["--disable-log-stats"]);
167
+ expect![[r#"
168
+ [
169
+ "--reasoning-parser",
170
+ "qwen3",
171
+ "--disable-log-stats",
172
+ ]
173
+ "#]]
174
+ .assert_debug_eq(&config.python_args);
175
}
176
177
#[test]
@@ -171,7 +194,106 @@ fn serve_args_forward_max_logprobs_to_frontend_and_managed_engine() {
194
assert_eq!(frontend_config.max_logprobs, Some(-1));
195
196
let engine_config = args.to_managed_engine_config(5555);
174
- assert_eq!(engine_config.python_args, vec!["--max-logprobs", "-1"]);
197
+ expect![[r#"
198
+ [
199
+ "--max-logprobs",
200
+ "-1",
201
+ "--reasoning-parser",
202
+ "qwen3",
203
+ ]
204
+ "#]]
205
+ .assert_debug_eq(&engine_config.python_args);
206
+}
207
+
208
+#[test]
209
+fn serve_args_resolve_auto_reasoning_parser_for_managed_engine() {
210
+ let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B"]).unwrap();
211
+
212
+ let Command::Serve(args) = cli.command else {
213
+ panic!("expected serve args");
214
+ };
215
+ assert_eq!(args.runtime.reasoning_parser, ParserSelection::Auto);
216
+
217
+ let config = args.to_managed_engine_config(5555);
218
+ expect![[r#"
219
+ [
220
+ "--reasoning-parser",
221
+ "qwen3",
222
+ ]
223
+ "#]]
224
+ .assert_debug_eq(&config.python_args);
225
+}
226
+
227
+#[test]
228
+fn serve_args_forward_explicit_reasoning_parser_to_managed_engine() {
229
+ let cli = Cli::try_parse_from([
230
+ "vllm-rs",
231
+ "serve",
232
+ "Unknown/Model",
233
+ "--reasoning-parser",
234
+ "deepseek_r1",
235
+ ])
236
+ .unwrap();
237
+
238
+ let Command::Serve(args) = cli.command else {
239
+ panic!("expected serve args");
240
+ };
241
+
242
+ let config = args.to_managed_engine_config(5555);
243
+ expect![[r#"
244
+ [
245
+ "--reasoning-parser",
246
+ "deepseek_r1",
247
+ ]
248
+ "#]]
249
+ .assert_debug_eq(&config.python_args);
250
+}
251
+
252
+#[test]
253
+fn serve_args_do_not_forward_disabled_reasoning_parser_to_managed_engine() {
254
+ let cli = Cli::try_parse_from([
255
+ "vllm-rs",
256
+ "serve",
257
+ "Qwen/Qwen3-0.6B",
258
+ "--reasoning-parser",
259
+ "none",
260
+ ])
261
+ .unwrap();
262
+
263
+ let Command::Serve(args) = cli.command else {
264
+ panic!("expected serve args");
265
+ };
266
+
267
+ let config = args.to_managed_engine_config(5555);
268
+ assert!(config.python_args.is_empty());
269
+}
270
+
271
+#[test]
272
+fn serve_args_forward_reasoning_parser_even_with_passthrough_reasoning_parser() {
273
+ let cli = Cli::try_parse_from([
274
+ "vllm-rs",
275
+ "serve",
276
+ "Qwen/Qwen3-0.6B",
277
+ "--",
278
+ "--reasoning-parser",
279
+ "deepseek_r1",
280
+ ])
281
+ .unwrap();
282
+
283
+ let Command::Serve(args) = cli.command else {
284
+ panic!("expected serve args");
285
+ };
286
+
287
+ let config = args.to_managed_engine_config(5555);
288
+ expect![[r#"
289
+ [
290
+ "--reasoning-parser",
291
+ "deepseek_r1",
292
+ "--reasoning-parser",
293
+ "qwen3",
294
+ ]
295
+ "#]]
296
+ .assert_debug_eq(&config.python_args);
297
}
298
299
#[test]
@@ -181,10 +303,13 @@ fn serve_args_auto_forward_python_multi_char_alias_without_separator() {
303
let Command::Serve(args) = cli.command else {
304
panic!("expected serve args");
305
};
184
- assert_eq!(
185
- args.managed_engine.python_args,
186
- vec!["--tensor-parallel-size", "2"]
187
- );
306
+ expect![[r#"
307
+ [
308
+ "--tensor-parallel-size",
309
+ "2",
310
+ ]
311
+ "#]]
312
+ .assert_debug_eq(&args.managed_engine.python_args);
313
}
314
315
#[test]
@@ -429,8 +554,8 @@ fn frontend_args_accept_json() {
554
runtime: SharedRuntimeArgs {
555
model: "Qwen/Qwen3-0.6B",
556
engine_ready_timeout_secs: 600,
432
- tool_call_parser: Auto,
433
- reasoning_parser: Auto,
557
+ tool_call_parser: None,
558
+ reasoning_parser: None,
559
renderer: Auto,
560
language_model_only: false,
561
max_model_len: None,
@@ -490,8 +615,8 @@ fn frontend_args_json_applies_defaults() {
615
};
616
assert_eq!(args.runtime.model, "Qwen/Qwen3-0.6B");
617
assert_eq!(args.runtime.engine_ready_timeout_secs, 600);
493
- assert_eq!(args.runtime.tool_call_parser, ParserSelection::Auto);
494
- assert_eq!(args.runtime.reasoning_parser, ParserSelection::Auto);
618
+ assert_eq!(args.runtime.tool_call_parser, ParserSelection::None);
619
+ assert_eq!(args.runtime.reasoning_parser, ParserSelection::None);
620
assert_eq!(args.runtime.renderer, RendererSelection::Auto);
621
assert_eq!(args.runtime.max_model_len, None);
622
assert_eq!(args.runtime.max_logprobs, None);
@@ -784,10 +909,15 @@ fn serve_args_keep_python_passthrough_flags_after_separator() {
909
let Command::Serve(args) = cli.command else {
910
panic!("expected serve args");
911
};
787
- assert_eq!(
788
- args.managed_engine.python_args,
789
- vec!["--tensor-parallel-size", "2", "--dtype", "float16"]
790
- );
912
+ expect![[r#"
913
+ [
914
+ "--tensor-parallel-size",
915
+ "2",
916
+ "--dtype",
917
+ "float16",
918
+ ]
919
+ "#]]
920
+ .assert_debug_eq(&args.managed_engine.python_args);
921
}
922
923
#[test]
@@ -809,10 +939,15 @@ fn serve_args_keep_python_multi_char_alias_after_separator() {
939
let Command::Serve(args) = cli.command else {
940
panic!("expected serve args");
941
};
812
- assert_eq!(
813
- args.managed_engine.python_args,
814
- vec!["-tp", "2", "--dtype", "float16"]
815
- );
942
+ expect![[r#"
943
+ [
944
+ "-tp",
945
+ "2",
946
+ "--dtype",
947
+ "float16",
948
+ ]
949
+ "#]]
950
+ .assert_debug_eq(&args.managed_engine.python_args);
951
}
952
953
#[test]
@@ -830,10 +965,13 @@ fn serve_args_keep_frontend_arg_after_separator() {
965
let Command::Serve(args) = cli.command else {
966
panic!("expected serve args");
967
};
833
- assert_eq!(
834
- args.managed_engine.python_args,
835
- vec!["--uds", "/tmp/vllm.sock"]
836
- );
968
+ expect![[r#"
969
+ [
970
+ "--uds",
971
+ "/tmp/vllm.sock",
972
+ ]
973
+ "#]]
974
+ .assert_debug_eq(&args.managed_engine.python_args);
975
}
976
977
#[test]
@@ -853,10 +991,15 @@ fn serve_args_keep_python_multi_char_engine_aliases_after_separator() {
991
let Command::Serve(args) = cli.command else {
992
panic!("expected serve args");
993
};
856
- assert_eq!(
857
- args.managed_engine.python_args,
858
- vec!["-dpr", "1", "-dpl", "2"]
859
- );
994
+ expect![[r#"
995
+ [
996
+ "-dpr",
997
+ "1",
998
+ "-dpl",
999
+ "2",
1000
+ ]
1001
+ "#]]
1002
+ .assert_debug_eq(&args.managed_engine.python_args);
1003
}
1004
1005
#[test]
@@ -866,7 +1009,13 @@ fn serve_args_auto_forward_unknown_flags_without_separator() {
1009
let Command::Serve(args) = cli.command else {
1010
panic!("expected serve args");
1011
};
869
- assert_eq!(args.managed_engine.python_args, vec!["--foo", "bar"]);
1012
+ expect![[r#"
1013
+ [
1014
+ "--foo",
1015
+ "bar",
1016
+ ]
1017
+ "#]]
1018
+ .assert_debug_eq(&args.managed_engine.python_args);
1019
}
1020
1021
#[test]
@@ -883,10 +1032,13 @@ fn serve_args_auto_forward_negative_value_without_separator() {
1032
let Command::Serve(args) = cli.command else {
1033
panic!("expected serve args");
1034
};
886
- assert_eq!(
887
- args.managed_engine.python_args,
888
- vec!["--num-gpu-blocks-override", "-1"]
889
- );
1035
+ expect![[r#"
1036
+ [
1037
+ "--num-gpu-blocks-override",
1038
+ "-1",
1039
+ ]
1040
+ "#]]
1041
+ .assert_debug_eq(&args.managed_engine.python_args);
1042
}
1043
1044
#[test]
@@ -1237,8 +1389,8 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
1389
listener_mode: InheritedFd {
1390
fd: 3,
1391
},
1240
- tool_call_parser: Auto,
1241
- reasoning_parser: Auto,
1392
+ tool_call_parser: None,
1393
+ reasoning_parser: None,
1394
renderer: Auto,
1395
language_model_only: false,
1396
chat_template: None,
rust/src/managed-engine/src/cli.rs
+10
@@ -43,6 +43,11 @@ pub struct ManagedEngineArgs {
43
/// Arguments after an explicit `--` are forwarded verbatim. Before `--`,
44
/// `vllm-rs serve` automatically keeps recognized frontend options on
45
/// the Rust side and forwards everything else to Python.
46
+ ///
47
+ /// The explicit `--` passthrough is a last-resort escape hatch. Rust does
48
+ /// not interpret, validate, or de-duplicate those arguments against
49
+ /// managed-engine arguments that it appends later; if the same Python flag
50
+ /// appears more than once, Python argparse owns the final result.
51
#[arg(
52
last = true,
53
allow_hyphen_values = true,
@@ -72,6 +77,7 @@ impl ManagedEngineArgs {
77
model: String,
78
max_model_len: Option<u32>,
79
max_logprobs: Option<i32>,
80
+ reasoning_parser: Option<&str>,
81
language_model_only: bool,
82
disable_log_stats: bool,
83
shutdown_timeout: u64,
@@ -87,6 +93,10 @@ impl ManagedEngineArgs {
93
python_args.push("--max-logprobs".to_string());
94
python_args.push(max_logprobs.to_string());
95
}
96
+ if let Some(reasoning_parser) = reasoning_parser {
97
+ python_args.push("--reasoning-parser".to_string());
98
+ python_args.push(reasoning_parser.to_string());
99
+ }
100
if language_model_only {
101
python_args.push("--language-model-only".to_string());
102
}
rust/src/text/src/lower.rs
+1
@@ -164,6 +164,7 @@ pub fn lower_sampling_params(
164
logit_bias,
165
allowed_token_ids,
166
bad_words_token_ids: tokenize_bad_words(bad_words.as_deref(), tokenizer)?,
167
+ // TODO: Validate structured-output schemas and regexes before submitting requests to engine-core.
168
structured_outputs,
169
logprob_token_ids,
170
skip_reading_prefix_cache,