A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 2, 19:30 UTC
[Rust Frontend] Add reasoning/tool parser & renderer roundtrip tests (#43582)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
May 27, 2026 at 08:49 UTC
0b68f21e7c6ff146f12fd9a9018f5052d4b4722e
11 files changed
+671
-43
rust/Cargo.lock
+3
@@ -2591,6 +2591,7 @@ version = "2.18.0"
2591
source = "registry+https://github.com/rust-lang/crates.io-index"
2592
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
2593
dependencies = [
2594
+ "indexmap 2.13.0",
2595
"memo-map",
2596
"serde",
2597
"serde_json",
@@ -5608,6 +5609,7 @@ dependencies = [
5609
"minijinja",
5610
"minijinja-contrib",
5611
"openai-harmony",
5612
+ "paste",
5613
"reqwest",
5614
"rmp-serde",
5615
"serde",
@@ -5810,6 +5812,7 @@ dependencies = [
5812
"serde",
5813
"serde_json",
5814
"serde_with",
5815
+ "serial_test",
5816
"tempfile",
5817
"thiserror 2.0.18",
5818
"thiserror-ext",
rust/Cargo.toml
+4
-3
@@ -45,13 +45,14 @@ http-body = "1.0.1"
45
itertools = "0.14.0"
46
libc = "0.2.177"
47
llm-multimodal = { git = "https://github.com/vllm-project/llm-multimodal", rev = "5b558989844d1c7af3e43d0f604069ffd9c06320" }
48
-minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls"] }
48
+minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
49
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
50
native-tls-vendored = { package = "native-tls", version = "0.2.18", features = ["vendored"] }
51
ndarray = { version = "0.16.1", features = ["serde"] }
52
openai-harmony = "0.0.8"
53
openai-protocol = "1.6.0"
54
parking_lot = "0.12.5"
55
+paste = "1.0.15"
56
prometheus-client = "0.24.0"
57
prometheus-client-derive-encode = "0.5.0"
58
prost = "0.14.3"
@@ -65,11 +66,11 @@ rustc-hash = "1.1.0"
66
serde = { version = "1.0.228", features = ["derive"] }
67
serde-json-fmt = "0.1.0"
68
serde_default = "0.2.0"
68
-serde_json = "1.0.145"
69
+serde_json = { version = "1.0.145", features = ["arbitrary_precision", "preserve_order"] }
70
serde_repr = "0.1.20"
71
serde_tuple = "1.1.3"
72
serde_with = "3.18.0"
72
-serial_test = "3.2.0"
73
+serial_test = { version = "3.2.0", features = ["file_locks"] }
74
socket2 = "0.6.3"
75
subenum = "1.1.3"
76
task-local = "0.1.1"
rust/src/chat/Cargo.toml
+2
-1
@@ -39,8 +39,9 @@ anyhow.workspace = true
39
bytes.workspace = true
40
clap.workspace = true
41
expect-test.workspace = true
42
+paste.workspace = true
43
rmp-serde.workspace = true
43
-serial_test = { workspace = true, features = ["file_locks"] }
44
+serial_test.workspace = true
45
tempfile.workspace = true
46
tokio.workspace = true
47
tracing-subscriber.workspace = true
rust/src/chat/src/event.rs
+26
@@ -53,6 +53,25 @@ impl AssistantContentBlock {
53
_ => None,
54
}
55
}
56
+
57
+ /// Return a copy of this block with leading and trailing whitespace trimmed from all text
58
+ /// fields and tool call arguments, or `None` if the resulting text would be empty.
59
+ pub fn trim(mut self) -> Option<Self> {
60
+ match &mut self {
61
+ Self::Text { text } | Self::Reasoning { text } => {
62
+ let trimmed_text = text.trim();
63
+ if trimmed_text.is_empty() {
64
+ return None;
65
+ } else {
66
+ *text = trimmed_text.to_string();
67
+ }
68
+ }
69
+ Self::ToolCall(call) => {
70
+ call.arguments = call.arguments.trim().to_string();
71
+ }
72
+ }
73
+ Some(self)
74
+ }
75
}
76
77
#[easy_ext::ext(AssistantMessageExt)]
@@ -119,6 +138,13 @@ impl AssistantMessage {
138
pub(crate) fn push_block(&mut self, block: AssistantContentBlock) {
139
self.content.push(block);
140
}
141
+
142
+ /// Return a copy of this message with leading and trailing whitespace trimmed from all text
143
+ /// fields and tool call arguments, and with any blocks that are empty after trimming removed.
144
+ pub fn trim(mut self) -> Self {
145
+ self.content = self.content.into_iter().filter_map(|block| block.trim()).collect();
146
+ self
147
+ }
148
}
149
150
/// Streamed chat event emitted by [`crate::ChatEventStream`].
rust/src/chat/src/renderer/hf/tojson.rs
+12
-11
@@ -1,7 +1,7 @@
1
use minijinja::value::{Kwargs, ViaDeserialize};
2
use minijinja::{Error as MinijinjaError, ErrorKind, Value};
3
use serde::Deserialize;
4
-use serde_json::{self, Value as JsonValue};
4
+use serde_json::Value as JsonValue;
5
use serde_json_fmt::{JsonFormat, JsonSyntaxError};
6
use thiserror_ext::AsReport;
7
@@ -13,7 +13,7 @@ use thiserror_ext::AsReport;
13
/// - extra kwargs such as `ensure_ascii`, `separators`, and `sort_keys`
14
/// - Python-style `indent` handling
15
pub(super) fn hf_tojson_filter(
16
- value: Value,
16
+ ViaDeserialize(value): ViaDeserialize<JsonValue>,
17
kwargs: Kwargs,
18
) -> std::result::Result<Value, MinijinjaError> {
19
let ensure_ascii = kwargs.get::<Option<bool>>("ensure_ascii")?.unwrap_or(false);
@@ -30,18 +30,11 @@ pub(super) fn hf_tojson_filter(
30
31
kwargs.assert_all_used()?;
32
33
- let json_value: serde_json::Value = serde_json::to_value(&value).map_err(|e| {
34
- MinijinjaError::new(
35
- ErrorKind::InvalidOperation,
36
- format!("Failed to convert to JSON value: {e}"),
37
- )
38
- })?;
39
-
33
let json_str = {
34
let value_to_serialize = if sort_keys {
42
- &sort_json_keys(&json_value)
35
+ &sort_json_keys(&value)
36
} else {
44
- &json_value
37
+ &value
38
};
39
40
build_json_format(indent, separators.0, separators.1, ensure_ascii)?
@@ -214,6 +207,14 @@ mod tests {
207
assert_eq!(rendered, "{\"x\":[1,2]}");
208
}
209
210
+ #[test]
211
+ fn tojson_preserves_arbitrary_precision_number_spelling() {
212
+ let payload = serde_json::from_str(r#"{"x":2,"y":1.00}"#).unwrap();
213
+ let rendered = render("{{ payload|tojson }}", payload);
214
+
215
+ assert_eq!(rendered, "{\"x\": 2, \"y\": 1.00}");
216
+ }
217
+
218
#[test]
219
fn tojson_supports_negative_indent_as_newline_only() {
220
let rendered = render("{{ payload|tojson(indent=-1) }}", json!([1, 2]));
rust/src/chat/tests/roundtrip.rs
new
+541
@@ -0,0 +1,541 @@
1
+//! Text-level roundtrip tests for the real chat-template and output-processor pairing.
2
+//!
3
+//! The invariant under test is that a structured assistant message rendered as history can be
4
+//! parsed from the generated assistant completion and then rendered back to the exact same
5
+//! assistant-completion text.
6
+
7
+use std::pin::Pin;
8
+use std::sync::Arc;
9
+
10
+use anyhow::{Context as _, Result, bail, ensure};
11
+use futures::{Stream, StreamExt as _, stream};
12
+use serde_json_fmt::JsonFormat as JsonFmt;
13
+use serial_test::file_serial;
14
+use vllm_chat::{
15
+ AssistantContentBlock, AssistantMessage, AssistantMessageExt as _, AssistantToolCall,
16
+ ChatEvent, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice, FinishReason,
17
+ GenerationPromptMode, LoadModelBackendsOptions, NewChatOutputProcessorOptions, ParserSelection,
18
+ RendererSelection, load_model_backends,
19
+};
20
+use vllm_text::{DecodedTextEvent, Finished, Prompt};
21
+
22
+/// One model/parser configuration used to run the fixed roundtrip fixtures.
23
+struct RoundtripCase {
24
+ /// Hugging Face model id resolved through the production backend loader.
25
+ model_id: &'static str,
26
+ /// Final assistant-history suffix rendered by the chat template but not
27
+ /// generated by the model body consumed by the output processor.
28
+ // TODO: we should adopt `ContinueFinalAssistant` mode to naturally handle this.
29
+ assistant_stop_suffix: &'static str,
30
+ /// Tool parser selection used by the output processor.
31
+ tool_call_parser: ParserSelection,
32
+ /// Reasoning parser selection used by the output processor.
33
+ reasoning_parser: ParserSelection,
34
+ /// JSON formatting expected after this model's template has materialized
35
+ /// tool-call arguments.
36
+ json_fmt: JsonFmt,
37
+}
38
+
39
+impl RoundtripCase {
40
+ /// Qwen3 XML tool-call format with `qwen3` reasoning tags.
41
+ fn qwen3() -> Self {
42
+ Self {
43
+ model_id: "Qwen/Qwen3-0.6B",
44
+ assistant_stop_suffix: "<|im_end|>\n",
45
+ tool_call_parser: ParserSelection::Auto,
46
+ reasoning_parser: ParserSelection::Auto,
47
+ json_fmt: spaced_json_fmt(),
48
+ }
49
+ }
50
+
51
+ /// Qwen3.5 coder-style JSON tool-call format with `qwen3` reasoning tags.
52
+ fn qwen35() -> Self {
53
+ Self {
54
+ model_id: "Qwen/Qwen3.5-4B",
55
+ assistant_stop_suffix: "<|im_end|>\n",
56
+ tool_call_parser: ParserSelection::Auto,
57
+ reasoning_parser: ParserSelection::Auto,
58
+ json_fmt: compact_json_fmt(),
59
+ }
60
+ }
61
+
62
+ /// MiniMax M2.5 XML invoke format with `<think>` reasoning tags.
63
+ fn minimax_m25() -> Self {
64
+ Self {
65
+ model_id: "MiniMaxAI/MiniMax-M2.5",
66
+ assistant_stop_suffix: "[e~[\n",
67
+ tool_call_parser: ParserSelection::Auto,
68
+ reasoning_parser: ParserSelection::Auto,
69
+ json_fmt: compact_json_fmt(),
70
+ }
71
+ }
72
+
73
+ /// DeepSeek V4 DSML tool-call format.
74
+ fn deepseek_v4() -> Self {
75
+ Self {
76
+ model_id: "deepseek-ai/DeepSeek-V4-Flash",
77
+ assistant_stop_suffix: "<|end▁of▁sentence|>",
78
+ tool_call_parser: ParserSelection::Auto,
79
+ reasoning_parser: ParserSelection::Auto,
80
+ json_fmt: compact_json_fmt(),
81
+ }
82
+ }
83
+
84
+ /// GLM-4.7 XML-like argument format with `<think>` reasoning tags.
85
+ fn glm47() -> Self {
86
+ Self {
87
+ model_id: "zai-org/GLM-4.7-Flash",
88
+ assistant_stop_suffix: "",
89
+ tool_call_parser: ParserSelection::Auto,
90
+ reasoning_parser: ParserSelection::Auto,
91
+ json_fmt: compact_json_fmt(),
92
+ }
93
+ }
94
+
95
+ /// Kimi K2.5 tool-call format with `<think>` reasoning tags.
96
+ #[allow(dead_code)]
97
+ fn kimi_k25() -> Self {
98
+ Self {
99
+ model_id: "moonshotai/Kimi-K2.5",
100
+ assistant_stop_suffix: "<|im_end|>",
101
+ tool_call_parser: ParserSelection::Auto,
102
+ reasoning_parser: ParserSelection::Auto,
103
+ json_fmt: spaced_json_fmt(),
104
+ }
105
+ }
106
+}
107
+
108
+macro_rules! roundtrip_tests {
109
+ ($($case:ident => [$($fixture:ident),* $(,)?]),+ $(,)?) => {
110
+ paste::paste! {
111
+ $(
112
+ $(
113
+ #[tokio::test]
114
+ #[file_serial([<hf_ $case>])]
115
+ async fn [<roundtrip_ $case _ $fixture>]() -> Result<()> {
116
+ [<run_roundtrip_ $fixture>](RoundtripCase::$case()).await
117
+ }
118
+ )*
119
+ )+
120
+ }
121
+ };
122
+}
123
+
124
+roundtrip_tests! {
125
+ qwen3 => [reasoning_and_content, tool_call_mix],
126
+ qwen35 => [reasoning_and_content, tool_call_mix],
127
+ minimax_m25 => [reasoning_and_content, tool_call_mix],
128
+ deepseek_v4 => [reasoning_and_content, tool_call_mix],
129
+ glm47 => [reasoning_and_content, tool_call_mix],
130
+
131
+ // Note: Kimi K2.5 strips the reasoning content in history.
132
+ // TODO: we don't respect model-generated tool call id now so `tool_call_mix` cannot pass.
133
+ // kimi_k25 => [tool_call_mix],
134
+}
135
+
136
+/// Run the fixed reasoning+content fixture for one model/parser case.
137
+async fn run_roundtrip_reasoning_and_content(case: RoundtripCase) -> Result<()> {
138
+ let backends = load_roundtrip_backends(&case).await?;
139
+ let request = roundtrip_request(
140
+ "roundtrip-reasoning-content",
141
+ vec![ChatMessage::text(ChatRole::User, "What is 2 + 2?")],
142
+ Vec::new(),
143
+ );
144
+ let expected_reasoning = "Need compute 2 + 2 directly.";
145
+ let expected_text = "The answer is 4.";
146
+
147
+ let result = run_roundtrip(
148
+ &case,
149
+ &backends,
150
+ &request,
151
+ AssistantMessage {
152
+ content: vec![
153
+ AssistantContentBlock::Reasoning {
154
+ text: expected_reasoning.to_string(),
155
+ },
156
+ AssistantContentBlock::Text {
157
+ text: expected_text.to_string(),
158
+ },
159
+ ],
160
+ },
161
+ )
162
+ .await?;
163
+
164
+ assert_eq!(
165
+ result.parsed_message.reasoning().as_deref().map(str::trim),
166
+ Some(expected_reasoning)
167
+ );
168
+ assert_eq!(result.parsed_message.text().trim(), expected_text);
169
+ assert_eq!(result.parsed_message.tool_calls().count(), 0);
170
+
171
+ assert_eq!(
172
+ result.rerendered_closed_completion,
173
+ result.closed_completion
174
+ );
175
+
176
+ Ok(())
177
+}
178
+
179
+/// Run the fixed reasoning+multiple-tools fixture for one model/parser case.
180
+async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> {
181
+ let backends = load_roundtrip_backends(&case).await?;
182
+ let request = roundtrip_request(
183
+ "roundtrip-reasoning-tools",
184
+ vec![ChatMessage::text(
185
+ ChatRole::User,
186
+ "Check Shanghai weather and add 1.00 plus 2.",
187
+ )],
188
+ test_tools(),
189
+ );
190
+ let expected_reasoning = "Need call the weather and add tools.";
191
+ let expected_text = "I will call the tools.";
192
+
193
+ let result = run_roundtrip(
194
+ &case,
195
+ &backends,
196
+ &request,
197
+ AssistantMessage {
198
+ content: vec![
199
+ AssistantContentBlock::Reasoning {
200
+ text: expected_reasoning.to_string(),
201
+ },
202
+ AssistantContentBlock::Text {
203
+ text: expected_text.to_string(),
204
+ },
205
+ AssistantContentBlock::ToolCall(AssistantToolCall {
206
+ id: "functions.get_weather:0".to_string(),
207
+ name: "get_weather".to_string(),
208
+ arguments: r#"{"location":"Shanghai"}"#.to_string(),
209
+ }),
210
+ AssistantContentBlock::ToolCall(AssistantToolCall {
211
+ id: "functions.add:1".to_string(),
212
+ name: "add".to_string(),
213
+ // Intentionally use a non-lexical order of keys and a different number
214
+ // formatting style to verify text-level fidelity of the roundtrip.
215
+ arguments: r#"{"y":1.00,"x":2}"#.to_string(),
216
+ }),
217
+ ],
218
+ },
219
+ )
220
+ .await?;
221
+
222
+ assert_eq!(
223
+ result.parsed_message.reasoning().as_deref().map(str::trim),
224
+ Some(expected_reasoning)
225
+ );
226
+ assert_eq!(result.parsed_message.text().trim(), expected_text);
227
+
228
+ let tool_calls = result.parsed_message.tool_calls().collect::<Vec<_>>();
229
+ assert_eq!(
230
+ tool_calls.len(),
231
+ 2,
232
+ "parsed message: {:#?}",
233
+ result.parsed_message
234
+ );
235
+ assert_eq!(tool_calls[0].name, "get_weather");
236
+ assert_eq!(
237
+ tool_calls[0].arguments,
238
+ expected_arguments(&case, r#"{"location": "Shanghai"}"#)?,
239
+ );
240
+ assert_eq!(tool_calls[1].name, "add");
241
+ assert_eq!(
242
+ tool_calls[1].arguments,
243
+ expected_arguments(&case, r#"{"y": 1.00, "x": 2}"#)?,
244
+ );
245
+
246
+ assert_eq!(
247
+ result.rerendered_closed_completion,
248
+ result.closed_completion
249
+ );
250
+
251
+ Ok(())
252
+}
253
+
254
+/// Compact JSON argument formatting used by JSON-native parsers/renderers.
255
+fn compact_json_fmt() -> JsonFmt {
256
+ JsonFmt::new()
257
+}
258
+
259
+/// Python `json.dumps`-style compact formatting with a space after commas and
260
+/// colons.
261
+fn spaced_json_fmt() -> JsonFmt {
262
+ JsonFmt::new()
263
+ .comma(", ")
264
+ .expect("literal comma separator is valid JSON")
265
+ .colon(": ")
266
+ .expect("literal colon separator is valid JSON")
267
+}
268
+
269
+/// Parse and format expected tool-call arguments from raw JSON text.
270
+/// Pass in a raw JSON string instead of a structured value to ensure the exact precision and
271
+/// formatting of numbers are preserved.
272
+fn expected_arguments(case: &RoundtripCase, raw_json: &str) -> Result<String> {
273
+ let value: serde_json::Value =
274
+ serde_json::from_str(raw_json).context("invalid expected tool-call arguments")?;
275
+
276
+ case.json_fmt
277
+ .format_to_string(&value)
278
+ .context("failed to format expected tool-call arguments")
279
+}
280
+
281
+/// Load the real model chat/text backend for one roundtrip case.
282
+async fn load_roundtrip_backends(case: &RoundtripCase) -> Result<vllm_chat::LoadedModelBackends> {
283
+ load_model_backends(
284
+ case.model_id,
285
+ LoadModelBackendsOptions {
286
+ renderer: RendererSelection::Auto,
287
+ ..Default::default()
288
+ },
289
+ )
290
+ .await
291
+ .with_context(|| format!("failed to load HF model files for {}", case.model_id))
292
+}
293
+
294
+/// Roundtrip artifacts needed for semantic and exact-text assertions.
295
+struct RoundtripResult {
296
+ /// Final assistant message reconstructed by the output processor.
297
+ parsed_message: AssistantMessage,
298
+ /// Assistant-completion suffix cut from rendering the expected assistant as
299
+ /// history.
300
+ closed_completion: String,
301
+ /// Assistant-completion suffix cut after rendering the parsed assistant
302
+ /// back as history.
303
+ rerendered_closed_completion: String,
304
+}
305
+
306
+/// Render, parse, and rerender one assistant turn through the production
307
+/// renderer/output-processor boundary.
308
+async fn run_roundtrip(
309
+ case: &RoundtripCase,
310
+ backends: &vllm_chat::LoadedModelBackends,
311
+ request: &ChatRequest,
312
+ assistant: AssistantMessage,
313
+) -> Result<RoundtripResult> {
314
+ let renderer = backends.chat_backend.chat_renderer();
315
+ let (prompt, closed_completion_text) =
316
+ render_closed_completion(renderer.as_ref(), request, &assistant)?;
317
+ let completion_body = closed_completion_text
318
+ .strip_suffix(case.assistant_stop_suffix)
319
+ .with_context(|| {
320
+ format!(
321
+ "closed assistant completion did not end with {:?}: {:?}",
322
+ case.assistant_stop_suffix, closed_completion_text
323
+ )
324
+ })?;
325
+
326
+ let parsed_message =
327
+ parse_completion(case, backends, request, &prompt, completion_body).await?;
328
+ let (_, rerendered_closed_completion) =
329
+ render_closed_completion(renderer.as_ref(), request, &parsed_message)?;
330
+
331
+ Ok(RoundtripResult {
332
+ parsed_message,
333
+ closed_completion: closed_completion_text,
334
+ rerendered_closed_completion,
335
+ })
336
+}
337
+
338
+/// Render `history` as a production prompt and `history + assistant` as closed
339
+/// history, then return the production prompt and assistant-completion suffix.
340
+fn render_closed_completion(
341
+ renderer: &dyn vllm_chat::ChatRenderer,
342
+ base_request: &ChatRequest,
343
+ assistant: &AssistantMessage,
344
+) -> Result<(String, String)> {
345
+ let mut prompt_request = base_request.clone();
346
+ prompt_request.chat_options.generation_prompt_mode = GenerationPromptMode::StartNewAssistant;
347
+ let prompt = render_text(renderer, &prompt_request).context("failed to render prompt")?;
348
+
349
+ let mut full_request = base_request.clone();
350
+ full_request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
351
+ full_request.messages.push(ChatMessage::from(assistant.clone()));
352
+ let full = render_text(renderer, &full_request).context("failed to render full prompt")?;
353
+
354
+ ensure!(
355
+ full.starts_with(&prompt),
356
+ "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}"
357
+ );
358
+ let completion = full[prompt.len()..].to_string();
359
+
360
+ Ok((prompt, completion))
361
+}
362
+
363
+/// Render one chat request and require a text prompt.
364
+fn render_text(renderer: &dyn vllm_chat::ChatRenderer, request: &ChatRequest) -> Result<String> {
365
+ match renderer.render(request)?.prompt {
366
+ Prompt::Text(text) => Ok(text),
367
+ other => bail!("roundtrip tests expect text prompts, got {other:?}"),
368
+ }
369
+}
370
+
371
+/// Feed one rendered assistant completion body into the real output processor
372
+/// and collect its terminal assistant message.
373
+async fn parse_completion(
374
+ case: &RoundtripCase,
375
+ backends: &vllm_chat::LoadedModelBackends,
376
+ base_request: &ChatRequest,
377
+ prompt: &str,
378
+ completion_body: &str,
379
+) -> Result<AssistantMessage> {
380
+ let tokenizer = backends.text_backend.tokenizer();
381
+ let prompt_token_ids = tokenizer
382
+ .encode(prompt, base_request.add_special_tokens)
383
+ .context("failed to encode rendered prompt")?;
384
+
385
+ let mut request = base_request.clone();
386
+ let processor = backends.chat_backend.new_chat_output_processor(
387
+ &mut request,
388
+ NewChatOutputProcessorOptions {
389
+ tool_call_parser: &case.tool_call_parser,
390
+ reasoning_parser: &case.reasoning_parser,
391
+ },
392
+ )?;
393
+
394
+ let decoded = decoded_completion_stream(prompt_token_ids, completion_body);
395
+ let mut events = processor.process(decoded)?;
396
+
397
+ while let Some(event) = events.next().await {
398
+ if let ChatEvent::Done { message, .. } = event? {
399
+ // TODO: currently our parsers are not very strict about preserving or trimming
400
+ // whitespace, so we trim here to avoid roundtrip failures due to
401
+ // insignificant whitespace differences. However, this may hurt token-level
402
+ // fidelity so we should consider improving them.
403
+ return Ok(message.trim());
404
+ }
405
+ }
406
+
407
+ bail!("output processor finished without a Done event")
408
+}
409
+
410
+/// Build a decoded-text stream from an already-rendered completion body.
411
+///
412
+/// The first event carries real prompt token ids so reasoning parsers can
413
+/// initialize from the same prompt boundary production uses. Completion text is
414
+/// split into small chunks to exercise streaming parser state across marker
415
+/// and JSON boundaries.
416
+fn decoded_completion_stream(
417
+ prompt_token_ids: Vec<u32>,
418
+ completion_body: &str,
419
+) -> Pin<Box<dyn Stream<Item = vllm_chat::Result<DecodedTextEvent>> + Send>> {
420
+ let prompt_token_count = prompt_token_ids.len();
421
+ let mut events = vec![DecodedTextEvent::Start {
422
+ prompt_token_ids: Arc::from(prompt_token_ids.into_boxed_slice()),
423
+ prompt_logprobs: None,
424
+ }];
425
+
426
+ let chunks = split_by_chars(completion_body, 7);
427
+ if chunks.is_empty() {
428
+ events.push({
429
+ DecodedTextEvent::TextDelta {
430
+ delta: String::new(),
431
+ token_ids: Vec::new(),
432
+ logprobs: None,
433
+ finished: Some(Finished {
434
+ prompt_token_count: 0,
435
+ output_token_count: 0,
436
+ finish_reason: FinishReason::stop_eos(),
437
+ kv_transfer_params: None,
438
+ }),
439
+ }
440
+ });
441
+ } else {
442
+ let last_index = chunks.len() - 1;
443
+ for (index, chunk) in chunks.into_iter().enumerate() {
444
+ let finished = (index == last_index).then(|| Finished {
445
+ prompt_token_count,
446
+ output_token_count: completion_body.chars().count(),
447
+ finish_reason: FinishReason::stop_eos(),
448
+ kv_transfer_params: None,
449
+ });
450
+ events.push(DecodedTextEvent::TextDelta {
451
+ delta: chunk,
452
+ token_ids: Vec::new(),
453
+ logprobs: None,
454
+ finished,
455
+ });
456
+ }
457
+ }
458
+
459
+ stream::iter(events).map(Ok).boxed()
460
+}
461
+
462
+/// Split text into chunks containing at most `chunk_chars` Unicode scalar
463
+/// values.
464
+fn split_by_chars(text: &str, chunk_chars: usize) -> Vec<String> {
465
+ let mut chunks = Vec::new();
466
+ let mut start = 0;
467
+ let mut count = 0;
468
+
469
+ for (index, _) in text.char_indices() {
470
+ if count == chunk_chars {
471
+ chunks.push(text[start..index].to_string());
472
+ start = index;
473
+ count = 0;
474
+ }
475
+ count += 1;
476
+ }
477
+
478
+ if start < text.len() {
479
+ chunks.push(text[start..].to_string());
480
+ }
481
+
482
+ chunks
483
+}
484
+
485
+/// Build a chat request fixture with parser-enabling tool-choice semantics.
486
+fn roundtrip_request(
487
+ request_id: impl Into<String>,
488
+ messages: Vec<ChatMessage>,
489
+ tools: Vec<ChatTool>,
490
+) -> ChatRequest {
491
+ let mut request = ChatRequest {
492
+ request_id: request_id.into(),
493
+ messages,
494
+ tool_choice: if tools.is_empty() {
495
+ ChatToolChoice::None
496
+ } else {
497
+ ChatToolChoice::Auto
498
+ },
499
+ tools,
500
+ ..ChatRequest::for_test()
501
+ };
502
+
503
+ // Enable thinking for some models so that rendering and parsing the reasoning block is
504
+ // exercised in the roundtrip.
505
+ for key in ["thinking", "enable_thinking"] {
506
+ request.chat_options.template_kwargs.insert(key.to_string(), true.into());
507
+ }
508
+
509
+ request
510
+}
511
+
512
+/// Return the function tools used by the multiple-tool-call fixture.
513
+fn test_tools() -> Vec<ChatTool> {
514
+ vec![
515
+ ChatTool {
516
+ name: "get_weather".to_string(),
517
+ description: Some("Get weather for a location".to_string()),
518
+ parameters: serde_json::json!({
519
+ "type": "object",
520
+ "properties": {
521
+ "location": { "type": "string" }
522
+ },
523
+ "required": ["location"]
524
+ }),
525
+ strict: None,
526
+ },
527
+ ChatTool {
528
+ name: "add".to_string(),
529
+ description: Some("Add two integers".to_string()),
530
+ parameters: serde_json::json!({
531
+ "type": "object",
532
+ "properties": {
533
+ "y": { "type": "number" },
534
+ "x": { "type": "number" }
535
+ },
536
+ "required": ["y", "x"]
537
+ }),
538
+ strict: None,
539
+ },
540
+ ]
541
+}
rust/src/text/Cargo.toml
+1
@@ -26,6 +26,7 @@ vllm-tokenizer.workspace = true
26
[dev-dependencies]
27
expect-test.workspace = true
28
futures.workspace = true
29
+serial_test.workspace = true
30
tempfile.workspace = true
31
tokio.workspace = true
32
vllm-llm = { workspace = true, features = ["test-util"] }
rust/src/text/src/backend/hf/model_files.rs
+1
-1
@@ -401,7 +401,7 @@ mod tests {
401
}
402
403
#[tokio::test]
404
- #[ignore = "requires network access to Hugging Face and downloads the real Kimi K2.5 tokenizer"]
404
+ #[ignore = "too slow for CI and requires network access to Hugging Face"]
405
async fn tiktoken_real_kimi_k25_tokenizer_files_load_and_handle_special_tokens() {
406
let files = ResolvedModelFiles::new("moonshotai/Kimi-K2.5")
407
.await
rust/src/text/src/lower.rs
+14
-9
@@ -235,6 +235,8 @@ fn merge_unique_token_ids(
235
mod tests {
236
use std::collections::BTreeSet;
237
238
+ use serial_test::file_serial;
239
+
240
use super::*;
241
use crate::backend::hf::HfTextBackend;
242
use crate::backend::{SamplingHints, TextBackend as _};
@@ -386,7 +388,7 @@ mod tests {
388
}
389
390
#[tokio::test]
389
- #[ignore = "requires network access to Hugging Face"]
391
+ #[file_serial(hf_qwen3)]
392
async fn lower_text_request_uses_real_qwen_generation_defaults() {
393
let backend = HfTextBackend::from_model("Qwen/Qwen3-0.6B")
394
.await
@@ -410,12 +412,8 @@ mod tests {
412
default_top_k: Some(
413
20,
414
),
413
- default_min_p: Some(
414
- 0.1,
415
- ),
416
- default_repetition_penalty: Some(
417
- 1.2,
418
- ),
415
+ default_min_p: None,
416
+ default_repetition_penalty: None,
417
default_max_tokens: None,
418
max_model_len: Some(
419
40960,
@@ -439,10 +437,10 @@ mod tests {
437
min_tokens: 0,
438
logprobs: None,
439
prompt_logprobs: None,
442
- min_p: 0.1,
440
+ min_p: 0.0,
441
frequency_penalty: 0.0,
442
presence_penalty: 0.0,
445
- repetition_penalty: 1.2,
443
+ repetition_penalty: 1.0,
444
stop_token_ids: [
445
151643,
446
],
@@ -453,6 +451,13 @@ mod tests {
451
151643,
452
151645,
453
},
454
+ logit_bias: None,
455
+ allowed_token_ids: None,
456
+ bad_words_token_ids: None,
457
+ structured_outputs: None,
458
+ logprob_token_ids: None,
459
+ skip_reading_prefix_cache: None,
460
+ extra_args: None,
461
}
462
"#]]
463
.assert_debug_eq(¶ms);
rust/src/tool-parser/src/minimax_m2.rs
+48
-6
@@ -1,5 +1,5 @@
1
use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
2
-use winnow::combinator::{alt, delimited, repeat, seq, terminated};
2
+use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
3
use winnow::prelude::*;
4
use winnow::stream::Partial;
5
use winnow::token::{literal, rest, take_until};
@@ -183,16 +183,17 @@ fn tool_block_end_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2
183
184
/// Parse a complete MiniMax M2 invoke block.
185
fn invoke_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
186
- let (name, raw_params) = seq!(
186
+ let (name, body) = seq!(
187
_: ws0,
188
_: literal(INVOKE_START),
189
_: (ws1, literal("name=")),
190
- attr_value,
190
+ partial_attr_value,
191
_: literal(">"),
192
- repeat(0.., terminated(parameter, ws0)),
192
+ take_until(0.., INVOKE_END),
193
_: literal(INVOKE_END),
194
)
195
.parse_next(input)?;
196
+ let raw_params = parse_invoke_params(body)?;
197
198
Ok(MinimaxM2Event::Invoke {
199
name: name.trim().to_string(),
@@ -200,8 +201,14 @@ fn invoke_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
201
})
202
}
203
204
+/// Parse all parameter blocks inside a complete MiniMax M2 invoke body.
205
+fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<(String, String)>> {
206
+ let mut input = invoke_body;
207
+ delimited(ws0, repeat(0.., terminated(parameter, ws0)), eof).parse_next(&mut input)
208
+}
209
+
210
/// Parse a MiniMax M2 parameter block.
204
-fn parameter(input: &mut MinimaxM2Input<'_>) -> ModalResult<(String, String)> {
211
+fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
212
let (name, value) = seq!(
213
_: literal(PARAMETER_START),
214
_: (ws1, literal("name=")),
@@ -216,7 +223,17 @@ fn parameter(input: &mut MinimaxM2Input<'_>) -> ModalResult<(String, String)> {
223
}
224
225
/// Parse a quoted or unquoted XML attribute value.
219
-fn attr_value<'i>(input: &mut MinimaxM2Input<'i>) -> ModalResult<&'i str> {
226
+fn attr_value<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
227
+ alt((
228
+ delimited(literal("\""), take_until(1.., "\""), literal("\"")),
229
+ delimited(literal("'"), take_until(1.., "'"), literal("'")),
230
+ take_until(1.., ">"),
231
+ ))
232
+ .parse_next(input)
233
+}
234
+
235
+/// Parse a quoted or unquoted XML attribute value from partial streaming input.
236
+fn partial_attr_value<'i>(input: &mut MinimaxM2Input<'i>) -> ModalResult<&'i str> {
237
alt((
238
delimited(literal("\""), take_until(1.., "\""), literal("\"")),
239
delimited(literal("'"), take_until(1.., "'"), literal("'")),
@@ -470,6 +487,31 @@ mod tests {
487
assert_eq!(result.calls[1].tool_index, 1);
488
}
489
490
+ #[test]
491
+ fn minimax_m2_streaming_handles_template_whitespace_and_split_parameters() {
492
+ let text = concat!(
493
+ "I will call the tools.\n",
494
+ "<minimax:tool_call>\n",
495
+ "<invoke name=\"get_weather\">\n",
496
+ "<parameter name=\"city\">Seattle</parameter>\n",
497
+ "</invoke>\n",
498
+ "<invoke name=\"get_weather\">\n",
499
+ "<parameter name=\"city\">NYC</parameter>\n",
500
+ "</invoke>\n",
501
+ "</minimax:tool_call>",
502
+ );
503
+ let chunks = split_by_chars(text, 7);
504
+ let mut parser = MinimaxM2ToolParser::new(&test_tools());
505
+ let result = collect_stream(&mut parser, &chunks);
506
+
507
+ assert_eq!(result.normal_text, "I will call the tools.\n");
508
+ assert_eq!(result.calls.len(), 2);
509
+ assert_eq!(result.calls[0].tool_index, 0);
510
+ assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
511
+ assert_eq!(result.calls[1].tool_index, 1);
512
+ assert_eq!(result.calls[1].name.as_deref(), Some("get_weather"));
513
+ }
514
+
515
#[test]
516
fn minimax_m2_streaming_ignores_text_after_tool_block() {
517
let text = format!(
rust/src/tool-parser/src/parameters.rs
+19
-12
@@ -224,10 +224,11 @@ fn convert_value(param_type: &JsonParamType, value: &str) -> Option<Value> {
224
225
/// Convert one raw string value to a JSON number.
226
fn convert_number(value: &str) -> Option<Value> {
227
- if let Ok(parsed) = value.parse::<i64>() {
228
- return Some(Value::Number(Number::from(parsed)));
229
- }
230
- Number::from_f64(value.parse::<f64>().ok()?).map(Value::Number)
227
+ serde_json::from_str::<Number>(value)
228
+ .or_else(|_| value.parse::<i64>().map(Number::from))
229
+ .or_else(|_| value.parse::<f64>().ok().and_then(Number::from_f64).ok_or(()))
230
+ .ok()
231
+ .map(Value::Number)
232
}
233
234
/// Convert one raw string value to a boolean.
@@ -304,7 +305,7 @@ mod tests {
305
}
306
307
#[test]
307
- fn number_conversion_parses_int_then_float() {
308
+ fn number_conversion_preserves_json_number_spelling_with_legacy_fallback() {
309
let params = ToolSchema::from_schema(&json!({
310
"type": "object",
311
"properties": {
@@ -312,17 +313,23 @@ mod tests {
313
}
314
}));
315
315
- assert_eq!(params.convert("value", "5"), json!(5));
316
- assert_eq!(params.convert("value", "5.0"), json!(5.0));
317
- assert_eq!(params.convert("value", "5."), json!(5.0));
318
- assert_eq!(params.convert("value", "+1"), json!(1));
319
- assert_eq!(params.convert("value", "+1.0"), json!(1.0));
316
+ assert_eq!(converted_number_text(¶ms, "5"), "5");
317
+ assert_eq!(converted_number_text(¶ms, "5.0"), "5.0");
318
+ assert_eq!(converted_number_text(¶ms, "5.00"), "5.00");
319
+ assert_eq!(converted_number_text(¶ms, "1e0"), "1e+0");
320
+ assert_eq!(converted_number_text(¶ms, "5."), "5.0");
321
+ assert_eq!(converted_number_text(¶ms, "+1"), "1");
322
+ assert_eq!(converted_number_text(¶ms, "+1.0"), "1.0");
323
assert_eq!(
321
- params.convert("value", "9223372036854775807.5"),
322
- json!(9223372036854775808.0)
324
+ converted_number_text(¶ms, "9223372036854775807.5"),
325
+ "9223372036854775807.5"
326
);
327
}
328
329
+ fn converted_number_text(params: &ToolSchema, value: &str) -> String {
330
+ serde_json::to_string(¶ms.convert("value", value)).unwrap()
331
+ }
332
+
333
#[test]
334
fn converts_upstream_aliases() {
335
let params = ToolSchema::from_schema(&json!({