A high-throughput and memory-efficient inference and serving engine for LLMs
[Rust Frontend] Support `parallel_tool_calls = false` (#44760)
Signed-off-by: zhoujinyu <2319109590@qq.com>
FAUST committed
Jun 15, 2026 at 15:50 UTC
40eac9a9d92bba51ad49ca777a5517ee212ea394
8 files changed
+135
-23
rust/src/chat/src/output/default/mod.rs
+4
-1
@@ -37,6 +37,7 @@ trait_set! {
37
pub struct DefaultChatOutputProcessor {
38
reasoning_parser: Option<Box<dyn ReasoningParser>>,
39
tool_parser: Option<Box<dyn ToolParser>>,
40
+ parallel_tool_calls: bool,
41
}
42
43
impl DefaultChatOutputProcessor {
@@ -74,6 +75,7 @@ impl DefaultChatOutputProcessor {
75
Ok(Self {
76
reasoning_parser,
77
tool_parser,
78
+ parallel_tool_calls: request.parallel_tool_calls,
79
})
80
}
81
@@ -86,6 +88,7 @@ impl DefaultChatOutputProcessor {
88
Self {
89
reasoning_parser: None,
90
tool_parser: None,
91
+ parallel_tool_calls: true,
92
}
93
}
94
@@ -159,7 +162,7 @@ impl ChatOutputProcessor for DefaultChatOutputProcessor {
162
fn process(self: Box<Self>, decoded: DynDecodedTextEventStream) -> Result<DynChatEventStream> {
163
let reasoning = reasoning_event_stream(decoded, self.reasoning_parser);
164
let tool = tool_event_stream(reasoning, self.tool_parser);
162
- let structured = structured_chat_event_stream(tool);
165
+ let structured = structured_chat_event_stream(tool, self.parallel_tool_calls);
166
167
Ok(structured.boxed())
168
}
rust/src/chat/src/output/default/tool.rs
+9
-7
@@ -473,7 +473,7 @@ mod tests {
473
})));
474
let parser = DeepSeekV4ToolParser::create(&deepseek_v4_test_tools()).unwrap();
475
let assistant_events = tool_event_stream(stream::iter(events), Some(parser));
476
- let chat_events = structured_chat_event_stream(assistant_events);
476
+ let chat_events = structured_chat_event_stream(assistant_events, true);
477
478
ChatEventStream::new("req_deepseek_v4".to_string(), Box::pin(chat_events))
479
.collect_message()
@@ -717,9 +717,10 @@ mod tests {
717
718
let message = ChatEventStream::new(
719
"req_fallback".to_string(),
720
- Box::pin(structured_chat_event_stream(stream::iter(
721
- events.into_iter().map(Ok),
722
- ))),
720
+ Box::pin(structured_chat_event_stream(
721
+ stream::iter(events.into_iter().map(Ok)),
722
+ true,
723
+ )),
724
)
725
.collect_message()
726
.await
@@ -968,9 +969,10 @@ mod tests {
969
));
970
let collected = ChatEventStream::new(
971
"req_final_only".to_string(),
971
- Box::pin(structured_chat_event_stream(stream::iter(
972
- events.into_iter().map(Ok),
973
- ))),
972
+ Box::pin(structured_chat_event_stream(
973
+ stream::iter(events.into_iter().map(Ok)),
974
+ true,
975
+ )),
976
)
977
.collect_message()
978
.await
rust/src/chat/src/output/harmony/mod.rs
+7
-1
@@ -35,6 +35,7 @@ use crate::request::ChatRequest;
35
pub struct HarmonyChatOutputProcessor {
36
encoding: &'static HarmonyEncoding,
37
tool_calls_enabled: bool,
38
+ parallel_tool_calls: bool,
39
}
40
41
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -76,6 +77,7 @@ impl HarmonyChatOutputProcessor {
77
Ok(Self {
78
encoding: harmony_encoding()?,
79
tool_calls_enabled: request.tool_parsing_enabled(),
80
+ parallel_tool_calls: request.parallel_tool_calls,
81
})
82
}
83
}
@@ -110,7 +112,11 @@ impl ChatOutputProcessor for HarmonyChatOutputProcessor {
112
fn process(self: Box<Self>, decoded: DynDecodedTextEventStream) -> Result<DynChatEventStream> {
113
let assistant =
114
harmony_assistant_event_stream(decoded, self.encoding, self.tool_calls_enabled);
113
- Ok(crate::output::structured::structured_chat_event_stream(assistant).boxed())
115
+ Ok(crate::output::structured::structured_chat_event_stream(
116
+ assistant,
117
+ self.parallel_tool_calls,
118
+ )
119
+ .boxed())
120
}
121
}
122
rust/src/chat/src/output/structured.rs
+81
-7
@@ -53,16 +53,22 @@ struct StructuredEventState {
53
open_tool_call: Option<OpenToolCall>,
54
/// Next OpenAI-compatible tool-call ordinal.
55
next_tool_call_index: usize,
56
+ /// Whether more than one tool call may be surfaced northbound.
57
+ parallel_tool_calls: bool,
58
+ /// Whether the current tool-call parse is being suppressed.
59
+ suppressing_tool_call: bool,
60
}
61
62
impl StructuredEventState {
63
/// Create one fresh assembly state for a new streamed response.
60
- fn new() -> Self {
64
+ fn new(parallel_tool_calls: bool) -> Self {
65
Self {
66
message: AssistantMessage::default(),
67
open_text_block: None,
68
open_tool_call: None,
69
next_tool_call_index: 0,
70
+ parallel_tool_calls,
71
+ suppressing_tool_call: false,
72
}
73
}
74
@@ -98,6 +104,12 @@ impl StructuredEventState {
104
105
let index = self.next_tool_call_index;
106
self.next_tool_call_index += 1;
107
+ if !self.parallel_tool_calls && index >= 1 {
108
+ self.suppressing_tool_call = true;
109
+ return Ok(events);
110
+ }
111
+
112
+ self.suppressing_tool_call = false;
113
self.open_tool_call = Some(OpenToolCall {
114
index,
115
id: id.clone(),
@@ -110,6 +122,10 @@ impl StructuredEventState {
122
123
/// Append one incremental tool-call arguments delta.
124
fn push_tool_call_arguments(&mut self, delta: String) -> Result<Vec<ChatEvent>> {
125
+ if self.suppressing_tool_call {
126
+ return Ok(Vec::new());
127
+ }
128
+
129
let mut events = Vec::new();
130
let Some(open_tool_call) = self.open_tool_call.as_mut() else {
131
return Err(Error::ToolCallStreamInvariant {
@@ -207,6 +223,11 @@ impl StructuredEventState {
223
224
/// Finalize the currently open tool call, if present.
225
fn close_open_tool_call(&mut self, events: &mut Vec<ChatEvent>) {
226
+ if self.suppressing_tool_call {
227
+ self.suppressing_tool_call = false;
228
+ return;
229
+ }
230
+
231
let Some(open_tool_call) = self.open_tool_call.take() else {
232
return;
233
};
@@ -229,11 +250,12 @@ impl StructuredEventState {
250
#[try_stream]
251
pub(crate) async fn structured_chat_event_stream(
252
stream: impl AssistantEventStream,
253
+ parallel_tool_calls: bool,
254
mut y: TryYielder<ChatEvent, Error>,
255
) -> Result<()> {
256
pin_mut!(stream);
257
236
- let mut state = StructuredEventState::new();
258
+ let mut state = StructuredEventState::new(parallel_tool_calls);
259
260
while let Some(event) = stream.next().await.transpose()? {
261
match event {
@@ -315,7 +337,7 @@ mod tests {
337
}),
338
]);
339
318
- let events = structured_chat_event_stream(events)
340
+ let events = structured_chat_event_stream(events, true)
341
.collect::<Vec<_>>()
342
.await
343
.into_iter()
@@ -369,7 +391,7 @@ mod tests {
391
}),
392
]);
393
372
- let events = structured_chat_event_stream(events)
394
+ let events = structured_chat_event_stream(events, true)
395
.collect::<Vec<_>>()
396
.await
397
.into_iter()
@@ -420,7 +442,7 @@ mod tests {
442
}),
443
]);
444
423
- let events = structured_chat_event_stream(events)
445
+ let events = structured_chat_event_stream(events, true)
446
.collect::<Vec<_>>()
447
.await
448
.into_iter()
@@ -471,7 +493,7 @@ mod tests {
493
}),
494
]);
495
474
- let events = structured_chat_event_stream(events)
496
+ let events = structured_chat_event_stream(events, true)
497
.collect::<Vec<_>>()
498
.await
499
.into_iter()
@@ -499,7 +521,7 @@ mod tests {
521
delta: "{}".to_string(),
522
})]);
523
502
- let err = structured_chat_event_stream(events)
524
+ let err = structured_chat_event_stream(events, true)
525
.collect::<Vec<_>>()
526
.await
527
.into_iter()
@@ -509,4 +531,56 @@ mod tests {
531
532
assert!(matches!(err, Error::ToolCallStreamInvariant { .. }));
533
}
534
+
535
+ #[tokio::test]
536
+ async fn structured_stream_suppresses_later_tool_calls_when_parallel_disabled() {
537
+ let events = stream::iter(vec![
538
+ Ok(AssistantEvent::ToolCallStart {
539
+ id: "call_1".to_string(),
540
+ name: "first".to_string(),
541
+ }),
542
+ Ok(AssistantEvent::ToolCallArgumentsDelta {
543
+ delta: r#"{"a":1}"#.to_string(),
544
+ }),
545
+ Ok(AssistantEvent::ToolCallStart {
546
+ id: "call_2".to_string(),
547
+ name: "second".to_string(),
548
+ }),
549
+ Ok(AssistantEvent::ToolCallArgumentsDelta {
550
+ delta: r#"{"b":2}"#.to_string(),
551
+ }),
552
+ Ok(AssistantEvent::Done {
553
+ usage: vllm_llm::TokenUsage {
554
+ prompt_token_count: 1,
555
+ output_token_count: 1,
556
+ cached_token_count: 0,
557
+ },
558
+ finish_reason: FinishReason::stop_eos(),
559
+ kv_transfer_params: None,
560
+ }),
561
+ ]);
562
+
563
+ let events = structured_chat_event_stream(events, false)
564
+ .collect::<Vec<_>>()
565
+ .await
566
+ .into_iter()
567
+ .collect::<crate::Result<Vec<_>>>()
568
+ .unwrap();
569
+
570
+ assert!(matches!(
571
+ events[0],
572
+ ChatEvent::ToolCallStart { index: 0, .. }
573
+ ));
574
+ assert!(matches!(
575
+ events[1],
576
+ ChatEvent::ToolCallArgumentsDelta { index: 0, .. }
577
+ ));
578
+ assert!(matches!(events[2], ChatEvent::ToolCallEnd { index: 0, .. }));
579
+ let ChatEvent::Done { message, .. } = &events[3] else {
580
+ panic!("expected done");
581
+ };
582
+ let tool_calls = message.tool_calls().collect::<Vec<_>>();
583
+ assert_eq!(tool_calls.len(), 1);
584
+ assert_eq!(tool_calls[0].name, "first");
585
+ }
586
}
rust/src/chat/src/request.rs
+5
@@ -406,6 +406,10 @@ pub struct ChatRequest {
406
pub tools: Vec<ChatTool>,
407
/// Tool-choice behavior for this request.
408
pub tool_choice: ChatToolChoice,
409
+ /// Whether the model may return more than one tool call per response.
410
+ ///
411
+ /// When `false`, only the first parsed tool call is surfaced northbound.
412
+ pub parallel_tool_calls: bool,
413
/// Text decode options for incremental detokenization.
414
pub decode_options: TextDecodeOptions,
415
/// Whether to emit intermediate northbound content deltas before the
@@ -442,6 +446,7 @@ impl ChatRequest {
446
chat_options: ChatOptions::default(),
447
tools: Vec::new(),
448
tool_choice: ChatToolChoice::None,
449
+ parallel_tool_calls: true,
450
decode_options: TextDecodeOptions::default(),
451
intermediate: true,
452
priority: 0,
rust/src/server/src/routes/openai/chat_completions/convert.rs
+28
@@ -142,6 +142,7 @@ pub(super) fn prepare_chat_request(
142
},
143
tools: convert_tools(request.tools)?,
144
tool_choice: convert_tool_choice(request.tool_choice.as_ref())?,
145
+ parallel_tool_calls: request.parallel_tool_calls.unwrap_or(true),
146
decode_options: vllm_text::output::TextDecodeOptions {
147
skip_special_tokens: request.skip_special_tokens,
148
include_stop_str_in_output: request.include_stop_str_in_output,
@@ -412,6 +413,33 @@ mod tests {
413
}
414
}
415
416
+ #[test]
417
+ fn prepare_chat_request_maps_parallel_tool_calls() {
418
+ let mut request = base_request();
419
+ request.parallel_tool_calls = Some(false);
420
+
421
+ let prepared = prepare_chat_request(
422
+ request,
423
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
424
+ ResolvedRequestContext::default(),
425
+ )
426
+ .expect("request is valid");
427
+
428
+ assert!(!prepared.chat_request.parallel_tool_calls);
429
+ }
430
+
431
+ #[test]
432
+ fn prepare_chat_request_defaults_parallel_tool_calls_to_true() {
433
+ let prepared = prepare_chat_request(
434
+ base_request(),
435
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
436
+ ResolvedRequestContext::default(),
437
+ )
438
+ .expect("request is valid");
439
+
440
+ assert!(prepared.chat_request.parallel_tool_calls);
441
+ }
442
+
443
#[test]
444
fn prepare_chat_request_maps_text_parts() {
445
let mut request = base_request();
rust/src/server/src/routes/openai/chat_completions/validate.rs
-7
@@ -93,13 +93,6 @@ pub(super) fn validate_request_compat(
93
// ---- Reject parameters that are accepted for deserialization but not yet
94
// implemented ----
95
96
- if request.parallel_tool_calls.is_some() {
97
- bail_invalid_request!(
98
- param = "parallel_tool_calls",
99
- "parallel_tool_calls is not supported."
100
- );
101
- }
102
-
96
reject_non_default(
97
request.length_penalty.as_ref(),
98
"length_penalty",
rust/src/server/src/routes/tokenize/types.rs
+1
@@ -83,6 +83,7 @@ impl TokenizeChatRequest {
83
},
84
tools: convert_tools(self.tools)?,
85
tool_choice: ChatToolChoice::Auto,
86
+ parallel_tool_calls: true,
87
decode_options: TextDecodeOptions::default(),
88
intermediate: false,
89
priority: 0,