[Rust Frontend] Support thinking_token_budget for chat and completions (#46137)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: RickyChen / 陳昭儒 <ricky.chen@infinirc.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Chao-Ju Chen committed Jun 22, 2026 at 16:00 UTC 80abe0de7d20523e465597d823a40ab4a29df20a
13 files changed +178 -7
rust/src/engine-core-client/src/protocol/mod.rs
+7
@@ -277,6 +277,12 @@ pub struct EngineCoreSamplingParams {
277 pub max_tokens: u32,
278 /// Minimum number of tokens to generate before EOS or stop-token handling.
279 pub min_tokens: u32,
280 + /// Maximum number of reasoning ("thinking") tokens to emit before the
281 + /// reasoning section is force-closed. `None` means unlimited; the
282 + /// user-facing `-1` sentinel is normalized to `None` by the frontend before
283 + /// reaching this DTO, so only non-negative values are sent. Enforced
284 + /// engine-side (and only when a reasoning parser is configured).
285 + pub thinking_token_budget: Option<u64>,
286 /// Number of log probabilities to return per generated token.
287 ///
288 /// `None` disables sample logprobs. `-1` requests the full vocabulary.
@@ -345,6 +351,7 @@ impl EngineCoreSamplingParams {
351 seed: None,
352 max_tokens: 65536,
353 min_tokens: 0,
354 + thinking_token_budget: None,
355 logprobs: None,
356 prompt_logprobs: None,
357 min_p: 0.0,
rust/src/engine-core-client/src/tests/client.rs
+2
@@ -150,6 +150,7 @@ fn sample_request_with_id(request_id: &str) -> EngineCoreRequest {
150 top_k: 8,
151 max_tokens: 32,
152 min_tokens: 1,
153 + thinking_token_budget: Some(256),
154 stop_token_ids: vec![151643],
155 eos_token_id: Some(151645),
156 all_stop_token_ids: BTreeSet::from([151643, 151645]),
@@ -2502,6 +2503,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2503 seed: None,
2504 max_tokens: 16,
2505 min_tokens: 0,
2506 + thinking_token_budget: None,
2507 logprobs: None,
2508 prompt_logprobs: None,
2509 min_p: 0.0,
rust/src/engine-core-client/src/tests/python_compat.py
+2
@@ -39,6 +39,7 @@ class EngineCoreSamplingParams(msgspec.Struct, dict=True, omit_defaults=True):
39 seed: int | None = None
40 max_tokens: int = 16
41 min_tokens: int = 0
42 + thinking_token_budget: int | None = None
43 min_p: float = 0.0
44 frequency_penalty: float = 0.0
45 presence_penalty: float = 0.0
@@ -122,6 +123,7 @@ request = EngineCoreRequest(
123 seed=None,
124 max_tokens=32,
125 min_tokens=1,
126 + thinking_token_budget=256,
127 min_p=0.0,
128 frequency_penalty=0.0,
129 presence_penalty=0.0,
rust/src/server/src/error.rs
+13
@@ -103,6 +103,7 @@ fn is_request_validation_error(error: &vllm_text::Error) -> bool {
103 | vllm_text::Error::EmptyPromptTokenIds { .. }
104 | vllm_text::Error::Logprobs(_)
105 | vllm_text::Error::OutOfVocab(_)
106 + | vllm_text::Error::InvalidThinkingTokenBudget
107 // An empty tokenized prompt detected later, at request prepare
108 // time, surfaces through the transparent Llm wrapper.
109 | vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds { .. })
@@ -127,6 +128,18 @@ mod tests {
128 assert!(response.error.message.contains("9000"));
129 }
130
131 + #[test]
132 + fn invalid_thinking_token_budget_maps_to_invalid_request() {
133 + let api_error = text_submit_error(
134 + "failed to submit completion request",
135 + vllm_text::Error::InvalidThinkingTokenBudget,
136 + );
137 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
138 + let response = api_error.to_error_response();
139 + assert_eq!(response.error.error_type, "invalid_request_error");
140 + assert!(response.error.message.contains("thinking_token_budget"));
141 + }
142 +
143 #[test]
144 fn chat_wrapped_prompt_too_long_maps_to_invalid_request() {
145 let error = vllm_chat::Error::Text(vllm_text::Error::PromptTooLong {
rust/src/server/src/routes/inference/generate/convert.rs
+27
@@ -150,6 +150,33 @@ mod tests {
150 );
151 }
152
153 + #[test]
154 + fn prepare_generate_request_forwards_thinking_token_budget() {
155 + let request: GenerateRequest = serde_json::from_value(json!({
156 + "model": "Qwen/Qwen1.5-0.5B-Chat",
157 + "token_ids": [11, 22, 33],
158 + "sampling_params": {
159 + "thinking_token_budget": 64
160 + }
161 + }))
162 + .expect("parse request");
163 +
164 + let prepared = prepare_generate_request(
165 + request,
166 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
167 + ResolvedRequestContext::default(),
168 + )
169 + .expect("prepare");
170 +
171 + // The raw inference route shares `vllm_text::SamplingParams`, so the
172 + // field is carried through to lowering exactly like the OpenAI routes
173 + // (normalization/validation then happens in `lower_sampling_params`).
174 + assert_eq!(
175 + prepared.text_request.sampling_params.thinking_token_budget,
176 + Some(64)
177 + );
178 + }
179 +
180 #[test]
181 fn prepare_generate_request_gates_continuous_usage_on_include_usage() {
182 let request: GenerateRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/chat_completions/convert.rs
+26
@@ -115,6 +115,7 @@ pub(super) fn prepare_chat_request(
115 seed: request.seed,
116 max_tokens: request.max_completion_tokens,
117 min_tokens: request.min_tokens,
118 + thinking_token_budget: request.thinking_token_budget,
119 logprobs: request.logprobs.then_some(top_logprobs),
120 prompt_logprobs,
121 min_p: request.min_p,
@@ -613,6 +614,31 @@ mod tests {
614 assert_eq!(prepared.chat_request.sampling_params, expected);
615 }
616
617 + #[test]
618 + fn prepare_chat_request_passes_through_thinking_token_budget() {
619 + let prepare = |budget: Option<i64>| {
620 + prepare_chat_request(
621 + ChatCompletionRequest {
622 + thinking_token_budget: budget,
623 + ..base_request()
624 + },
625 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
626 + ResolvedRequestContext::default(),
627 + )
628 + .expect("request is valid")
629 + .chat_request
630 + .sampling_params
631 + .thinking_token_budget
632 + };
633 +
634 + // The convert layer forwards the raw value verbatim (including the `-1`
635 + // "unlimited" sentinel); normalization/validation happens during
636 + // lowering (see `vllm_text::lower`).
637 + assert_eq!(prepare(Some(64)), Some(64));
638 + assert_eq!(prepare(Some(-1)), Some(-1));
639 + assert_eq!(prepare(None), None);
640 + }
641 +
642 #[test]
643 fn prepare_chat_request_accepts_developer_messages() {
644 let request = ChatCompletionRequest {
rust/src/server/src/routes/openai/chat_completions/types.rs
+4 -2
@@ -165,8 +165,10 @@ pub struct ChatCompletionRequest {
165 pub bad_words: Option<Vec<String>>,
166
167 // -------- Extra vLLM Parameters --------
168 - /// Token budget for reasoning/thinking
169 - pub thinking_token_budget: Option<u32>,
168 + /// Token budget for reasoning/thinking. Accepts a non-negative integer, or
169 + /// `-1` for unlimited (mirroring the Python frontend, which normalizes `-1`
170 + /// to "no budget").
171 + pub thinking_token_budget: Option<i64>,
172
173 /// Whether to include reasoning content in the response
174 #[serde(default = "default_true")]
rust/src/server/src/routes/openai/chat_completions/validate.rs
-5
@@ -108,11 +108,6 @@ pub(super) fn validate_request_compat(
108 "truncate_prompt_tokens",
109 "truncate_prompt_tokens is not supported.",
110 )?;
111 - reject_non_default(
112 - request.thinking_token_budget.as_ref(),
113 - "thinking_token_budget",
114 - "thinking_token_budget is not supported.",
115 - )?;
111 reject_non_default(
112 request.media_io_kwargs.as_ref(),
113 "media_io_kwargs",
rust/src/server/src/routes/openai/completions/convert.rs
+29
@@ -108,6 +108,7 @@ pub(super) fn prepare_completion_request(
108 seed: request.seed,
109 max_tokens,
110 min_tokens: request.min_tokens,
111 + thinking_token_budget: request.thinking_token_budget,
112 logprobs,
113 prompt_logprobs,
114 min_p: request.min_p,
@@ -266,6 +267,34 @@ mod tests {
267 assert!(!prepared.text_request.decode_options.skip_special_tokens);
268 }
269
270 + #[test]
271 + fn prepare_completion_request_passes_through_thinking_token_budget() {
272 + let prepare = |budget: serde_json::Value| {
273 + let request: CompletionRequest = serde_json::from_value(json!({
274 + "model": "Qwen/Qwen1.5-0.5B-Chat",
275 + "prompt": "hello",
276 + "thinking_token_budget": budget,
277 + }))
278 + .expect("parse request");
279 + prepare_completion_request(
280 + request,
281 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
282 + ResolvedRequestContext::default(),
283 + )
284 + .expect("prepare")
285 + .text_request
286 + .sampling_params
287 + .thinking_token_budget
288 + };
289 +
290 + // The convert layer forwards the raw value verbatim (including the `-1`
291 + // "unlimited" sentinel); normalization/validation happens during
292 + // lowering (see `vllm_text::lower`).
293 + assert_eq!(prepare(json!(64)), Some(64));
294 + assert_eq!(prepare(json!(-1)), Some(-1));
295 + assert_eq!(prepare(json!(null)), None);
296 + }
297 +
298 #[test]
299 fn prepare_completion_request_maps_stream_usage_and_token_format_options() {
300 let request: CompletionRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/completions/types.rs
+5
@@ -146,6 +146,11 @@ pub struct CompletionRequest {
146 /// Additional kwargs for structured outputs
147 pub structured_outputs: Option<Value>,
148
149 + /// Token budget for reasoning/thinking. Accepts a non-negative integer, or
150 + /// `-1` for unlimited (mirroring the Python frontend, which normalizes `-1`
151 + /// to "no budget").
152 + pub thinking_token_budget: Option<i64>,
153 +
154 /// Request scheduling priority (lower means earlier; default 0)
155 pub priority: Option<i32>,
156
rust/src/text/src/error.rs
+2
@@ -20,6 +20,8 @@ pub enum Error {
20 Logprobs(#[from] LogprobsError),
21 #[error(transparent)]
22 OutOfVocab(#[from] OutOfVocabError),
23 + #[error("`thinking_token_budget` must be a non-negative integer or -1 for unlimited.")]
24 + InvalidThinkingTokenBudget,
25 #[error("text request stream `{request_id}` closed before terminal output")]
26 StreamClosedBeforeTerminalOutput { request_id: String },
27 #[error(transparent)]
rust/src/text/src/lower.rs
+54
@@ -87,6 +87,7 @@ pub fn lower_sampling_params(
87 seed,
88 max_tokens,
89 min_tokens,
90 + thinking_token_budget,
91 logprobs,
92 prompt_logprobs,
93 min_p,
@@ -128,6 +129,7 @@ pub fn lower_sampling_params(
129 prompt_len,
130 )?;
131 let min_tokens = min_tokens.unwrap_or(0);
132 + let thinking_token_budget = normalize_thinking_token_budget(thinking_token_budget)?;
133 let frequency_penalty = frequency_penalty.unwrap_or(0.0);
134 let presence_penalty = presence_penalty.unwrap_or(0.0);
135
@@ -149,6 +151,7 @@ pub fn lower_sampling_params(
151 seed,
152 max_tokens,
153 min_tokens,
154 + thinking_token_budget,
155 logprobs,
156 prompt_logprobs,
157 min_p,
@@ -170,6 +173,21 @@ pub fn lower_sampling_params(
173 Ok(params)
174 }
175
176 +/// Normalize the user-facing `thinking_token_budget` into the engine value.
177 +///
178 +/// Mirrors Python's `validate_thinking_token_budget`
179 +/// (<https://github.com/vllm-project/vllm/blob/ecf9d83520eb217401b47d8a5451a27c5231b8c2/vllm/sampling_params.py#L35-L55>):
180 +/// `None` and the `-1` "unlimited" sentinel both map to `None`; any other
181 +/// negative value is rejected; non-negative values pass through unchanged. Like
182 +/// Python's `int`, no upper bound is imposed.
183 +fn normalize_thinking_token_budget(value: Option<i64>) -> Result<Option<u64>> {
184 + match value {
185 + None | Some(-1) => Ok(None),
186 + Some(budget) if budget >= 0 => Ok(Some(budget as u64)),
187 + Some(_) => Err(Error::InvalidThinkingTokenBudget),
188 + }
189 +}
190 +
191 /// Convert bad-word strings into token-ID sequences, following the Python vLLM
192 /// logic in `SamplingParams.update_from_tokenizer()`.
193 ///
@@ -366,6 +384,36 @@ mod tests {
384 )
385 }
386
387 + #[test]
388 + fn lower_sampling_params_normalizes_thinking_token_budget() {
389 + let lower = |budget: Option<i64>| {
390 + lower_sampling_params_with_limits(
391 + SamplingParams {
392 + thinking_token_budget: budget,
393 + ..SamplingParams::default()
394 + },
395 + sample_sampling_limits(),
396 + )
397 + };
398 +
399 + // Non-negative budgets (including 0) pass through unchanged.
400 + assert_eq!(lower(Some(256)).unwrap().thinking_token_budget, Some(256));
401 + assert_eq!(lower(Some(0)).unwrap().thinking_token_budget, Some(0));
402 + // `None` and the `-1` "unlimited" sentinel both disable the budget.
403 + assert_eq!(lower(None).unwrap().thinking_token_budget, None);
404 + assert_eq!(lower(Some(-1)).unwrap().thinking_token_budget, None);
405 + // No upper bound is imposed, matching Python's `int`.
406 + assert_eq!(
407 + lower(Some(i64::from(u32::MAX) + 1)).unwrap().thinking_token_budget,
408 + Some(u64::from(u32::MAX) + 1)
409 + );
410 + // Other negatives are rejected.
411 + assert!(matches!(
412 + lower(Some(-2)),
413 + Err(Error::InvalidThinkingTokenBudget)
414 + ));
415 + }
416 +
417 #[test]
418 fn lower_text_request_applies_python_style_eos_hints() {
419 let prepared = lower_text_request(
@@ -386,6 +434,7 @@ mod tests {
434 seed: None,
435 max_tokens: 999997,
436 min_tokens: 0,
437 + thinking_token_budget: None,
438 logprobs: None,
439 prompt_logprobs: None,
440 min_p: 0.0,
@@ -437,6 +486,7 @@ mod tests {
486 seed: None,
487 max_tokens: 999997,
488 min_tokens: 0,
489 + thinking_token_budget: None,
490 logprobs: None,
491 prompt_logprobs: None,
492 min_p: 0.0,
@@ -567,6 +617,7 @@ mod tests {
617 seed: None,
618 max_tokens: 40957,
619 min_tokens: 0,
620 + thinking_token_budget: None,
621 logprobs: None,
622 prompt_logprobs: None,
623 min_p: 0.0,
@@ -628,6 +679,7 @@ mod tests {
679 seed: None,
680 max_tokens: 999997,
681 min_tokens: 0,
682 + thinking_token_budget: None,
683 logprobs: None,
684 prompt_logprobs: None,
685 min_p: 0.0,
@@ -697,6 +749,7 @@ mod tests {
749 seed: None,
750 max_tokens: 32,
751 min_tokens: 2,
752 + thinking_token_budget: None,
753 logprobs: None,
754 prompt_logprobs: None,
755 min_p: 0.1,
@@ -929,6 +982,7 @@ mod tests {
982 seed: None,
983 max_tokens: 128,
984 min_tokens: 0,
985 + thinking_token_budget: None,
986 logprobs: None,
987 prompt_logprobs: None,
988 min_p: 0.1,
rust/src/text/src/request.rs
+7
@@ -56,6 +56,12 @@ pub struct SamplingParams {
56 pub max_tokens: Option<u32>,
57 /// Minimum number of tokens to generate before EOS or stop-token handling.
58 pub min_tokens: Option<u32>,
59 + /// Maximum number of reasoning ("thinking") tokens to emit before the
60 + /// reasoning section is force-closed. `None` or the user-facing `-1`
61 + /// "unlimited" sentinel both disable the budget. The raw value is carried
62 + /// here; `-1` is normalized to `None` (and other negatives rejected) during
63 + /// lowering (see `lower_sampling_params`).
64 + pub thinking_token_budget: Option<i64>,
65 /// Number of log probabilities to return per generated token.
66 ///
67 /// `None` disables sample logprobs. `-1` requests the full vocabulary.
@@ -116,6 +122,7 @@ impl Default for SamplingParams {
122 seed: None,
123 max_tokens: None,
124 min_tokens: None,
125 + thinking_token_budget: None,
126 logprobs: None,
127 prompt_logprobs: None,
128 min_p: None,