[Rust Frontend] Support dynamic tools from developer messages (#51144)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Bugen Zhao committed Aug 11, 2026 at 11:30 UTC f1e921b6d6404ad1debd14ec0275b007c8c2ee11
28 files changed +686 -258
rust/src/chat/src/error.rs
+10 -1
@@ -71,6 +71,12 @@ pub enum Error {
71 StreamClosedBeforeTerminalOutput { request_id: String },
72 #[error("tool call stream state is inconsistent: {message}")]
73 ToolCallStreamInvariant { message: String },
74 + #[error("duplicate tool name `{name}`")]
75 + DuplicateToolName { name: String },
76 + #[error("tool_choice requires at least one available tool")]
77 + ToolChoiceRequiresTools,
78 + #[error("tool_choice function `{name}` was not found in the available tools")]
79 + ToolChoiceFunctionNotFound { name: String },
80 #[error("failed to build structural tag: {message}")]
81 StructuralTag { message: String },
82 #[error(transparent)]
@@ -85,7 +91,10 @@ impl Error {
91 /// Whether this error represents invalid user request parameters.
92 pub fn is_request_validation_error(&self) -> bool {
93 match self {
88 - Self::PromptTooLong { .. } => true,
94 + Self::PromptTooLong { .. }
95 + | Self::DuplicateToolName { .. }
96 + | Self::ToolChoiceRequiresTools
97 + | Self::ToolChoiceFunctionNotFound { .. } => true,
98 Self::Text(error) => error.is_request_validation_error(),
99 Self::UnsupportedMultimodalRenderer
100 | Self::UnsupportedMultimodalContent(_)
rust/src/chat/src/lib.rs
+1 -1
@@ -39,7 +39,7 @@ pub use renderer::{
39 };
40 pub use request::{
41 ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
42 - ChatToolChoice, GenerationPromptMode, ReasoningEffort, SamplingParams,
42 + ChatToolChoice, GenerationPromptMode, ReasoningEffort, ResolvedToolContext, SamplingParams,
43 };
44 pub use stream::{ChatEventStream, ChatEventStreamTrait, CollectedAssistantMessage};
45 pub use vllm_engine_core_client::protocol::multimodal::MmFeatures;
rust/src/chat/src/output/default/mod.rs
+3 -3
@@ -52,7 +52,7 @@ impl DefaultChatOutputProcessor {
52 ) -> ChatResult<Self> {
53 let parser = if tool_call_parser == reasoning_parser
54 && let Some(parser) = Self::resolve_optional_unified_parser(
55 - &request.tools,
55 + request.tools(),
56 model_id,
57 tokenizer.clone(),
58 tool_call_parser,
@@ -62,7 +62,7 @@ impl DefaultChatOutputProcessor {
62 let tool_parsing_enabled = request.tool_parsing_enabled();
63 let tool_parser = if tool_parsing_enabled {
64 Some(Self::resolve_tool_parser(
65 - &request.tools,
65 + request.tools(),
66 model_id,
67 tool_call_parser,
68 )?)
@@ -82,7 +82,7 @@ impl DefaultChatOutputProcessor {
82
83 Ok(Self {
84 parser,
85 - parallel_tool_calls: request.parallel_tool_calls,
85 + parallel_tool_calls: request.parallel_tool_calls(),
86 })
87 }
88
rust/src/chat/src/output/default/structural_tag.rs
+66 -14
@@ -31,7 +31,7 @@ pub(super) fn apply_structural_tag_constraint(
31 };
32
33 let tools = request
34 - .tools
34 + .tools()
35 .iter()
36 .map(|tool| {
37 ToolParam::Function(FunctionToolParam::new(FunctionDefinition {
@@ -67,13 +67,13 @@ pub(super) fn apply_structural_tag_constraint(
67 ///
68 /// Returns `None` if no structural tag constraints should be applied.
69 fn structural_tag_tool_choice(request: &ChatRequest) -> Option<StructuralTagToolChoice> {
70 - if request.tools.is_empty() {
70 + if request.tools().is_empty() {
71 return None;
72 }
73
74 - match &request.tool_choice {
74 + match request.tool_choice() {
75 // For `Auto`, only apply the structural tag if there's at least one strict tool.
76 - ChatToolChoice::Auto if request.tools.iter().any(|tool| tool.strict == Some(true)) => {
76 + ChatToolChoice::Auto if request.tools().iter().any(|tool| tool.strict == Some(true)) => {
77 Some(StructuralTagToolChoice::auto())
78 }
79 ChatToolChoice::Auto | ChatToolChoice::None => None,
@@ -92,6 +92,7 @@ mod tests {
92 use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser};
93
94 use super::*;
95 + use crate::request::{ChatMessage, ResolvedToolContext};
96
97 fn chat_tool(name: &str, strict: Option<bool>) -> Tool {
98 Tool {
@@ -114,8 +115,8 @@ mod tests {
115
116 fn request(tool_choice: ChatToolChoice, tools: Vec<Tool>) -> ChatRequest {
117 ChatRequest {
117 - tool_choice,
118 - tools,
118 + tool_context: ResolvedToolContext::new(&[], tools, Some(tool_choice), true)
119 + .expect("tool context should resolve"),
120 ..ChatRequest::for_test()
121 }
122 }
@@ -145,7 +146,7 @@ mod tests {
146 #[test]
147 fn auto_strict_tool_choice_builds_structural_tag() {
148 let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
148 - let parser = qwen3_coder_parser(&request.tools);
149 + let parser = qwen3_coder_parser(request.tools());
150
151 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
152 .expect("structural tag should build");
@@ -155,10 +156,61 @@ mod tests {
156 assert!(tag.to_string().contains("search"));
157 }
158
159 + #[test]
160 + fn required_dynamic_tool_builds_structural_tag_from_effective_tools() {
161 + let messages = vec![ChatMessage::developer(
162 + "",
163 + Some(vec![chat_tool("lookup", None)]),
164 + )];
165 + let tool_context =
166 + ResolvedToolContext::new(&messages, Vec::new(), Some(ChatToolChoice::Required), true)
167 + .expect("dynamic tool context should resolve");
168 + let mut request = ChatRequest {
169 + messages,
170 + tool_context,
171 + ..ChatRequest::for_test()
172 + };
173 + let parser = qwen3_coder_parser(request.tools());
174 +
175 + apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
176 + .expect("structural tag should build");
177 +
178 + let tag = structural_tag_value(&request);
179 + assert!(tag.to_string().contains("lookup"));
180 + }
181 +
182 + #[test]
183 + fn required_initial_and_dynamic_tools_build_one_structural_tag() {
184 + let messages = vec![ChatMessage::developer(
185 + "",
186 + Some(vec![chat_tool("lookup", None)]),
187 + )];
188 + let tool_context = ResolvedToolContext::new(
189 + &messages,
190 + vec![chat_tool("search", None)],
191 + Some(ChatToolChoice::Required),
192 + true,
193 + )
194 + .expect("tool context should resolve");
195 + let mut request = ChatRequest {
196 + messages,
197 + tool_context,
198 + ..ChatRequest::for_test()
199 + };
200 + let parser = qwen3_coder_parser(request.tools());
201 +
202 + apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
203 + .expect("structural tag should build");
204 +
205 + let tag = structural_tag_value(&request).to_string();
206 + assert!(tag.contains("search"));
207 + assert!(tag.contains("lookup"));
208 + }
209 +
210 #[test]
211 fn auto_non_strict_tool_choice_skips_structural_tag() {
212 let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]);
161 - let parser = qwen3_coder_parser(&request.tools);
213 + let parser = qwen3_coder_parser(request.tools());
214
215 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
216 .expect("structural tag decision should succeed");
@@ -173,7 +225,7 @@ mod tests {
225 backend: StructuredOutputBackend::Xgrammar,
226 ..StructuredOutputsParams::json(json!({"type": "object"}))
227 });
176 - let parser = qwen3_coder_parser(&request.tools);
228 + let parser = qwen3_coder_parser(request.tools());
229
230 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
231 .expect("structural tag should build");
@@ -188,7 +240,7 @@ mod tests {
240 #[test]
241 fn required_tool_choice_builds_structural_tag_without_strict_tools() {
242 let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
191 - let parser = qwen3_coder_parser(&request.tools);
243 + let parser = qwen3_coder_parser(request.tools());
244
245 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
246 .expect("structural tag should build");
@@ -205,7 +257,7 @@ mod tests {
257 backend: StructuredOutputBackend::Xgrammar,
258 ..StructuredOutputsParams::json_object()
259 });
208 - let parser = qwen3_coder_parser(&request.tools);
260 + let parser = qwen3_coder_parser(request.tools());
261
262 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
263 .expect("structural tag should build");
@@ -225,7 +277,7 @@ mod tests {
277 },
278 vec![chat_tool("search", None), chat_tool("lookup", None)],
279 );
228 - let parser = qwen3_coder_parser(&request.tools);
280 + let parser = qwen3_coder_parser(request.tools());
281
282 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
283 .expect("structural tag should build");
@@ -238,7 +290,7 @@ mod tests {
290 #[test]
291 fn none_tool_choice_skips_structural_tag() {
292 let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
241 - let parser = qwen3_coder_parser(&request.tools);
293 + let parser = qwen3_coder_parser(request.tools());
294
295 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
296 .expect("structural tag decision should succeed");
@@ -253,7 +305,7 @@ mod tests {
305 backend: StructuredOutputBackend::Xgrammar,
306 ..StructuredOutputsParams::json_object()
307 });
256 - let parser = qwen3_coder_parser(&request.tools);
308 + let parser = qwen3_coder_parser(request.tools());
309
310 apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
311 .expect("structural tag decision should succeed");
rust/src/chat/src/output/harmony/mod.rs
+1 -1
@@ -75,7 +75,7 @@ impl HarmonyChatOutputProcessor {
75 Ok(Self {
76 encoding: harmony_encoding()?,
77 tool_calls_enabled: request.tool_parsing_enabled(),
78 - parallel_tool_calls: request.parallel_tool_calls,
78 + parallel_tool_calls: request.parallel_tool_calls(),
79 })
80 }
81 }
rust/src/chat/src/output/harmony/tests.rs
+13 -12
@@ -10,7 +10,7 @@ use vllm_text::output::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTextEve
10
11 use super::*;
12 use crate::output::ChatOutputProcessor;
13 -use crate::request::{ChatRequest, ChatTool, ChatToolChoice};
13 +use crate::request::{ChatRequest, ChatTool, ChatToolChoice, ResolvedToolContext};
14 use crate::{AssistantMessageExt, ChatEvent, FinishReason};
15
16 fn assistant_prefix() -> Vec<u32> {
@@ -72,18 +72,19 @@ async fn collect_events(
72 }
73
74 fn request_with_tools() -> ChatRequest {
75 + let tools = vec![ChatTool {
76 + name: "get_weather".to_string(),
77 + description: Some("Get weather".to_string()),
78 + parameters: serde_json::json!({
79 + "type": "object",
80 + "properties": {"city": {"type": "string"}},
81 + "required": ["city"]
82 + }),
83 + strict: None,
84 + }];
85 ChatRequest {
76 - tool_choice: ChatToolChoice::Auto,
77 - tools: vec![ChatTool {
78 - name: "get_weather".to_string(),
79 - description: Some("Get weather".to_string()),
80 - parameters: serde_json::json!({
81 - "type": "object",
82 - "properties": {"city": {"type": "string"}},
83 - "required": ["city"]
84 - }),
85 - strict: None,
86 - }],
86 + tool_context: ResolvedToolContext::new(&[], tools, Some(ChatToolChoice::Auto), true)
87 + .expect("tool context should resolve"),
88 ..ChatRequest::for_test()
89 }
90 }
rust/src/chat/src/renderer/deepseek_v32/encoding.rs
+11 -3
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 //! DeepSeek V3.2 prompt renderer.
5 +//!
6 +//! Official Python reference:
7 +//! <https://huggingface.co/deepseek-ai/DeepSeek-V3.2/blob/main/encoding/encoding_dsv32.py>
8
9 use std::collections::{HashMap, HashSet};
10 use std::fmt::Write as _;
@@ -48,15 +51,20 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
51 request.messages.last().map(ChatMessage::role),
52 Some(ChatRole::User | ChatRole::Developer)
53 );
51 - let render_offset = isize::from(request.tool_parsing_enabled());
54 + let initial_tools = if request.tool_parsing_enabled() {
55 + request.initial_tools()
56 + } else {
57 + &[]
58 + };
59 + let render_offset = isize::from(!initial_tools.is_empty());
60 let last_user_render_index =
61 find_last_user_render_index(request.messages.as_slice(), render_offset);
62 let last_user_actual_index = find_last_user_actual_index(request.messages.as_slice());
63 let continue_final_message = request.chat_options.continue_final_message();
64 let mut prompt = String::from(BOS_TOKEN);
65
58 - if request.tool_parsing_enabled() {
59 - render_system_message(&mut prompt, None, &request.tools)?;
66 + if !initial_tools.is_empty() {
67 + render_system_message(&mut prompt, None, initial_tools)?;
68 }
69
70 for (message_index, message) in request.messages.iter().enumerate() {
rust/src/chat/src/renderer/deepseek_v32/fixtures/test_input_developer_tools.json new
+32
@@ -0,0 +1,32 @@
1 +{
2 + "messages": [
3 + {
4 + "role": "user",
5 + "content": "question"
6 + },
7 + {
8 + "role": "developer",
9 + "content": "policy",
10 + "tools": [
11 + {
12 + "type": "function",
13 + "function": {
14 + "name": "lookup",
15 + "parameters": {
16 + "type": "object"
17 + }
18 + }
19 + }
20 + ]
21 + },
22 + {
23 + "role": "assistant",
24 + "reasoning_content": "thought",
25 + "content": "answer"
26 + }
27 + ],
28 + "add_generation_prompt": false,
29 + "template_kwargs": {
30 + "thinking": true
31 + }
32 +}
rust/src/chat/src/renderer/deepseek_v32/fixtures/test_output_developer_tools.txt new
+37
@@ -0,0 +1,37 @@
1 +<|begin▁of▁sentence|><|User|>question<|Assistant|></think><|User|>
2 +
3 +## Tools
4 +
5 +You have access to a set of tools you can use to answer the user's question.
6 +You can invoke functions by writing a "<|DSML|function_calls>" block like the following as part of your reply to the user:
7 +<|DSML|function_calls>
8 +<|DSML|invoke name="$FUNCTION_NAME">
9 +<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
10 +...
11 +</|DSML|invoke>
12 +<|DSML|invoke name="$FUNCTION_NAME2">
13 +...
14 +</|DSML|invoke>
15 +</|DSML|function_calls>
16 +
17 +String and scalar parameters should be specified as is without any escaping or quotes, while lists and objects should use JSON format. The "string" attribute should be set to "true" for string type parameters and "false" for other types (numbers, booleans, arrays, objects).
18 +
19 +If the thinking_mode is enabled, then after function results you should strongly consider outputting a thinking block. Here is an example:
20 +
21 +<|DSML|function_calls>
22 +...
23 +</|DSML|function_calls>
24 +
25 +<function_results>
26 +...
27 +</function_results>
28 +
29 +<think>...thinking about results</think>
30 +
31 +Here are the functions available in JSONSchema format:
32 +<functions>
33 +{"name": "lookup", "parameters": {"type": "object"}}
34 +</functions>
35 +
36 +
37 +# The user's message is: policy<|Assistant|><think>thought</think>answer<|end▁of▁sentence|>
\ No newline at end of file
rust/src/chat/src/renderer/deepseek_v32/tests.rs
+30 -19
@@ -13,6 +13,7 @@ use crate::event::{AssistantContentBlock, AssistantToolCall};
13 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
14 use crate::request::{
15 ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice, GenerationPromptMode,
16 + ResolvedToolContext,
17 };
18 use crate::{ChatRenderer, ChatRole};
19
@@ -85,6 +86,14 @@ fn renders_vllm_parity_prompt_for_request_level_tools_fixture() {
86 );
87 }
88
89 +#[test]
90 +fn renders_developer_tools_like_hf_python() {
91 + assert_fixture(
92 + "test_input_developer_tools.json",
93 + expect_file!["fixtures/test_output_developer_tools.txt"],
94 + );
95 +}
96 +
97 #[test]
98 fn renders_official_search_fixture_without_date() {
99 assert_fixture(
@@ -103,27 +112,29 @@ fn renders_official_search_fixture_with_date() {
112
113 #[test]
114 fn request_level_tools_are_lowered_as_synthetic_leading_system_message() {
115 + let messages = vec![
116 + ChatMessage::system("System prompt."),
117 + ChatMessage::text(ChatRole::User, "Hello"),
118 + ];
119 + let tools = vec![ChatTool {
120 + name: "lookup".to_string(),
121 + description: Some("Look things up".to_string()),
122 + parameters: json!({
123 + "type": "object",
124 + "properties": {
125 + "query": {
126 + "type": "string"
127 + }
128 + },
129 + "required": ["query"]
130 + }),
131 + strict: None,
132 + }];
133 let mut request = ChatRequest {
134 request_id: "deepseek-v32-tools".to_string(),
108 - messages: vec![
109 - ChatMessage::system("System prompt."),
110 - ChatMessage::text(ChatRole::User, "Hello"),
111 - ],
112 - tools: vec![ChatTool {
113 - name: "lookup".to_string(),
114 - description: Some("Look things up".to_string()),
115 - parameters: json!({
116 - "type": "object",
117 - "properties": {
118 - "query": {
119 - "type": "string"
120 - }
121 - },
122 - "required": ["query"]
123 - }),
124 - strict: None,
125 - }],
126 - tool_choice: ChatToolChoice::Auto,
135 + tool_context: ResolvedToolContext::new(&messages, tools, Some(ChatToolChoice::Auto), true)
136 + .expect("tool context should resolve"),
137 + messages,
138 ..ChatRequest::for_test()
139 };
140 request
rust/src/chat/src/renderer/deepseek_v4/encoding.rs
+3 -3
@@ -3,8 +3,8 @@
3
4 //! DeepSeek V4 prompt renderer.
5 //!
6 -//! Original Python implementation:
7 -//! <https://github.com/vllm-project/vllm/blob/main/vllm/tokenizers/deepseek_v4_encoding.py>
6 +//! Official Python reference:
7 +//! <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash/blob/main/encoding/encoding_dsv4.py>
8
9 use std::collections::HashMap;
10 use std::fmt::Write as _;
@@ -156,7 +156,7 @@ fn resolve_thinking_options(request: &ChatRequest) -> Result<(ThinkingMode, &'st
156 /// Return request-level tools only when native tool parsing is enabled.
157 fn request_tools(request: &ChatRequest) -> &[ChatTool] {
158 if request.tool_parsing_enabled() {
159 - request.tools.as_slice()
159 + request.initial_tools()
160 } else {
161 &[]
162 }
rust/src/chat/src/renderer/deepseek_v4/fixtures/test_input_developer_tools.json new
+31
@@ -0,0 +1,31 @@
1 +{
2 + "messages": [
3 + {
4 + "role": "user",
5 + "content": "question"
6 + },
7 + {
8 + "role": "developer",
9 + "content": "policy",
10 + "tools": [
11 + {
12 + "type": "function",
13 + "function": {
14 + "name": "lookup",
15 + "parameters": {
16 + "type": "object"
17 + }
18 + }
19 + }
20 + ]
21 + },
22 + {
23 + "role": "assistant",
24 + "content": "answer"
25 + }
26 + ],
27 + "add_generation_prompt": false,
28 + "template_kwargs": {
29 + "thinking": true
30 + }
31 +}
rust/src/chat/src/renderer/deepseek_v4/fixtures/test_output_developer_tools.txt new
+28
@@ -0,0 +1,28 @@
1 +<|begin▁of▁sentence|><|User|>question<|User|>policy
2 +
3 +## Tools
4 +
5 +You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
6 +
7 +<|DSML|tool_calls>
8 +<|DSML|invoke name="$TOOL_NAME">
9 +<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
10 +...
11 +</|DSML|invoke>
12 +<|DSML|invoke name="$TOOL_NAME2">
13 +...
14 +</|DSML|invoke>
15 +</|DSML|tool_calls>
16 +
17 +String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
18 +
19 +If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
20 +
21 +Otherwise, output directly after </think> with tool calls or final response.
22 +
23 +### Available Tool Schemas
24 +
25 +{"name": "lookup", "parameters": {"type": "object"}}
26 +
27 +You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
28 +<|Assistant|><think></think>answer<|end▁of▁sentence|>
\ No newline at end of file
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+8
@@ -63,6 +63,14 @@ fn renders_v4_fixture_2_multi_turn_drop_thinking() {
63 );
64 }
65
66 +#[test]
67 +fn renders_developer_tools_like_hf_python() {
68 + assert_fixture(
69 + "test_input_developer_tools.json",
70 + expect_file!["fixtures/test_output_developer_tools.txt"],
71 + );
72 +}
73 +
74 #[test]
75 fn reasoning_effort_max_adds_prefix_when_thinking_is_enabled() {
76 let mut request = ChatRequest {
rust/src/chat/src/renderer/harmony/mod.rs
+1 -1
@@ -216,7 +216,7 @@ fn preamble_tool_descriptions(
216 ) -> Vec<ToolDescription> {
217 let mut tools = Vec::new();
218 if request.tool_parsing_enabled() {
219 - tools.extend(to_tool_descriptions(&request.tools));
219 + tools.extend(to_tool_descriptions(request.initial_tools()));
220 }
221 if let Some(leading_developer_tools) = leading_developer_tools {
222 tools.extend(to_tool_descriptions(leading_developer_tools));
rust/src/chat/src/renderer/hf/mod.rs
+46 -5
@@ -179,7 +179,7 @@ impl HfChatRenderer {
179 None
180 };
181
182 - let tools = request.tool_parsing_enabled().then(|| to_template_tools(&request.tools));
182 + let tools = request.tool_parsing_enabled().then(|| to_template_tools(request.tools()));
183 trace!(
184 message_count = messages.len(),
185 content_format = ?effective_template.content_format(),
@@ -579,7 +579,7 @@ mod tests {
579 use super::{ChatTemplateContentFormatOption, HfChatRenderer, MultimodalRenderInfo};
580 use crate::request::{
581 ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice,
582 - GenerationPromptMode, ReasoningEffort,
582 + GenerationPromptMode, ReasoningEffort, ResolvedToolContext,
583 };
584 use crate::{AssistantContentBlock, ChatRenderer, Error, Result};
585
@@ -587,13 +587,48 @@ mod tests {
587 const QWEN3_5_0_8B_TEMPLATE: &str = include_str!("../../../tests/templates/qwen35.jinja");
588
589 fn sample_request(messages: Vec<ChatMessage>) -> ChatRequest {
590 + let tool_context = ResolvedToolContext::new(&messages, Vec::new(), None, true).unwrap();
591 ChatRequest {
592 messages,
593 + tool_context,
594 request_id: "render-test".to_string(),
595 ..ChatRequest::for_test()
596 }
597 }
598
599 + #[test]
600 + fn dynamic_tools_are_exposed_as_effective_template_tools() {
601 + let messages = vec![
602 + ChatMessage::developer(
603 + "",
604 + Some(vec![ChatTool {
605 + name: "lookup".to_string(),
606 + description: None,
607 + parameters: serde_json::json!({"type": "object"}),
608 + strict: None,
609 + }]),
610 + ),
611 + ChatMessage::user("hello"),
612 + ];
613 + let tool_context = ResolvedToolContext::new(&messages, Vec::new(), None, true).unwrap();
614 + let request = ChatRequest {
615 + messages,
616 + tool_context,
617 + request_id: "render-test".to_string(),
618 + ..ChatRequest::for_test()
619 + };
620 +
621 + let rendered = render(
622 + Some("{{ messages|length }}:{{ messages[0].role }}:{{ messages[0].tools[0].function.name }}:{{ tools[0].function.name }}"),
623 + &request,
624 + )
625 + .unwrap();
626 +
627 + assert_eq!(rendered, "2:developer:lookup:lookup");
628 + assert_eq!(request.tool_choice(), &ChatToolChoice::Auto);
629 + assert_eq!(request.tools().len(), 1);
630 + }
631 +
632 fn render(template: Option<&str>, request: &ChatRequest) -> Result<String> {
633 HfChatRenderer::new(
634 template.map(str::to_owned),
@@ -897,7 +932,7 @@ mod tests {
932 }
933
934 #[test]
900 - fn chat_template_exposes_developer_tools() {
935 + fn chat_template_preserves_developer_tools() {
936 let request = sample_request(vec![ChatMessage::developer(
937 "policy",
938 Some(vec![ChatTool {
@@ -1218,7 +1253,7 @@ mod tests {
1253 #[test]
1254 fn chat_template_exposes_tools_to_templates_when_auto_enabled() {
1255 let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
1221 - request.tools = vec![ChatTool {
1256 + let tools = vec![ChatTool {
1257 name: "get_weather".to_string(),
1258 description: Some("Get weather".to_string()),
1259 parameters: serde_json::json!({
@@ -1228,7 +1263,13 @@ mod tests {
1263 }),
1264 strict: None,
1265 }];
1231 - request.tool_choice = ChatToolChoice::Auto;
1266 + request.tool_context = crate::request::ResolvedToolContext::new(
1267 + &request.messages,
1268 + tools,
1269 + Some(ChatToolChoice::Auto),
1270 + true,
1271 + )
1272 + .expect("tool context should resolve");
1273
1274 let rendered = render(
1275 Some("{{ tools[0].function.name }}|{{ tools[0].function.parameters.required[0] }}"),
rust/src/chat/src/renderer/inkling/mod.rs
+1 -12
@@ -383,18 +383,7 @@ fn rendered_tools(request: &ChatRequest) -> Vec<&ChatTool> {
383 return Vec::new();
384 }
385
386 - let mut tools = Vec::with_capacity(request.tools.len());
387 - tools.extend(request.tools.iter());
388 - for message in &request.messages {
389 - if let ChatMessage::Developer {
390 - tools: Some(local_tools),
391 - ..
392 - } = message
393 - {
394 - tools.extend(local_tools.iter());
395 - }
396 - }
397 - tools
386 + request.tools().iter().collect()
387 }
388
389 fn tool_call_json(tool_call: &AssistantToolCall) -> Result<String> {
rust/src/chat/src/renderer/kimi_k3/encoding.rs
+3 -3
@@ -191,7 +191,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
191 }
192 flush_tool_run(&mut out, &mut pending_tool_run, &tool_call_id_index)?;
193
194 - match &request.tool_choice {
194 + match request.tool_choice() {
195 ChatToolChoice::Required => {
196 write_internal_system(
197 &mut out,
@@ -202,7 +202,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
202 }
203 // Emit only when tools are present: Rust defaults tool_choice to None
204 // for tool-free requests, which must not inject a tool-choice message.
205 - ChatToolChoice::None if !request.tools.is_empty() => {
205 + ChatToolChoice::None if !request.tools().is_empty() => {
206 write_internal_system(
207 &mut out,
208 "tool-choice",
@@ -226,7 +226,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
226 fn request_tools(request: &ChatRequest) -> &[ChatTool] {
227 // Declare tools whenever the request carries them. K3 tool-declare is
228 // independent of tool_choice; tool_choice only injects control messages.
229 - request.tools.as_slice()
229 + request.initial_tools()
230 }
231
232 fn thinking_enabled(request: &ChatRequest) -> Result<bool> {
rust/src/chat/src/renderer/kimi_k3/fixtures/dynamic_only_tool_declare_input.json new
+40
@@ -0,0 +1,40 @@
1 +{
2 + "messages": [
3 + {
4 + "role": "user",
5 + "content": "Load the calculator."
6 + },
7 + {
8 + "role": "developer",
9 + "tools": [
10 + {
11 + "type": "function",
12 + "function": {
13 + "name": "calc",
14 + "description": "Evaluate an expression",
15 + "parameters": {
16 + "type": "object",
17 + "properties": {
18 + "expression": {
19 + "type": "string"
20 + }
21 + },
22 + "required": [
23 + "expression"
24 + ]
25 + }
26 + }
27 + }
28 + ]
29 + },
30 + {
31 + "role": "user",
32 + "content": "What is 2 + 2?"
33 + }
34 + ],
35 + "add_generation_prompt": true,
36 + "tool_choice": "none",
37 + "template_kwargs": {
38 + "thinking": true
39 + }
40 +}
rust/src/chat/src/renderer/kimi_k3/fixtures/dynamic_only_tool_declare_output.txt new
+10
@@ -0,0 +1,10 @@
1 +<|open|>message role="system" type="thinking-effort"<|sep|>`thinking_effort` guides on how much to think in your thinking channel (not including the response channel), supported values include `low`, `medium`, `high`, and `max`.
2 +Now the system is invoked with `thinking_effort=max`.<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>Load the calculator.<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="tool-declare"<|sep|>## New Tools Available
3 +The system dynamically extends the toolset via lazy-loading.
4 +You have access to all existing and extended tools.
5 +Here are the specs for the extended tools.
6 +
7 +```json
8 +[{"function":{"description":"Evaluate an expression","name":"calc","parameters":{"properties":{"expression":{"type":"string"}},"required":["expression"],"type":"object"}},"type":"function"}]
9 +```<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>What is 2 + 2?<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="tool-choice"<|sep|>The system is invoked with `tool_choice=none`.
10 +You MUST NOT call any tools in the next message.<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>
\ No newline at end of file
rust/src/chat/src/renderer/kimi_k3/tests.rs
+13 -2
@@ -92,10 +92,15 @@ fn golden_dynamic_system_tool_declare() {
92 assert_golden("dynamic_system_tool_declare");
93 }
94
95 +#[test]
96 +fn golden_dynamic_only_tool_declare() {
97 + assert_golden("dynamic_only_tool_declare");
98 +}
99 +
100 #[test]
101 fn tool_declare_omits_absent_fields_and_preserves_parameter_nulls() {
102 let mut request = crate::request::ChatRequest::for_test();
98 - request.tools = vec![ChatTool {
103 + let tools = vec![ChatTool {
104 name: "lookup".to_string(),
105 description: None,
106 parameters: json!({
@@ -109,6 +114,9 @@ fn tool_declare_omits_absent_fields_and_preserves_parameter_nulls() {
114 }),
115 strict: None,
116 }];
117 + request.tool_context =
118 + crate::request::ResolvedToolContext::new(&request.messages, tools, None, true)
119 + .expect("tool context should resolve");
120
121 let rendered = render_request(&request);
122
@@ -122,12 +130,15 @@ fn tool_declare_omits_absent_fields_and_preserves_parameter_nulls() {
130 #[test]
131 fn tool_declare_preserves_present_optional_fields() {
132 let mut request = crate::request::ChatRequest::for_test();
125 - request.tools = vec![ChatTool {
133 + let tools = vec![ChatTool {
134 name: "lookup".to_string(),
135 description: Some("Look up a record".to_string()),
136 parameters: json!({"type": "object"}),
137 strict: Some(false),
138 }];
139 + request.tool_context =
140 + crate::request::ResolvedToolContext::new(&request.messages, tools, None, true)
141 + .expect("tool context should resolve");
142
143 let rendered = render_request(&request);
144
rust/src/chat/src/renderer/test_utils.rs
+12 -14
@@ -11,7 +11,7 @@ use serde_json::Value;
11 use crate::event::{AssistantContentBlock, AssistantToolCall};
12 use crate::request::{
13 ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
14 - GenerationPromptMode, ReasoningEffort,
14 + GenerationPromptMode, ReasoningEffort, ResolvedToolContext,
15 };
16
17 /// Options for constructing a [`ChatRequest`] from a fixture file.
@@ -158,22 +158,20 @@ struct FixtureToolCallFunction {
158 impl FixtureRequest {
159 fn into_chat_request(self, options: FixtureRequestOptions) -> ChatRequest {
160 let tools = to_chat_tools(&self.tools);
161 - let tool_choice = self.tool_choice.unwrap_or(if tools.is_empty() {
162 - ChatToolChoice::None
163 - } else {
164 - ChatToolChoice::Auto
165 - });
161 +
162 + let messages = self
163 + .messages
164 + .into_iter()
165 + .enumerate()
166 + .map(|(index, message)| fixture_message_to_chat_message(index, message))
167 + .collect::<Vec<_>>();
168 + let tool_context = ResolvedToolContext::new(&messages, tools, self.tool_choice, true)
169 + .expect("fixture tool context should resolve");
170
171 let mut request = ChatRequest {
172 request_id: "renderer-fixture".to_string(),
169 - messages: self
170 - .messages
171 - .into_iter()
172 - .enumerate()
173 - .map(|(index, message)| fixture_message_to_chat_message(index, message))
174 - .collect(),
175 - tools,
176 - tool_choice,
173 + messages,
174 + tool_context,
175 ..ChatRequest::for_test()
176 };
177
rust/src/chat/src/request.rs
+224 -14
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use std::collections::HashMap;
4 +use std::collections::{HashMap, HashSet};
5
6 use llm_multimodal::ImageDetail;
7 use serde::{Deserialize, Serialize};
@@ -248,6 +248,16 @@ impl ChatMessage {
248 }
249 }
250
251 + /// Return message-scoped tool declarations carried by this message.
252 + pub fn declared_tools(&self) -> Option<&[ChatTool]> {
253 + match self {
254 + Self::Developer {
255 + tools: Some(tools), ..
256 + } if !tools.is_empty() => Some(tools),
257 + _ => None,
258 + }
259 + }
260 +
261 /// Construct one chat message with user role.
262 pub fn user(content: impl Into<ChatContent>) -> Self {
263 Self::User {
@@ -450,6 +460,93 @@ pub enum ChatToolChoice {
460 },
461 }
462
463 +/// Resolved tool state shared by rendering, output parsing, and constraints.
464 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465 +pub struct ResolvedToolContext {
466 + /// Request-level tools rendered before the ordered chat history.
467 + pub initial_tools: Vec<ChatTool>,
468 + /// Request-level and message-scoped tools available for this generation.
469 + pub effective_tools: Vec<ChatTool>,
470 + /// Resolved tool-choice behavior for this request.
471 + pub tool_choice: ChatToolChoice,
472 + /// Whether more than one parsed tool call may be surfaced northbound.
473 + pub parallel_tool_calls: bool,
474 +}
475 +
476 +impl ResolvedToolContext {
477 + /// Resolve one complete tool context from request and message declarations.
478 + pub fn new(
479 + messages: &[ChatMessage],
480 + initial_tools: Vec<ChatTool>,
481 + requested_tool_choice: Option<ChatToolChoice>,
482 + parallel_tool_calls: bool,
483 + ) -> Result<Self> {
484 + let dynamic_tool_count = messages
485 + .iter()
486 + .filter_map(ChatMessage::declared_tools)
487 + .map(<[ChatTool]>::len)
488 + .sum::<usize>();
489 + let mut effective_tools = Vec::with_capacity(initial_tools.len() + dynamic_tool_count);
490 + let mut names = HashSet::with_capacity(effective_tools.capacity());
491 +
492 + for tool in initial_tools
493 + .iter()
494 + .chain(messages.iter().filter_map(ChatMessage::declared_tools).flatten())
495 + {
496 + if !names.insert(tool.name.clone()) {
497 + return Err(Error::DuplicateToolName {
498 + name: tool.name.clone(),
499 + });
500 + }
501 + effective_tools.push(tool.clone());
502 + }
503 +
504 + let tool_choice = requested_tool_choice.unwrap_or({
505 + if effective_tools.is_empty() {
506 + ChatToolChoice::None
507 + } else {
508 + ChatToolChoice::Auto
509 + }
510 + });
511 +
512 + match &tool_choice {
513 + ChatToolChoice::Auto | ChatToolChoice::Required if effective_tools.is_empty() => {
514 + return Err(Error::ToolChoiceRequiresTools);
515 + }
516 + ChatToolChoice::Function { name } if !names.contains(name) => {
517 + return Err(Error::ToolChoiceFunctionNotFound { name: name.clone() });
518 + }
519 + ChatToolChoice::None
520 + | ChatToolChoice::Auto
521 + | ChatToolChoice::Required
522 + | ChatToolChoice::Function { .. } => {}
523 + }
524 +
525 + Ok(Self {
526 + initial_tools,
527 + effective_tools,
528 + tool_choice,
529 + parallel_tool_calls,
530 + })
531 + }
532 +
533 + /// Return whether tool output parsing is active for this request.
534 + pub fn parsing_enabled(&self) -> bool {
535 + !matches!(self.tool_choice, ChatToolChoice::None) && !self.effective_tools.is_empty()
536 + }
537 +}
538 +
539 +impl Default for ResolvedToolContext {
540 + fn default() -> Self {
541 + Self {
542 + initial_tools: Vec::new(),
543 + effective_tools: Vec::new(),
544 + tool_choice: ChatToolChoice::None,
545 + parallel_tool_calls: true,
546 + }
547 + }
548 +}
549 +
550 /// One chat request ready to be rendered into a prompt and lowered into a
551 /// generate request.
552 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -462,14 +559,8 @@ pub struct ChatRequest {
559 pub sampling_params: SamplingParams,
560 /// Chat-specific rendering options.
561 pub chat_options: ChatOptions,
465 - /// Function tools made available to the model for this request.
466 - pub tools: Vec<ChatTool>,
467 - /// Tool-choice behavior for this request.
468 - pub tool_choice: ChatToolChoice,
469 - /// Whether the model may return more than one tool call per response.
470 - ///
471 - /// When `false`, only the first parsed tool call is surfaced northbound.
472 - pub parallel_tool_calls: bool,
562 + /// Resolved request-level and message-scoped tool behavior.
563 + pub tool_context: ResolvedToolContext,
564 /// Text decode options for incremental detokenization.
565 pub decode_options: TextDecodeOptions,
566 /// Whether to emit intermediate northbound content deltas before the
@@ -507,9 +598,7 @@ impl ChatRequest {
598 messages: vec![ChatMessage::text(ChatRole::User, "test")],
599 sampling_params: SamplingParams::default(),
600 chat_options: ChatOptions::default(),
510 - tools: Vec::new(),
511 - tool_choice: ChatToolChoice::None,
512 - parallel_tool_calls: true,
601 + tool_context: ResolvedToolContext::default(),
602 decode_options: TextDecodeOptions::default(),
603 intermediate: true,
604 priority: 0,
@@ -547,10 +636,33 @@ impl ChatRequest {
636 self.messages.iter().any(ChatMessage::has_multimodal)
637 }
638
639 + /// Return every tool available for this generation.
640 + pub fn tools(&self) -> &[ChatTool] {
641 + &self.tool_context.effective_tools
642 + }
643 +
644 + /// Return request-level tools before message-scoped declarations.
645 + ///
646 + /// Renderers should use [`Self::tools`] unless their prompt format places
647 + /// initial and message-scoped declarations differently.
648 + pub fn initial_tools(&self) -> &[ChatTool] {
649 + &self.tool_context.initial_tools
650 + }
651 +
652 + /// Return the resolved tool-choice behavior.
653 + pub fn tool_choice(&self) -> &ChatToolChoice {
654 + &self.tool_context.tool_choice
655 + }
656 +
657 + /// Return whether more than one parsed tool call may be surfaced.
658 + pub fn parallel_tool_calls(&self) -> bool {
659 + self.tool_context.parallel_tool_calls
660 + }
661 +
662 /// Return true if this request should enable tool parsing based on the tool
663 /// choice and tool list.
664 pub(crate) fn tool_parsing_enabled(&self) -> bool {
553 - !matches!(self.tool_choice, ChatToolChoice::None) && !self.tools.is_empty()
665 + self.tool_context.parsing_enabled()
666 }
667
668 /// Return the request-level thinking toggle when explicitly requested.
@@ -605,7 +717,10 @@ impl ChatRole {
717 mod tests {
718 use serde_json::{json, to_value};
719
608 - use super::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool};
720 + use super::{
721 + ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice,
722 + ResolvedToolContext,
723 + };
724 use crate::Error;
725 use crate::event::AssistantContentBlock;
726
@@ -702,6 +817,101 @@ mod tests {
817 assert_eq!(decoded, message);
818 }
819
820 + fn chat_tool(name: &str) -> ChatTool {
821 + ChatTool {
822 + name: name.to_string(),
823 + description: None,
824 + parameters: json!({"type": "object"}),
825 + strict: None,
826 + }
827 + }
828 +
829 + #[test]
830 + fn resolved_tool_context_defaults_dynamic_only_tools_to_auto() {
831 + let messages = vec![
832 + ChatMessage::user("hello"),
833 + ChatMessage::developer("", Some(vec![chat_tool("lookup")])),
834 + ];
835 +
836 + let context = ResolvedToolContext::new(&messages, Vec::new(), None, false).unwrap();
837 +
838 + assert!(context.initial_tools.is_empty());
839 + assert_eq!(context.effective_tools, vec![chat_tool("lookup")]);
840 + assert_eq!(context.tool_choice, ChatToolChoice::Auto);
841 + assert!(!context.parallel_tool_calls);
842 + assert!(context.parsing_enabled());
843 + }
844 +
845 + #[test]
846 + fn resolved_tool_context_preserves_initial_then_message_order() {
847 + let messages = vec![
848 + ChatMessage::developer("", Some(vec![chat_tool("middle")])),
849 + ChatMessage::developer("policy", Some(vec![chat_tool("last")])),
850 + ];
851 +
852 + let context = ResolvedToolContext::new(
853 + &messages,
854 + vec![chat_tool("first")],
855 + Some(ChatToolChoice::Required),
856 + true,
857 + )
858 + .unwrap();
859 +
860 + assert_eq!(
861 + context
862 + .effective_tools
863 + .iter()
864 + .map(|tool| tool.name.as_str())
865 + .collect::<Vec<_>>(),
866 + vec!["first", "middle", "last"]
867 + );
868 + assert_eq!(context.tool_choice, ChatToolChoice::Required);
869 + }
870 +
871 + #[test]
872 + fn resolved_tool_context_rejects_duplicate_names() {
873 + let messages = vec![ChatMessage::developer("", Some(vec![chat_tool("lookup")]))];
874 +
875 + let error =
876 + ResolvedToolContext::new(&messages, vec![chat_tool("lookup")], None, true).unwrap_err();
877 +
878 + assert!(matches!(error, Error::DuplicateToolName { name } if name == "lookup"));
879 + }
880 +
881 + #[test]
882 + fn resolved_tool_context_validates_named_choice_against_effective_tools() {
883 + let messages = vec![ChatMessage::developer("", Some(vec![chat_tool("lookup")]))];
884 + let context = ResolvedToolContext::new(
885 + &messages,
886 + Vec::new(),
887 + Some(ChatToolChoice::Function {
888 + name: "lookup".to_string(),
889 + }),
890 + true,
891 + )
892 + .unwrap();
893 + assert_eq!(
894 + context.tool_choice,
895 + ChatToolChoice::Function {
896 + name: "lookup".to_string()
897 + }
898 + );
899 +
900 + let error = ResolvedToolContext::new(
901 + &messages,
902 + Vec::new(),
903 + Some(ChatToolChoice::Function {
904 + name: "missing".to_string(),
905 + }),
906 + true,
907 + )
908 + .unwrap_err();
909 + assert!(matches!(
910 + error,
911 + Error::ToolChoiceFunctionNotFound { name } if name == "missing"
912 + ));
913 + }
914 +
915 #[test]
916 fn enable_thinking_is_none_when_no_kwargs_are_present() {
917 let request = ChatRequest::for_test();
rust/src/chat/tests/chat.rs
+8 -2
@@ -302,7 +302,7 @@ fn sample_request(request_id: &str) -> ChatRequest {
302
303 fn sample_tool_request(request_id: &str) -> ChatRequest {
304 let mut request = sample_request(request_id);
305 - request.tools = vec![ChatTool {
305 + let tools = vec![ChatTool {
306 name: "get_weather".to_string(),
307 description: Some("Get weather".to_string()),
308 parameters: serde_json::json!({
@@ -312,7 +312,13 @@ fn sample_tool_request(request_id: &str) -> ChatRequest {
312 }),
313 strict: None,
314 }];
315 - request.tool_choice = ChatToolChoice::Auto;
315 + request.tool_context = vllm_chat::ResolvedToolContext::new(
316 + &request.messages,
317 + tools,
318 + Some(ChatToolChoice::Auto),
319 + true,
320 + )
321 + .expect("tool context should resolve");
322 request
323 }
324
rust/src/chat/tests/roundtrip.rs
+6 -9
@@ -16,9 +16,9 @@ use serde_json_fmt::JsonFormat as JsonFmt;
16 use serial_test::file_serial;
17 use vllm_chat::{
18 AssistantContentBlock, AssistantMessage, AssistantMessageExt as _, AssistantToolCall,
19 - ChatEvent, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice, FinishReason,
20 - GenerationPromptMode, LoadModelBackendsOptions, NewChatOutputProcessorOptions, ParserSelection,
21 - RendererSelection, load_model_backends,
19 + ChatEvent, ChatMessage, ChatRequest, ChatRole, ChatTool, FinishReason, GenerationPromptMode,
20 + LoadModelBackendsOptions, NewChatOutputProcessorOptions, ParserSelection, RendererSelection,
21 + load_model_backends,
22 };
23 use vllm_text::{DecodedTextEvent, Finished, Prompt};
24 use vllm_tokenizer::Tokenizer;
@@ -825,15 +825,12 @@ fn roundtrip_request(
825 tools: Vec<ChatTool>,
826 thinking: Option<bool>,
827 ) -> ChatRequest {
828 + let tool_context = vllm_chat::ResolvedToolContext::new(&messages, tools, None, true)
829 + .expect("tool context should resolve");
830 let mut request = ChatRequest {
831 request_id: request_id.into(),
832 messages,
831 - tool_choice: if tools.is_empty() {
832 - ChatToolChoice::None
833 - } else {
834 - ChatToolChoice::Auto
835 - },
836 - tools,
833 + tool_context,
834 ..ChatRequest::for_test()
835 };
836
rust/src/server/src/routes/openai/chat_completions/convert.rs
+38 -25
@@ -5,16 +5,16 @@ use itertools::Itertools as _;
5 use vllm_chat::{
6 AssistantContentBlock, AssistantToolCall, ChatContent, ChatContentPart,
7 ChatMessage as VllmChatMessage, ChatOptions, ChatRequest, ChatTool, ChatToolChoice,
8 - GenerationPromptMode, SamplingParams,
8 + GenerationPromptMode, ResolvedToolContext, SamplingParams,
9 };
10
11 use super::types::ChatCompletionRequest;
12 use super::validate;
13 -use crate::error::{ApiError, bail_invalid_request};
13 +use crate::error::{ApiError, bail_invalid_request, chat_submit_error};
14 use crate::lora::LoraModelResolution;
15 use crate::routes::openai::utils::structured_outputs::convert_from_response_format;
16 use crate::routes::openai::utils::types::{
17 - ChatMessage, ContentPart, MessageContent, Tool, ToolChoice, ToolChoiceValue,
17 + ChatMessage, ContentPart, MessageContent, Tool, ToolChoice, ToolChoiceValue, ToolReference,
18 };
19 use crate::utils::{
20 ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
@@ -130,6 +130,14 @@ pub(super) fn prepare_chat_request(
130 request.vllm_xargs.as_ref(),
131 );
132
133 + let tool_context = ResolvedToolContext::new(
134 + &messages,
135 + convert_tools(request.tools)?,
136 + request.tool_choice.as_ref().map(convert_tool_choice).transpose()?,
137 + request.parallel_tool_calls.unwrap_or(true),
138 + )
139 + .map_err(|error| chat_submit_error("failed to resolve request tools", error))?;
140 +
141 let chat_request = ChatRequest {
142 request_id: request_id.clone(),
143 messages,
@@ -168,9 +176,7 @@ pub(super) fn prepare_chat_request(
176 response_format,
177 template_kwargs,
178 },
171 - tools: convert_tools(request.tools)?,
172 - tool_choice: convert_tool_choice(request.tool_choice.as_ref())?,
173 - parallel_tool_calls: request.parallel_tool_calls.unwrap_or(true),
179 + tool_context,
180 decode_options: vllm_text::output::TextDecodeOptions {
181 skip_special_tokens: request.skip_special_tokens,
182 include_stop_str_in_output: request.include_stop_str_in_output,
@@ -399,17 +405,21 @@ fn convert_message_tools(tools: Option<Vec<Tool>>) -> Result<Option<Vec<ChatTool
405 Ok((!tools.is_empty()).then_some(tools))
406 }
407
402 -fn convert_tool_choice(tool_choice: Option<&ToolChoice>) -> Result<ChatToolChoice, ApiError> {
408 +fn convert_tool_choice(tool_choice: &ToolChoice) -> Result<ChatToolChoice, ApiError> {
409 match tool_choice {
404 - None | Some(ToolChoice::Value(ToolChoiceValue::Auto)) => Ok(ChatToolChoice::Auto),
405 - Some(ToolChoice::Value(ToolChoiceValue::None)) => Ok(ChatToolChoice::None),
406 - Some(ToolChoice::Value(ToolChoiceValue::Required)) => Ok(ChatToolChoice::Required),
407 - Some(ToolChoice::Function {
410 + ToolChoice::Value(ToolChoiceValue::Auto) => Ok(ChatToolChoice::Auto),
411 + ToolChoice::Value(ToolChoiceValue::None) => Ok(ChatToolChoice::None),
412 + ToolChoice::Value(ToolChoiceValue::Required) => Ok(ChatToolChoice::Required),
413 + ToolChoice::Function {
414 tool_type,
415 function,
410 - }) if tool_type == "function" => Ok(ChatToolChoice::Function {
416 + } if tool_type == "function" => Ok(ChatToolChoice::Function {
417 name: function.name.clone(),
418 }),
419 + ToolChoice::AllowedTools { tools, .. } => bail_invalid_request!(
420 + "allowed_tools tool_choice is not supported yet: {}.",
421 + tools.iter().map(ToolReference::identifier).join(", ")
422 + ),
423 _ => bail_invalid_request!("tool_choice={:?} is not supported yet.", tool_choice),
424 }
425 }
@@ -478,7 +488,7 @@ mod tests {
488 )
489 .expect("request is valid");
490
481 - assert!(!prepared.chat_request.parallel_tool_calls);
491 + assert!(!prepared.chat_request.parallel_tool_calls());
492 }
493
494 #[test]
@@ -490,7 +500,7 @@ mod tests {
500 )
501 .expect("request is valid");
502
493 - assert!(prepared.chat_request.parallel_tool_calls);
503 + assert!(prepared.chat_request.parallel_tool_calls());
504 }
505
506 #[test]
@@ -596,8 +606,8 @@ mod tests {
606 min_tokens: 0,
607 }
608 );
599 - assert!(prepared.chat_request.tools.is_empty());
600 - assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Auto);
609 + assert!(prepared.chat_request.initial_tools().is_empty());
610 + assert_eq!(prepared.chat_request.tool_choice(), &ChatToolChoice::None);
611 }
612
613 #[test]
@@ -671,8 +681,8 @@ mod tests {
681 min_tokens: 0,
682 }
683 );
674 - assert!(prepared.chat_request.tools.is_empty());
675 - assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Auto);
684 + assert!(prepared.chat_request.initial_tools().is_empty());
685 + assert_eq!(prepared.chat_request.tool_choice(), &ChatToolChoice::None);
686 }
687
688 #[test]
@@ -1009,8 +1019,8 @@ mod tests {
1019 },
1020 ])]
1021 );
1012 - assert!(prepared.chat_request.tools.is_empty());
1013 - assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Auto);
1022 + assert!(prepared.chat_request.initial_tools().is_empty());
1023 + assert_eq!(prepared.chat_request.tool_choice(), &ChatToolChoice::None);
1024 }
1025
1026 #[test]
@@ -1105,7 +1115,7 @@ mod tests {
1115 ]
1116 );
1117 assert_eq!(
1108 - prepared.chat_request.tools,
1118 + prepared.chat_request.initial_tools(),
1119 vec![VllmChatTool {
1120 name: "get_weather".to_string(),
1121 description: Some("Get weather".to_string()),
@@ -1116,7 +1126,7 @@ mod tests {
1126 strict: None,
1127 }]
1128 );
1119 - assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::None);
1129 + assert_eq!(prepared.chat_request.tool_choice(), &ChatToolChoice::None);
1130 }
1131
1132 #[test]
@@ -1145,7 +1155,10 @@ mod tests {
1155 )
1156 .expect("request is valid");
1157
1148 - assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Required);
1158 + assert_eq!(
1159 + prepared.chat_request.tool_choice(),
1160 + &ChatToolChoice::Required
1161 + );
1162 assert!(!prepared.options.is_named_tool_choice);
1163 }
1164
@@ -1181,8 +1194,8 @@ mod tests {
1194 .expect("request is valid");
1195
1196 assert_eq!(
1184 - prepared.chat_request.tool_choice,
1185 - ChatToolChoice::Function {
1197 + prepared.chat_request.tool_choice(),
1198 + &ChatToolChoice::Function {
1199 name: "get_weather".to_string(),
1200 }
1201 );
rust/src/server/src/routes/openai/chat_completions/types.rs
+2 -109
@@ -14,8 +14,8 @@ use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
14 use crate::routes::openai::utils::structured_outputs::ResponseFormat;
15 use crate::routes::openai::utils::types::{
16 ChatLogProbs, ChatMessage, Normalizable, StreamOptions, StringOrArray, Tool, ToolCall,
17 - ToolCallDelta, ToolChoice, ToolChoiceValue, ToolReference, UNKNOWN_MODEL_ID, Usage,
18 - default_true, validate_messages, validate_stop, validate_top_p_value,
17 + ToolCallDelta, ToolChoice, UNKNOWN_MODEL_ID, Usage, default_true, validate_messages,
18 + validate_stop, validate_top_p_value,
19 };
20
21 /// vLLM-compatible request type for the Chat Completions API.
@@ -325,19 +325,6 @@ impl Normalizable for ChatCompletionRequest {
325 self.max_completion_tokens = self.max_tokens;
326 self.max_tokens = None;
327 }
328 -
329 - // Apply tool_choice defaults
330 - // If tools is None, leave tool_choice as None (don't set it)
331 - if self.tool_choice.is_none()
332 - && let Some(tools) = &self.tools
333 - {
334 - let choice_value = if tools.is_empty() {
335 - ToolChoiceValue::None
336 - } else {
337 - ToolChoiceValue::Auto
338 - };
339 - self.tool_choice = Some(ToolChoice::Value(choice_value));
340 - }
328 }
329 }
330
@@ -492,99 +479,5 @@ fn validate_chat_cross_parameters(
479 return Err(e);
480 }
481
495 - // 5. Validate tool_choice requires tools (except for "none")
496 - if let Some(ref tool_choice) = req.tool_choice {
497 - let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
498 -
499 - // Check if tool_choice is anything other than "none"
500 - let is_some_choice = !matches!(tool_choice, ToolChoice::Value(ToolChoiceValue::None));
501 -
502 - if is_some_choice && !has_tools {
503 - let mut e = validator::ValidationError::new("tool_choice_requires_tools");
504 - e.message = Some("Invalid value for 'tool_choice': 'tool_choice' is only allowed when 'tools' are specified.".into());
505 - return Err(e);
506 - }
507 -
508 - // Additional validation when tools are present
509 - if let Some(tools) = req.tools.as_ref().filter(|t| !t.is_empty()) {
510 - match tool_choice {
511 - ToolChoice::Function { function, .. } => {
512 - // Validate that the specified function name exists in tools
513 - let function_exists = tools.iter().any(|tool| {
514 - tool.tool_type == "function" && tool.function.name == function.name
515 - });
516 -
517 - if !function_exists {
518 - let mut e =
519 - validator::ValidationError::new("tool_choice_function_not_found");
520 - e.message = Some(
521 - format!(
522 - "Invalid value for 'tool_choice': function '{}' not found in 'tools'.",
523 - function.name
524 - )
525 - .into(),
526 - );
527 - return Err(e);
528 - }
529 - }
530 - ToolChoice::AllowedTools {
531 - mode,
532 - tools: allowed_tools,
533 - ..
534 - } => {
535 - // Validate mode is "auto" or "required"
536 - if mode != "auto" && mode != "required" {
537 - let mut e = validator::ValidationError::new("tool_choice_invalid_mode");
538 - e.message = Some(format!(
539 - "Invalid value for 'tool_choice.mode': must be 'auto' or 'required', got '{mode}'."
540 - ).into());
541 - return Err(e);
542 - }
543 -
544 - // Validate that all ToolReferences are Function type (Chat API only supports
545 - // function tools)
546 - for tool_ref in allowed_tools {
547 - match tool_ref {
548 - ToolReference::Function { name } => {
549 - // Validate that the function exists in tools array
550 - let tool_exists = tools.iter().any(|tool| {
551 - tool.tool_type == "function" && tool.function.name == *name
552 - });
553 -
554 - if !tool_exists {
555 - let mut e = validator::ValidationError::new(
556 - "tool_choice_tool_not_found",
557 - );
558 - e.message = Some(
559 - format!(
560 - "Invalid value for 'tool_choice.tools': tool '{name}' not found in 'tools'."
561 - )
562 - .into(),
563 - );
564 - return Err(e);
565 - }
566 - }
567 - _ => {
568 - // Chat Completion API only supports function tools in tool_choice
569 - let mut e = validator::ValidationError::new(
570 - "tool_choice_invalid_tool_type",
571 - );
572 - e.message = Some(
573 - format!(
574 - "Invalid value for 'tool_choice.tools': Chat Completion API only supports function tools, got '{}'.",
575 - tool_ref.identifier()
576 - )
577 - .into(),
578 - );
579 - return Err(e);
580 - }
581 - }
582 - }
583 - }
584 - ToolChoice::Value(_) => {}
585 - }
586 - }
587 - }
588 -
482 Ok(())
483 }
rust/src/server/src/routes/tokenize/types.rs
+8 -5
@@ -7,7 +7,7 @@ use itertools::Itertools as _;
7 use serde::{Deserialize, Serialize};
8 use serde_json::Value;
9 use validator::{Validate, ValidationErrors};
10 -use vllm_chat::{ChatOptions, ChatRequest, ChatToolChoice, SamplingParams};
10 +use vllm_chat::{ChatOptions, ChatRequest, ResolvedToolContext, SamplingParams};
11 use vllm_text::output::TextDecodeOptions;
12 use vllm_text::{Prompt, TextRequest};
13
@@ -97,6 +97,11 @@ impl TokenizeChatRequest {
97 &messages,
98 )?;
99
100 + let tool_context =
101 + ResolvedToolContext::new(&messages, convert_tools(self.tools)?, None, true).map_err(
102 + |error| crate::error::chat_submit_error("failed to resolve request tools", error),
103 + )?;
104 +
105 Ok(ChatRequest {
106 request_id,
107 messages,
@@ -108,9 +113,7 @@ impl TokenizeChatRequest {
113 response_format: None,
114 template_kwargs: self.chat_template_kwargs.unwrap_or_default(),
115 },
111 - tools: convert_tools(self.tools)?,
112 - tool_choice: ChatToolChoice::Auto,
113 - parallel_tool_calls: true,
116 + tool_context,
117 decode_options: TextDecodeOptions::default(),
118 intermediate: false,
119 priority: 0,
@@ -199,7 +202,7 @@ mod tests {
202 req.into_chat_request("tokenize-test".to_string()).expect("request is valid");
203
204 assert_eq!(
202 - chat_request.tools,
205 + chat_request.initial_tools(),
206 vec![ChatTool {
207 name: "get_weather".to_string(),
208 description: Some("Get weather".to_string()),