[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,