[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 - &registered_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 - &registered_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([