[Rust Frontend] Resolve unified and split parser selections consistently (#56018)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Sep 10, 2026 at 11:19 UTC
285cbce6bd0982f5936d729d3fa2ac310dfeca59
10 files changed
+129
-148
rust/src/chat/src/error.rs
+4
@@ -34,6 +34,10 @@ pub enum Error {
34
},
35
#[error("{kind} parsing is disabled by frontend configuration")]
36
ParserDisabled { kind: &'static str },
37
+ #[error(
38
+ "unified parsing requires the tool and reasoning selections to resolve to the same parser; resolved tool={tool}, reasoning={reasoning}"
39
+ )]
40
+ IncompatibleParserSelections { tool: String, reasoning: String },
41
#[error(
42
"{kind} parser `{name}` is not registered{}",
43
available_parser_hint(.available_names)
rust/src/chat/src/lib.rs
+3
-45
@@ -26,11 +26,11 @@ pub use output::{
26
ChatOutputProcessor, DefaultChatOutputProcessor, DynChatOutputProcessor,
27
HarmonyChatOutputProcessor,
28
};
29
-pub use parser::ParserSelection;
29
pub use parser::reasoning::{
30
ReasoningDelta, ReasoningError, ReasoningParser, ReasoningParserFactory,
31
};
32
pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
33
+pub use parser::{ParserSelection, validate_parser_overrides};
34
pub use renderer::hf::ChatTemplateContentFormatOption;
35
pub use renderer::{
36
ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer,
@@ -62,36 +62,6 @@ use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
62
use vllm_llm::Llm;
63
use vllm_text::{Prompt, TextLlm, TextRequest};
64
65
-/// Validate explicit parser override names without starting request processing.
66
-pub fn validate_parser_overrides(
67
- tool_call_parser: &ParserSelection,
68
- reasoning_parser: &ParserSelection,
69
-) -> Result<()> {
70
- let tool_parser_factory = ToolParserFactory::global();
71
- if let ParserSelection::Explicit(name) = tool_call_parser
72
- && !tool_parser_factory.contains(name)
73
- {
74
- return Err(Error::ParserUnavailableByName {
75
- kind: "tool",
76
- name: name.clone(),
77
- available_names: tool_parser_factory.list(),
78
- });
79
- }
80
-
81
- let reasoning_parser_factory = ReasoningParserFactory::global();
82
- if let ParserSelection::Explicit(name) = reasoning_parser
83
- && !reasoning_parser_factory.contains(name)
84
- {
85
- return Err(Error::ParserUnavailableByName {
86
- kind: "reasoning",
87
- name: name.clone(),
88
- available_names: reasoning_parser_factory.list(),
89
- });
90
- }
91
-
92
- Ok(())
93
-}
94
-
65
/// Chat request preparation shared by inference and render-only frontends.
66
pub struct ChatRequestProcessor {
67
backend: DynChatBackend,
@@ -326,24 +296,12 @@ impl ChatLlm {
296
297
/// Effective tool-call parser name for this model, if parsing is enabled.
298
pub fn tool_call_parser_name(&self) -> Option<&str> {
329
- match &self.processor.tool_call_parser {
330
- ParserSelection::Auto => {
331
- ToolParserFactory::global().resolve_name_for_model(self.model_id())
332
- }
333
- ParserSelection::None => None,
334
- ParserSelection::Explicit(name) => Some(name),
335
- }
299
+ self.processor.tool_call_parser.resolve_tool_name(self.model_id())
300
}
301
302
/// Effective reasoning parser name for this model, if parsing is enabled.
303
pub fn reasoning_parser_name(&self) -> Option<&str> {
340
- match &self.processor.reasoning_parser {
341
- ParserSelection::Auto => {
342
- ReasoningParserFactory::global().resolve_name_for_model(self.model_id())
343
- }
344
- ParserSelection::None => None,
345
- ParserSelection::Explicit(name) => Some(name),
346
- }
304
+ self.processor.reasoning_parser.resolve_reasoning_name(self.model_id())
305
}
306
307
/// Render, tokenize, and submit one chat request.
rust/src/chat/src/output/default/mod.rs
+41
-33
@@ -50,13 +50,12 @@ impl DefaultChatOutputProcessor {
50
tool_call_parser: &ParserSelection,
51
reasoning_parser: &ParserSelection,
52
) -> ChatResult<Self> {
53
- let parser = if tool_call_parser == reasoning_parser
54
- && let Some(parser) = Self::resolve_optional_unified_parser(
55
- request.tools(),
56
- model_id,
57
- tokenizer.clone(),
58
- tool_call_parser,
59
- )? {
53
+ let parser = if let Some(parser) = Self::resolve_optional_unified_parser(
54
+ request.tools(),
55
+ tokenizer.clone(),
56
+ tool_call_parser.resolve_tool_name(model_id),
57
+ reasoning_parser.resolve_reasoning_name(model_id),
58
+ )? {
59
parser
60
} else {
61
let tool_parsing_enabled = request.tool_parsing_enabled();
@@ -105,7 +104,7 @@ impl DefaultChatOutputProcessor {
104
) -> ChatResult<Box<dyn ToolParser>> {
105
let factory = ToolParserFactory::global();
106
let parser_name = match selection {
108
- ParserSelection::Auto => factory.resolve_name_for_model(model_id).ok_or_else(|| {
107
+ ParserSelection::Auto => selection.resolve_tool_name(model_id).ok_or_else(|| {
108
Error::ParserUnavailableForModel {
109
kind: "tool",
110
model_id: model_id.to_string(),
@@ -123,21 +122,22 @@ impl DefaultChatOutputProcessor {
122
123
fn resolve_optional_unified_parser(
124
tools: &[ChatTool],
126
- model_id: &str,
125
tokenizer: DynTokenizer,
128
- selection: &ParserSelection,
126
+ tool_name: Option<&str>,
127
+ reasoning_name: Option<&str>,
128
) -> ChatResult<Option<Box<dyn UnifiedParser>>> {
129
let factory = UnifiedParserFactory::global();
131
- let parser_name = match selection {
132
- ParserSelection::Auto => factory.resolve_name_for_model(model_id),
133
- ParserSelection::None => None,
134
- ParserSelection::Explicit(name) if factory.contains(name) => Some(name.as_str()),
135
- ParserSelection::Explicit(_) => None,
136
- };
137
-
138
- let Some(parser_name) = parser_name else {
130
+ let Some(parser_name) =
131
+ tool_name.into_iter().chain(reasoning_name).find(|name| factory.contains(name))
132
+ else {
133
return Ok(None);
134
};
135
+ if tool_name != reasoning_name {
136
+ return Err(Error::IncompatibleParserSelections {
137
+ tool: tool_name.unwrap_or("none").to_owned(),
138
+ reasoning: reasoning_name.unwrap_or("none").to_owned(),
139
+ });
140
+ }
141
142
let parser = factory.create(parser_name, tools, tokenizer)?;
143
@@ -151,11 +151,7 @@ impl DefaultChatOutputProcessor {
151
selection: &ParserSelection,
152
) -> ChatResult<Option<Box<dyn ReasoningParser>>> {
153
let factory = ReasoningParserFactory::global();
154
- let parser_name = match selection {
155
- ParserSelection::Auto => factory.resolve_name_for_model(model_id),
156
- ParserSelection::None => None,
157
- ParserSelection::Explicit(name) => Some(name.as_str()),
158
- };
154
+ let parser_name = selection.resolve_reasoning_name(model_id);
155
156
let Some(parser_name) = parser_name else {
157
REASONING_PARSER_LOG_ONCE.call_once(|| info!("reasoning parsing disabled"));
@@ -195,7 +191,6 @@ mod tests {
191
use vllm_tokenizer::test_utils::TestTokenizer;
192
193
use super::DefaultChatOutputProcessor;
198
- use crate::Error;
194
use crate::parser::ParserSelection;
195
use crate::request::ChatRequest;
196
@@ -237,11 +232,29 @@ mod tests {
232
}
233
234
#[test]
240
- fn mixed_gemma4_selection_uses_split_dummy_error() {
235
+ fn auto_and_explicit_gemma4_selections_use_unified_parser() {
236
+ let explicit = ParserSelection::Explicit("gemma4".to_string());
237
+ for (tool, reasoning) in [
238
+ (&ParserSelection::Auto, &explicit),
239
+ (&explicit, &ParserSelection::Auto),
240
+ ] {
241
+ DefaultChatOutputProcessor::new(
242
+ &mut ChatRequest::for_test(),
243
+ "google/gemma-4-27b-it",
244
+ tokenizer(),
245
+ tool,
246
+ reasoning,
247
+ )
248
+ .unwrap();
249
+ }
250
+ }
251
+
252
+ #[test]
253
+ fn conflicting_unified_parser_selections_report_resolved_names() {
254
let mut request = ChatRequest::for_test();
255
let error = match DefaultChatOutputProcessor::new(
256
&mut request,
244
- "other-model",
257
+ "Qwen/Qwen3-8B",
258
tokenizer(),
259
&ParserSelection::Auto,
260
&ParserSelection::Explicit("gemma4".to_string()),
@@ -250,12 +263,7 @@ mod tests {
263
Err(error) => error,
264
};
265
253
- let Error::ParserInitialization { error, .. } = error else {
254
- panic!("expected parser initialization error");
255
- };
256
- assert_eq!(
257
- error.to_string(),
258
- "`gemma4` only provides a unified parser; the same reasoning parser and tool parser should be specified together"
259
- );
266
+ expect_test::expect!["unified parsing requires the tool and reasoning selections to resolve to the same parser; resolved tool=qwen3_xml, reasoning=gemma4"]
267
+ .assert_eq(&format!("{error}"));
268
}
269
}
rust/src/chat/src/parser/mod.rs
+64
@@ -12,6 +12,10 @@ use std::str::FromStr;
12
13
use serde_with::{DeserializeFromStr, SerializeDisplay};
14
15
+use self::reasoning::ReasoningParserFactory;
16
+use self::tool::ToolParserFactory;
17
+use self::unified::UnifiedParserFactory;
18
+
19
/// Specify which reasoning or tool-call parser implementation to use.
20
#[derive(Debug, Clone, PartialEq, Eq, Default, DeserializeFromStr, SerializeDisplay)]
21
pub enum ParserSelection {
@@ -27,6 +31,66 @@ pub enum ParserSelection {
31
impl ParserSelection {
32
pub const AUTO_LITERAL: &str = "auto";
33
pub const NONE_LITERAL: &str = "none";
34
+
35
+ /// Resolve a reasoning parser name, preferring a matching unified parser.
36
+ pub fn resolve_reasoning_name(&self, model_id: &str) -> Option<&str> {
37
+ self.resolve_name(model_id, ReasoningParserFactory::global())
38
+ }
39
+
40
+ /// Resolve a tool parser name, preferring a matching unified parser.
41
+ pub fn resolve_tool_name(&self, model_id: &str) -> Option<&str> {
42
+ self.resolve_name(model_id, ToolParserFactory::global())
43
+ }
44
+
45
+ fn resolve_name<'a, C>(
46
+ &'a self,
47
+ model_id: &str,
48
+ factory: &'a ParserFactory<C>,
49
+ ) -> Option<&'a str> {
50
+ match self {
51
+ Self::Auto => UnifiedParserFactory::global()
52
+ .resolve_name_for_model(model_id)
53
+ .or_else(|| factory.resolve_name_for_model(model_id)),
54
+ Self::None => None,
55
+ Self::Explicit(name) => Some(name),
56
+ }
57
+ }
58
+}
59
+
60
+/// Validate explicit parser override names without starting request processing.
61
+pub fn validate_parser_overrides(
62
+ tool_call_parser: &ParserSelection,
63
+ reasoning_parser: &ParserSelection,
64
+) -> crate::Result<()> {
65
+ validate_selection(tool_call_parser, "tool", ToolParserFactory::global())?;
66
+ validate_selection(
67
+ reasoning_parser,
68
+ "reasoning",
69
+ ReasoningParserFactory::global(),
70
+ )
71
+}
72
+
73
+fn validate_selection<C>(
74
+ selection: &ParserSelection,
75
+ kind: &'static str,
76
+ factory: &ParserFactory<C>,
77
+) -> crate::Result<()> {
78
+ let unified = UnifiedParserFactory::global();
79
+ if let ParserSelection::Explicit(name) = selection
80
+ && !factory.contains(name)
81
+ && !unified.contains(name)
82
+ {
83
+ let mut available_names = factory.list();
84
+ available_names.extend(unified.list());
85
+ available_names.sort_unstable();
86
+ available_names.dedup();
87
+ return Err(crate::Error::ParserUnavailableByName {
88
+ kind,
89
+ name: name.clone(),
90
+ available_names,
91
+ });
92
+ }
93
+ Ok(())
94
}
95
96
impl FromStr for ParserSelection {
rust/src/chat/src/parser/reasoning/mod.rs
-24
@@ -22,15 +22,10 @@ pub mod names {
22
pub const DEEPSEEK_R1: &str = "deepseek_r1";
23
pub const DEEPSEEK_V3: &str = "deepseek_v3";
24
pub const DEEPSEEK_V4: &str = "deepseek_v4";
25
- pub const GEMMA4: &str = "gemma4";
26
- pub const INKLING: &str = "inkling";
25
pub const GLM45: &str = "glm45";
26
pub const GLM47: &str = "glm47";
29
- pub const HY_V3: &str = "hy_v3";
30
- pub const HY_V4: &str = "hy_v4";
27
pub const KIMI: &str = "kimi";
28
pub const KIMI_K2: &str = "kimi_k2";
33
- pub const KIMI_K3: &str = "kimi_k3";
29
pub const MINIMAX_M2: &str = "minimax_m2";
30
pub const MINIMAX_M3: &str = "minimax_m3";
31
pub const NEMOTRON_V3: &str = "nemotron_v3";
@@ -67,15 +62,10 @@ impl ReasoningParserFactory {
62
.register_parser::<DeepSeekR1ReasoningParser>(names::DEEPSEEK_R1)
63
.register_parser::<DeepSeekV3ReasoningParser>(names::DEEPSEEK_V3)
64
.register_parser::<DeepSeekV4ReasoningParser>(names::DEEPSEEK_V4)
70
- .register_unified_dummy(names::GEMMA4)
71
- .register_unified_dummy(names::INKLING)
65
.register_parser::<Glm45ReasoningParser>(names::GLM45)
66
.register_parser::<Glm47ReasoningParser>(names::GLM47)
74
- .register_unified_dummy(names::HY_V3)
75
- .register_unified_dummy(names::HY_V4)
67
.register_parser::<KimiReasoningParser>(names::KIMI)
68
.register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
78
- .register_unified_dummy(names::KIMI_K3)
69
.register_parser::<MiniMaxM2ReasoningParser>(names::MINIMAX_M2)
70
.register_parser::<MiniMaxM3ReasoningParser>(names::MINIMAX_M3)
71
.register_parser::<NemotronV3ReasoningParser>(names::NEMOTRON_V3)
@@ -89,16 +79,12 @@ impl ReasoningParserFactory {
79
.register_pattern("deepseek-v4", names::DEEPSEEK_V4)
80
.register_pattern("deepseek_v4", names::DEEPSEEK_V4)
81
.register_pattern("deepseek-v3", names::DEEPSEEK_V3)
92
- .register_pattern("gemma-4", names::GEMMA4)
93
- .register_pattern("gemma4", names::GEMMA4)
82
.register_pattern("qwq", names::DEEPSEEK_R1)
83
.register_pattern("qwen3", names::QWEN3)
84
.register_pattern("glm-5", names::GLM47)
85
.register_pattern("glm-4.7", names::GLM47)
86
.register_pattern("glm-4.6", names::GLM45)
87
.register_pattern("glm-4.5", names::GLM45)
100
- .register_pattern("hy3", names::HY_V3)
101
- .register_pattern("hy4", names::HY_V4)
88
.register_pattern("kimi-k2", names::KIMI_K2)
89
.register_pattern("kimi", names::KIMI)
90
// step3p5 patterns must precede `step3`: substring matching would
@@ -129,16 +115,6 @@ impl ReasoningParserFactory {
115
self.register_creator(name, Arc::new(T::create))
116
}
117
132
- /// Register one unified-only parser name in the split reasoning registry.
133
- pub fn register_unified_dummy(&mut self, name: &str) -> &mut Self {
134
- let name = name.to_string();
135
- let registered_name = name.clone();
136
- self.register_creator(
137
- ®istered_name,
138
- Arc::new(move |_| Err(ReasoningError::DummyUnifiedParser { name: name.clone() })),
139
- )
140
- }
141
-
118
/// Construct a parser from an exact name.
119
pub fn create(
120
&self,
rust/src/chat/src/parser/reasoning/tests.rs
-2
@@ -15,13 +15,11 @@ fn factory_contains_and_lists_registered_parsers() {
15
assert!(factory.contains(names::SEED_OSS));
16
assert!(factory.contains(names::STEP3P5));
17
assert!(factory.contains(names::MINIMAX_M3));
18
- assert!(factory.contains(names::GEMMA4));
18
assert!(factory.list().contains(&names::QWEN3.to_string()));
19
assert!(factory.list().contains(&names::DEEPSEEK_V4.to_string()));
20
assert!(factory.list().contains(&names::SEED_OSS.to_string()));
21
assert!(factory.list().contains(&names::STEP3P5.to_string()));
22
assert!(factory.list().contains(&names::MINIMAX_M3.to_string()));
24
- assert!(factory.list().contains(&names::GEMMA4.to_string()));
23
}
24
25
#[test]
rust/src/chat/src/parser/tool/mod.rs
-24
@@ -24,17 +24,12 @@ pub mod names {
24
pub const DEEPSEEK_V4: &str = "deepseek_v4";
25
pub const GLM45: &str = "glm45";
26
pub const GLM47: &str = "glm47";
27
- pub const GEMMA4: &str = "gemma4";
28
- pub const INKLING: &str = "inkling";
27
pub const GRANITE4: &str = "granite4";
28
pub const HERMES: &str = "hermes";
31
- pub const HY_V3: &str = "hy_v3";
32
- pub const HY_V4: &str = "hy_v4";
29
// Matches the Python CLI name `--tool-call-parser internlm`, which Python
30
// also routes to `Internlm2ToolParser` despite the version-agnostic name.
31
pub const INTERNLM: &str = "internlm";
32
pub const KIMI_K2: &str = "kimi_k2";
37
- pub const KIMI_K3: &str = "kimi_k3";
33
pub const LLAMA3_JSON: &str = "llama3_json";
34
pub const LLAMA4_JSON: &str = "llama4_json";
35
pub const MINIMAX_M2: &str = "minimax_m2";
@@ -73,15 +68,10 @@ impl ToolParserFactory {
68
.register_parser::<DeepSeekV4ToolParser>(names::DEEPSEEK_V4)
69
.register_parser::<Glm45MoeToolParser>(names::GLM45)
70
.register_parser::<Glm47MoeToolParser>(names::GLM47)
76
- .register_unified_dummy(names::GEMMA4)
77
- .register_unified_dummy(names::INKLING)
71
.register_parser::<Granite4ToolParser>(names::GRANITE4)
72
.register_parser::<HermesToolParser>(names::HERMES)
80
- .register_unified_dummy(names::HY_V3)
81
- .register_unified_dummy(names::HY_V4)
73
.register_parser::<Internlm2ToolParser>(names::INTERNLM)
74
.register_parser::<KimiK2ToolParser>(names::KIMI_K2)
84
- .register_unified_dummy(names::KIMI_K3)
75
.register_parser::<Llama3JsonToolParser>(names::LLAMA3_JSON)
76
.register_parser::<Llama3JsonToolParser>(names::LLAMA4_JSON)
77
.register_parser::<MinimaxM2ToolParser>(names::MINIMAX_M2)
@@ -101,8 +91,6 @@ impl ToolParserFactory {
91
.register_pattern("qwen2.5", names::HERMES)
92
.register_pattern("qwen3", names::QWEN3_XML)
93
.register_pattern("hermes", names::HERMES)
104
- .register_pattern("hy3", names::HY_V3)
105
- .register_pattern("hy4", names::HY_V4)
94
// Narrow to `internlm2` substring so it matches `internlm2-chat-7b`
95
// and `internlm2_5-7b-chat` but NOT `internlm-chat-7b` (InternLM v1,
96
// routes to Llama), `internlm3-*` (also Llama-architecture per
@@ -122,8 +110,6 @@ impl ToolParserFactory {
110
.register_pattern("glm-4.7", names::GLM47)
111
.register_pattern("glm-4.6", names::GLM45)
112
.register_pattern("glm-4.5", names::GLM45)
125
- .register_pattern("gemma4", names::GEMMA4)
126
- .register_pattern("gemma-4", names::GEMMA4)
113
.register_pattern("granite-4", names::GRANITE4)
114
.register_pattern("kimi-k2", names::KIMI_K2)
115
.register_pattern("minimax-m3", names::MINIMAX_M3)
@@ -144,16 +130,6 @@ impl ToolParserFactory {
130
self.register_creator(name, Arc::new(T::create))
131
}
132
147
- /// Register one unified-only parser name in the split tool registry.
148
- pub fn register_unified_dummy(&mut self, name: &str) -> &mut Self {
149
- let name = name.to_string();
150
- let registered_name = name.clone();
151
- self.register_creator(
152
- ®istered_name,
153
- Arc::new(move |_| Err(ToolParserError::DummyUnifiedParser { name: name.clone() })),
154
- )
155
- }
156
-
133
/// Construct a parser from an exact name.
134
pub fn create(&self, name: &str, tools: &[ChatTool]) -> crate::Result<Box<dyn ToolParser>> {
135
let creator = self.creator(name).ok_or_else(|| crate::Error::ParserUnavailableByName {
rust/src/chat/src/parser/tool/tests.rs
-12
@@ -167,10 +167,6 @@ fn factory_new_resolves_default_patterns() {
167
factory.resolve_name_for_model("glm-4.7"),
168
Some(names::GLM47)
169
);
170
- assert_eq!(
171
- factory.resolve_name_for_model("google/gemma-4-27b-it"),
172
- Some(names::GEMMA4)
173
- );
170
assert_eq!(
171
factory.resolve_name_for_model("ibm-granite/granite-4.0-h-tiny"),
172
Some(names::GRANITE4)
@@ -179,14 +175,6 @@ fn factory_new_resolves_default_patterns() {
175
factory.resolve_name_for_model("NousResearch/Hermes-3-Llama-3.1-8B"),
176
Some(names::HERMES)
177
);
182
- assert_eq!(
183
- factory.resolve_name_for_model("tencent/Hy3-preview"),
184
- Some(names::HY_V3)
185
- );
186
- assert_eq!(
187
- factory.resolve_name_for_model("tencent/Hy4-preview"),
188
- Some(names::HY_V4)
189
- );
178
assert_eq!(
179
factory.resolve_name_for_model("MiniMax/MiniMax-M3-Text"),
180
Some(names::MINIMAX_M3)
rust/src/cmd/src/cli.rs
+2
-8
@@ -23,8 +23,8 @@ use serde_json::Value;
23
use serde_with::{DefaultOnNull, OneOrMany, serde_as};
24
use thiserror_ext::AsReport as _;
25
use uuid::Uuid;
26
+use vllm_chat::GenerationConfigMode;
27
use vllm_chat::multimodal::MmLimitPerPrompt;
27
-use vllm_chat::{GenerationConfigMode, ReasoningParserFactory};
28
use vllm_engine_core_client::TransportMode;
29
use vllm_managed_engine::ManagedEngineConfig;
30
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
@@ -756,13 +756,7 @@ impl ServeArgs {
756
}
757
758
fn effective_engine_reasoning_parser(selection: &ParserSelection, model: &str) -> Option<String> {
759
- match selection {
760
- ParserSelection::Auto => ReasoningParserFactory::global()
761
- .resolve_name_for_model(model)
762
- .map(str::to_string),
763
- ParserSelection::None => None,
764
- ParserSelection::Explicit(name) => Some(name.clone()),
765
- }
759
+ selection.resolve_reasoning_name(model).map(str::to_owned)
760
}
761
762
/// Allocate fresh IPC endpoints for one managed frontend instance.
rust/src/cmd/src/cli/tests.rs
+15
@@ -413,6 +413,21 @@ fn serve_args_resolve_auto_reasoning_parser_for_managed_engine() {
413
.assert_debug_eq(&config.python_args);
414
}
415
416
+#[test]
417
+fn serve_args_resolve_unified_reasoning_parser_for_managed_engine() {
418
+ let cli = Cli::try_parse_from(["vllm-rs", "serve", "moonshotai/Kimi-K3"]).unwrap();
419
+ let Command::Serve(args) = cli.command else {
420
+ panic!("expected serve args");
421
+ };
422
+ expect![[r#"
423
+ [
424
+ "--reasoning-parser",
425
+ "kimi_k3",
426
+ ]
427
+ "#]]
428
+ .assert_debug_eq(&args.to_managed_engine_config(5555).python_args);
429
+}
430
+
431
#[test]
432
fn serve_args_forward_explicit_reasoning_parser_to_managed_engine() {
433
let cli = Cli::try_parse_from([