main
rs 1,311 lines 47.5 KB
Raw
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 //! Roundtrip tests for the real chat-template and output-processor pairing.
5 //!
6 //! The invariant under test is that a structured assistant message rendered as history can be
7 //! parsed from the generated assistant completion and then rendered back to the exact same
8 //! assistant completion.
9 //!
10 //! The tool-call fixture also snapshots the output grammar built by the initialized parser for each
11 //! tool-choice variant, with the rendered completion as its generation, as one file per model under
12 //! `tests/grammar_replay/`. `grammar_replay.rs` replays these cases through the real XGrammar.
13
14 use std::collections::{BTreeMap, BTreeSet};
15 use std::fmt::Debug;
16 use std::ops::Range;
17 use std::path::PathBuf;
18 use std::pin::Pin;
19 use std::sync::Arc;
20
21 use anyhow::{Context as _, Result, bail, ensure};
22 use futures::{Stream, StreamExt as _, stream};
23 use serde::ser::Error as _;
24 use serde::{Serialize, Serializer};
25 use serde_json_fmt::JsonFormat as JsonFmt;
26 use serial_test::file_serial;
27 use vllm_chat::{
28 AssistantContentBlock, AssistantMessage, AssistantMessageExt as _, AssistantToolCall,
29 ChatEvent, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice, FinishReason,
30 GenerationPromptMode, LoadModelBackendsOptions, NewChatOutputProcessorOptions, ParserSelection,
31 RendererSelection, ToolStrictLevel, load_model_backends,
32 };
33 use vllm_parser::output_grammar::BuiltOutputGrammar;
34 use vllm_text::{DecodedTextEvent, Finished, Prompt};
35 use vllm_tokenizer::Tokenizer;
36
37 const TEXT_COMPLETION_CHUNK_CHARS: usize = 7;
38 const TOKEN_COMPLETION_CHUNK_TOKENS: usize = 1;
39
40 /// One model/parser configuration used to run the fixed roundtrip fixtures.
41 #[derive(Clone)]
42 struct RoundtripCase {
43 /// Hugging Face model id or local checkpoint resolved through the production backend loader.
44 model_id: &'static str,
45 /// Final assistant-history suffix rendered by the chat template but not
46 /// generated by the model body consumed by the output processor.
47 // TODO: we should adopt `ContinueFinalAssistant` mode to naturally handle this.
48 assistant_stop_suffix: &'static str,
49 /// Tool parser selection used by the output processor.
50 tool_call_parser: ParserSelection,
51 /// Reasoning parser selection used by the output processor.
52 reasoning_parser: ParserSelection,
53 /// How this model's chat template handles thinking mode.
54 thinking_behavior: ThinkingBehavior,
55 /// JSON formatting expected after this model's template has materialized
56 /// tool-call arguments.
57 json_fmt: JsonFmt,
58 /// Whether the template renders tool-call argument object keys in sorted order.
59 sort_json_keys: bool,
60 }
61
62 #[derive(Clone, Copy)]
63 enum ThinkingBehavior {
64 /// The chat template accepts explicit thinking on/off kwargs, and uses
65 /// `default` when the request does not specify either kwarg.
66 Toggleable { default: bool },
67 /// The chat template always behaves as `value` for this fixture.
68 Always { value: bool },
69 /// The chat template selects thinking mode through `reasoning_effort`.
70 ReasoningEffort {
71 default: bool,
72 enabled: &'static str,
73 disabled: &'static str,
74 },
75 }
76
77 impl ThinkingBehavior {
78 fn default(self) -> bool {
79 match self {
80 Self::Toggleable { default } => default,
81 Self::Always { value } => value,
82 Self::ReasoningEffort { default, .. } => default,
83 }
84 }
85
86 fn fixtures(self) -> Vec<Option<bool>> {
87 match self {
88 Self::Toggleable { .. } => vec![
89 Some(true), // explicitly enable thinking
90 Some(false), // explicitly disable thinking
91 None, // use default template behavior
92 ],
93 Self::Always { value } => vec![
94 Some(value), // explicitly request the supported thinking behavior
95 None, // use default template behavior
96 ],
97 Self::ReasoningEffort { .. } => vec![
98 Some(true), // explicitly enable thinking
99 Some(false), // explicitly disable thinking
100 None, // use default template behavior
101 ],
102 }
103 }
104
105 fn apply(self, request: &mut ChatRequest, thinking: Option<bool>) {
106 let Some(thinking) = thinking else {
107 return;
108 };
109
110 match self {
111 Self::Toggleable { .. } | Self::Always { .. } => {
112 for key in ["thinking", "enable_thinking"] {
113 request.chat_options.template_kwargs.insert(key.to_string(), thinking.into());
114 }
115 }
116 Self::ReasoningEffort {
117 enabled, disabled, ..
118 } => {
119 request.chat_options.template_kwargs.insert(
120 "reasoning_effort".to_string(),
121 if thinking { enabled } else { disabled }.into(),
122 );
123 }
124 }
125 }
126 }
127
128 /// Tool-choice variant of the tool-call fixture.
129 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
130 #[serde(rename_all = "snake_case")]
131 enum ToolChoiceVariant {
132 /// `auto`, with content before the tool calls.
133 Auto,
134 /// `required`, with tool calls only: builder grammars admit no content
135 /// before the calls.
136 Required,
137 /// Named `get_weather`, with a single call to it and no content.
138 Named,
139 }
140
141 impl ToolChoiceVariant {
142 const ALL: [Self; 3] = [Self::Auto, Self::Required, Self::Named];
143
144 fn tool_choice(self) -> ChatToolChoice {
145 match self {
146 Self::Auto => ChatToolChoice::Auto,
147 Self::Required => ChatToolChoice::Required,
148 Self::Named => ChatToolChoice::Function {
149 name: "get_weather".to_string(),
150 },
151 }
152 }
153 }
154
155 impl RoundtripCase {
156 /// Qwen3 XML tool-call format with `qwen3` reasoning tags.
157 fn qwen3() -> Self {
158 Self {
159 model_id: "Qwen/Qwen3-0.6B",
160 assistant_stop_suffix: "<|im_end|>\n",
161 tool_call_parser: ParserSelection::Auto,
162 reasoning_parser: ParserSelection::Auto,
163 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
164 json_fmt: spaced_json_fmt(),
165 sort_json_keys: false,
166 }
167 }
168
169 /// Qwen3.5 coder-style JSON tool-call format with `qwen3` reasoning tags.
170 fn qwen35() -> Self {
171 Self {
172 model_id: "Qwen/Qwen3.5-4B",
173 assistant_stop_suffix: "<|im_end|>\n",
174 tool_call_parser: ParserSelection::Auto,
175 reasoning_parser: ParserSelection::Auto,
176 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
177 json_fmt: compact_json_fmt(),
178 sort_json_keys: false,
179 }
180 }
181
182 /// MiMo V2.6 uses bare reasoning and Qwen Coder tool tags.
183 fn mimo_v26() -> Self {
184 Self {
185 model_id: "XiaomiMiMo/MiMo-V2.6-Flash-RL",
186 assistant_stop_suffix: "<|im_end|>",
187 tool_call_parser: ParserSelection::Auto,
188 reasoning_parser: ParserSelection::Auto,
189 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
190 json_fmt: compact_json_fmt(),
191 sort_json_keys: false,
192 }
193 }
194
195 /// MiniMax M2.5 XML invoke format with `<think>` reasoning tags.
196 fn minimax_m25() -> Self {
197 Self {
198 model_id: "MiniMaxAI/MiniMax-M2.5",
199 assistant_stop_suffix: "[e~[\n",
200 tool_call_parser: ParserSelection::Auto,
201 reasoning_parser: ParserSelection::Auto,
202 thinking_behavior: ThinkingBehavior::Always { value: true },
203 json_fmt: compact_json_fmt(),
204 sort_json_keys: false,
205 }
206 }
207
208 /// MiniMax M3 invoke format with `<mm:think>` reasoning tags.
209 fn minimax_m3() -> Self {
210 Self {
211 model_id: "MiniMaxAI/MiniMax-M3",
212 assistant_stop_suffix: "[e~[\n",
213 tool_call_parser: ParserSelection::Auto,
214 reasoning_parser: ParserSelection::Auto,
215 thinking_behavior: ThinkingBehavior::Always { value: true },
216 json_fmt: compact_json_fmt(),
217 sort_json_keys: false,
218 }
219 }
220
221 /// DeepSeek V4 DSML tool-call format.
222 fn deepseek_v4() -> Self {
223 Self {
224 model_id: "deepseek-ai/DeepSeek-V4-Flash",
225 assistant_stop_suffix: "<|end▁of▁sentence|>",
226 tool_call_parser: ParserSelection::Auto,
227 reasoning_parser: ParserSelection::Auto,
228 thinking_behavior: ThinkingBehavior::Toggleable { default: false },
229 json_fmt: compact_json_fmt(),
230 sort_json_keys: false,
231 }
232 }
233
234 /// DeepSeek V4.1 Flash DSML tool-call format.
235 fn deepseek_v41() -> Self {
236 Self {
237 model_id: "deepseek-ai/DeepSeek-V4.1-Flash",
238 assistant_stop_suffix: "<|end▁of▁sentence|>",
239 tool_call_parser: ParserSelection::Auto,
240 reasoning_parser: ParserSelection::Auto,
241 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
242 json_fmt: compact_json_fmt(),
243 sort_json_keys: false,
244 }
245 }
246
247 /// DeepSeek V3.2 DSML tool-call format.
248 fn deepseek_v32() -> Self {
249 Self {
250 model_id: "deepseek-ai/DeepSeek-V3.2-Exp",
251 assistant_stop_suffix: "<|end▁of▁sentence|>",
252 tool_call_parser: ParserSelection::Auto,
253 reasoning_parser: ParserSelection::Auto,
254 thinking_behavior: ThinkingBehavior::Toggleable { default: false },
255 json_fmt: compact_json_fmt(),
256 sort_json_keys: false,
257 }
258 }
259
260 /// GLM-4.5 XML-like argument format with `<think>` reasoning tags.
261 fn glm45() -> Self {
262 Self {
263 model_id: "zai-org/GLM-4.5",
264 assistant_stop_suffix: "",
265 tool_call_parser: ParserSelection::Auto,
266 reasoning_parser: ParserSelection::Auto,
267 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
268 json_fmt: compact_json_fmt(),
269 sort_json_keys: false,
270 }
271 }
272
273 /// GLM-4.7 XML-like argument format with `<think>` reasoning tags.
274 fn glm47() -> Self {
275 Self {
276 model_id: "zai-org/GLM-4.7-Flash",
277 assistant_stop_suffix: "",
278 tool_call_parser: ParserSelection::Auto,
279 reasoning_parser: ParserSelection::Auto,
280 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
281 json_fmt: compact_json_fmt(),
282 sort_json_keys: false,
283 }
284 }
285
286 /// GLM-5.2 XML-like argument format with `<think>` reasoning tags.
287 fn glm52() -> Self {
288 Self {
289 model_id: "zai-org/GLM-5.2-FP8",
290 assistant_stop_suffix: "",
291 tool_call_parser: ParserSelection::Auto,
292 reasoning_parser: ParserSelection::Auto,
293 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
294 json_fmt: compact_json_fmt(),
295 sort_json_keys: false,
296 }
297 }
298
299 /// Gemma4 channel reasoning with custom function-call arguments.
300 fn gemma4() -> Self {
301 Self {
302 model_id: "google/gemma-4-E4B-it",
303 assistant_stop_suffix: "<|tool_response>",
304 tool_call_parser: ParserSelection::Auto,
305 reasoning_parser: ParserSelection::Auto,
306 thinking_behavior: ThinkingBehavior::Always { value: true },
307 json_fmt: compact_json_fmt(),
308 sort_json_keys: true,
309 }
310 }
311
312 /// Gemma4 through the checkpoint's `response_template` (`hf` parser).
313 fn gemma4_hf() -> Self {
314 Self {
315 tool_call_parser: ParserSelection::Explicit("hf".to_string()),
316 reasoning_parser: ParserSelection::Explicit("hf".to_string()),
317 ..Self::gemma4()
318 }
319 }
320
321 /// Kimi K2.5 tool-call format with `<think>` reasoning tags.
322 #[allow(dead_code)]
323 fn kimi_k25() -> Self {
324 Self {
325 model_id: "moonshotai/Kimi-K2.5",
326 assistant_stop_suffix: "<|im_end|>",
327 tool_call_parser: ParserSelection::Auto,
328 reasoning_parser: ParserSelection::Auto,
329 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
330 json_fmt: spaced_json_fmt(),
331 sort_json_keys: false,
332 }
333 }
334
335 /// Kimi K3 XTML tool/reasoning channels (native renderer + unified parser).
336 ///
337 /// Needs HF tokenizer files under `HF_HOME` (`tiktoken.model` +
338 /// `tokenizer_config.json`). Weights are not required for this text-level
339 /// roundtrip.
340 fn kimi_k3() -> Self {
341 Self {
342 model_id: "moonshotai/Kimi-K3",
343 assistant_stop_suffix: "<|end_of_msg|>",
344 tool_call_parser: ParserSelection::Auto,
345 reasoning_parser: ParserSelection::Auto,
346 thinking_behavior: ThinkingBehavior::Toggleable { default: true },
347 json_fmt: compact_json_fmt(),
348 sort_json_keys: false,
349 }
350 }
351
352 /// HY3 suffixed reasoning/tool markers discovered from tokenizer added vocab.
353 fn hy_v3() -> Self {
354 Self {
355 model_id: "tencent/Hy3",
356 assistant_stop_suffix: "<|hy_eos:opensource|>",
357 tool_call_parser: ParserSelection::Auto,
358 reasoning_parser: ParserSelection::Auto,
359 thinking_behavior: ThinkingBehavior::ReasoningEffort {
360 default: false,
361 enabled: "high",
362 disabled: "no_think",
363 },
364 json_fmt: compact_json_fmt(),
365 sort_json_keys: false,
366 }
367 }
368
369 /// SeedOSS with `<seed:think>` / `</seed:think>` reasoning tags.
370 fn seed_oss() -> Self {
371 Self {
372 model_id: "ByteDance-Seed/Seed-OSS-36B-Instruct",
373 assistant_stop_suffix: "<seed:eos>",
374 tool_call_parser: ParserSelection::Auto,
375 reasoning_parser: ParserSelection::Auto,
376 thinking_behavior: ThinkingBehavior::Always { value: true },
377 json_fmt: compact_json_fmt(),
378 sort_json_keys: false,
379 }
380 }
381
382 /// Step-3.5 with `<think>` / `</think>` reasoning tags and newline trimming.
383 fn step3p5() -> Self {
384 Self {
385 model_id: "stepfun-ai/Step-3.5-Flash",
386 assistant_stop_suffix: "<|im_end|>\n",
387 tool_call_parser: ParserSelection::Auto,
388 reasoning_parser: ParserSelection::Auto,
389 thinking_behavior: ThinkingBehavior::Always { value: true },
390 json_fmt: compact_json_fmt(),
391 sort_json_keys: false,
392 }
393 }
394
395 /// Nemotron V3 with `<think>` / `</think>` reasoning tags.
396 fn nemotron_v3() -> Self {
397 Self {
398 model_id: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
399 assistant_stop_suffix: "<|im_end|>\n",
400 tool_call_parser: ParserSelection::Auto,
401 reasoning_parser: ParserSelection::Auto,
402 thinking_behavior: ThinkingBehavior::Always { value: true },
403 json_fmt: compact_json_fmt(),
404 sort_json_keys: false,
405 }
406 }
407
408 /// GPT-OSS Harmony token-id renderer and native Harmony output processor.
409 fn gpt_oss() -> Self {
410 Self {
411 model_id: "openai/gpt-oss-20b",
412 assistant_stop_suffix: "",
413 tool_call_parser: ParserSelection::Auto,
414 reasoning_parser: ParserSelection::Auto,
415 thinking_behavior: ThinkingBehavior::Always { value: true },
416 json_fmt: compact_json_fmt(),
417 sort_json_keys: false,
418 }
419 }
420
421 /// Inkling typed content blocks with native token-id rendering.
422 fn inkling() -> Self {
423 Self {
424 model_id: "thinkingmachines/Inkling",
425 assistant_stop_suffix: "<|content_model_end_sampling|>",
426 tool_call_parser: ParserSelection::Auto,
427 reasoning_parser: ParserSelection::Auto,
428 thinking_behavior: ThinkingBehavior::Always { value: true },
429 json_fmt: compact_json_fmt(),
430 sort_json_keys: true,
431 }
432 }
433 }
434
435 macro_rules! roundtrip_tests {
436 ($($case:ident => $(#[$case_attr:meta])* [$($fixture:ident),* $(,)?]),+ $(,)?) => {
437 paste::paste! {
438 $(
439 #[tokio::test]
440 #[file_serial([<hf_ $case>])]
441 $(#[$case_attr])*
442 async fn [<roundtrip_ $case>]() -> Result<()> {
443 let case = RoundtripCase::$case();
444 let backends = load_roundtrip_backends(&case).await?;
445 $(
446 [<run_roundtrip_ $fixture>](&case, &backends).await?;
447 )*
448 Ok(())
449 }
450 )+
451 }
452 };
453 }
454
455 roundtrip_tests! {
456 qwen3 => [reasoning_and_content, tool_call_mix],
457 qwen35 => [reasoning_and_content, tool_call_mix],
458 mimo_v26 => [reasoning_and_content, tool_call_mix],
459 minimax_m25 => [reasoning_and_content, tool_call_mix],
460 minimax_m3 => [reasoning_and_content, tool_call_mix],
461 deepseek_v4 => [reasoning_and_content, tool_call_mix],
462 deepseek_v41 => [reasoning_and_content, tool_call_mix],
463 deepseek_v32 => [tool_call_mix],
464 glm45 => [reasoning_and_content, tool_call_mix],
465 glm47 => [reasoning_and_content, tool_call_mix],
466 glm52 => [reasoning_and_content, tool_call_mix],
467 seed_oss => [reasoning_and_content, tool_call_mix],
468 step3p5 => [reasoning_and_content],
469 nemotron_v3 => [reasoning_and_content],
470 gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
471 gemma4_hf => [tool_call_mix],
472 kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
473 // K3 drops plain-assistant reasoning in history; tool-call turns keep it.
474 kimi_k3 => [tool_call_mix],
475 // HY3's final plain-assistant history omits EOS; tool-call history keeps it
476 // and exercises both stages of the tokenizer-derived unified parser.
477 hy_v3 => [tool_call_mix],
478 gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call
479 inkling => [reasoning_and_content, tool_call_mix],
480 }
481
482 /// Run the fixed reasoning+content fixture for one model/parser case.
483 async fn run_roundtrip_reasoning_and_content(
484 case: &RoundtripCase,
485 backends: &vllm_chat::LoadedModelBackends,
486 ) -> Result<()> {
487 for thinking in case.thinking_behavior.fixtures() {
488 run_roundtrip_reasoning_and_content_inner(case, backends, thinking).await?;
489 }
490 Ok(())
491 }
492
493 async fn run_roundtrip_reasoning_and_content_inner(
494 case: &RoundtripCase,
495 backends: &vllm_chat::LoadedModelBackends,
496 thinking: Option<bool>,
497 ) -> Result<()> {
498 let request = roundtrip_request(
499 "roundtrip-reasoning-content",
500 vec![ChatMessage::text(ChatRole::User, "What is 2 + 2?")],
501 Vec::new(),
502 None,
503 thinking,
504 case.thinking_behavior,
505 );
506 let expected_reasoning = "Need compute 2 + 2 directly.";
507 let expected_text = "The answer is 4.";
508 let effective_thinking = thinking.unwrap_or(case.thinking_behavior.default());
509
510 let assistant = {
511 let mut content = Vec::new();
512 if effective_thinking {
513 content.push(AssistantContentBlock::Reasoning {
514 text: expected_reasoning.to_string(),
515 });
516 }
517 content.push(AssistantContentBlock::Text {
518 text: expected_text.to_string(),
519 });
520 AssistantMessage { content }
521 };
522 let result = run_roundtrip(case, backends, &request, assistant).await?;
523
524 assert_eq!(
525 result.parsed_message.reasoning().as_deref(),
526 effective_thinking.then_some(expected_reasoning),
527 "parsed message: {:#?}",
528 result.parsed_message
529 );
530 assert_eq!(result.parsed_message.text(), expected_text);
531 assert_eq!(result.parsed_message.tool_calls().count(), 0);
532
533 assert_eq!(
534 result.rerendered_closed_completion,
535 result.closed_completion
536 );
537
538 Ok(())
539 }
540
541 /// Run the fixed reasoning+multiple-tools fixture for one model/parser case,
542 /// once per tool-choice variant.
543 async fn run_roundtrip_tool_call_mix(
544 case: &RoundtripCase,
545 backends: &vllm_chat::LoadedModelBackends,
546 ) -> Result<()> {
547 let mut results = Vec::new();
548 for variant in ToolChoiceVariant::ALL {
549 let result = run_roundtrip_tool_call_mix_inner(case, backends, variant).await?;
550 results.push((variant, result));
551 }
552 check_grammar_replay_file(case, backends, results)
553 }
554
555 async fn run_roundtrip_tool_call_mix_inner(
556 case: &RoundtripCase,
557 backends: &vllm_chat::LoadedModelBackends,
558 variant: ToolChoiceVariant,
559 ) -> Result<RoundtripResult> {
560 let request = roundtrip_request(
561 "roundtrip-reasoning-tools",
562 vec![ChatMessage::text(
563 ChatRole::User,
564 "Check Shanghai weather and add 1.0 plus 2.",
565 )],
566 test_tools(),
567 Some(variant.tool_choice()),
568 Some(true), // always enable thinking in this fixture
569 case.thinking_behavior,
570 );
571 let expected_reasoning = "Need call the weather and add tools.";
572 let (expected_text, tool_call_count) = match variant {
573 ToolChoiceVariant::Auto => (Some("I will call the tools."), 2),
574 ToolChoiceVariant::Required => (None, 2),
575 ToolChoiceVariant::Named => (None, 1),
576 };
577
578 let mut content = vec![AssistantContentBlock::Reasoning {
579 text: expected_reasoning.to_string(),
580 }];
581 content.extend(expected_text.map(|text| AssistantContentBlock::Text {
582 text: text.to_string(),
583 }));
584 let tool_calls = [
585 AssistantContentBlock::ToolCall(AssistantToolCall {
586 id: "functions.get_weather:0".to_string(),
587 name: "get_weather".to_string(),
588 arguments: r#"{"location":"Shanghai"}"#.to_string(),
589 }),
590 AssistantContentBlock::ToolCall(AssistantToolCall {
591 id: "functions.add:1".to_string(),
592 name: "add".to_string(),
593 // Intentionally use a non-lexical order of keys to verify text-level
594 // fidelity of the roundtrip where JSON formatting remains stable. The
595 // `items` key also exercises templates that call `arguments.items()`.
596 arguments: r#"{"y":1.0,"x":2,"items":["left","right"]}"#.to_string(),
597 }),
598 ];
599 content.extend(tool_calls.into_iter().take(tool_call_count));
600 let result = run_roundtrip(case, backends, &request, AssistantMessage { content }).await?;
601
602 assert_eq!(
603 result.parsed_message.reasoning().as_deref(),
604 Some(expected_reasoning),
605 "parsed message: {:#?}",
606 result.parsed_message
607 );
608 assert_eq!(
609 result.parsed_message.text(),
610 expected_text.unwrap_or_default()
611 );
612
613 let tool_calls = result.parsed_message.tool_calls().collect::<Vec<_>>();
614 assert_eq!(
615 tool_calls.len(),
616 tool_call_count,
617 "parsed message: {:#?}",
618 result.parsed_message
619 );
620 assert_eq!(tool_calls[0].name, "get_weather");
621 assert_eq!(
622 tool_calls[0].arguments,
623 expected_arguments(case, r#"{"location": "Shanghai"}"#)?,
624 );
625 if let Some(add) = tool_calls.get(1) {
626 assert_eq!(add.name, "add");
627 assert_eq!(
628 add.arguments,
629 expected_arguments(case, r#"{"y": 1.0, "x": 2, "items": ["left", "right"]}"#)?,
630 );
631 }
632
633 assert_eq!(
634 result.rerendered_closed_completion,
635 result.closed_completion
636 );
637
638 Ok(result)
639 }
640
641 /// Compact JSON argument formatting used by JSON-native parsers/renderers.
642 fn compact_json_fmt() -> JsonFmt {
643 JsonFmt::new()
644 }
645
646 /// Python `json.dumps`-style compact formatting with a space after commas and
647 /// colons.
648 fn spaced_json_fmt() -> JsonFmt {
649 JsonFmt::new()
650 .comma(", ")
651 .expect("literal comma separator is valid JSON")
652 .colon(": ")
653 .expect("literal colon separator is valid JSON")
654 }
655
656 /// Parse and format expected tool-call arguments from raw JSON text.
657 /// Pass in a raw JSON string instead of a structured value to ensure the exact precision and
658 /// formatting of numbers are preserved.
659 fn expected_arguments(case: &RoundtripCase, raw_json: &str) -> Result<String> {
660 let mut value: serde_json::Value =
661 serde_json::from_str(raw_json).context("invalid expected tool-call arguments")?;
662 if case.sort_json_keys {
663 sort_json_value(&mut value);
664 }
665
666 case.json_fmt
667 .format_to_string(&value)
668 .context("failed to format expected tool-call arguments")
669 }
670
671 /// Sort JSON object keys recursively to match templates that render mappings with `dictsort`.
672 fn sort_json_value(value: &mut serde_json::Value) {
673 match value {
674 serde_json::Value::Object(map) => {
675 for value in map.values_mut() {
676 sort_json_value(value);
677 }
678
679 let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
680 entries.sort_by(|(left, _), (right, _)| left.cmp(right));
681 map.extend(entries);
682 }
683 serde_json::Value::Array(values) => {
684 for value in values {
685 sort_json_value(value);
686 }
687 }
688 _ => {}
689 }
690 }
691
692 /// Load the real model chat/text backend for one roundtrip case.
693 async fn load_roundtrip_backends(case: &RoundtripCase) -> Result<vllm_chat::LoadedModelBackends> {
694 load_model_backends(
695 case.model_id,
696 LoadModelBackendsOptions {
697 renderer: RendererSelection::Auto,
698 ..Default::default()
699 },
700 )
701 .await
702 .with_context(|| format!("failed to load HF model files for {}", case.model_id))
703 }
704
705 /// Roundtrip artifacts needed for semantic and exact-text assertions.
706 struct RoundtripResult {
707 /// Final assistant message reconstructed by the output processor.
708 parsed_message: AssistantMessage,
709 /// Assistant-completion suffix cut from rendering the expected assistant as
710 /// history.
711 closed_completion: Prompt,
712 /// Assistant-completion suffix cut after rendering the parsed assistant
713 /// back as history.
714 rerendered_closed_completion: Prompt,
715 /// Output grammar built by the same initialized parser that parsed the
716 /// completion.
717 output_grammar: Option<BuiltOutputGrammar>,
718 }
719
720 /// Render, parse, and rerender one assistant turn through the production
721 /// renderer/output-processor boundary.
722 async fn run_roundtrip(
723 case: &RoundtripCase,
724 backends: &vllm_chat::LoadedModelBackends,
725 request: &ChatRequest,
726 assistant: AssistantMessage,
727 ) -> Result<RoundtripResult> {
728 let renderer = backends.chat_backend.chat_renderer();
729 let rendered = render_closed_completion(renderer.as_ref(), request, &assistant)?;
730
731 let ParsedCompletion {
732 message: parsed_message,
733 output_grammar,
734 } = parse_completion(case, backends, request, &rendered).await?;
735 let rerendered = render_closed_completion(renderer.as_ref(), request, &parsed_message)?;
736
737 Ok(RoundtripResult {
738 parsed_message,
739 closed_completion: rendered.completion,
740 rerendered_closed_completion: rerendered.completion,
741 output_grammar,
742 })
743 }
744
745 /// Rendered prompt/completion artifacts at the renderer boundary.
746 struct RenderedTurn {
747 prompt: Prompt,
748 completion: Prompt,
749 }
750
751 /// Render `history` as a production prompt and `history + assistant` as closed
752 /// history, then return the production prompt and the assistant completion.
753 fn render_closed_completion(
754 renderer: &dyn vllm_chat::ChatRenderer,
755 base_request: &ChatRequest,
756 assistant: &AssistantMessage,
757 ) -> Result<RenderedTurn> {
758 let mut prompt_request = base_request.clone();
759 prompt_request.chat_options.generation_prompt_mode = GenerationPromptMode::StartNewAssistant;
760 let prompt = renderer.render(&prompt_request).context("failed to render prompt")?.prompt;
761
762 let mut full_request = base_request.clone();
763 full_request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
764 full_request.messages.push(ChatMessage::from(assistant.clone()));
765 let full = renderer.render(&full_request).context("failed to render full prompt")?.prompt;
766
767 let completion = match (&prompt, full) {
768 (Prompt::Text(prompt), Prompt::Text(full)) => {
769 let range = completion_range(prompt.as_bytes(), full.as_bytes())
770 .with_context(|| format!("prompt: {prompt:?}\nfull: {full:?}"))?;
771 let completion = full.get(range).context("completion splits a UTF-8 character")?;
772 Prompt::Text(completion.to_string())
773 }
774 (Prompt::TokenIds(prompt), Prompt::TokenIds(full)) => {
775 let range = completion_range(prompt, &full)
776 .with_context(|| format!("prompt: {prompt:?}\nfull: {full:?}"))?;
777 Prompt::TokenIds(full[range].to_vec())
778 }
779 (prompt, full) => bail!("prompt kind changed between renders: {prompt:?} vs {full:?}"),
780 };
781
782 Ok(RenderedTurn { prompt, completion })
783 }
784
785 /// Locate the assistant completion in `full`, the history rendered with the
786 /// assistant message, given `prompt`, the same history rendered for generation.
787 ///
788 /// Some templates render request-scoped control messages after the whole
789 /// history, such as Kimi K3's `tool_choice=required` message. With history `A`,
790 /// control messages `C`, generation prompt `G`, and completion `B`, the renders
791 /// are `prompt = A + C + G` and `full = A + G + B + C`. `C` is empty for most
792 /// templates, which makes `prompt` a prefix of `full`.
793 fn completion_range<T: PartialEq + Debug>(prompt: &[T], full: &[T]) -> Result<Range<usize>> {
794 if full.starts_with(prompt) {
795 return Ok(prompt.len()..full.len());
796 }
797 ensure!(
798 full.len() >= prompt.len(),
799 "full prompt is shorter than production prompt"
800 );
801
802 // `A` is a common prefix of both renders; try every split of the remaining
803 // `prompt` into `C + G` and require exactly one to fit `full`.
804 let common_prefix_len = prompt.iter().zip(full).take_while(|(p, f)| p == f).count();
805 let mut control_lens = Vec::new();
806 for history_len in 0..=common_prefix_len {
807 let rest = &prompt[history_len..];
808 for control_len in 1..=rest.len() {
809 let (control, generation) = rest.split_at(control_len);
810 if full[history_len..].starts_with(generation) && full.ends_with(control) {
811 control_lens.push(control_len);
812 }
813 }
814 }
815 let [control_len] = control_lens[..] else {
816 bail!(
817 "full prompt must be `A + G + B + C` for production prompt `A + C + G`; found {} splits",
818 control_lens.len()
819 );
820 };
821
822 Ok(prompt.len() - control_len..full.len() - control_len)
823 }
824
825 #[test]
826 fn completion_range_skips_trailing_control_messages() {
827 let history = "<user>hi</user>";
828 let control = "<system>call tools</system>";
829 let generation = "<assistant>";
830 let completion = "call()</assistant>";
831
832 let prompt = format!("{history}{generation}");
833 let full = format!("{history}{generation}{completion}");
834 let range = completion_range(prompt.as_bytes(), full.as_bytes()).unwrap();
835 assert_eq!(&full[range], completion);
836
837 let prompt = format!("{history}{control}{generation}");
838 let full = format!("{history}{generation}{completion}{control}");
839 let range = completion_range(prompt.as_bytes(), full.as_bytes()).unwrap();
840 assert_eq!(&full[range], completion);
841 }
842
843 /// Output of one production output processor over a rendered completion.
844 struct ParsedCompletion {
845 /// Terminal assistant message.
846 message: AssistantMessage,
847 /// Output grammar built by the processor after initialization.
848 output_grammar: Option<BuiltOutputGrammar>,
849 }
850
851 /// Feed one rendered assistant completion body into the real output processor
852 /// and collect its terminal assistant message, along with the output grammar
853 /// the initialized processor builds for the request.
854 async fn parse_completion(
855 case: &RoundtripCase,
856 backends: &vllm_chat::LoadedModelBackends,
857 base_request: &ChatRequest,
858 rendered: &RenderedTurn,
859 ) -> Result<ParsedCompletion> {
860 let tokenizer = backends.text_backend.tokenizer();
861 let prompt_token_ids = match &rendered.prompt {
862 Prompt::Text(prompt) => tokenizer
863 .encode(prompt, base_request.add_special_tokens)
864 .context("failed to encode rendered prompt")?,
865 Prompt::TokenIds(token_ids) => token_ids.clone(),
866 };
867
868 let mut request = base_request.clone();
869 let mut processor = backends.chat_backend.new_chat_output_processor(
870 &mut request,
871 NewChatOutputProcessorOptions {
872 // Pin every tool schema so that builder-backed parsers build an
873 // output grammar for replay. The strict level does not affect parsing.
874 tool_strict_level: ToolStrictLevel::Parameter,
875 tool_call_parser: &case.tool_call_parser,
876 reasoning_parser: &case.reasoning_parser,
877 },
878 )?;
879 processor.initialize(&prompt_token_ids)?;
880 let output_grammar = processor.build_output_grammar()?;
881
882 let decoded = decoded_completion_stream(
883 tokenizer.as_ref(),
884 prompt_token_ids,
885 &rendered.completion,
886 case.assistant_stop_suffix,
887 )?;
888 let mut events = processor.process(decoded)?;
889
890 while let Some(event) = events.next().await {
891 if let ChatEvent::Done { message, .. } = event? {
892 return Ok(ParsedCompletion {
893 message,
894 output_grammar,
895 });
896 }
897 }
898
899 bail!("output processor finished without a Done event")
900 }
901
902 /// Build a decoded-text stream from an already-rendered completion body.
903 ///
904 /// The first event carries real prompt token ids so reasoning parsers can
905 /// initialize from the same prompt boundary production uses. Completion text is
906 /// split into small chunks to exercise streaming parser state across marker
907 /// and JSON boundaries.
908 fn decoded_completion_stream(
909 tokenizer: &dyn Tokenizer,
910 prompt_token_ids: Vec<u32>,
911 completion: &Prompt,
912 assistant_stop_suffix: &str,
913 ) -> Result<Pin<Box<dyn Stream<Item = vllm_chat::Result<DecodedTextEvent>> + Send>>> {
914 let mut events = vec![DecodedTextEvent::Start {
915 prompt_token_ids: Arc::from(prompt_token_ids.clone().into_boxed_slice()),
916 prompt_logprobs: None,
917 }];
918
919 let chunks = match completion_body(tokenizer, completion, assistant_stop_suffix)? {
920 CompletionBody::Text(body) => {
921 // The engine returns token ids, so attribute the rendered text
922 // through the production decoder, then split it into small char
923 // chunks to exercise marker splits.
924 let token_ids =
925 tokenizer.encode(body, false).context("failed to encode text completion")?;
926 let mut rest = vllm_text::DecodedText::default();
927 for chunk in incremental_decode_chunks(
928 tokenizer,
929 &prompt_token_ids,
930 &token_ids,
931 TOKEN_COMPLETION_CHUNK_TOKENS,
932 )? {
933 rest.append(chunk.delta);
934 }
935 ensure!(
936 rest.text == body,
937 "text completion must roundtrip through the tokenizer\nbody: {body:?}\ndecoded: {:?}",
938 rest.text
939 );
940 split_by_chars(body, TEXT_COMPLETION_CHUNK_CHARS)
941 .into_iter()
942 .map(|delta| DecodedCompletionChunk {
943 delta: rest.drain_prefix(delta.len()),
944 token_ids: Vec::new(), // unused for text-level roundtrip cases
945 })
946 .collect()
947 }
948 CompletionBody::TokenIds(body) => incremental_decode_chunks(
949 tokenizer,
950 &prompt_token_ids,
951 body,
952 TOKEN_COMPLETION_CHUNK_TOKENS,
953 )?,
954 };
955
956 if chunks.is_empty() {
957 events.push({
958 DecodedTextEvent::TextDelta {
959 decoded: vllm_text::DecodedText::default(),
960 sampled: vllm_text::SampledDelta::default(),
961 finished: Some(Box::new(Finished {
962 usage: Default::default(),
963 finish_reason: FinishReason::stop_eos(),
964 kv_transfer_params: None,
965 ec_transfer_params: None,
966 sampling_mask: None,
967 })),
968 }
969 });
970 } else {
971 let last_index = chunks.len() - 1;
972 for (index, chunk) in chunks.into_iter().enumerate() {
973 let finished = (index == last_index).then(|| Finished {
974 usage: Default::default(),
975 finish_reason: FinishReason::stop_eos(),
976 kv_transfer_params: None,
977 ec_transfer_params: None,
978 sampling_mask: None,
979 });
980 events.push(DecodedTextEvent::TextDelta {
981 decoded: chunk.delta,
982 sampled: vllm_text::SampledDelta {
983 token_ids: chunk.token_ids,
984 logprobs: None,
985 },
986 finished: finished.map(Box::new),
987 });
988 }
989 }
990
991 Ok(stream::iter(events).map(Ok).boxed())
992 }
993
994 /// Generated part of a closed assistant completion, without the rendered stop
995 /// suffix.
996 enum CompletionBody<'a> {
997 Text(&'a str),
998 TokenIds(&'a [u32]),
999 }
1000
1001 /// Strip the rendered stop suffix from a closed assistant completion.
1002 fn completion_body<'a>(
1003 tokenizer: &dyn Tokenizer,
1004 completion: &'a Prompt,
1005 assistant_stop_suffix: &str,
1006 ) -> Result<CompletionBody<'a>> {
1007 match completion {
1008 Prompt::Text(text) => {
1009 let body = text.strip_suffix(assistant_stop_suffix).with_context(|| {
1010 format!(
1011 "closed assistant completion did not end with {:?}: {:?}",
1012 assistant_stop_suffix, text
1013 )
1014 })?;
1015 Ok(CompletionBody::Text(body))
1016 }
1017 Prompt::TokenIds(token_ids) => {
1018 let body = if assistant_stop_suffix.is_empty() {
1019 token_ids.as_slice()
1020 } else {
1021 let stop_token_ids = tokenizer
1022 .encode(assistant_stop_suffix, false)
1023 .context("failed to encode token-id completion stop suffix")?;
1024 token_ids.strip_suffix(stop_token_ids.as_slice()).with_context(|| {
1025 format!(
1026 "token-id completion did not end with {:?}: {:?}",
1027 assistant_stop_suffix, token_ids
1028 )
1029 })?
1030 };
1031 Ok(CompletionBody::TokenIds(body))
1032 }
1033 }
1034 }
1035
1036 /// One decoded completion chunk fed into the output processor.
1037 struct DecodedCompletionChunk {
1038 /// Attributed for token-id completions, as the production decoder emits it.
1039 delta: vllm_text::DecodedText,
1040 token_ids: Vec<u32>,
1041 }
1042
1043 /// Split text into chunks containing at most `chunk_chars` Unicode scalar
1044 /// values.
1045 fn split_by_chars(text: &str, chunk_chars: usize) -> Vec<String> {
1046 let mut chunks = Vec::new();
1047 let mut start = 0;
1048 let mut count = 0;
1049
1050 for (index, _) in text.char_indices() {
1051 if count == chunk_chars {
1052 chunks.push(text[start..index].to_string());
1053 start = index;
1054 count = 0;
1055 }
1056 count += 1;
1057 }
1058
1059 if start < text.len() {
1060 chunks.push(text[start..].to_string());
1061 }
1062
1063 chunks
1064 }
1065
1066 /// Split token ids into chunks containing at most `chunk_size` ids.
1067 fn split_by_count(token_ids: &[u32], chunk_size: usize) -> Vec<Vec<u32>> {
1068 token_ids.chunks(chunk_size).map(<[u32]>::to_vec).collect()
1069 }
1070
1071 /// Decode token ids incrementally using the production tokenizer stream.
1072 fn incremental_decode_chunks(
1073 tokenizer: &dyn Tokenizer,
1074 prompt_token_ids: &[u32],
1075 token_ids: &[u32],
1076 chunk_size: usize,
1077 ) -> Result<Vec<DecodedCompletionChunk>> {
1078 let mut decoder = tokenizer.create_decode_stream(prompt_token_ids, false, 0);
1079 let mut chunks = Vec::new();
1080 for chunk_token_ids in split_by_count(token_ids, chunk_size) {
1081 let mut delta = vllm_text::DecodedText::default();
1082 for token_id in chunk_token_ids.iter().copied() {
1083 decoder.push_token(token_id)?;
1084 while let Some(chunk) = decoder.next_chunk() {
1085 delta.append(chunk);
1086 }
1087 }
1088 chunks.push(DecodedCompletionChunk {
1089 delta,
1090 token_ids: chunk_token_ids,
1091 });
1092 }
1093
1094 let (last_chunk, _) = decoder.flush(None)?;
1095 if let Some(last_chunk) = last_chunk {
1096 if let Some(delta) = chunks.last_mut() {
1097 delta.delta.append(last_chunk);
1098 } else {
1099 chunks.push(DecodedCompletionChunk {
1100 delta: last_chunk,
1101 token_ids: Vec::new(),
1102 });
1103 }
1104 }
1105
1106 Ok(chunks)
1107 }
1108
1109 /// Grammar replay cases of one model, replayed through the real XGrammar by
1110 /// `grammar_replay.py`.
1111 #[derive(Serialize)]
1112 struct GrammarReplayFile {
1113 /// Hugging Face model id whose tokenizer the replay loads.
1114 model_id: &'static str,
1115 /// Model vocabulary size, which sizes the XGrammar token mask.
1116 vocab_size: usize,
1117 /// Request stop set, passed to the matcher as `override_stop_tokens`.
1118 #[serde(serialize_with = "serialize_one_line")]
1119 all_stop_token_ids: BTreeSet<u32>,
1120 /// Cases keyed by tool-choice variant.
1121 cases: BTreeMap<ToolChoiceVariant, GrammarReplayCase>,
1122 }
1123
1124 /// One parser-built output grammar with the generation it must accept.
1125 #[derive(Serialize)]
1126 struct GrammarReplayCase {
1127 /// Parser-built grammar; only token-zero grammars are replayed from the
1128 /// first generated token.
1129 grammar: BuiltOutputGrammar,
1130 /// Generated completion text, without the stop token.
1131 completion: String,
1132 /// Generated token IDs presented to the matcher, ending with a stop token.
1133 #[serde(serialize_with = "serialize_one_line")]
1134 generation_token_ids: Vec<u32>,
1135 }
1136
1137 /// Serialize a token ID list on one line inside the pretty-printed file.
1138 fn serialize_one_line<S: Serializer>(
1139 token_ids: &impl Serialize,
1140 serializer: S,
1141 ) -> std::result::Result<S::Ok, S::Error> {
1142 let json = spaced_json_fmt().format_to_string(token_ids).map_err(S::Error::custom)?;
1143 serde_json::value::RawValue::from_string(json)
1144 .map_err(S::Error::custom)?
1145 .serialize(serializer)
1146 }
1147
1148 /// Directory of checked-in grammar replay files.
1149 fn grammar_replay_dir() -> PathBuf {
1150 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/grammar_replay")
1151 }
1152
1153 /// Snapshot the output grammars of one model's roundtrips, keyed by variant,
1154 /// as its grammar replay file, or check that no stale file remains when the
1155 /// parser builds none.
1156 fn check_grammar_replay_file(
1157 case: &RoundtripCase,
1158 backends: &vllm_chat::LoadedModelBackends,
1159 results: Vec<(ToolChoiceVariant, RoundtripResult)>,
1160 ) -> Result<()> {
1161 let model_name = case.model_id.rsplit_once('/').map_or(case.model_id, |(_, name)| name);
1162 let path = grammar_replay_dir().join(format!("{model_name}.json"));
1163 let outline_path = path.with_extension("txt");
1164 let results = results
1165 .into_iter()
1166 .filter_map(|(variant, result)| {
1167 Some((variant, result.output_grammar?, result.closed_completion))
1168 })
1169 .collect::<Vec<_>>();
1170 if results.is_empty() {
1171 for path in [&path, &outline_path] {
1172 ensure!(
1173 !path.exists(),
1174 "{} builds no output grammar, but grammar replay file {path:?} exists",
1175 case.model_id
1176 );
1177 }
1178 return Ok(());
1179 }
1180
1181 let tokenizer = backends.text_backend.tokenizer();
1182 let hints = backends.text_backend.sampling_hints()?;
1183 // Lowered `_all_stop_token_ids` of a request without user stop tokens.
1184 let mut all_stop_token_ids = hints.extra_eos_token_ids;
1185 all_stop_token_ids.extend(hints.primary_eos_token_id);
1186
1187 // The generation ends with the first token of the rendered stop suffix, or
1188 // with the primary EOS for templates that render none.
1189 let stop_token_id = if case.assistant_stop_suffix.is_empty() {
1190 hints.primary_eos_token_id.context("model has no primary EOS token")?
1191 } else {
1192 tokenizer.encode(case.assistant_stop_suffix, false)?[0]
1193 };
1194 ensure!(
1195 all_stop_token_ids.contains(&stop_token_id),
1196 "stop token {stop_token_id} of {:?} is not in the stop set {all_stop_token_ids:?}",
1197 case.assistant_stop_suffix
1198 );
1199
1200 let mut cases = BTreeMap::new();
1201 for (variant, grammar, closed_completion) in results {
1202 let (completion, mut generation_token_ids) = match completion_body(
1203 tokenizer.as_ref(),
1204 &closed_completion,
1205 case.assistant_stop_suffix,
1206 )? {
1207 CompletionBody::Text(body) => (body.to_string(), tokenizer.encode(body, false)?),
1208 CompletionBody::TokenIds(body) => (tokenizer.decode(body, false)?, body.to_vec()),
1209 };
1210 generation_token_ids.push(stop_token_id);
1211 cases.insert(
1212 variant,
1213 GrammarReplayCase {
1214 grammar,
1215 completion,
1216 generation_token_ids,
1217 },
1218 );
1219 }
1220
1221 // A readable outline of the same grammars, for review only.
1222 let mut outline = String::new();
1223 for (variant, case) in &cases {
1224 if !outline.is_empty() {
1225 outline.push('\n');
1226 }
1227 let variant = serde_json::to_value(variant)?;
1228 let coverage = serde_json::to_value(case.grammar.coverage)?;
1229 outline.push_str(&format!(
1230 "# {} ({})\n",
1231 variant.as_str().context("variant name")?,
1232 coverage.as_str().context("coverage name")?,
1233 ));
1234 outline.push_str(&vllm_parser::output_grammar::test_utils::outline(
1235 &case.grammar.format,
1236 ));
1237 }
1238 expect_test::expect_file![outline_path].assert_eq(&outline);
1239
1240 let file = GrammarReplayFile {
1241 model_id: case.model_id,
1242 vocab_size: backends.text_backend.model_vocab_size(),
1243 all_stop_token_ids,
1244 cases,
1245 };
1246 let mut json = serde_json::to_string_pretty(&file)?;
1247 json.push('\n');
1248 expect_test::expect_file![path].assert_eq(&json);
1249
1250 Ok(())
1251 }
1252
1253 /// Build a chat request fixture with parser-enabling tool-choice semantics.
1254 fn roundtrip_request(
1255 request_id: impl Into<String>,
1256 messages: Vec<ChatMessage>,
1257 tools: Vec<ChatTool>,
1258 tool_choice: Option<ChatToolChoice>,
1259 thinking: Option<bool>,
1260 thinking_behavior: ThinkingBehavior,
1261 ) -> ChatRequest {
1262 let tool_context = vllm_chat::ResolvedToolContext::new(&messages, tools, tool_choice, true)
1263 .expect("tool context should resolve");
1264 let mut request = ChatRequest {
1265 request_id: request_id.into(),
1266 messages,
1267 tool_context,
1268 ..ChatRequest::for_test()
1269 };
1270
1271 // Explicitly enable or disable thinking using the controls understood by
1272 // this model's template. If unspecified, use the template default.
1273 thinking_behavior.apply(&mut request, thinking);
1274
1275 request
1276 }
1277
1278 /// Return the function tools used by the multiple-tool-call fixture.
1279 fn test_tools() -> Vec<ChatTool> {
1280 vec![
1281 ChatTool {
1282 name: "get_weather".to_string(),
1283 description: Some("Get weather for a location".to_string()),
1284 parameters: serde_json::json!({
1285 "type": "object",
1286 "properties": {
1287 "location": { "type": "string" }
1288 },
1289 "required": ["location"]
1290 }),
1291 strict: None,
1292 },
1293 ChatTool {
1294 name: "add".to_string(),
1295 description: Some("Add two integers".to_string()),
1296 parameters: serde_json::json!({
1297 "type": "object",
1298 "properties": {
1299 "y": { "type": "number" },
1300 "x": { "type": "number" },
1301 "items": {
1302 "type": "array",
1303 "items": { "type": "string" }
1304 }
1305 },
1306 "required": ["y", "x", "items"]
1307 }),
1308 strict: None,
1309 },
1310 ]
1311 }