[Rust Frontend] Normalize native renderer reasoning controls (#56998)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 17, 2026 at 09:15 UTC e52da038d19c202ede695a4273adc2316b4ba1a7
29 files changed +1252 -453
rust/src/chat/src/backend/hf.rs
+53 -6
@@ -70,12 +70,26 @@ impl HfChatBackend {
70 options,
71 multimodal_render_info,
72 )?),
73 - RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new()),
74 - RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()),
75 - RendererSelection::DeepSeekV41 => Arc::new(DeepSeekV41ChatRenderer::new()),
76 - RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?),
77 - RendererSelection::Inkling => Arc::new(InklingChatRenderer::new(tokenizer.clone())?),
78 - RendererSelection::KimiK3 => Arc::new(KimiK3ChatRenderer::new(tokenizer.clone())),
73 + RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new(
74 + options.default_chat_template_kwargs,
75 + )),
76 + RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new(
77 + options.default_chat_template_kwargs,
78 + )),
79 + RendererSelection::DeepSeekV41 => Arc::new(DeepSeekV41ChatRenderer::new(
80 + options.default_chat_template_kwargs,
81 + )),
82 + RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new(
83 + options.default_chat_template_kwargs,
84 + )?),
85 + RendererSelection::Inkling => Arc::new(InklingChatRenderer::new(
86 + tokenizer.clone(),
87 + options.default_chat_template_kwargs,
88 + )?),
89 + RendererSelection::KimiK3 => Arc::new(KimiK3ChatRenderer::new(
90 + tokenizer.clone(),
91 + options.default_chat_template_kwargs,
92 + )),
93 };
94
95 info!(
@@ -280,6 +294,39 @@ mod tests {
294 assert_eq!(prompt, "hello");
295 }
296
297 + #[test]
298 + fn native_renderer_inherits_deployment_reasoning_below_request_controls() {
299 + let backend = HfChatBackend::from_resolved_model_files(
300 + resolved_files(r#"{"model_type":"deepseek_v4"}"#, "{}"),
301 + "test-model".to_string(),
302 + LoadModelBackendsOptions {
303 + default_chat_template_kwargs: [
304 + ("thinking".to_string(), serde_json::json!(false)),
305 + ("reasoning_effort".to_string(), serde_json::json!("low")),
306 + ]
307 + .into(),
308 + ..Default::default()
309 + },
310 + test_tokenizer(),
311 + )
312 + .unwrap();
313 + let mut request = request_with_user_text("hello");
314 + let rendered = backend.chat_renderer().render(&request).unwrap();
315 + assert!(rendered.prompt.into_text().unwrap().ends_with("</think>"));
316 + assert_eq!(rendered.effective_template_kwargs["enable_thinking"], false);
317 +
318 + request.chat_options.reasoning_effort = Some(crate::EffortValue::from("max"));
319 + let rendered = backend.chat_renderer().render(&request).unwrap();
320 + let prompt = rendered.prompt.into_text().unwrap();
321 + assert!(prompt.contains("Reasoning Effort: Beyond maximum"));
322 + assert!(prompt.ends_with("<think>"));
323 + assert_eq!(
324 + rendered.effective_template_kwargs["reasoning_effort"],
325 + "max"
326 + );
327 + assert_eq!(rendered.effective_template_kwargs["thinking"], true);
328 + }
329 +
330 #[test]
331 fn auto_uses_harmony_renderer_and_output_processor_for_gpt_oss_model_type() {
332 let backend = backend_for_selection(
rust/src/chat/src/backend/mod.rs
+2 -2
@@ -78,8 +78,8 @@ pub struct LoadModelBackendsOptions {
78 /// Optional server-default chat template override, provided either as an
79 /// inline template or as a path to a template file.
80 pub chat_template: Option<String>,
81 - /// Optional server-default keyword arguments merged into every
82 - /// chat-template render before request-level `chat_template_kwargs`.
81 + /// Server-default keyword arguments. HF merges these before request kwargs;
82 + /// native renderers inherit supported reasoning controls below request controls.
83 pub default_chat_template_kwargs: HashMap<String, Value>,
84 /// Maximum number of input items allowed per prompt for each modality.
85 /// Unspecified modalities are unlimited.
rust/src/chat/src/error.rs
+3
@@ -19,6 +19,8 @@ pub enum Error {
19 ChatTemplate(String),
20 #[error("{0}")]
21 InvalidReasoningEffort(String),
22 + #[error("{message}")]
23 + InvalidReasoningControl { message: String },
24 #[error("multimodal input is not supported by this chat renderer")]
25 UnsupportedMultimodalRenderer,
26 #[error("unsupported multimodal content: {0}")]
@@ -101,6 +103,7 @@ impl Error {
103 match self {
104 Self::PromptTooLong { .. }
105 | Self::InvalidReasoningEffort(_)
106 + | Self::InvalidReasoningControl { .. }
107 | Self::DuplicateToolName { .. }
108 | Self::ToolChoiceRequiresTools
109 | Self::ToolChoiceFunctionNotFound { .. } => true,
rust/src/chat/src/lib.rs
+3 -1
@@ -31,6 +31,7 @@ pub use parser::reasoning::{
31 };
32 pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
33 pub use parser::{ParserSelection, validate_parser_overrides};
34 +pub use reasoning::EffortValue;
35 pub use renderer::hf::ChatTemplateContentFormatOption;
36 pub use renderer::{
37 ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer,
@@ -39,7 +40,7 @@ pub use renderer::{
40 };
41 pub use request::{
42 ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
42 - ChatToolChoice, GenerationPromptMode, ReasoningEffort, ResolvedToolContext, SamplingParams,
43 + ChatToolChoice, GenerationPromptMode, ResolvedToolContext, SamplingParams,
44 };
45 pub use stream::{ChatEventStream, ChatEventStreamTrait, CollectedAssistantMessage};
46 pub use vllm_engine_core_client::protocol::multimodal::MmFeatures;
@@ -52,6 +53,7 @@ mod event;
53 pub mod multimodal;
54 mod output;
55 mod parser;
56 +mod reasoning;
57 mod renderer;
58 mod request;
59 mod stream;
rust/src/chat/src/reasoning.rs new
+397
@@ -0,0 +1,397 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Shared reasoning-control resolution for native renderers.
5 +//!
6 +//! Each input source is parsed independently into [`ReasoningControl`]. Sources
7 +//! then fall back in priority order: request, deployment defaults, model default.
8 +//! Within a source, an explicit toggle decides the mode and effort supplies its
9 +//! intensity. Keeping sources separate lets request effort override a deployment
10 +//! toggle, and lets an enabled request inherit effort from enabled defaults.
11 +//!
12 +//! This module owns input types, precedence, and the standard `"none"` sentinel.
13 +//! Model adapters own supported effort names, numeric ranges, and prompt encoding.
14 +//! After model lowering, [`ReasoningControl::template_kwargs`] projects the prompt's
15 +//! decision for downstream consumers. HF prepares raw template inputs separately.
16 +
17 +use std::collections::HashMap;
18 +use std::fmt;
19 +
20 +use serde::{Deserialize, Serialize};
21 +use serde_json::{Number, Value, json};
22 +
23 +use crate::error::{Result, invalid_reasoning_control};
24 +use crate::request::ChatRequest;
25 +
26 +/// A model-specific reasoning effort, before or after renderer lowering.
27 +///
28 +/// Strings preserve model-specific names; JSON numbers preserve integer and
29 +/// floating-point representations for model-local range validation. Missing/null
30 +/// effort is represented by `Option::None` in [`crate::ChatOptions`].
31 +/// Supported names and ranges are validated by each renderer or HF template.
32 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33 +#[serde(untagged)]
34 +pub enum EffortValue {
35 + String(String),
36 + Number(Number),
37 +}
38 +
39 +impl TryFrom<Value> for EffortValue {
40 + type Error = crate::error::Error;
41 +
42 + fn try_from(value: Value) -> Result<Self> {
43 + match value {
44 + Value::String(value) => Ok(Self::String(value)),
45 + Value::Number(value) => Ok(Self::Number(value)),
46 + _ => Err(invalid_reasoning_control!(
47 + "reasoning effort must be a string or number, got {value}"
48 + )),
49 + }
50 + }
51 +}
52 +
53 +impl TryFrom<f64> for EffortValue {
54 + type Error = crate::error::Error;
55 +
56 + /// Construct a numeric effort, rejecting NaN and infinities.
57 + fn try_from(value: f64) -> Result<Self> {
58 + Number::from_f64(value).map(Self::Number).ok_or_else(|| {
59 + invalid_reasoning_control!("reasoning effort must be finite, got {value}")
60 + })
61 + }
62 +}
63 +
64 +impl From<&str> for EffortValue {
65 + fn from(value: &str) -> Self {
66 + Self::String(value.to_owned())
67 + }
68 +}
69 +
70 +impl fmt::Display for EffortValue {
71 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 + match self {
73 + Self::String(value) => json!(value).fmt(f),
74 + Self::Number(value) => value.fmt(f),
75 + }
76 + }
77 +}
78 +
79 +impl EffortValue {
80 + pub fn as_str(&self) -> Option<&str> {
81 + match self {
82 + Self::String(value) => Some(value),
83 + Self::Number(_) => None,
84 + }
85 + }
86 +
87 + pub fn as_f64(&self) -> Option<f64> {
88 + match self {
89 + Self::Number(value) => value.as_f64(),
90 + Self::String(_) => None,
91 + }
92 + }
93 +}
94 +
95 +/// Reasoning intent for one source, or the result of falling back across sources.
96 +#[derive(Debug, Clone, PartialEq)]
97 +pub(crate) enum ReasoningControl {
98 + /// This source leaves the mode and effort to a lower-priority source.
99 + Default,
100 + /// Reasoning is explicitly disabled; lower-priority defaults preserve this mode.
101 + Disabled,
102 + /// Reasoning is enabled. Missing effort inherits from an enabled default.
103 + Enabled { effort: Option<EffortValue> },
104 +}
105 +
106 +impl ReasoningControl {
107 + /// Parse request controls; the typed effort field takes precedence over kwargs effort.
108 + pub(crate) fn from_request(request: &ChatRequest) -> Result<Self> {
109 + Self::from_kwargs(
110 + &request.chat_options.template_kwargs,
111 + request.chat_options.reasoning_effort.as_ref(),
112 + )
113 + }
114 +
115 + /// Parse a deployment source, whose controls all come from template kwargs.
116 + pub(crate) fn from_template_kwargs(kwargs: &HashMap<String, Value>) -> Result<Self> {
117 + Self::from_kwargs(kwargs, None)
118 + }
119 +
120 + /// Select controls within one source, then derive its mode and optional effort.
121 + fn from_kwargs(
122 + kwargs: &HashMap<String, Value>,
123 + typed_effort: Option<&EffortValue>,
124 + ) -> Result<Self> {
125 + // Presence sets alias priority: `thinking` wins, including when its value
126 + // is invalid. Validate the selected spelling as a boolean.
127 + let toggle = ["thinking", "enable_thinking"]
128 + .into_iter()
129 + .find_map(|key| kwargs.get(key).map(|value| (key, value)))
130 + .map(|(key, value)| {
131 + value.as_bool().ok_or_else(|| {
132 + invalid_reasoning_control!(
133 + "template kwarg `{key}` must be a boolean, got {value}"
134 + )
135 + })
136 + })
137 + .transpose()?;
138 +
139 + // The typed request field wins over kwargs. Parse kwargs only when used;
140 + // disabled mode discards effort, and JSON null means omitted effort.
141 + let effort = match (toggle, typed_effort) {
142 + (Some(false), _) => None,
143 + (_, Some(effort)) => Some(effort.clone()),
144 + (_, None) => kwargs
145 + .get("reasoning_effort")
146 + .filter(|effort| !effort.is_null())
147 + .cloned()
148 + .map(EffortValue::try_from)
149 + .transpose()?,
150 + };
151 +
152 + match (toggle, effort) {
153 + // 1. Explicit false disables reasoning and discards any effort,
154 + // including values that would fail validation in an enabled mode.
155 + (Some(false), _) => Ok(Self::Disabled),
156 + // 2. `none` disables reasoning when the toggle is absent. Explicit true
157 + // enables reasoning and leaves effort to enabled deployment/model defaults.
158 + (toggle, Some(EffortValue::String(effort))) if effort == "none" => {
159 + Ok(if toggle == Some(true) {
160 + Self::Enabled { effort: None }
161 + } else {
162 + Self::Disabled
163 + })
164 + }
165 + // 3. Absent toggle and absent/null effort leave the entire decision
166 + // to the next source in the fallback chain.
167 + (None, None) => Ok(Self::Default),
168 + // 4. Explicit true or a concrete effort enables reasoning;
169 + // missing effort inherits later.
170 + (_, effort) => Ok(Self::Enabled { effort }),
171 + }
172 + }
173 +
174 + /// Parse request and deployment independently, then apply request precedence.
175 + ///
176 + /// Each source validates its selected input types. Model adapters subsequently
177 + /// apply their model default and validate/map the resulting effective effort.
178 + pub(crate) fn resolve(
179 + request: &ChatRequest,
180 + defaults: &HashMap<String, Value>,
181 + ) -> Result<Self> {
182 + Ok(Self::from_request(request)?.fallback(Self::from_template_kwargs(defaults)?))
183 + }
184 +
185 + /// Fill this higher-priority source's missing decision or effort from `defaults`.
186 + ///
187 + /// `Default` inherits the whole lower-priority state. `Enabled { effort: None }`
188 + /// inherits effort only from enabled defaults and retains its enabled mode.
189 + /// Explicit disabling and concrete effort are final at this priority level.
190 + pub(crate) fn fallback(self, defaults: Self) -> Self {
191 + match (self, defaults) {
192 + (Self::Default, defaults) => defaults,
193 + (Self::Enabled { effort: None }, Self::Enabled { effort }) => Self::Enabled { effort },
194 + (request, _) => request,
195 + }
196 + }
197 +
198 + /// Construct an enabled model default or a model-lowered concrete effort.
199 + pub(crate) fn enabled(effort: impl Into<EffortValue>) -> Self {
200 + Self::Enabled {
201 + effort: Some(effort.into()),
202 + }
203 + }
204 +
205 + /// Report the resolved mode; callers apply model defaults before rendering.
206 + pub(crate) fn is_enabled(&self) -> bool {
207 + matches!(self, Self::Enabled { .. })
208 + }
209 +
210 + /// Return the active effort, which may still be missing before model fallback.
211 + pub(crate) fn effort(&self) -> Option<&EffortValue> {
212 + match self {
213 + Self::Enabled { effort } => effort.as_ref(),
214 + Self::Default | Self::Disabled => None,
215 + }
216 + }
217 +
218 + /// Project the same model-lowered decision used to render the native prompt.
219 + ///
220 + /// Preserve request extras and write both toggle aliases plus the effective
221 + /// effort. Disabled mode emits `"none"`; enabled mode with no effective effort
222 + /// clears the original effort key. `Default` preserves request kwargs.
223 + pub(crate) fn template_kwargs(&self, request: &ChatRequest) -> HashMap<String, Value> {
224 + let mut kwargs = request.chat_options.template_kwargs.clone();
225 + if matches!(self, Self::Default) {
226 + return kwargs;
227 + }
228 + kwargs.insert("thinking".to_string(), json!(self.is_enabled()));
229 + kwargs.insert("enable_thinking".to_string(), json!(self.is_enabled()));
230 + match self {
231 + Self::Disabled => {
232 + kwargs.insert("reasoning_effort".to_string(), json!("none"));
233 + }
234 + Self::Enabled {
235 + effort: Some(effort),
236 + } => {
237 + kwargs.insert("reasoning_effort".to_string(), json!(effort));
238 + }
239 + Self::Enabled { effort: None } => {
240 + kwargs.remove("reasoning_effort");
241 + }
242 + Self::Default => unreachable!(),
243 + }
244 + kwargs
245 + }
246 +}
247 +
248 +#[cfg(test)]
249 +mod tests {
250 + use super::*;
251 +
252 + fn kwargs(value: Value) -> HashMap<String, Value> {
253 + serde_json::from_value(value).unwrap()
254 + }
255 +
256 + #[test]
257 + fn numeric_effort_rejects_non_finite_values() {
258 + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
259 + assert!(EffortValue::try_from(value).unwrap_err().is_request_validation_error());
260 + }
261 + }
262 +
263 + #[test]
264 + fn explicit_toggle_decides_mode_and_none_inherits_enabled_effort() {
265 + use ReasoningControl::{Default, Disabled, Enabled};
266 +
267 + for (source, expected) in [
268 + (json!({}), Default),
269 + (json!({"reasoning_effort": "none"}), Disabled),
270 + (
271 + json!({"reasoning_effort": "high"}),
272 + ReasoningControl::enabled("high"),
273 + ),
274 + (json!({"enable_thinking": true}), Enabled { effort: None }),
275 + (
276 + json!({"enable_thinking": true, "reasoning_effort": "none"}),
277 + Enabled { effort: None },
278 + ),
279 + (
280 + json!({"enable_thinking": true, "reasoning_effort": "low"}),
281 + ReasoningControl::enabled("low"),
282 + ),
283 + (
284 + json!({"enable_thinking": false, "reasoning_effort": "high"}),
285 + Disabled,
286 + ),
287 + (
288 + json!({"thinking": true, "enable_thinking": false}),
289 + Enabled { effort: None },
290 + ),
291 + (
292 + json!({"thinking": false, "enable_thinking": true}),
293 + Disabled,
294 + ),
295 + (
296 + json!({"reasoning_effort": 37}),
297 + ReasoningControl::enabled(EffortValue::Number(37.into())),
298 + ),
299 + (json!({"reasoning_effort": null}), Default),
300 + ] {
301 + let source = kwargs(source);
302 + assert_eq!(
303 + ReasoningControl::from_template_kwargs(&source).unwrap(),
304 + expected,
305 + "{source:?}"
306 + );
307 + }
308 + }
309 +
310 + #[test]
311 + fn request_and_deployment_states_fall_back_before_model_defaults() {
312 + for (request, defaults, expected) in [
313 + (
314 + json!({}),
315 + json!({"enable_thinking": false}),
316 + ReasoningControl::Disabled,
317 + ),
318 + (
319 + json!({"reasoning_effort": "high"}),
320 + json!({"thinking": false}),
321 + ReasoningControl::enabled("high"),
322 + ),
323 + (
324 + json!({"enable_thinking": false}),
325 + json!({"reasoning_effort": "high"}),
326 + ReasoningControl::Disabled,
327 + ),
328 + (
329 + json!({"thinking": true, "reasoning_effort": "none"}),
330 + json!({"reasoning_effort": "low"}),
331 + ReasoningControl::enabled("low"),
332 + ),
333 + (
334 + json!({"thinking": true}),
335 + json!({"thinking": false, "reasoning_effort": "low"}),
336 + ReasoningControl::enabled("max"),
337 + ),
338 + (json!({}), json!({}), ReasoningControl::enabled("max")),
339 + ] {
340 + let request = kwargs(request);
341 + let defaults = kwargs(defaults);
342 + let actual = ReasoningControl::from_template_kwargs(&request)
343 + .unwrap()
344 + .fallback(ReasoningControl::from_template_kwargs(&defaults).unwrap())
345 + .fallback(ReasoningControl::enabled("max"));
346 + assert_eq!(
347 + actual, expected,
348 + "request={request:?}, defaults={defaults:?}"
349 + );
350 + }
351 + }
352 +
353 + #[test]
354 + fn selected_toggle_requires_a_boolean() {
355 + for value in [
356 + json!("true"),
357 + json!(1),
358 + json!(null),
359 + json!({"type": "enabled"}),
360 + ] {
361 + assert!(
362 + ReasoningControl::from_template_kwargs(&kwargs(json!({
363 + "thinking": value, "enable_thinking": true
364 + })))
365 + .unwrap_err()
366 + .is_request_validation_error()
367 + );
368 + }
369 + }
370 +
371 + #[test]
372 + fn invalid_effort_types_are_rejected_only_when_the_source_uses_them() {
373 + for value in [json!(true), json!(false), json!([]), json!({})] {
374 + for toggle in [None, Some(true), Some(false)] {
375 + let mut source = kwargs(json!({"reasoning_effort": value}));
376 + if let Some(toggle) = toggle {
377 + source.insert("thinking".to_owned(), json!(toggle));
378 + }
379 + let control = ReasoningControl::from_template_kwargs(&source);
380 + if toggle == Some(false) {
381 + assert_eq!(control.unwrap(), ReasoningControl::Disabled);
382 + } else {
383 + assert!(control.unwrap_err().is_request_validation_error());
384 + }
385 + }
386 +
387 + // A typed request effort shadows malformed kwargs effort.
388 + let mut request = ChatRequest::for_test();
389 + request.chat_options.reasoning_effort = Some(EffortValue::from("high"));
390 + request.chat_options.template_kwargs = kwargs(json!({"reasoning_effort": value}));
391 + assert_eq!(
392 + ReasoningControl::from_request(&request).unwrap(),
393 + ReasoningControl::enabled("high")
394 + );
395 + }
396 + }
397 +}
rust/src/chat/src/renderer/deepseek.rs
+67 -75
@@ -17,10 +17,10 @@ use serde_json_fmt::JsonFormat;
17 use llm_multimodal::DEEPSEEK_V41_IMAGE_PLACEHOLDER;
18
19 use super::MediaPartSource;
20 +use crate::EffortValue;
21 use crate::error::{Error, Result};
21 -use crate::request::{
22 - ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ReasoningEffort,
23 -};
22 +use crate::reasoning::ReasoningControl;
23 +use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool};
24 use crate::{AssistantContentBlock, AssistantMessageExt, AssistantToolCall};
25
26 const BOS_TOKEN: &str = "<|begin▁of▁sentence|>";
@@ -87,21 +87,26 @@ struct RenderedToolSchema<'a> {
87 }
88
89 /// Render one chat request into the final prompt string.
90 -pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Result<String> {
91 - render_request_with_media_order(request, dialect).map(|(prompt, _)| prompt)
90 +pub(super) fn render_request(
91 + request: &ChatRequest,
92 + dialect: DsDialect,
93 + reasoning: &ReasoningControl,
94 +) -> Result<String> {
95 + render_request_with_media_order(request, dialect, reasoning).map(|(prompt, _)| prompt)
96 }
97
98 /// Render one chat request and record media in placeholder order.
99 pub(super) fn render_request_with_media_order(
100 request: &ChatRequest,
101 dialect: DsDialect,
102 + reasoning: &ReasoningControl,
103 ) -> Result<(String, Vec<MediaPartSource>)> {
99 - let (thinking_mode, reasoning_effort_prompt) = match dialect {
100 - DsDialect::V4 => {
101 - resolve_thinking_options(request).map(|(mode, prompt)| (mode, Cow::Borrowed(prompt)))?
102 - }
103 - DsDialect::V41 => resolve_v41_thinking_options(request)?,
104 + let thinking_mode = if reasoning.is_enabled() {
105 + ThinkingMode::Thinking
106 + } else {
107 + ThinkingMode::Chat
108 };
109 + let reasoning_effort_prompt = reasoning_prompt(reasoning, dialect);
110 let request_tools = request_tools(request);
111 let synthetic_tool_system = needs_synthetic_tool_system(request, request_tools);
112 let drop_thinking = request.parse_template_bool("drop_thinking")?.unwrap_or(true)
@@ -254,76 +259,63 @@ pub(super) fn render_request_with_media_order(
259 Ok((out, media_order))
260 }
261
257 -/// Resolve DeepSeek V4's thinking controls. Unlike the Python tokenizer
258 -/// wrapper, the Rust renderer only consumes the typed top-level
259 -/// `reasoning_effort`; the generic template-kwargs map is left for HF
260 -/// templates.
261 -fn resolve_thinking_options(request: &ChatRequest) -> Result<(ThinkingMode, &'static str)> {
262 - let mut thinking_mode = match request.enable_thinking()?.unwrap_or(true) {
263 - true => ThinkingMode::Thinking,
264 - false => ThinkingMode::Chat,
265 - };
266 - let mut reasoning_effort_prompt = REASONING_EFFORT_HIGH;
267 -
268 - match request.chat_options.reasoning_effort {
269 - Some(ReasoningEffort::None) => thinking_mode = ThinkingMode::Chat,
270 - Some(ReasoningEffort::Max) => {
271 - reasoning_effort_prompt = REASONING_EFFORT_MAX;
272 - }
273 - Some(ReasoningEffort::XHigh | ReasoningEffort::High) => {
274 - reasoning_effort_prompt = REASONING_EFFORT_HIGH;
262 +/// Map the shared reasoning decision to the selected DeepSeek effort dialect.
263 +pub(super) fn resolve_reasoning(
264 + control: ReasoningControl,
265 + dialect: DsDialect,
266 +) -> Result<ReasoningControl> {
267 + let control = control.fallback(ReasoningControl::enabled("high"));
268 + if !control.is_enabled() {
269 + return Ok(control);
270 + }
271 + let effort = control.effort().expect("enabled DeepSeek reasoning has a default effort");
272 + let effort = match dialect {
273 + DsDialect::V4 => {
274 + let effort = match effort.as_str() {
275 + Some("minimal" | "medium" | "low") => "low",
276 + Some("high" | "xhigh") => "high",
277 + Some("max") => "max",
278 + _ => {
279 + return Err(Error::InvalidReasoningEffort(format!(
280 + "DeepSeek V4 reasoning_effort must be minimal, low, medium, high, xhigh, or max, got {effort}"
281 + )));
282 + }
283 + };
284 + EffortValue::from(effort)
285 }
276 - Some(ReasoningEffort::Minimal | ReasoningEffort::Medium | ReasoningEffort::Low) => {
277 - reasoning_effort_prompt = "";
286 + DsDialect::V41 => {
287 + let budget = match effort {
288 + EffortValue::String(effort) => match effort.as_str() {
289 + "low" => 25,
290 + "high" => 50,
291 + "xhigh" => 75,
292 + "max" => 100,
293 + _ => return Err(invalid_v41_reasoning_effort()),
294 + },
295 + EffortValue::Number(budget) => budget
296 + .as_u64()
297 + .filter(|budget| (1..=100).contains(budget))
298 + .ok_or_else(invalid_v41_reasoning_effort)?,
299 + };
300 + EffortValue::Number(budget.into())
301 }
279 - None => {}
280 - }
281 -
282 - Ok((thinking_mode, reasoning_effort_prompt))
302 + };
303 + Ok(ReasoningControl::enabled(effort))
304 }
305
285 -/// Resolve V4.1's numeric reasoning effort using the top-level value before template kwargs.
286 -fn resolve_v41_thinking_options(
287 - request: &ChatRequest,
288 -) -> Result<(ThinkingMode, Cow<'static, str>)> {
289 - let mut thinking = request.enable_thinking()?.unwrap_or(true);
290 - let budget = match request.chat_options.reasoning_effort {
291 - Some(ReasoningEffort::None) => {
292 - thinking = false;
293 - 50
294 - }
295 - Some(ReasoningEffort::Low) => 25,
296 - Some(ReasoningEffort::High) => 50,
297 - Some(ReasoningEffort::XHigh) => 75,
298 - Some(ReasoningEffort::Max) => 100,
299 - Some(ReasoningEffort::Minimal | ReasoningEffort::Medium) => {
300 - return Err(invalid_v41_reasoning_effort());
301 - }
302 - None => match request.chat_options.template_kwargs.get("reasoning_effort") {
303 - None | Some(Value::Null) => 50,
304 - Some(Value::String(effort)) => match effort.as_str() {
305 - "low" => 25,
306 - "high" => 50,
307 - "xhigh" => 75,
308 - "max" => 100,
309 - _ => return Err(invalid_v41_reasoning_effort()),
310 - },
311 - Some(Value::Number(budget)) => budget
312 - .as_u64()
313 - .filter(|budget| (1..=100).contains(budget))
314 - .ok_or_else(invalid_v41_reasoning_effort)?,
315 - Some(_) => return Err(invalid_v41_reasoning_effort()),
316 - },
306 +fn reasoning_prompt(control: &ReasoningControl, dialect: DsDialect) -> Cow<'static, str> {
307 + let Some(effort) = control.effort() else {
308 + return Cow::Borrowed("");
309 };
318 - if thinking {
319 - Ok((
320 - ThinkingMode::Thinking,
321 - Cow::Owned(format!(
322 - "Reasoning Effort: {budget} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
323 - )),
324 - ))
325 - } else {
326 - Ok((ThinkingMode::Chat, Cow::Borrowed("")))
310 + match dialect {
311 + DsDialect::V4 => Cow::Borrowed(match effort.as_str() {
312 + Some("high") => REASONING_EFFORT_HIGH,
313 + Some("max") => REASONING_EFFORT_MAX,
314 + _ => "",
315 + }),
316 + DsDialect::V41 => Cow::Owned(format!(
317 + "Reasoning Effort: {effort} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
318 + )),
319 }
320 }
321
rust/src/chat/src/renderer/deepseek_v32/encoding.rs
+6 -2
@@ -14,6 +14,7 @@ use serde_json::Value;
14 use serde_json_fmt::JsonFormat;
15
16 use crate::error::{Error, Result};
17 +use crate::reasoning::ReasoningControl;
18 use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatRole, ChatTool};
19 use crate::{AssistantContentBlock, AssistantMessageExt, AssistantToolCall};
20
@@ -42,8 +43,11 @@ struct RenderedToolSchema<'a> {
43 }
44
45 /// Render one chat request into the final prompt string.
45 -pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
46 - let thinking_mode = match request.enable_thinking()?.unwrap_or(false) {
46 +pub(super) fn render_request(
47 + request: &ChatRequest,
48 + reasoning: &ReasoningControl,
49 +) -> Result<String> {
50 + let thinking_mode = match reasoning.is_enabled() {
51 true => ThinkingMode::Thinking,
52 false => ThinkingMode::Chat,
53 };
rust/src/chat/src/renderer/deepseek_v32/mod.rs
+17 -7
@@ -3,31 +3,41 @@
3
4 mod encoding;
5
6 +use std::collections::HashMap;
7 +
8 +use serde_json::Value;
9 use vllm_text::Prompt;
10
8 -use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
11 +use super::{ChatRenderer, RenderedPrompt};
12 use crate::Result;
13 +use crate::reasoning::ReasoningControl;
14 use crate::request::ChatRequest;
15
16 /// Dedicated DeepSeek V3.2 renderer.
13 -#[derive(Debug, Clone, Copy, Default)]
14 -pub struct DeepSeekV32ChatRenderer;
17 +#[derive(Debug, Clone, Default)]
18 +pub struct DeepSeekV32ChatRenderer {
19 + default_template_kwargs: HashMap<String, Value>,
20 +}
21
22 impl DeepSeekV32ChatRenderer {
23 /// Create the dedicated DeepSeek V3.2 renderer.
18 - pub fn new() -> Self {
19 - Self
24 + pub fn new(default_template_kwargs: HashMap<String, Value>) -> Self {
25 + Self {
26 + default_template_kwargs,
27 + }
28 }
29 }
30
31 impl ChatRenderer for DeepSeekV32ChatRenderer {
32 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
33 request.validate()?;
34 + let reasoning = ReasoningControl::resolve(request, &self.default_template_kwargs)?
35 + .fallback(ReasoningControl::Disabled);
36
37 Ok(RenderedPrompt {
28 - prompt: Prompt::Text(encoding::render_request(request)?),
38 + prompt: Prompt::Text(encoding::render_request(request, &reasoning)?),
39 media_order: None,
30 - effective_template_kwargs: request_template_kwargs(request),
40 + effective_template_kwargs: reasoning.template_kwargs(request),
41 })
42 }
43 }
rust/src/chat/src/renderer/deepseek_v32/tests.rs
+26 -3
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
8 use thiserror_ext::AsReport;
9
10 use super::DeepSeekV32ChatRenderer;
11 +use crate::EffortValue;
12 use crate::error::Error;
13 use crate::event::{AssistantContentBlock, AssistantToolCall};
14 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
@@ -18,7 +19,7 @@ use crate::request::{
19 use crate::{ChatRenderer, ChatRole};
20
21 fn render_request(request: &ChatRequest) -> String {
21 - DeepSeekV32ChatRenderer::new()
22 + DeepSeekV32ChatRenderer::default()
23 .render(request)
24 .unwrap()
25 .prompt
@@ -26,8 +27,30 @@ fn render_request(request: &ChatRequest) -> String {
27 .expect("deepseek renderer should return text prompt")
28 }
29
30 +#[test]
31 +fn effort_enables_binary_thinking_and_none_disables_it() {
32 + let mut request = ChatRequest::for_test();
33 + for (effort, enabled) in [
34 + (EffortValue::from("high"), true),
35 + (EffortValue::from("custom"), true),
36 + (EffortValue::Number(42.into()), true),
37 + (EffortValue::from("none"), false),
38 + ] {
39 + request.chat_options.reasoning_effort = Some(effort);
40 + let rendered = DeepSeekV32ChatRenderer::default().render(&request).unwrap();
41 + assert_eq!(
42 + rendered.prompt.into_text().unwrap().ends_with("<think>"),
43 + enabled
44 + );
45 + assert_eq!(
46 + rendered.effective_template_kwargs["enable_thinking"],
47 + enabled
48 + );
49 + }
50 +}
51 +
52 fn render_result(request: &ChatRequest) -> Result<String, Error> {
30 - DeepSeekV32ChatRenderer::new().render(request).map(|rendered| {
53 + DeepSeekV32ChatRenderer::default().render(request).map(|rendered| {
54 rendered
55 .prompt
56 .into_text()
@@ -345,7 +368,7 @@ fn render_rejects_multimodal_input() {
368 ..ChatRequest::for_test()
369 };
370
348 - let error = DeepSeekV32ChatRenderer::new().render(&request).unwrap_err();
371 + let error = DeepSeekV32ChatRenderer::default().render(&request).unwrap_err();
372
373 assert!(matches!(
374 error,
rust/src/chat/src/renderer/deepseek_v4/mod.rs
+23 -7
@@ -1,31 +1,47 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +use std::collections::HashMap;
5 +
6 +use serde_json::Value;
7 use vllm_text::Prompt;
8
9 use super::deepseek::{self, DsDialect};
7 -use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
10 +use super::{ChatRenderer, RenderedPrompt};
11 use crate::Result;
12 +use crate::reasoning::ReasoningControl;
13 use crate::request::ChatRequest;
14
15 /// Dedicated DeepSeek V4 renderer.
12 -#[derive(Debug, Clone, Copy, Default)]
13 -pub struct DeepSeekV4ChatRenderer;
16 +#[derive(Debug, Clone, Default)]
17 +pub struct DeepSeekV4ChatRenderer {
18 + default_template_kwargs: HashMap<String, Value>,
19 +}
20
21 impl DeepSeekV4ChatRenderer {
16 - pub fn new() -> Self {
17 - Self
22 + pub fn new(default_template_kwargs: HashMap<String, Value>) -> Self {
23 + Self {
24 + default_template_kwargs,
25 + }
26 }
27 }
28
29 impl ChatRenderer for DeepSeekV4ChatRenderer {
30 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
31 request.validate()?;
32 + let reasoning = deepseek::resolve_reasoning(
33 + ReasoningControl::resolve(request, &self.default_template_kwargs)?,
34 + DsDialect::V4,
35 + )?;
36
37 Ok(RenderedPrompt {
26 - prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V4)?),
38 + prompt: Prompt::Text(deepseek::render_request(
39 + request,
40 + DsDialect::V4,
41 + &reasoning,
42 + )?),
43 media_order: None,
28 - effective_template_kwargs: request_template_kwargs(request),
44 + effective_template_kwargs: reasoning.template_kwargs(request),
45 })
46 }
47 }
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+62 -22
@@ -8,13 +8,14 @@ use serde_json::Value;
8
9 use super::DeepSeekV4ChatRenderer;
10 use crate::ChatRenderer;
11 +use crate::EffortValue;
12 use crate::event::{AssistantContentBlock, AssistantToolCall};
13 use crate::renderer::deepseek_v41::DeepSeekV41ChatRenderer;
14 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
14 -use crate::request::{ChatMessage, ChatRequest, ChatTool, GenerationPromptMode, ReasoningEffort};
15 +use crate::request::{ChatMessage, ChatRequest, ChatTool, GenerationPromptMode};
16
17 fn render_request(request: &ChatRequest) -> String {
17 - DeepSeekV4ChatRenderer::new()
18 + DeepSeekV4ChatRenderer::default()
19 .render(request)
20 .unwrap()
21 .prompt
@@ -31,13 +32,13 @@ fn thinking_request(messages: Vec<ChatMessage>) -> ChatRequest {
32 .chat_options
33 .template_kwargs
34 .insert("thinking".to_string(), Value::Bool(true));
34 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
35 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
36 request
37 }
38
39 fn fixture_request(input_name: &str) -> ChatRequest {
40 let mut request = fixture_chat_request(&fixture_path(input_name), deepseek_fixture_options());
40 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
41 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
42 request
43 }
44
@@ -203,7 +204,7 @@ fn reasoning_effort_max_adds_prefix_when_thinking_is_enabled() {
204 .chat_options
205 .template_kwargs
206 .insert("thinking".to_string(), Value::Bool(true));
206 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Max);
207 + request.chat_options.reasoning_effort = Some(EffortValue::from("max"));
208
209 let rendered = render_request(&request);
210
@@ -226,7 +227,7 @@ fn reasoning_effort_high_adds_0731_high_prefix() {
227 .chat_options
228 .template_kwargs
229 .insert("thinking".to_string(), Value::Bool(true));
229 - request.chat_options.reasoning_effort = Some(ReasoningEffort::High);
230 + request.chat_options.reasoning_effort = Some(EffortValue::from("high"));
231
232 let rendered = render_request(&request);
233
@@ -258,7 +259,7 @@ fn reasoning_effort_xhigh_maps_to_high() {
259 messages: vec![ChatMessage::user("solve it")],
260 ..ChatRequest::for_test()
261 };
261 - request.chat_options.reasoning_effort = Some(ReasoningEffort::XHigh);
262 + request.chat_options.reasoning_effort = Some(EffortValue::from("xhigh"));
263
264 let rendered = render_request(&request);
265
@@ -268,7 +269,7 @@ fn reasoning_effort_xhigh_maps_to_high() {
269
270 #[test]
271 fn minimal_and_medium_reasoning_effort_map_to_low() {
271 - for effort in [ReasoningEffort::Minimal, ReasoningEffort::Medium] {
272 + for effort in [EffortValue::from("minimal"), EffortValue::from("medium")] {
273 let mut request = ChatRequest {
274 messages: vec![ChatMessage::user("solve it")],
275 ..ChatRequest::for_test()
@@ -292,7 +293,7 @@ fn reasoning_effort_low_keeps_the_default_prompt() {
293 .chat_options
294 .template_kwargs
295 .insert("thinking".to_string(), Value::Bool(true));
295 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
296 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
297
298 let rendered = render_request(&request);
299
@@ -305,11 +306,7 @@ fn reasoning_effort_none_disables_thinking() {
306 messages: vec![ChatMessage::user("answer directly")],
307 ..ChatRequest::for_test()
308 };
308 - request
309 - .chat_options
310 - .template_kwargs
311 - .insert("thinking".to_string(), Value::Bool(true));
312 - request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
309 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
310
311 let rendered = render_request(&request);
312
@@ -317,6 +314,49 @@ fn reasoning_effort_none_disables_thinking() {
314 .assert_eq(&rendered);
315 }
316
317 +#[test]
318 +fn normalized_reasoning_drives_both_prompt_and_compatibility_kwargs() {
319 + for (kwargs, enabled, effort, prefix) in [
320 + (
321 + serde_json::json!({"enable_thinking": true, "reasoning_effort": "none"}),
322 + true,
323 + "high",
324 + "Absolute maximum",
325 + ),
326 + (
327 + serde_json::json!({"thinking": false, "enable_thinking": true, "reasoning_effort": "max"}),
328 + false,
329 + "none",
330 + "",
331 + ),
332 + (
333 + serde_json::json!({"reasoning_effort": "xhigh"}),
334 + true,
335 + "high",
336 + "Absolute maximum",
337 + ),
338 + ] {
339 + let mut request = ChatRequest::for_test();
340 + request.chat_options.template_kwargs = serde_json::from_value(kwargs).unwrap();
341 + let rendered = DeepSeekV4ChatRenderer::default().render(&request).unwrap();
342 + let prompt = rendered.prompt.into_text().unwrap();
343 + assert_eq!(prompt.ends_with("<think>"), enabled);
344 + assert_eq!(prompt.contains("Reasoning Effort:"), enabled);
345 + if enabled {
346 + assert!(prompt.contains(prefix));
347 + }
348 + assert_eq!(rendered.effective_template_kwargs["thinking"], enabled);
349 + assert_eq!(
350 + rendered.effective_template_kwargs["enable_thinking"],
351 + enabled
352 + );
353 + assert_eq!(
354 + rendered.effective_template_kwargs["reasoning_effort"],
355 + effort
356 + );
357 + }
358 +}
359 +
360 #[test]
361 fn system_to_assistant_transitions_in_chat_and_thinking_modes() {
362 let rendered = [false, true].map(|enable_thinking| {
@@ -338,7 +378,7 @@ fn system_to_assistant_transitions_in_chat_and_thinking_modes() {
378 .chat_options
379 .template_kwargs
380 .insert("enable_thinking".to_string(), Value::Bool(enable_thinking));
341 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
381 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
382 render_request(&request)
383 });
384
@@ -366,7 +406,7 @@ fn trailing_system_transitions_in_chat_and_thinking_modes() {
406 .chat_options
407 .template_kwargs
408 .insert("enable_thinking".to_string(), Value::Bool(enable_thinking));
369 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
409 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
410 render_request(&request)
411 });
412
@@ -380,7 +420,7 @@ fn trailing_system_transitions_in_chat_and_thinking_modes() {
420 }
421
422 #[test]
383 -fn reasoning_effort_template_kwarg_is_ignored() {
423 +fn reasoning_effort_template_kwarg_selects_effort() {
424 let mut request = ChatRequest {
425 messages: vec![ChatMessage::user("solve it")],
426 ..ChatRequest::for_test()
@@ -396,15 +436,15 @@ fn reasoning_effort_template_kwarg_is_ignored() {
436
437 let rendered = render_request(&request);
438
399 - assert!(rendered.starts_with("<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum"));
439 + assert!(rendered.starts_with("<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum"));
440 assert!(rendered.ends_with("<|Assistant|><think>"));
441 }
442
443 #[test]
444 fn tool_call_arguments_preserve_non_objects_like_recipe() {
445 let renderers: [(&dyn ChatRenderer, &str); 2] = [
406 - (&DeepSeekV4ChatRenderer::new(), "parameter"),
407 - (&DeepSeekV41ChatRenderer::new(), " parameter"),
446 + (&DeepSeekV4ChatRenderer::default(), "parameter"),
447 + (&DeepSeekV41ChatRenderer::default(), " parameter"),
448 ];
449 for arguments in [
450 "not json",
@@ -600,7 +640,7 @@ fn drop_thinking_false_keeps_prior_assistant_reasoning() {
640 .chat_options
641 .template_kwargs
642 .insert("drop_thinking".to_string(), Value::Bool(false));
603 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
643 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
644
645 let rendered = render_request(&request);
646
@@ -693,7 +733,7 @@ fn trailing_system_omits_generation_prompt_when_disabled() {
733 .chat_options
734 .template_kwargs
735 .insert("enable_thinking".to_string(), Value::Bool(false));
696 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
736 + request.chat_options.reasoning_effort = Some(EffortValue::from("low"));
737
738 let rendered = render_request(&request);
739
rust/src/chat/src/renderer/deepseek_v41/mod.rs
+19 -7
@@ -1,33 +1,45 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +use std::collections::HashMap;
5 +
6 +use serde_json::Value;
7 use vllm_text::Prompt;
8
9 use super::deepseek::{self, DsDialect};
7 -use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
10 +use super::{ChatRenderer, RenderedPrompt};
11 use crate::Result;
12 +use crate::reasoning::ReasoningControl;
13 use crate::request::ChatRequest;
14
15 /// Dedicated DeepSeek V4.1 renderer.
12 -#[derive(Debug, Clone, Copy, Default)]
13 -pub struct DeepSeekV41ChatRenderer;
16 +#[derive(Debug, Clone, Default)]
17 +pub struct DeepSeekV41ChatRenderer {
18 + default_template_kwargs: HashMap<String, Value>,
19 +}
20
21 impl DeepSeekV41ChatRenderer {
16 - pub fn new() -> Self {
17 - Self
22 + pub fn new(default_template_kwargs: HashMap<String, Value>) -> Self {
23 + Self {
24 + default_template_kwargs,
25 + }
26 }
27 }
28
29 impl ChatRenderer for DeepSeekV41ChatRenderer {
30 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
31 request.validate()?;
32 + let reasoning = deepseek::resolve_reasoning(
33 + ReasoningControl::resolve(request, &self.default_template_kwargs)?,
34 + DsDialect::V41,
35 + )?;
36 let (prompt, media_order) =
25 - deepseek::render_request_with_media_order(request, DsDialect::V41)?;
37 + deepseek::render_request_with_media_order(request, DsDialect::V41, &reasoning)?;
38
39 Ok(RenderedPrompt {
40 prompt: Prompt::Text(prompt),
41 media_order: Some(media_order),
30 - effective_template_kwargs: request_template_kwargs(request),
42 + effective_template_kwargs: reasoning.template_kwargs(request),
43 })
44 }
45 }
rust/src/chat/src/renderer/deepseek_v41/tests.rs
+49 -15
@@ -8,14 +8,15 @@ use serde_json::{Value, json};
8
9 use super::DeepSeekV41ChatRenderer;
10 use crate::ChatRenderer;
11 +use crate::EffortValue;
12 use crate::event::{AssistantContentBlock, AssistantToolCall};
13 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
14 use crate::request::{
14 - ChatContent, ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort,
15 + ChatContent, ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode,
16 };
17
18 fn render(request: &ChatRequest) -> String {
18 - DeepSeekV41ChatRenderer::new()
19 + DeepSeekV41ChatRenderer::default()
20 .render(request)
21 .unwrap()
22 .prompt
@@ -64,7 +65,7 @@ fn inlines_image_placeholder_at_image_part_positions() {
65 // The placeholder is unconditional (matching the Python encoding's
66 // `IMAGE_PLACEHOLDER`); requests to a multimodal-less backend fail later,
67 // at media preparation.
67 - let renderer = DeepSeekV41ChatRenderer::new();
68 + let renderer = DeepSeekV41ChatRenderer::default();
69 let request = ChatRequest {
70 messages: vec![ChatMessage::User {
71 content: ChatContent::Parts(vec![
@@ -87,7 +88,7 @@ fn inlines_image_placeholder_at_image_part_positions() {
88
89 #[test]
90 fn media_order_follows_reordered_tool_results() {
90 - let renderer = DeepSeekV41ChatRenderer::new();
91 + let renderer = DeepSeekV41ChatRenderer::default();
92 let request = ChatRequest {
93 messages: vec![
94 ChatMessage::assistant_blocks(vec![
@@ -136,7 +137,7 @@ fn media_order_follows_reordered_tool_results() {
137
138 #[test]
139 fn media_order_excludes_dropped_historical_developer_content() {
139 - let renderer = DeepSeekV41ChatRenderer::new();
140 + let renderer = DeepSeekV41ChatRenderer::default();
141 let request = ChatRequest {
142 messages: vec![
143 ChatMessage::developer(vec![image_part("historical-image")], None),
@@ -165,10 +166,11 @@ fn image_part(uuid: &str) -> ChatContentPart {
166 fn maps_reasoning_effort_to_reference_numeric_budget() {
167 for (effort, budget) in [
168 (None, 50),
168 - (Some(ReasoningEffort::Low), 25),
169 - (Some(ReasoningEffort::High), 50),
170 - (Some(ReasoningEffort::XHigh), 75),
171 - (Some(ReasoningEffort::Max), 100),
169 + (Some(EffortValue::from("low")), 25),
170 + (Some(EffortValue::from("high")), 50),
171 + (Some(EffortValue::from("xhigh")), 75),
172 + (Some(EffortValue::from("max")), 100),
173 + (Some(EffortValue::Number(37.into())), 37),
174 ] {
175 let mut request = request();
176 request.chat_options.reasoning_effort = effort;
@@ -193,11 +195,43 @@ fn accepts_numeric_template_effort_with_top_level_precedence() {
195 request.chat_options.template_kwargs.insert("reasoning_effort".into(), effort);
196 assert!(render(&request).contains(&format!("Reasoning Effort: {budget} (range 1-100")));
197
196 - request.chat_options.reasoning_effort = Some(ReasoningEffort::XHigh);
198 + request.chat_options.reasoning_effort = Some(EffortValue::from("xhigh"));
199 assert!(render(&request).contains("Reasoning Effort: 75 (range 1-100"));
200 }
201 }
202
203 +#[test]
204 +fn enabling_with_none_inherits_deployment_effort_and_reports_numeric_control() {
205 + let renderer =
206 + DeepSeekV41ChatRenderer::new([("reasoning_effort".to_string(), json!(37))].into());
207 + let mut request = request();
208 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
209 + request
210 + .chat_options
211 + .template_kwargs
212 + .insert("enable_thinking".into(), json!(true));
213 + let rendered = renderer.render(&request).unwrap();
214 + let prompt = rendered.prompt.into_text().unwrap();
215 + assert!(prompt.contains("Reasoning Effort: 37 (range 1-100"));
216 + assert!(prompt.ends_with("<think>"));
217 + assert_eq!(rendered.effective_template_kwargs["reasoning_effort"], 37);
218 + assert_eq!(rendered.effective_template_kwargs["enable_thinking"], true);
219 +
220 + request.chat_options.template_kwargs.insert("thinking".into(), json!(false));
221 + request
222 + .chat_options
223 + .template_kwargs
224 + .insert("reasoning_effort".into(), json!(101));
225 + request.chat_options.reasoning_effort = None;
226 + let rendered = renderer.render(&request).unwrap();
227 + assert!(rendered.prompt.into_text().unwrap().ends_with("</think>"));
228 + assert_eq!(
229 + rendered.effective_template_kwargs["reasoning_effort"],
230 + "none"
231 + );
232 + assert_eq!(rendered.effective_template_kwargs["enable_thinking"], false);
233 +}
234 +
235 #[test]
236 fn rejects_undefined_effort_names_and_invalid_numeric_budgets() {
237 for effort in [
@@ -211,13 +245,13 @@ fn rejects_undefined_effort_names_and_invalid_numeric_budgets() {
245 ] {
246 let mut request = request();
247 request.chat_options.template_kwargs.insert("reasoning_effort".into(), effort);
214 - let error = DeepSeekV41ChatRenderer::new().render(&request).unwrap_err();
248 + let error = DeepSeekV41ChatRenderer::default().render(&request).unwrap_err();
249 assert!(error.is_request_validation_error());
250 }
217 - for effort in [ReasoningEffort::Minimal, ReasoningEffort::Medium] {
251 + for effort in [EffortValue::from("minimal"), EffortValue::from("medium")] {
252 let mut request = request();
253 request.chat_options.reasoning_effort = Some(effort);
220 - let error = DeepSeekV41ChatRenderer::new().render(&request).unwrap_err();
254 + let error = DeepSeekV41ChatRenderer::default().render(&request).unwrap_err();
255 assert!(error.is_request_validation_error());
256 }
257 }
@@ -229,8 +263,8 @@ fn chat_mode_and_none_effort_use_closed_thinking_prefix() {
263 let expected = expect!["<|begin▁of▁sentence|><|User|>question<|Assistant|></think>"];
264 expected.assert_eq(&render(&request));
265
232 - request.chat_options.template_kwargs.insert("thinking".into(), json!(true));
233 - request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
266 + request.chat_options.template_kwargs.remove("thinking");
267 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
268 expected.assert_eq(&render(&request));
269 }
270
rust/src/chat/src/renderer/harmony/mod.rs
+51 -27
@@ -5,21 +5,26 @@
5
6 pub(crate) mod encoding;
7
8 +use std::collections::HashMap;
9 +
10 use openai_harmony::HarmonyEncoding;
11 use openai_harmony::chat::{
12 Author, Conversation, DeveloperContent, Message, ReasoningEffort as HarmonyReasoningEffort,
13 Role, SystemContent, ToolDescription,
14 };
15 +use serde_json::Value;
16 use thiserror_ext::AsReport as _;
17 use time::macros::format_description;
18 use vllm_text::Prompt;
19
20 use self::encoding::harmony_encoding;
18 -use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
21 +use super::{ChatRenderer, RenderedPrompt};
22 +use crate::AssistantMessageExt as _;
23 +use crate::EffortValue;
24 use crate::error::{Error, Result};
25 use crate::event::AssistantContentBlock;
26 +use crate::reasoning::ReasoningControl;
27 use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, GenerationPromptMode};
22 -use crate::{AssistantMessageExt as _, ReasoningEffort};
28
29 const SYSTEM_START_DATE_ENV: &str = "VLLM_SYSTEM_START_DATE";
30 const HARMONY_SYSTEM_INSTRUCTIONS_ENV: &str = "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS";
@@ -28,6 +33,7 @@ const HARMONY_SYSTEM_INSTRUCTIONS_ENV: &str = "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTR
33 pub struct HarmonyChatRenderer {
34 encoding: &'static HarmonyEncoding,
35 options: Options,
36 + default_template_kwargs: HashMap<String, Value>,
37 }
38
39 struct Options {
@@ -44,8 +50,9 @@ impl HarmonyChatRenderer {
50 /// unset, the renderer uses the current local date with a UTC fallback.
51 /// - `VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS` moves leading instructions
52 /// into the system model identity when set to a non-zero integer.
47 - pub fn new() -> Result<Self> {
53 + pub fn new(default_template_kwargs: HashMap<String, Value>) -> Result<Self> {
54 Self::with_options(
55 + default_template_kwargs,
56 env_system_start_date(),
57 env_use_harmony_system_instructions(),
58 )
@@ -57,11 +64,13 @@ impl HarmonyChatRenderer {
64 /// Production code should call [`Self::new`] so the renderer observes the
65 /// same environment contract as the Python Harmony path.
66 pub fn with_options(
67 + default_template_kwargs: HashMap<String, Value>,
68 system_start_date: impl Into<String>,
69 use_system_instructions: bool,
70 ) -> Result<Self> {
71 Ok(Self {
72 encoding: harmony_encoding()?,
73 + default_template_kwargs,
74 options: Options {
75 system_start_date: system_start_date.into(),
76 use_system_instructions,
@@ -73,7 +82,11 @@ impl HarmonyChatRenderer {
82 ///
83 /// Harmony owns both prompt formatting and tokenization, so the Rust
84 /// frontend bypasses the generic HF tokenizer path for GPT-OSS input.
76 - fn render_token_ids(&self, request: &ChatRequest) -> Result<Vec<u32>> {
85 + fn render_token_ids(
86 + &self,
87 + request: &ChatRequest,
88 + effort: HarmonyReasoningEffort,
89 + ) -> Result<Vec<u32>> {
90 if request.has_multimodal() {
91 return Err(Error::UnsupportedMultimodalContent("image_url"));
92 }
@@ -86,7 +99,8 @@ impl HarmonyChatRenderer {
99 ));
100 }
101
89 - let messages = auto_drop_analysis_messages(to_harmony_messages(request, &self.options)?);
102 + let messages =
103 + auto_drop_analysis_messages(to_harmony_messages(request, &self.options, effort)?);
104 let conversation = Conversation::from_messages(messages);
105 // Pass `None` so oss-harmony does not apply its narrower built-in
106 // analysis-drop policy after the Rust-side Python-parity cleanup above.
@@ -114,10 +128,13 @@ impl ChatRenderer for HarmonyChatRenderer {
128 /// Render a chat request as [`Prompt::TokenIds`] with template kwargs echoed
129 /// for downstream accounting/debugging.
130 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
131 + let reasoning = ReasoningControl::resolve(request, &self.default_template_kwargs)?
132 + .fallback(ReasoningControl::enabled("medium"));
133 + let effort = to_harmony_reasoning_effort(reasoning.effort())?;
134 Ok(RenderedPrompt {
118 - prompt: Prompt::TokenIds(self.render_token_ids(request)?),
135 + prompt: Prompt::TokenIds(self.render_token_ids(request, effort)?),
136 media_order: None,
120 - effective_template_kwargs: request_template_kwargs(request),
137 + effective_template_kwargs: reasoning.template_kwargs(request),
138 })
139 }
140 }
@@ -127,12 +144,21 @@ impl ChatRenderer for HarmonyChatRenderer {
144 /// This adds the Harmony system/developer preamble, peels at most one leading
145 /// system/developer instruction message, and then lowers the remaining chat
146 /// history message-by-message.
130 -fn to_harmony_messages(request: &ChatRequest, options: &Options) -> Result<Vec<Message>> {
147 +fn to_harmony_messages(
148 + request: &ChatRequest,
149 + options: &Options,
150 + effort: HarmonyReasoningEffort,
151 +) -> Result<Vec<Message>> {
152 let (instructions, leading_developer_tools, remaining_messages) =
153 peel_leading_instructions(&request.messages)?;
154 let tool_call_names = tool_call_names(&request.messages);
134 - let mut messages =
135 - build_harmony_preamble(request, instructions, leading_developer_tools, options)?;
155 + let mut messages = build_harmony_preamble(
156 + request,
157 + instructions,
158 + leading_developer_tools,
159 + options,
160 + effort,
161 + )?;
162
163 for message in remaining_messages {
164 messages.extend(to_harmony_message(message, &tool_call_names, options)?);
@@ -177,12 +203,13 @@ fn build_harmony_preamble(
203 instructions: Option<String>,
204 leading_developer_tools: Option<&[ChatTool]>,
205 options: &Options,
206 + effort: HarmonyReasoningEffort,
207 ) -> Result<Vec<Message>> {
208 let mut messages = vec![Message::from_role_and_content(
209 Role::System,
210 system_content(
211 instructions.as_deref().filter(|_| options.use_system_instructions),
185 - request.chat_options.reasoning_effort,
212 + Some(effort),
213 &options.system_start_date,
214 )?,
215 )];
@@ -227,18 +254,18 @@ fn preamble_tool_descriptions(
254
255 /// Construct the Harmony system content for the request preamble.
256 ///
230 -/// Harmony defaults the reasoning effort to `medium` when none is provided, so
231 -/// this only sets an explicit effort after validating vLLM's request value.
257 +/// The preamble receives normalized effort; later system messages use Harmony's
258 +/// own default when their effort is omitted.
259 fn system_content(
260 instructions: Option<&str>,
234 - reasoning_effort: Option<ReasoningEffort>,
261 + reasoning_effort: Option<HarmonyReasoningEffort>,
262 system_start_date: &str,
263 ) -> Result<SystemContent> {
264 let mut content =
265 SystemContent::new().with_conversation_start_date(system_start_date.to_string());
266
267 if let Some(reasoning_effort) = reasoning_effort {
241 - content = content.with_reasoning_effort(to_harmony_reasoning_effort(reasoning_effort)?);
268 + content = content.with_reasoning_effort(reasoning_effort);
269 }
270
271 if let Some(instructions) = instructions.filter(|text| !text.is_empty()) {
@@ -445,20 +472,17 @@ fn to_tool_descriptions(tools: &[ChatTool]) -> Vec<ToolDescription> {
472 .collect()
473 }
474
448 -/// Map supported OpenAI reasoning-effort values onto Harmony's enum.
475 +/// Map supported reasoning-effort values onto Harmony's enum.
476 fn to_harmony_reasoning_effort(
450 - reasoning_effort: ReasoningEffort,
477 + reasoning_effort: Option<&EffortValue>,
478 ) -> Result<HarmonyReasoningEffort> {
452 - match reasoning_effort {
453 - ReasoningEffort::Low => Ok(HarmonyReasoningEffort::Low),
454 - ReasoningEffort::Medium => Ok(HarmonyReasoningEffort::Medium),
455 - ReasoningEffort::High => Ok(HarmonyReasoningEffort::High),
456 - ReasoningEffort::None
457 - | ReasoningEffort::Minimal
458 - | ReasoningEffort::XHigh
459 - | ReasoningEffort::Max => Err(Error::ChatTemplate(format!(
460 - "reasoning_effort={:?} is not supported by Harmony. Supported values are: low, medium, high.",
461 - reasoning_effort.as_str()
479 + match reasoning_effort.and_then(EffortValue::as_str) {
480 + Some("low") => Ok(HarmonyReasoningEffort::Low),
481 + Some("medium") => Ok(HarmonyReasoningEffort::Medium),
482 + Some("high") => Ok(HarmonyReasoningEffort::High),
483 + _ => Err(Error::InvalidReasoningEffort(format!(
484 + "reasoning_effort={} is not supported by Harmony. Supported values are: low, medium, high.",
485 + reasoning_effort.unwrap_or(&EffortValue::from("none"))
486 ))),
487 }
488 }
rust/src/chat/src/renderer/harmony/tests.rs
+41 -7
@@ -9,12 +9,11 @@ use thiserror_ext::AsReport as _;
9 use super::HarmonyChatRenderer;
10 use super::encoding::harmony_encoding;
11 use crate::ChatRenderer;
12 +use crate::EffortValue;
13 use crate::error::Error;
14 use crate::event::{AssistantContentBlock, AssistantToolCall};
15 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
15 -use crate::request::{
16 - ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort,
17 -};
16 +use crate::request::{ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode};
17
18 const PINNED_DATE: &str = "2025-06-28";
19
@@ -22,7 +21,7 @@ fn fixture_request(input_name: &str) -> ChatRequest {
21 fixture_chat_request(
22 &fixture_path(input_name),
23 FixtureRequestOptions {
25 - enable_thinking: Some(false),
24 + enable_thinking: None,
25 no_generation_prompt_when_last_assistant: false,
26 },
27 )
@@ -36,7 +35,8 @@ fn fixture_path(name: &str) -> PathBuf {
35 }
36
37 fn test_renderer(use_system_instructions: bool) -> HarmonyChatRenderer {
39 - HarmonyChatRenderer::with_options(PINNED_DATE, use_system_instructions).unwrap()
38 + HarmonyChatRenderer::with_options(Default::default(), PINNED_DATE, use_system_instructions)
39 + .unwrap()
40 }
41
42 fn render_token_ids(request: &ChatRequest) -> Vec<u32> {
@@ -138,14 +138,48 @@ fn drops_stale_analysis_fixture() {
138 #[test]
139 fn rejects_invalid_reasoning_effort() {
140 let mut request = ChatRequest::for_test();
141 - request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
141 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
142
143 let error = test_renderer(false).render(&request).unwrap_err();
144
145 - expect![[r#"chat template error: reasoning_effort="none" is not supported by Harmony. Supported values are: low, medium, high."#]]
145 + expect![[r#"reasoning_effort="none" is not supported by Harmony. Supported values are: low, medium, high."#]]
146 .assert_eq(&error.to_report_string());
147 }
148
149 +#[test]
150 +fn normalized_reasoning_respects_harmony_capability_and_deployment_effort() {
151 + let renderer = HarmonyChatRenderer::with_options(
152 + [("reasoning_effort".to_string(), serde_json::json!("low"))].into(),
153 + PINNED_DATE,
154 + false,
155 + )
156 + .unwrap();
157 + let mut request = ChatRequest::for_test();
158 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
159 + request
160 + .chat_options
161 + .template_kwargs
162 + .insert("enable_thinking".into(), serde_json::json!(true));
163 + let rendered = renderer.render(&request).unwrap();
164 + let prompt = harmony_encoding()
165 + .unwrap()
166 + .tokenizer()
167 + .decode_utf8(rendered.prompt.into_token_ids().unwrap())
168 + .unwrap();
169 + assert!(prompt.contains("Reasoning: low"));
170 + assert_eq!(
171 + rendered.effective_template_kwargs["reasoning_effort"],
172 + "low"
173 + );
174 + assert_eq!(rendered.effective_template_kwargs["enable_thinking"], true);
175 +
176 + request
177 + .chat_options
178 + .template_kwargs
179 + .insert("thinking".into(), serde_json::json!(false));
180 + assert!(renderer.render(&request).is_err());
181 +}
182 +
183 #[test]
184 fn rejects_unknown_tool_response_id() {
185 let request = ChatRequest {
rust/src/chat/src/renderer/hf/mod.rs
+94 -4
@@ -16,7 +16,7 @@ use self::format::{
16 ChatTemplateContentFormat, ChatTemplateContentFormatOption as ContentFormatOption,
17 };
18 use self::template::{CompiledChatTemplate, TemplateContext};
19 -use super::{ChatRenderer, RenderedPrompt, effective_template_kwargs};
19 +use super::{ChatRenderer, RenderedPrompt};
20 use crate::error::Result;
21 use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
22 use crate::{
@@ -33,6 +33,31 @@ pub use template::{load_chat_template, resolve_chat_template};
33
34 pub use self::format::ChatTemplateContentFormatOption;
35
36 +/// Extract the effective chat-template kwargs visible to the renderer from the request,
37 +/// using the provided defaults as the base.
38 +fn effective_template_kwargs(
39 + default_template_kwargs: &HashMap<String, JsonValue>,
40 + request: &ChatRequest,
41 +) -> HashMap<String, JsonValue> {
42 + let mut kwargs = default_template_kwargs.clone();
43 + kwargs.extend(request.chat_options.template_kwargs.clone());
44 +
45 + if let Some(reasoning_effort) = &request.chat_options.reasoning_effort {
46 + kwargs.insert(
47 + "reasoning_effort".to_string(),
48 + serde_json::json!(reasoning_effort),
49 + );
50 + if !request.chat_options.template_kwargs.contains_key("enable_thinking") {
51 + kwargs.insert(
52 + "enable_thinking".to_string(),
53 + serde_json::json!(reasoning_effort.as_str() != Some("none")),
54 + );
55 + }
56 + }
57 +
58 + kwargs
59 +}
60 +
61 /// Template-visible placeholder tokens per supported modality.
62 ///
63 /// A `None` token means the loaded model does not support that modality, and
@@ -580,9 +605,10 @@ mod tests {
605 use vllm_text::backend::hf::{HfSpecialTokens, NamedSpecialToken};
606
607 use super::{ChatTemplateContentFormatOption, HfChatRenderer, MultimodalRenderInfo};
608 + use crate::EffortValue;
609 use crate::request::{
610 ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice,
585 - GenerationPromptMode, ReasoningEffort, ResolvedToolContext,
611 + GenerationPromptMode, ResolvedToolContext,
612 };
613 use crate::{AssistantContentBlock, ChatRenderer, Error, Result};
614
@@ -1149,7 +1175,7 @@ mod tests {
1175 #[test]
1176 fn chat_template_reasoning_effort_overrides_template_kwargs() {
1177 let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
1152 - request.chat_options.reasoning_effort = Some(ReasoningEffort::Max);
1178 + request.chat_options.reasoning_effort = Some(EffortValue::from("max"));
1179 request.chat_options.template_kwargs.insert(
1180 "reasoning_effort".to_string(),
1181 Value::String("low".to_string()),
@@ -1181,7 +1207,7 @@ mod tests {
1207 #[test]
1208 fn chat_template_reasoning_effort_preserves_request_enable_thinking() {
1209 let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
1184 - request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
1210 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
1211 request
1212 .chat_options
1213 .template_kwargs
@@ -1207,6 +1233,70 @@ mod tests {
1233 );
1234 }
1235
1236 + #[test]
1237 + fn chat_template_preserves_typed_effort_values() {
1238 + for effort in [
1239 + serde_json::json!(37),
1240 + serde_json::json!(0.37),
1241 + serde_json::json!("custom"),
1242 + ] {
1243 + let mut request = sample_request(vec![ChatMessage::user("hello")]);
1244 + request.chat_options.reasoning_effort =
1245 + Some(serde_json::from_value(effort.clone()).unwrap());
1246 + request.chat_options.template_kwargs = [
1247 + ("reasoning_effort".to_string(), serde_json::json!("high")),
1248 + ("enable_thinking".to_string(), serde_json::json!(true)),
1249 + ]
1250 + .into();
1251 + let rendered = HfChatRenderer::new(
1252 + Some("{{ reasoning_effort|tojson }}|{{ enable_thinking }}".to_string()),
1253 + HashMap::new(),
1254 + ChatTemplateContentFormatOption::Auto,
1255 + )
1256 + .unwrap()
1257 + .render(&request)
1258 + .unwrap();
1259 + assert_eq!(rendered.prompt, Prompt::Text(format!("{effort}|True")));
1260 + assert_eq!(
1261 + rendered.effective_template_kwargs["reasoning_effort"],
1262 + effort
1263 + );
1264 + }
1265 + }
1266 +
1267 + #[test]
1268 + fn chat_template_keeps_native_control_conflicts_and_raw_effort_values() {
1269 + for effort in [
1270 + serde_json::json!(37),
1271 + serde_json::json!(null),
1272 + serde_json::json!("none"),
1273 + ] {
1274 + let mut request = sample_request(vec![ChatMessage::user("hello")]);
1275 + request.chat_options.template_kwargs = [
1276 + ("thinking".to_string(), serde_json::json!("custom-mode")),
1277 + ("enable_thinking".to_string(), serde_json::json!(true)),
1278 + ("reasoning_effort".to_string(), effort),
1279 + ]
1280 + .into();
1281 + let rendered = HfChatRenderer::new(
1282 + Some(
1283 + "{{ thinking }}|{{ enable_thinking }}|{{ reasoning_effort is none }}"
1284 + .to_string(),
1285 + ),
1286 + HashMap::new(),
1287 + ChatTemplateContentFormatOption::Auto,
1288 + )
1289 + .unwrap()
1290 + .render(&request)
1291 + .unwrap();
1292 + assert!(rendered.prompt.into_text().unwrap().starts_with("custom-mode|True|"));
1293 + assert_eq!(
1294 + rendered.effective_template_kwargs,
1295 + request.chat_options.template_kwargs
1296 + );
1297 + }
1298 + }
1299 +
1300 #[test]
1301 fn qwen3_template_omits_reasoning_for_historical_assistant_messages() {
1302 let request = sample_request(vec![
rust/src/chat/src/renderer/inkling/mod.rs
+47 -15
@@ -8,8 +8,10 @@ use thiserror_ext::AsReport as _;
8 use vllm_text::Prompt;
9 use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
10
11 -use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
11 +use super::{ChatRenderer, RenderedPrompt};
12 +use crate::EffortValue;
13 use crate::error::{Error, Result};
14 +use crate::reasoning::ReasoningControl;
15 use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool};
16 use crate::{AssistantContentBlock, AssistantToolCall};
17
@@ -35,6 +37,7 @@ const MAX_REASONING_EFFORT: f64 = 0.99;
37 pub struct InklingChatRenderer {
38 tokenizer: DynTokenizer,
39 special: InklingSpecialTokenIds,
40 + default_template_kwargs: HashMap<String, Value>,
41 }
42
43 #[derive(Debug, Clone, Copy)]
@@ -78,9 +81,16 @@ impl InklingSpecialTokenIds {
81 }
82
83 impl InklingChatRenderer {
81 - pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
84 + pub fn new(
85 + tokenizer: DynTokenizer,
86 + default_template_kwargs: HashMap<String, Value>,
87 + ) -> Result<Self> {
88 let special = InklingSpecialTokenIds::resolve(tokenizer.as_ref())?;
83 - Ok(Self { tokenizer, special })
89 + Ok(Self {
90 + tokenizer,
91 + special,
92 + default_template_kwargs,
93 + })
94 }
95
96 fn write_text_tokens(&self, out: &mut Vec<u32>, text: &str) -> Result<()> {
@@ -150,11 +160,6 @@ impl InklingChatRenderer {
160 }
161
162 fn write_reasoning_effort(&self, out: &mut Vec<u32>, effort: f64) -> Result<()> {
153 - if !(0.0..=MAX_REASONING_EFFORT).contains(&effort) {
154 - return Err(Error::ChatTemplate(format!(
155 - "Inkling reasoning_effort must be in [0.0, 0.99], got {effort}"
156 - )));
157 - }
163 let formatted = format!("{effort:.2}");
164 let effort = formatted.trim_end_matches('0').trim_end_matches('.');
165 let effort = if matches!(effort, "0" | "-0") {
@@ -294,11 +299,18 @@ impl ChatRenderer for InklingChatRenderer {
299
300 let mut out = Vec::new();
301 let mut tool_call_id_to_name = HashMap::new();
297 - let effective_template_kwargs = request_template_kwargs(request);
302 + let reasoning = resolve_reasoning(ReasoningControl::resolve(
303 + request,
304 + &self.default_template_kwargs,
305 + )?)?;
306 + let effective_template_kwargs = reasoning.template_kwargs(request);
307 let tools = rendered_tools(request);
308 self.write_tool_declarations(&mut out, &tools)?;
300 - let mut reasoning_effort =
301 - resolve_reasoning_effort(effective_template_kwargs.get("reasoning_effort"));
309 + let mut reasoning_effort = if reasoning.is_enabled() {
310 + reasoning.effort().and_then(EffortValue::as_f64)
311 + } else {
312 + Some(0.0)
313 + };
314
315 for message in &request.messages {
316 if !matches!(
@@ -352,12 +364,33 @@ impl ChatRenderer for InklingChatRenderer {
364 }
365 }
366
355 -fn resolve_reasoning_effort(value: Option<&Value>) -> Option<f64> {
367 +fn resolve_reasoning(control: ReasoningControl) -> Result<ReasoningControl> {
368 + let control = control.fallback(ReasoningControl::enabled("high"));
369 + if !control.is_enabled() {
370 + return Ok(control);
371 + }
372 + let effort = resolve_reasoning_effort(control.effort());
373 + if let Some(effort) = effort {
374 + if !(0.0..=MAX_REASONING_EFFORT).contains(&effort) {
375 + return Err(Error::InvalidReasoningEffort(format!(
376 + "Inkling reasoning_effort must be in [0.0, 0.99], got {effort}"
377 + )));
378 + }
379 + if effort == 0.0 {
380 + return Ok(ReasoningControl::Disabled);
381 + }
382 + }
383 + Ok(ReasoningControl::Enabled {
384 + effort: effort.map(EffortValue::try_from).transpose()?,
385 + })
386 +}
387 +
388 +fn resolve_reasoning_effort(value: Option<&EffortValue>) -> Option<f64> {
389 let Some(value) = value else {
390 return Some(0.9);
391 };
392 match value {
360 - Value::String(name) => match name.as_str() {
393 + EffortValue::String(name) => match name.as_str() {
394 "none" => Some(0.0),
395 "minimal" => Some(0.1),
396 "low" => Some(0.2),
@@ -366,8 +399,7 @@ fn resolve_reasoning_effort(value: Option<&Value>) -> Option<f64> {
399 "xhigh" | "max" => Some(0.99),
400 _ => None,
401 },
369 - Value::Number(number) => number.as_f64(),
370 - _ => None,
402 + EffortValue::Number(number) => number.as_f64(),
403 }
404 }
405
rust/src/chat/src/renderer/inkling/tests.rs
+58 -22
@@ -9,6 +9,7 @@ use serde_json::json;
9 use thiserror_ext::AsReport;
10 use vllm_text::tokenizer::Tokenizer;
11
12 +use crate::EffortValue;
13 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
14
15 use super::{
@@ -17,7 +18,7 @@ use super::{
18 MESSAGE_TOOL, MESSAGE_USER,
19 };
20 use crate::event::{AssistantContentBlock, AssistantToolCall};
20 -use crate::request::{ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort};
21 +use crate::request::{ChatMessage, ChatRequest, GenerationPromptMode};
22 use crate::{ChatRenderer, Error};
23
24 struct FixtureTokenizer;
@@ -87,7 +88,7 @@ impl Tokenizer for FixtureTokenizer {
88 }
89
90 fn renderer() -> InklingChatRenderer {
90 - InklingChatRenderer::new(Arc::new(FixtureTokenizer)).unwrap()
91 + InklingChatRenderer::new(Arc::new(FixtureTokenizer), Default::default()).unwrap()
92 }
93
94 fn render_token_ids(request: &ChatRequest) -> Vec<u32> {
@@ -105,7 +106,7 @@ fn fixture_request(name: &str) -> ChatRequest {
106
107 fn inkling_fixture_options() -> FixtureRequestOptions {
108 FixtureRequestOptions {
108 - enable_thinking: Some(false),
109 + enable_thinking: None,
110 no_generation_prompt_when_last_assistant: false,
111 }
112 }
@@ -206,13 +207,13 @@ fn renders_request_and_developer_tools_as_tool_declare() {
207 #[test]
208 fn renders_named_reasoning_effort_after_tool_declarations() {
209 for (effort, expected) in [
209 - (ReasoningEffort::None, "0.0"),
210 - (ReasoningEffort::Minimal, "0.1"),
211 - (ReasoningEffort::Low, "0.2"),
212 - (ReasoningEffort::Medium, "0.7"),
213 - (ReasoningEffort::High, "0.9"),
214 - (ReasoningEffort::XHigh, "0.99"),
215 - (ReasoningEffort::Max, "0.99"),
210 + (EffortValue::from("none"), "0.0"),
211 + (EffortValue::from("minimal"), "0.1"),
212 + (EffortValue::from("low"), "0.2"),
213 + (EffortValue::from("medium"), "0.7"),
214 + (EffortValue::from("high"), "0.9"),
215 + (EffortValue::from("xhigh"), "0.99"),
216 + (EffortValue::from("max"), "0.99"),
217 ] {
218 let mut request = fixture_request("tool_declare_input.json");
219 request.chat_options.reasoning_effort = Some(effort);
@@ -284,6 +285,43 @@ fn defaults_reasoning_effort_to_high() {
285 );
286 }
287
288 +#[test]
289 +fn normalized_mode_and_scalar_are_reflected_in_prompt_and_kwargs() {
290 + for (kwargs, expected, enabled) in [
291 + (
292 + json!({"enable_thinking": true, "reasoning_effort": "none"}),
293 + "0.9",
294 + true,
295 + ),
296 + (
297 + json!({"thinking": false, "reasoning_effort": 10}),
298 + "0.0",
299 + false,
300 + ),
301 + (json!({"reasoning_effort": 0}), "0.0", false),
302 + (json!({"reasoning_effort": null}), "0.9", true),
303 + (json!({"reasoning_effort": 0.37}), "0.37", true),
304 + ] {
305 + let mut request = ChatRequest::for_test();
306 + request.chat_options.template_kwargs = serde_json::from_value(kwargs).unwrap();
307 + let rendered = renderer().render(&request).unwrap();
308 + let prompt = render_symbolic_tokens(&rendered.prompt.into_token_ids().unwrap());
309 + assert!(prompt.contains(&format!("Thinking effort level: {expected}<|end_message|>")));
310 + assert_eq!(
311 + rendered.effective_template_kwargs["enable_thinking"],
312 + enabled
313 + );
314 + assert_eq!(
315 + rendered.effective_template_kwargs["reasoning_effort"],
316 + if enabled {
317 + json!(expected.parse::<f64>().unwrap())
318 + } else {
319 + json!("none")
320 + }
321 + );
322 + }
323 +}
324 +
325 #[test]
326 fn renders_numeric_reasoning_effort_template_kwarg() {
327 let mut request = ChatRequest::for_test();
@@ -301,19 +339,17 @@ fn renders_numeric_reasoning_effort_template_kwarg() {
339 }
340
341 #[test]
304 -fn ignores_unsupported_reasoning_effort_values() {
305 - for value in [json!(true), json!("invalid"), json!(null)] {
306 - let mut request = ChatRequest::for_test();
307 - request
308 - .chat_options
309 - .template_kwargs
310 - .insert("reasoning_effort".to_string(), value);
342 +fn ignores_unsupported_reasoning_effort_names() {
343 + let mut request = ChatRequest::for_test();
344 + request
345 + .chat_options
346 + .template_kwargs
347 + .insert("reasoning_effort".to_string(), json!("invalid"));
348
312 - assert_eq!(
313 - render_symbolic_tokens(&render_token_ids(&request)),
314 - "<|message_user|><|content_text|>test<|end_message|><|message_model|>"
315 - );
316 - }
349 + assert_eq!(
350 + render_symbolic_tokens(&render_token_ids(&request)),
351 + "<|message_user|><|content_text|>test<|end_message|><|message_model|>"
352 + );
353 }
354
355 #[test]
rust/src/chat/src/renderer/kimi_k3/encoding.rs
+20 -49
@@ -11,7 +11,9 @@ use serde_json::{Map, Value, json};
11 use vllm_tokenizer::Tokenizer;
12
13 use super::super::MediaPartSource;
14 +use crate::EffortValue;
15 use crate::error::{Error, Result};
16 +use crate::reasoning::ReasoningControl;
17 use crate::request::{
18 ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
19 };
@@ -76,9 +78,10 @@ impl<'a> K3TokenWriter<'a> {
78 pub(super) fn render_request_with_media_order(
79 request: &ChatRequest,
80 tokenizer: &dyn Tokenizer,
81 + reasoning: &ReasoningControl,
82 ) -> Result<(Vec<u32>, Vec<MediaPartSource>)> {
80 - let thinking = thinking_enabled(request)?;
81 - let thinking_effort = thinking.then(|| thinking_effort(request)).transpose()?;
83 + let thinking = reasoning.is_enabled();
84 + let thinking_effort = reasoning.effort().and_then(EffortValue::as_str);
85 let tools = request_tools(request);
86 let mut out = K3TokenWriter::new(tokenizer);
87
@@ -244,58 +247,26 @@ fn request_tools(request: &ChatRequest) -> &[ChatTool] {
247 request.initial_tools()
248 }
249
247 -fn thinking_enabled(request: &ChatRequest) -> Result<bool> {
248 - if let Some(thinking) = request.parse_template_bool("thinking")? {
249 - return Ok(thinking);
250 - }
251 - if let Some(enable_thinking) = request.parse_template_bool("enable_thinking")? {
252 - return Ok(enable_thinking);
253 - }
254 - if let Some(reasoning_effort) = request.chat_options.reasoning_effort {
255 - return Ok(reasoning_effort != crate::request::ReasoningEffort::None);
256 - }
257 - Ok(request
258 - .chat_options
259 - .template_kwargs
260 - .get("reasoning_effort")
261 - .and_then(Value::as_str)
262 - != Some("none"))
263 -}
264 -
265 -fn thinking_effort(request: &ChatRequest) -> Result<String> {
266 - let effort = if let Some(value) = request.chat_options.template_kwargs.get("thinking_effort") {
267 - value.as_str().ok_or_else(|| {
268 - Error::ChatTemplate(format!(
269 - "template kwarg `thinking_effort` must be a string, got {value}"
270 - ))
271 - })?
272 - } else if let Some(effort) = request.chat_options.reasoning_effort {
273 - if effort == crate::request::ReasoningEffort::None {
274 - DEFAULT_THINKING_EFFORT
275 - } else {
276 - effort.as_str()
277 - }
278 - } else if let Some(value) = request.chat_options.template_kwargs.get("reasoning_effort") {
250 +/// Resolve standard reasoning controls and validate K3's supported effort grades.
251 +pub(super) fn resolve_reasoning(
252 + request: &ChatRequest,
253 + defaults: &HashMap<String, Value>,
254 +) -> Result<ReasoningControl> {
255 + let reasoning = ReasoningControl::resolve(request, defaults)?
256 + .fallback(ReasoningControl::enabled(DEFAULT_THINKING_EFFORT));
257 + if let Some(value) = reasoning.effort() {
258 let effort = value.as_str().ok_or_else(|| {
280 - Error::ChatTemplate(format!(
281 - "template kwarg `reasoning_effort` must be a string, got {value}"
259 + Error::InvalidReasoningEffort(format!(
260 + "Kimi K3 reasoning_effort must be a string, got {value}"
261 ))
262 })?;
284 - if effort == "none" {
285 - DEFAULT_THINKING_EFFORT
286 - } else {
287 - effort
263 + if !VALID_THINKING_EFFORTS.contains(&effort) {
264 + return Err(Error::InvalidReasoningEffort(format!(
265 + "unsupported reasoning_effort={effort:?}; supported values are `low`, `high`, and `max`"
266 + )));
267 }
289 - } else {
290 - DEFAULT_THINKING_EFFORT
291 - };
292 -
293 - if !VALID_THINKING_EFFORTS.contains(&effort) {
294 - return Err(Error::ChatTemplate(format!(
295 - "unsupported thinking_effort={effort:?}; supported values are `low`, `high`, and `max`"
296 - )));
268 }
298 - Ok(effort.to_string())
269 + Ok(reasoning)
270 }
271
272 fn content_is_empty(content: &ChatContent) -> bool {
rust/src/chat/src/renderer/kimi_k3/mod.rs
+26 -6
@@ -7,10 +7,13 @@ mod encoding;
7 #[cfg(test)]
8 mod tests;
9
10 +use std::collections::HashMap;
11 +
12 +use serde_json::Value;
13 use vllm_text::Prompt;
14 use vllm_text::tokenizer::DynTokenizer;
15
13 -use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
16 +use super::{ChatRenderer, RenderedPrompt};
17 use crate::Result;
18 use crate::request::ChatRequest;
19
@@ -18,25 +21,42 @@ use crate::request::ChatRequest;
21 #[derive(Clone)]
22 pub struct KimiK3ChatRenderer {
23 tokenizer: DynTokenizer,
24 + default_template_kwargs: HashMap<String, Value>,
25 }
26
27 impl KimiK3ChatRenderer {
28 /// Create a Kimi K3 renderer.
25 - pub fn new(tokenizer: DynTokenizer) -> Self {
26 - Self { tokenizer }
29 + pub fn new(tokenizer: DynTokenizer, default_template_kwargs: HashMap<String, Value>) -> Self {
30 + Self {
31 + tokenizer,
32 + default_template_kwargs,
33 + }
34 }
35 }
36
37 impl ChatRenderer for KimiK3ChatRenderer {
38 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
39 request.validate()?;
33 - let (token_ids, media_order) =
34 - encoding::render_request_with_media_order(request, self.tokenizer.as_ref())?;
40 + let reasoning = encoding::resolve_reasoning(request, &self.default_template_kwargs)?;
41 + let mut effective_template_kwargs = reasoning.template_kwargs(request);
42 + // Export K3's native effort key from the resolved standard controls.
43 + // Input `thinking_effort` is ignored; clear it when reasoning is disabled.
44 + if let Some(effort) = reasoning.effort() {
45 + effective_template_kwargs
46 + .insert("thinking_effort".to_string(), serde_json::json!(effort));
47 + } else {
48 + effective_template_kwargs.remove("thinking_effort");
49 + }
50 + let (token_ids, media_order) = encoding::render_request_with_media_order(
51 + request,
52 + self.tokenizer.as_ref(),
53 + &reasoning,
54 + )?;
55
56 Ok(RenderedPrompt {
57 prompt: Prompt::TokenIds(token_ids),
58 media_order: Some(media_order),
39 - effective_template_kwargs: request_template_kwargs(request),
59 + effective_template_kwargs,
60 })
61 }
62 }
rust/src/chat/src/renderer/kimi_k3/tests.rs
+80 -19
@@ -15,11 +15,10 @@ use vllm_tokenizer::test_utils::TestTokenizer;
15
16 use super::KimiK3ChatRenderer;
17 use crate::ChatRenderer;
18 +use crate::EffortValue;
19 use crate::renderer::kimi_k3::encoding::{CLOSE, END_OF_MSG, IMAGE_PLACEHOLDER, OPEN, SEP};
20 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
20 -use crate::request::{
21 - ChatContentPart, ChatMessage, ChatTool, GenerationPromptMode, ReasoningEffort,
22 -};
21 +use crate::request::{ChatContentPart, ChatMessage, ChatTool, GenerationPromptMode};
22 use crate::{AssistantContentBlock, AssistantToolCall};
23
24 const OPEN_ID: u32 = 256;
@@ -37,8 +36,70 @@ fn test_tokenizer() -> TestTokenizer {
36 .with_special_token(IMAGE_PLACEHOLDER, MEDIA_ID)
37 }
38
39 +#[test]
40 +fn standard_effort_controls_prompt_and_exported_native_effort() {
41 + let tokenizer = Arc::new(test_tokenizer());
42 + for (kwargs, typed, defaults, effort) in [
43 + (json!({}), None, json!({}), "max"),
44 + (
45 + json!({}),
46 + Some(EffortValue::from("low")),
47 + json!({"reasoning_effort": "high"}),
48 + "low",
49 + ),
50 + (
51 + json!({"enable_thinking": true, "reasoning_effort": "none"}),
52 + None,
53 + json!({"reasoning_effort": "low"}),
54 + "low",
55 + ),
56 + (
57 + json!({}),
58 + Some(EffortValue::from("none")),
59 + json!({"reasoning_effort": "low"}),
60 + "none",
61 + ),
62 + (
63 + json!({"thinking": true}),
64 + Some(EffortValue::from("none")),
65 + json!({}),
66 + "max",
67 + ),
68 + ] {
69 + let renderer =
70 + KimiK3ChatRenderer::new(tokenizer.clone(), serde_json::from_value(defaults).unwrap());
71 + let mut request = crate::ChatRequest::for_test();
72 + request.chat_options.reasoning_effort = typed;
73 + request.chat_options.template_kwargs = serde_json::from_value(kwargs).unwrap();
74 + let rendered = renderer.render(&request).unwrap();
75 + let prompt = tokenizer.decode(&rendered.prompt.into_token_ids().unwrap(), false).unwrap();
76 + let enabled = effort != "none";
77 + assert_eq!(prompt.ends_with("<|open|>think<|sep|>"), enabled);
78 + assert_eq!(
79 + rendered.effective_template_kwargs["reasoning_effort"],
80 + effort
81 + );
82 + assert_eq!(
83 + rendered.effective_template_kwargs["enable_thinking"],
84 + enabled
85 + );
86 + if enabled {
87 + assert!(prompt.contains(&format!("thinking_effort={effort}")));
88 + assert_eq!(
89 + rendered.effective_template_kwargs["thinking_effort"],
90 + effort
91 + );
92 + } else {
93 + assert!(!rendered.effective_template_kwargs.contains_key("thinking_effort"));
94 + }
95 + }
96 +}
97 +
98 fn render_token_ids(request: &crate::request::ChatRequest, tokenizer: DynTokenizer) -> Vec<u32> {
41 - let prompt = KimiK3ChatRenderer::new(tokenizer).render(request).unwrap().prompt;
99 + let prompt = KimiK3ChatRenderer::new(tokenizer, Default::default())
100 + .render(request)
101 + .unwrap()
102 + .prompt;
103 let Prompt::TokenIds(token_ids) = prompt else {
104 panic!("kimi k3 renderer should return token IDs")
105 };
@@ -67,7 +128,11 @@ fn kimi_k3_fixture_options() -> FixtureRequestOptions {
128
129 fn assert_golden(name: &str) {
130 let input_name = format!("{name}_input.json");
70 - let request = fixture_chat_request(&fixture_path(&input_name), kimi_k3_fixture_options());
131 + let mut request = fixture_chat_request(&fixture_path(&input_name), kimi_k3_fixture_options());
132 + // Reference fixtures use encoder-native keys; Rust accepts standard effort input.
133 + if let Some(effort) = request.chat_options.template_kwargs.remove("thinking_effort") {
134 + request.chat_options.reasoning_effort = Some(EffortValue::try_from(effort).unwrap());
135 + }
136 let rendered = render_request(&request);
137 expect_file![format!("fixtures/{name}_output.txt")].assert_eq(&rendered);
138 }
@@ -310,7 +375,7 @@ fn media_order_follows_reordered_tool_results() {
375 ),
376 ];
377 request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
313 - let renderer = KimiK3ChatRenderer::new(Arc::new(test_tokenizer()));
378 + let renderer = KimiK3ChatRenderer::new(Arc::new(test_tokenizer()), Default::default());
379
380 let rendered = renderer.render(&request).unwrap();
381 let media_order = rendered.media_order.unwrap();
@@ -371,19 +436,18 @@ fn translates_standard_thinking_kwargs() {
436 }
437
438 #[test]
374 -fn native_k3_kwargs_take_precedence() {
439 +fn thinking_alias_takes_precedence() {
440 let mut request = crate::request::ChatRequest::for_test();
441 request.chat_options.template_kwargs.extend([
442 ("thinking".to_string(), json!(true)),
443 ("enable_thinking".to_string(), json!(false)),
379 - ("thinking_effort".to_string(), json!("low")),
444 ("reasoning_effort".to_string(), json!("high")),
445 ]);
446
447 let rendered = render_request(&request);
448
385 - assert!(rendered.contains("thinking_effort=low"));
386 - assert!(!rendered.contains("thinking_effort=high"));
449 + assert!(rendered.contains("thinking_effort=high"));
450 + assert!(rendered.ends_with("<|open|>think<|sep|>"));
451 }
452
453 #[test]
@@ -431,7 +495,7 @@ fn enable_thinking_true_overrides_standard_none() {
495 #[test]
496 fn typed_none_disables_thinking() {
497 let mut request = crate::request::ChatRequest::for_test();
434 - request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
498 + request.chat_options.reasoning_effort = Some(EffortValue::from("none"));
499
500 let rendered = render_request(&request);
501
@@ -440,20 +504,17 @@ fn typed_none_disables_thinking() {
504 }
505
506 #[test]
443 -fn rejects_removed_medium_thinking_effort() {
507 +fn rejects_unsupported_standard_reasoning_effort() {
508 let mut request = crate::request::ChatRequest::for_test();
445 - request
446 - .chat_options
447 - .template_kwargs
448 - .insert("thinking_effort".to_string(), json!("medium"));
509 + request.chat_options.reasoning_effort = Some(EffortValue::from("medium"));
510
450 - let error = KimiK3ChatRenderer::new(Arc::new(test_tokenizer()))
511 + let error = KimiK3ChatRenderer::new(Arc::new(test_tokenizer()), Default::default())
512 .render(&request)
513 .unwrap_err();
514
515 expect![[r#"
455 - ChatTemplate(
456 - "unsupported thinking_effort=\"medium\"; supported values are `low`, `high`, and `max`",
516 + InvalidReasoningEffort(
517 + "unsupported reasoning_effort=\"medium\"; supported values are `low`, `high`, and `max`",
518 )
519 "#]]
520 .assert_debug_eq(&error);
rust/src/chat/src/renderer/mod.rs
+2 -32
@@ -4,11 +4,11 @@
4 use std::collections::HashMap;
5 use std::sync::Arc;
6
7 -use serde_json::{Value, json};
7 +use serde_json::Value;
8 use vllm_text::Prompt;
9
10 use crate::error::Result;
11 -use crate::request::{ChatRequest, ReasoningEffort};
11 +use crate::request::ChatRequest;
12
13 mod deepseek;
14 pub mod deepseek_v32;
@@ -62,33 +62,3 @@ pub trait ChatRenderer: Send + Sync {
62
63 /// Shared trait-object form of [`ChatRenderer`].
64 pub type DynChatRenderer = Arc<dyn ChatRenderer>;
65 -
66 -/// Extract the effective chat-template kwargs visible to the renderer from the request,
67 -/// using the provided defaults as the base.
68 -pub(crate) fn effective_template_kwargs(
69 - default_template_kwargs: &HashMap<String, Value>,
70 - request: &ChatRequest,
71 -) -> HashMap<String, Value> {
72 - let mut kwargs = default_template_kwargs.clone();
73 - kwargs.extend(request.chat_options.template_kwargs.clone());
74 -
75 - if let Some(reasoning_effort) = request.chat_options.reasoning_effort {
76 - kwargs.insert(
77 - "reasoning_effort".to_string(),
78 - Value::String(reasoning_effort.as_str().to_string()),
79 - );
80 - if !request.chat_options.template_kwargs.contains_key("enable_thinking") {
81 - kwargs.insert(
82 - "enable_thinking".to_string(),
83 - json!(reasoning_effort != ReasoningEffort::None),
84 - );
85 - }
86 - }
87 -
88 - kwargs
89 -}
90 -
91 -/// Extract the effective chat-template kwargs visible to the renderer from the request.
92 -pub(crate) fn request_template_kwargs(request: &ChatRequest) -> HashMap<String, Value> {
93 - effective_template_kwargs(&HashMap::new(), request)
94 -}
rust/src/chat/src/renderer/test_utils.rs
+3 -2
@@ -8,10 +8,11 @@ use std::path::Path;
8 use serde::Deserialize;
9 use serde_json::Value;
10
11 +use crate::EffortValue;
12 use crate::event::{AssistantContentBlock, AssistantToolCall};
13 use crate::request::{
14 ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
14 - GenerationPromptMode, ReasoningEffort, ResolvedToolContext,
15 + GenerationPromptMode, ResolvedToolContext,
16 };
17
18 /// Options for constructing a [`ChatRequest`] from a fixture file.
@@ -47,7 +48,7 @@ pub(crate) struct FixtureRequest {
48 tools: Vec<FixtureTool>,
49 messages: Vec<FixtureMessage>,
50 add_generation_prompt: Option<bool>,
50 - reasoning_effort: Option<ReasoningEffort>,
51 + reasoning_effort: Option<EffortValue>,
52 /// Standard response format passed to model-specific renderers.
53 #[serde(default)]
54 response_format: Option<Value>,
rust/src/chat/src/request.rs
+6 -106
@@ -11,9 +11,9 @@ pub use vllm_parser::tool::Tool as ChatTool;
11 use vllm_text::TextDecodeOptions;
12 pub use vllm_text::{PromptTruncation, SamplingParams};
13
14 -use crate::AssistantMessageExt;
14 use crate::error::{Error, Result};
15 use crate::event::{AssistantContentBlock, AssistantMessage};
16 +use crate::{AssistantMessageExt, EffortValue};
17
18 /// Role label for one text-only chat message.
19 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -362,33 +362,6 @@ pub enum GenerationPromptMode {
362 NoGenerationPrompt,
363 }
364
365 -/// Effort level for reasoning models.
366 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367 -#[serde(rename_all = "lowercase")]
368 -pub enum ReasoningEffort {
369 - None,
370 - Minimal,
371 - Low,
372 - Medium,
373 - High,
374 - XHigh,
375 - Max,
376 -}
377 -
378 -impl ReasoningEffort {
379 - pub fn as_str(self) -> &'static str {
380 - match self {
381 - Self::None => "none",
382 - Self::Minimal => "minimal",
383 - Self::Low => "low",
384 - Self::Medium => "medium",
385 - Self::High => "high",
386 - Self::XHigh => "xhigh",
387 - Self::Max => "max",
388 - }
389 - }
390 -}
391 -
365 /// Chat-template-related request options.
366 ///
367 /// These are the small subset of chat controls that currently affect prompt
@@ -404,8 +377,10 @@ pub struct ChatOptions {
377 /// used instead of the model's default chat template.
378 pub chat_template: Option<String>,
379
407 - /// Effort level exposed to chat templates for reasoning models.
408 - pub reasoning_effort: Option<ReasoningEffort>,
380 + /// Model-specific reasoning effort, typically `none`, `minimal`, `low`,
381 + /// `medium`, `high`, `xhigh`, or `max`. Supported names and numeric ranges
382 + /// are validated by the selected renderer or HF template.
383 + pub reasoning_effort: Option<EffortValue>,
384
385 /// Standard response format available to model-specific renderers.
386 #[serde(default)]
@@ -673,29 +648,6 @@ impl ChatRequest {
648 self.tool_context.parsing_enabled()
649 }
650
676 - /// Return the request-level thinking toggle when explicitly requested.
677 - ///
678 - /// We currently accept the two request kwargs `thinking` and
679 - /// `enable_thinking`. Both must be booleans when present. If both are
680 - /// present, they must have the same value. If neither key is provided,
681 - /// return `None`.
682 - pub(crate) fn enable_thinking(&self) -> Result<Option<bool>> {
683 - let thinking = self.parse_template_bool("thinking")?;
684 - let enable_thinking = self.parse_template_bool("enable_thinking")?;
685 -
686 - match (thinking, enable_thinking) {
687 - (None, None) => Ok(None),
688 - (Some(thinking), Some(enable_thinking)) if thinking != enable_thinking => {
689 - Err(Error::ChatTemplate(
690 - "template kwargs `thinking` and `enable_thinking` must match when both are set"
691 - .to_string(),
692 - ))
693 - }
694 - (Some(thinking), _) => Ok(Some(thinking)),
695 - (None, Some(enable_thinking)) => Ok(Some(enable_thinking)),
696 - }
697 - }
698 -
651 pub(crate) fn parse_template_bool(&self, key: &str) -> Result<Option<bool>> {
652 match self.chat_options.template_kwargs.get(key) {
653 None => Ok(None),
@@ -726,7 +678,7 @@ mod tests {
678 use serde_json::{json, to_value};
679
680 use super::{
729 - ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice,
681 + ChatContent, ChatContentPart, ChatMessage, ChatRole, ChatTool, ChatToolChoice,
682 ResolvedToolContext,
683 };
684 use crate::Error;
@@ -919,56 +871,4 @@ mod tests {
871 Error::ToolChoiceFunctionNotFound { name } if name == "missing"
872 ));
873 }
922 -
923 - #[test]
924 - fn enable_thinking_is_none_when_no_kwargs_are_present() {
925 - let request = ChatRequest::for_test();
926 - assert_eq!(request.enable_thinking().unwrap(), None);
927 - }
928 -
929 - #[test]
930 - fn enable_thinking_accepts_matching_duplicate_kwargs() {
931 - let mut request = ChatRequest::for_test();
932 - request.chat_options.template_kwargs.insert("thinking".to_string(), json!(true));
933 - request
934 - .chat_options
935 - .template_kwargs
936 - .insert("enable_thinking".to_string(), json!(true));
937 -
938 - assert_eq!(request.enable_thinking().unwrap(), Some(true));
939 - }
940 -
941 - #[test]
942 - fn enable_thinking_rejects_non_boolean_kwargs() {
943 - let mut request = ChatRequest::for_test();
944 - request
945 - .chat_options
946 - .template_kwargs
947 - .insert("thinking".to_string(), json!("yes"));
948 -
949 - assert!(matches!(
950 - request.enable_thinking(),
951 - Err(Error::ChatTemplate(message))
952 - if message.contains("`thinking` must be a boolean")
953 - ));
954 - }
955 -
956 - #[test]
957 - fn enable_thinking_rejects_conflicting_duplicate_kwargs() {
958 - let mut request = ChatRequest::for_test();
959 - request
960 - .chat_options
961 - .template_kwargs
962 - .insert("thinking".to_string(), json!(false));
963 - request
964 - .chat_options
965 - .template_kwargs
966 - .insert("enable_thinking".to_string(), json!(true));
967 -
968 - assert!(matches!(
969 - request.enable_thinking(),
970 - Err(Error::ChatTemplate(message))
971 - if message.contains("`thinking` and `enable_thinking` must match")
972 - ));
973 - }
874 }
rust/src/server/src/error.rs
+16 -10
@@ -175,16 +175,22 @@ mod tests {
175 }
176
177 #[test]
178 - fn invalid_reasoning_effort_maps_to_invalid_request() {
179 - let error = vllm_chat::Error::InvalidReasoningEffort(
180 - "DeepSeek V4.1 reasoning_effort must be within [1, 100]".to_string(),
181 - );
182 - let api_error = chat_submit_error("failed to submit chat request", error);
183 - assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
184 - assert_eq!(
185 - api_error.to_error_response().error.error_type,
186 - "invalid_request_error"
187 - );
178 + fn invalid_reasoning_parameters_map_to_invalid_request() {
179 + for error in [
180 + vllm_chat::Error::InvalidReasoningEffort(
181 + "DeepSeek V4.1 reasoning_effort must be within [1, 100]".to_string(),
182 + ),
183 + vllm_chat::Error::InvalidReasoningControl {
184 + message: "template kwarg `thinking` must be a boolean".to_string(),
185 + },
186 + ] {
187 + let api_error = chat_submit_error("failed to submit chat request", error);
188 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
189 + assert_eq!(
190 + api_error.to_error_response().error.error_type,
191 + "invalid_request_error"
192 + );
193 + }
194 }
195
196 #[test]
rust/src/server/src/routes/openai/chat_completions/convert.rs
+46 -2
@@ -180,7 +180,7 @@ pub(super) fn prepare_chat_request(
180 chat_options: ChatOptions {
181 generation_prompt_mode,
182 chat_template: request.chat_template,
183 - reasoning_effort: request.reasoning_effort,
183 + reasoning_effort: request.reasoning_effort.map(|effort| effort.as_str().into()),
184 response_format,
185 template_kwargs,
186 },
@@ -488,6 +488,50 @@ mod tests {
488 }
489 }
490
491 + #[test]
492 + fn chat_http_reasoning_effort_preserves_omission_none_and_kwargs() {
493 + for effort in [None, Some(json!(null))].into_iter().chain(
494 + ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
495 + .map(|name| Some(json!(name))),
496 + ) {
497 + let kwargs = json!({"reasoning_effort": 37, "thinking": true});
498 + let mut value = json!({
499 + "messages": [{"role": "user", "content": "hello"}],
500 + "chat_template_kwargs": kwargs,
501 + });
502 + if let Some(effort) = &effort {
503 + value["reasoning_effort"] = effort.clone();
504 + }
505 + let request: ChatCompletionRequest = serde_json::from_value(value).unwrap();
506 + let prepared = prepare_chat_request(
507 + request,
508 + &served(&["test-model"]),
509 + request_context(&HeaderMap::new(), None),
510 + )
511 + .unwrap();
512 + let options = prepared.chat_request.chat_options;
513 + assert_eq!(
514 + serde_json::to_value(options.reasoning_effort).unwrap(),
515 + effort.unwrap_or(serde_json::Value::Null)
516 + );
517 + assert_eq!(
518 + serde_json::to_value(options.template_kwargs).unwrap(),
519 + kwargs
520 + );
521 + }
522 + }
523 +
524 + #[test]
525 + fn chat_http_reasoning_effort_rejects_model_extensions_at_top_level() {
526 + for effort in [json!(37), json!("custom")] {
527 + let request = json!({
528 + "messages": [{"role": "user", "content": "hello"}],
529 + "reasoning_effort": effort,
530 + });
531 + assert!(serde_json::from_value::<ChatCompletionRequest>(request).is_err());
532 + }
533 + }
534 +
535 #[test]
536 fn chat_http_request_defaults_missing_or_null_model() {
537 for model in [None, Some(serde_json::Value::Null)] {
@@ -747,7 +791,7 @@ mod tests {
791 );
792
793 let tokenizer = Arc::new(TestTokenizer::new());
750 - let prompt = KimiK3ChatRenderer::new(tokenizer.clone())
794 + let prompt = KimiK3ChatRenderer::new(tokenizer.clone(), Default::default())
795 .render(&prepared.chat_request)
796 .expect("Kimi K3 rendering succeeds")
797 .prompt;
rust/src/server/src/routes/openai/chat_completions/types.rs
+4 -4
@@ -9,15 +9,15 @@ use serde::{Deserialize, Serialize};
9 use serde_json::Value;
10 use serde_with::SerializeDisplay;
11 use validator::Validate;
12 -use vllm_chat::ReasoningEffort;
12 use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
13 use vllm_text::TruncationSide;
14
15 use crate::routes::openai::utils::structured_outputs::ResponseFormat;
16 use crate::routes::openai::utils::types::{
18 - ChatLogProbs, ChatMessage, Normalizable, PromptLogprobs, StreamOptions, StreamResponseEnvelope,
19 - StringOrArray, Tool, ToolCall, ToolCallDelta, ToolChoice, Usage, default_true,
20 - deserialize_request_top_k, validate_messages, validate_stop, validate_top_p_value,
17 + ChatLogProbs, ChatMessage, Normalizable, PromptLogprobs, ReasoningEffort, StreamOptions,
18 + StreamResponseEnvelope, StringOrArray, Tool, ToolCall, ToolCallDelta, ToolChoice, Usage,
19 + default_true, deserialize_request_top_k, validate_messages, validate_stop,
20 + validate_top_p_value,
21 };
22
23 /// vLLM-compatible request type for the Chat Completions API.
rust/src/server/src/routes/openai/chat_completions/validate.rs
+1 -1
@@ -125,8 +125,8 @@ fn validate_function_tools(tools: &[Tool], param: &'static str) -> Result<(), Ap
125 mod tests {
126 use std::collections::HashMap;
127
128 + use crate::routes::openai::utils::types::ReasoningEffort;
129 use serde_json::json;
129 - use vllm_chat::ReasoningEffort;
130
131 use super::validate_request_compat;
132 use crate::routes::openai::chat_completions::types::ChatCompletionRequest;
rust/src/server/src/routes/openai/utils/types.rs
+30
@@ -39,6 +39,36 @@ where
39 }
40 }
41
42 +/// Effort level for reasoning models.
43 +///
44 +/// Fixed OpenAI HTTP request grades. Request conversion maps these names to
45 +/// the model-independent `vllm_chat::EffortValue` before renderer validation.
46 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47 +#[serde(rename_all = "lowercase")]
48 +pub enum ReasoningEffort {
49 + None,
50 + Minimal,
51 + Low,
52 + Medium,
53 + High,
54 + XHigh,
55 + Max,
56 +}
57 +
58 +impl ReasoningEffort {
59 + pub fn as_str(self) -> &'static str {
60 + match self {
61 + Self::None => "none",
62 + Self::Minimal => "minimal",
63 + Self::Low => "low",
64 + Self::Medium => "medium",
65 + Self::High => "high",
66 + Self::XHigh => "xhigh",
67 + Self::Max => "max",
68 + }
69 + }
70 +}
71 +
72 // ============================================================================
73 // String/Array Utilities
74 // ============================================================================