[Rust Frontend] Pass effective `reasoning_parser_kwargs` for structured output (#46360)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jun 24, 2026 at 11:13 UTC ce9f64020ba94b2fb1d318f15cfb579bf0500293
19 files changed +165 -34
rust/src/chat/src/lib.rs
+10 -1
@@ -50,7 +50,7 @@ mod request;
50 mod stream;
51
52 use vllm_engine_core_client::EngineCoreClient;
53 -use vllm_engine_core_client::protocol::ModelDtype;
53 +use vllm_engine_core_client::protocol::{ModelDtype, ReasoningParserKwargs};
54 use vllm_llm::Llm;
55 use vllm_text::{Prompt, TextLlm, TextRequest};
56
@@ -179,6 +179,14 @@ impl ChatLlm {
179 },
180 )?;
181 let rendered = self.backend.chat_renderer().render(&request)?;
182 + let reasoning_parser_kwargs =
183 + request
184 + .sampling_params
185 + .structured_outputs
186 + .is_some()
187 + .then(|| ReasoningParserKwargs {
188 + chat_template_kwargs: rendered.effective_template_kwargs.clone(),
189 + });
190
191 let (prompt, mm_features) = multimodal::finalize_rendered_prompt(
192 &request,
@@ -199,6 +207,7 @@ impl ChatLlm {
207 cache_salt: request.cache_salt,
208 add_special_tokens: request.add_special_tokens,
209 data_parallel_rank: request.data_parallel_rank,
210 + reasoning_parser_kwargs,
211 lora_request: request.lora_request,
212 };
213 let decoded_stream = self.text.generate(text_request).await?.map_err(Error::from).boxed();
rust/src/chat/src/renderer/deepseek_v32/mod.rs
+2 -1
@@ -2,7 +2,7 @@ mod encoding;
2
3 use vllm_text::Prompt;
4
5 -use super::{ChatRenderer, RenderedPrompt};
5 +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
6 use crate::Result;
7 use crate::request::ChatRequest;
8
@@ -23,6 +23,7 @@ impl ChatRenderer for DeepSeekV32ChatRenderer {
23
24 Ok(RenderedPrompt {
25 prompt: Prompt::Text(encoding::render_request(request)?),
26 + effective_template_kwargs: request_template_kwargs(request),
27 })
28 }
29 }
rust/src/chat/src/renderer/deepseek_v4/mod.rs
+2 -1
@@ -2,7 +2,7 @@ mod encoding;
2
3 use vllm_text::Prompt;
4
5 -use super::{ChatRenderer, RenderedPrompt};
5 +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
6 use crate::Result;
7 use crate::request::ChatRequest;
8
@@ -22,6 +22,7 @@ impl ChatRenderer for DeepSeekV4ChatRenderer {
22
23 Ok(RenderedPrompt {
24 prompt: Prompt::Text(encoding::render_request(request)?),
25 + effective_template_kwargs: request_template_kwargs(request),
26 })
27 }
28 }
rust/src/chat/src/renderer/hf/mod.rs
+44 -7
@@ -14,7 +14,7 @@ use self::format::{
14 };
15 use self::template::{CompiledChatTemplate, TemplateContext};
16 use self::value::{TemplateValue, to_template_value};
17 -use super::{ChatRenderer, RenderedPrompt};
17 +use super::{ChatRenderer, RenderedPrompt, effective_template_kwargs};
18 use crate::error::Result;
19 use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
20 use crate::{
@@ -169,8 +169,8 @@ impl HfChatRenderer {
169 "applying chat template"
170 );
171
172 - let mut merged_template_kwargs = self.default_template_kwargs.clone();
173 - merged_template_kwargs.extend(request.chat_options.template_kwargs.clone());
172 + let effective_template_kwargs =
173 + effective_template_kwargs(&self.default_template_kwargs, request);
174 let prompt = effective_template
175 .apply(TemplateContext {
176 messages: &messages,
@@ -178,9 +178,8 @@ impl HfChatRenderer {
178 continue_final_message: request.chat_options.continue_final_message(),
179 tools: tools.as_deref(),
180 documents: request.documents.as_deref(),
181 - template_kwargs: Some(&merged_template_kwargs),
181 + template_kwargs: Some(&effective_template_kwargs),
182 special_tokens: self.special_tokens.as_ref(),
183 - reasoning_effort: request.chat_options.reasoning_effort,
183 })
184 .map_err(|error| Error::ChatTemplate(error.to_report_string()))?;
185
@@ -191,6 +190,7 @@ impl HfChatRenderer {
190
191 Ok(RenderedPrompt {
192 prompt: Prompt::Text(prompt),
193 + effective_template_kwargs,
194 })
195 }
196 }
@@ -797,9 +797,46 @@ mod tests {
797 )
798 .unwrap();
799
800 - let rendered = renderer.render(&request).unwrap().prompt;
800 + let rendered = renderer.render(&request).unwrap();
801 +
802 + assert_eq!(rendered.prompt, Prompt::Text("max".to_string()));
803 + assert_eq!(
804 + rendered.effective_template_kwargs.get("reasoning_effort"),
805 + Some(&Value::String("max".to_string()))
806 + );
807 + assert_eq!(
808 + rendered.effective_template_kwargs.get("enable_thinking"),
809 + Some(&Value::Bool(true))
810 + );
811 + }
812
802 - assert_eq!(rendered, Prompt::Text("max".to_string()));
813 + #[test]
814 + fn chat_template_reasoning_effort_preserves_request_enable_thinking() {
815 + let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
816 + request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
817 + request
818 + .chat_options
819 + .template_kwargs
820 + .insert("enable_thinking".to_string(), Value::Bool(true));
821 +
822 + let renderer = HfChatRenderer::new(
823 + Some("{{ reasoning_effort }}|{{ enable_thinking }}".to_string()),
824 + HashMap::new(),
825 + ChatTemplateContentFormatOption::Auto,
826 + )
827 + .unwrap();
828 +
829 + let rendered = renderer.render(&request).unwrap();
830 +
831 + assert_eq!(rendered.prompt, Prompt::Text("none|true".to_string()));
832 + assert_eq!(
833 + rendered.effective_template_kwargs.get("reasoning_effort"),
834 + Some(&Value::String("none".to_string()))
835 + );
836 + assert_eq!(
837 + rendered.effective_template_kwargs.get("enable_thinking"),
838 + Some(&Value::Bool(true))
839 + );
840 }
841
842 #[test]
rust/src/chat/src/renderer/hf/template.rs
-4
@@ -19,7 +19,6 @@ use super::format::{
19 };
20 use super::tojson::hf_tojson_filter;
21 use crate::renderer::hf::{TemplateMessage, TemplateTool};
22 -use crate::request::ReasoningEffort;
22
23 type Result<T> = std::result::Result<T, TemplateError>;
24
@@ -50,9 +49,6 @@ pub(super) struct TemplateContext<'a> {
49 pub(super) special_tokens: Option<&'a HfSpecialTokens>,
50 #[serde(flatten)]
51 pub(super) template_kwargs: Option<&'a HashMap<String, serde_json::Value>>,
53 - // By putting top-level `reasoning_effort` after `template_kwargs`, this overrides any
54 - // `reasoning_effort` value that might be present there.
55 - pub(super) reasoning_effort: Option<ReasoningEffort>,
52 }
53
54 /// Load chat template from a file (`.jinja` or `.json` containing Jinja).
rust/src/chat/src/renderer/mod.rs
+38 -2
@@ -1,9 +1,11 @@
1 +use std::collections::HashMap;
2 use std::sync::Arc;
3
4 +use serde_json::{Value, json};
5 use vllm_text::Prompt;
6
7 use crate::error::Result;
6 -use crate::request::ChatRequest;
8 +use crate::request::{ChatRequest, ReasoningEffort};
9
10 pub mod deepseek_v32;
11 pub mod deepseek_v4;
@@ -15,9 +17,13 @@ pub use deepseek_v32::DeepSeekV32ChatRenderer;
17 pub use selection::RendererSelection;
18
19 /// Rendered chat prompt submitted to the text backend.
18 -#[derive(Debug, Clone, PartialEq, Eq)]
20 +#[derive(Debug, Clone, PartialEq)]
21 pub struct RenderedPrompt {
22 + /// The rendered prompt, either as text or already tokenized.
23 pub prompt: Prompt,
24 + /// Effective chat-template kwargs visible to the renderer after applying
25 + /// server defaults, request overrides, and typed reasoning controls.
26 + pub effective_template_kwargs: HashMap<String, Value>,
27 }
28
29 /// Minimal chat-prompt renderer used by `vllm-chat`.
@@ -29,3 +35,33 @@ pub trait ChatRenderer: Send + Sync {
35
36 /// Shared trait-object form of [`ChatRenderer`].
37 pub type DynChatRenderer = Arc<dyn ChatRenderer>;
38 +
39 +/// Extract the effective chat-template kwargs visible to the renderer from the request,
40 +/// using the provided defaults as the base.
41 +pub(crate) fn effective_template_kwargs(
42 + default_template_kwargs: &HashMap<String, Value>,
43 + request: &ChatRequest,
44 +) -> HashMap<String, Value> {
45 + let mut kwargs = default_template_kwargs.clone();
46 + kwargs.extend(request.chat_options.template_kwargs.clone());
47 +
48 + if let Some(reasoning_effort) = request.chat_options.reasoning_effort {
49 + kwargs.insert(
50 + "reasoning_effort".to_string(),
51 + Value::String(reasoning_effort.as_str().to_string()),
52 + );
53 + if !request.chat_options.template_kwargs.contains_key("enable_thinking") {
54 + kwargs.insert(
55 + "enable_thinking".to_string(),
56 + json!(reasoning_effort != ReasoningEffort::None),
57 + );
58 + }
59 + }
60 +
61 + kwargs
62 +}
63 +
64 +/// Extract the effective chat-template kwargs visible to the renderer from the request.
65 +pub(crate) fn request_template_kwargs(request: &ChatRequest) -> HashMap<String, Value> {
66 + effective_template_kwargs(&HashMap::new(), request)
67 +}
rust/src/chat/tests/chat.rs
+1
@@ -277,6 +277,7 @@ impl ChatRenderer for FakeChatBackend {
277
278 Ok(RenderedPrompt {
279 prompt: Prompt::Text(prompt),
280 + effective_template_kwargs: request.chat_options.template_kwargs.clone(),
281 })
282 }
283 }
rust/src/engine-core-client/src/protocol/mod.rs
+12 -2
@@ -372,6 +372,16 @@ impl EngineCoreSamplingParams {
372 }
373 }
374
375 +/// Extra kwargs consumed by engine-side reasoning parsers.
376 +///
377 +/// Original Python construction point:
378 +/// <https://github.com/vllm-project/vllm/blob/cec2ec11760f9f3beabd4c90451936078bf91533/vllm/entrypoints/openai/chat_completion/serving.py#L367-L369>
379 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380 +pub struct ReasoningParserKwargs {
381 + /// Effective kwargs visible to the chat template for this request.
382 + pub chat_template_kwargs: HashMap<String, serde_json::Value>,
383 +}
384 +
385 /// Engine-core add-request payload sent from frontend to engine.
386 ///
387 /// Original Python definition:
@@ -421,10 +431,10 @@ pub struct EngineCoreRequest {
431 pub external_req_id: Option<String>,
432 #[serde(default)]
433 pub reasoning_ended: Option<bool>,
424 - /// Opaque reasoning-parser kwargs forwarded from the frontend to the
434 + /// Reasoning-parser kwargs forwarded from the frontend to the
435 /// structured-output backend.
436 #[serde(default)]
427 - pub reasoning_parser_kwargs: Option<OpaqueValue>,
437 + pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
438 /// If `true`, the request should be added to the scheduler's waiting queue
439 /// and immediately aborted, so connector-side cleanup runs via the
440 /// standard `request_finished` hook.
rust/src/llm/examples/external_engine_smoke.rs
+1 -1
@@ -56,7 +56,7 @@ fn build_request(request_id: String, max_tokens: u32) -> GenerateRequest {
56 trace_headers: None,
57 priority: 0,
58 data_parallel_rank: None,
59 - reasoning_ended: None,
59 + reasoning_parser_kwargs: None,
60 lora_request: None,
61 }
62 }
rust/src/llm/src/request.rs
+41 -11
@@ -4,7 +4,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
4 use uuid::Uuid;
5 use vllm_engine_core_client::protocol::lora::LoraRequest;
6 use vllm_engine_core_client::protocol::multimodal::MmFeatures;
7 -use vllm_engine_core_client::protocol::{EngineCoreRequest, EngineCoreSamplingParams};
7 +use vllm_engine_core_client::protocol::{
8 + EngineCoreRequest, EngineCoreSamplingParams, ReasoningParserKwargs,
9 +};
10
11 use crate::error::{Error, Result};
12
@@ -27,14 +29,24 @@ pub struct GenerateRequest {
29 pub sampling_params: EngineCoreSamplingParams,
30 /// Optional multimodal features already prepared by `vllm-chat`.
31 pub mm_features: Option<MmFeatures>,
30 -
31 - // Fields below are currently likely unused by callers.
32 + /// Unix timestamp, in seconds, when this request arrived at the frontend.
33 + ///
34 + /// When omitted, the Rust frontend fills it immediately before sending the
35 + /// request to engine-core, matching Python's default arrival-time behavior.
36 pub arrival_time: Option<f64>,
37 + /// Optional salt used to partition prefix-cache entries for this request.
38 pub cache_salt: Option<String>,
39 + /// Optional tracing headers to forward to engine-core and downstream
40 + /// observability hooks.
41 pub trace_headers: Option<BTreeMap<String, String>>,
42 + /// Request scheduling priority. Lower values are scheduled earlier.
43 pub priority: i32,
44 + /// Optional data-parallel rank override for routing this request.
45 pub data_parallel_rank: Option<u32>,
37 - pub reasoning_ended: Option<bool>,
46 + /// Optional reasoning-parser kwargs forwarded to engine-side structured
47 + /// output logic.
48 + pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
49 + /// Optional LoRA adapter request applied to this generation.
50 pub lora_request: Option<LoraRequest>,
51 }
52
@@ -61,7 +73,7 @@ impl GenerateRequest {
73 trace_headers,
74 priority,
75 data_parallel_rank,
64 - reasoning_ended,
76 + reasoning_parser_kwargs,
77 lora_request,
78 } = self;
79
@@ -72,7 +84,6 @@ impl GenerateRequest {
84 } else {
85 external_request_id.clone()
86 };
75 -
87 Ok(PreparedGenerateRequest {
88 engine_request: EngineCoreRequest {
89 request_id: engine_request_id,
@@ -92,8 +103,10 @@ impl GenerateRequest {
103 trace_headers,
104 resumable: false,
105 external_req_id: Some(external_request_id),
95 - reasoning_ended,
96 - reasoning_parser_kwargs: None,
106 + // Rust parser doesn't expose this information, leave it unset and let the
107 + // reasoning logic in engine-sided structured output manager handle it.
108 + reasoning_ended: None,
109 + reasoning_parser_kwargs,
110 abort_immediately: false,
111 },
112 })
@@ -121,7 +134,7 @@ fn current_unix_timestamp_secs() -> f64 {
134 mod tests {
135 use std::collections::BTreeMap;
136
124 - use vllm_engine_core_client::protocol::EngineCoreSamplingParams;
137 + use vllm_engine_core_client::protocol::{EngineCoreSamplingParams, ReasoningParserKwargs};
138
139 use super::GenerateRequest;
140 use crate::error::Error;
@@ -140,7 +153,15 @@ mod tests {
153 )])),
154 priority: 3,
155 data_parallel_rank: Some(2),
143 - reasoning_ended: Some(true),
156 + reasoning_parser_kwargs: Some(ReasoningParserKwargs {
157 + chat_template_kwargs: [(
158 + "chat_template_kwargs".to_string(),
159 + serde_json::json!({
160 + "enable_thinking": true,
161 + }),
162 + )]
163 + .into(),
164 + }),
165 lora_request: None,
166 }
167 }
@@ -166,7 +187,16 @@ mod tests {
187 "abc".to_string(),
188 )]))
189 );
169 - assert_eq!(request.reasoning_ended, Some(true));
190 + assert_eq!(request.reasoning_ended, None);
191 + assert_eq!(
192 + request
193 + .reasoning_parser_kwargs
194 + .as_ref()
195 + .and_then(|kwargs| kwargs.chat_template_kwargs.get("chat_template_kwargs")),
196 + Some(&serde_json::json!({
197 + "enable_thinking": true
198 + }))
199 + );
200 }
201
202 #[test]
rust/src/llm/tests/generate.rs
+1 -1
@@ -179,7 +179,7 @@ fn sample_generate_request(request_id: &str, max_tokens: u32) -> GenerateRequest
179 trace_headers: None,
180 priority: 0,
181 data_parallel_rank: None,
182 - reasoning_ended: None,
182 + reasoning_parser_kwargs: None,
183 lora_request: None,
184 }
185 }
rust/src/server/src/grpc/convert.rs
+1
@@ -91,6 +91,7 @@ pub fn to_text_request(
91 cache_salt: kv.map(|k| &k.cache_salt).filter(|s| !s.is_empty()).cloned(),
92 add_special_tokens: true,
93 data_parallel_rank: None,
94 + reasoning_parser_kwargs: None,
95 lora_request: None,
96 })
97 }
rust/src/server/src/grpc/tests.rs
+1
@@ -206,6 +206,7 @@ impl ChatRenderer for FakeTextBackend {
206 fn render(&self, _request: &ChatRequest) -> vllm_chat::Result<RenderedPrompt> {
207 Ok(RenderedPrompt {
208 prompt: Prompt::Text(String::new()),
209 + effective_template_kwargs: Default::default(),
210 })
211 }
212 }
rust/src/server/src/routes/http_client_tests.rs
+1
@@ -223,6 +223,7 @@ impl ChatRenderer for FakeChatBackend {
223 }
224 Ok(RenderedPrompt {
225 prompt: Prompt::Text(prompt),
226 + effective_template_kwargs: Default::default(),
227 })
228 }
229 }
rust/src/server/src/routes/inference/generate/convert.rs
+1
@@ -68,6 +68,7 @@ pub(super) fn prepare_generate_request(
68 cache_salt: request.cache_salt,
69 add_special_tokens: false,
70 data_parallel_rank: ctx.data_parallel_rank,
71 + reasoning_parser_kwargs: None,
72 lora_request: lora_resolution.lora_request.clone(),
73 };
74
rust/src/server/src/routes/openai/completions/convert.rs
+1
@@ -141,6 +141,7 @@ pub(super) fn prepare_completion_request(
141 cache_salt: request.cache_salt,
142 add_special_tokens: request.add_special_tokens,
143 data_parallel_rank: ctx.data_parallel_rank,
144 + reasoning_parser_kwargs: None,
145 lora_request: lora_resolution.lora_request.clone(),
146 };
147
rust/src/server/src/routes/tests.rs
+1
@@ -581,6 +581,7 @@ impl ChatRenderer for FakeChatBackend {
581 }
582 Ok(vllm_chat::RenderedPrompt {
583 prompt: Prompt::Text(prompt),
584 + effective_template_kwargs: Default::default(),
585 })
586 }
587 }
rust/src/text/src/lower.rs
+1 -2
@@ -49,11 +49,10 @@ pub fn lower_text_request(
49 cache_salt: request.cache_salt.clone(),
50 priority: request.priority,
51 data_parallel_rank: request.data_parallel_rank,
52 + reasoning_parser_kwargs: request.reasoning_parser_kwargs.clone(),
53 lora_request: request.lora_request.clone(),
53 - // Fields below are currently placeholders.
54 arrival_time: None,
55 trace_headers: None,
56 - reasoning_ended: None,
56 };
57
58 Ok(PreparedTextRequest {
rust/src/text/src/request.rs
+6 -1
@@ -3,9 +3,9 @@ use std::collections::HashMap;
3 use enum_as_inner::EnumAsInner;
4 use serde::{Deserialize, Serialize};
5 use serde_json::Value;
6 -use vllm_engine_core_client::protocol::StructuredOutputsParams;
6 use vllm_engine_core_client::protocol::lora::LoraRequest;
7 use vllm_engine_core_client::protocol::multimodal::MmFeatures;
8 +use vllm_engine_core_client::protocol::{ReasoningParserKwargs, StructuredOutputsParams};
9
10 use crate::error::{Error, Result};
11 use crate::output::TextDecodeOptions;
@@ -174,6 +174,10 @@ pub struct TextRequest {
174 /// Override data parallel rank.
175 #[serde(default)]
176 pub data_parallel_rank: Option<u32>,
177 + /// Optional reasoning-parser kwargs forwarded to engine-side structured
178 + /// output logic.
179 + #[serde(default)]
180 + pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
181 /// LoRA adapter selected for this request.
182 #[serde(default)]
183 pub lora_request: Option<LoraRequest>,
@@ -193,6 +197,7 @@ impl TextRequest {
197 cache_salt: None,
198 add_special_tokens: false,
199 data_parallel_rank: None,
200 + reasoning_parser_kwargs: None,
201 lora_request: None,
202 }
203 }