@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / ffdd18111b
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 12, 02:15 UTC

Add DeepSeek-V3.2 tool parser. (#29848)

Signed-off-by: 许文卿 <xwq391974@alibaba-inc.com>

Xu Wenqing committed Dec 4, 2025 at 16:46 UTC ffdd18111b767d271786b982378d51b51ce151fe
2 files changed +595
vllm/entrypoints/openai/tool_parsers/__init__.py
+4
@@ -30,6 +30,10 @@ _TOOL_PARSERS_TO_REGISTER = {
30 "deepseekv31_tool_parser",
31 "DeepSeekV31ToolParser",
32 ),
33 + "deepseek_v32": (
34 + "deepseekv32_tool_parser",
35 + "DeepSeekV32ToolParser",
36 + ),
37 "ernie45": (
38 "ernie45_tool_parser",
39 "Ernie45ToolParser",
vllm/entrypoints/openai/tool_parsers/deepseekv32_tool_parser.py new
+591
@@ -0,0 +1,591 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import json
5 +import uuid
6 +from collections.abc import Sequence
7 +from typing import Any
8 +
9 +import regex as re
10 +
11 +from vllm.entrypoints.openai.protocol import (
12 + ChatCompletionRequest,
13 + DeltaFunctionCall,
14 + DeltaMessage,
15 + DeltaToolCall,
16 + ExtractedToolCallInformation,
17 + FunctionCall,
18 + ToolCall,
19 +)
20 +from vllm.entrypoints.openai.tool_parsers.abstract_tool_parser import (
21 + ToolParser,
22 +)
23 +from vllm.logger import init_logger
24 +from vllm.tokenizers import TokenizerLike
25 +
26 +logger = init_logger(__name__)
27 +
28 +
29 +class DeepSeekV32ToolParser(ToolParser):
30 + """
31 + example tool call content:
32 + <|DSML|function_calls>
33 + <|DSML|invoke name="get_weather">
34 + <|DSML|parameter name="location" string="true">杭州</|DSML|parameter>
35 + <|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
36 + </|DSML|invoke>
37 + <|DSML|invoke name="get_weather">
38 + <|DSML|parameter name="location" string="true">北京</|DSML|parameter>
39 + <|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
40 + </|DSML|invoke>
41 + </|DSML|function_calls>
42 + """
43 +
44 + def __init__(self, tokenizer: TokenizerLike):
45 + super().__init__(tokenizer)
46 +
47 + self.prev_tool_call_arr: list[dict] = []
48 +
49 + # Sentinel tokens
50 + self.dsml_token: str = "|DSML|"
51 + self.dsml_start_check: str = "<" + self.dsml_token
52 + self.tool_call_start_token: str = "<|DSML|function_calls>"
53 + self.tool_call_end_token: str = "</|DSML|function_calls>"
54 + self.invoke_start_prefix: str = "<|DSML|invoke name="
55 + self.invoke_end_token: str = "</|DSML|invoke>"
56 + self.parameter_prefix: str = "<|DSML|parameter name="
57 + self.parameter_end_token: str = "</|DSML|parameter>"
58 +
59 + # Streaming state variables
60 + self.current_tool_name_sent: bool = False
61 + # Override base class type - we use string IDs for tool calls
62 + self.current_tool_id: str | None = None # type: ignore
63 + self.streamed_args_for_tool: list[str] = []
64 + self.is_tool_call_started: bool = False
65 + self.failed_count: int = 0
66 +
67 + # Initialize streaming state variables
68 + self.current_tool_index: int = 0
69 + self.invoke_index: int = 0
70 + self.header_sent: bool = False
71 + self.current_function_name: str | None = None
72 + self.current_param_name: str | None = None
73 + self.current_param_value: str = ""
74 + self.param_count: int = 0
75 + self.in_param: bool = False
76 + self.in_function: bool = False
77 + self.json_started: bool = False
78 + self.json_closed: bool = False
79 + self.accumulated_params: dict = {}
80 + self.streaming_request: ChatCompletionRequest | None = None
81 +
82 + # Enhanced streaming state - reset for each new message
83 + self._reset_streaming_state()
84 +
85 + # Regex patterns for complete parsing
86 + self.tool_call_complete_regex = re.compile(
87 + r"<|DSML|function_calls>(.*?)</|DSML|function_calls>", re.DOTALL
88 + )
89 + self.invoke_complete_regex = re.compile(
90 + r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)</|DSML|invoke>', re.DOTALL
91 + )
92 + self.parameter_complete_regex = re.compile(
93 + r'<|DSML|parameter\s+name="([^"]+)"\s+string="(?:true|false)"\s*>(.*?)</|DSML|parameter>',
94 + re.DOTALL,
95 + )
96 +
97 + if not self.model_tokenizer:
98 + raise ValueError(
99 + "The model tokenizer must be passed to the ToolParser "
100 + "constructor during construction."
101 + )
102 +
103 + logger.debug(
104 + "vLLM Successfully import tool parser %s !", self.__class__.__name__
105 + )
106 +
107 + def _generate_tool_call_id(self) -> str:
108 + """Generate a unique tool call ID."""
109 + return f"call_{uuid.uuid4().hex[:24]}"
110 +
111 + def _reset_streaming_state(self):
112 + """Reset all streaming state."""
113 + self.current_tool_index = 0
114 + self.invoke_index = 0
115 + self.is_tool_call_started = False
116 + self.header_sent = False
117 + self.current_tool_id = None
118 + self.current_function_name = None
119 + self.current_param_name = None
120 + self.current_param_value = ""
121 + self.param_count = 0
122 + self.in_param = False
123 + self.in_function = False
124 + self.json_started = False
125 + self.json_closed = False
126 + # Store accumulated parameters for type conversion
127 + self.accumulated_params = {}
128 + self.streaming_request = None
129 + # Clear previous tool call history to avoid state pollution
130 + self.prev_tool_call_arr.clear()
131 +
132 + def _parse_invoke_params(self, invoke_str: str) -> dict | None:
133 + param_dict = dict()
134 + for param_name, param_val in self.parameter_complete_regex.findall(invoke_str):
135 + param_dict[param_name] = param_val
136 + return param_dict
137 +
138 + def extract_tool_calls(
139 + self,
140 + model_output: str,
141 + request: ChatCompletionRequest,
142 + ) -> ExtractedToolCallInformation:
143 + """Extract tool calls from complete model output (non-streaming)."""
144 + # Quick check
145 + if self.tool_call_start_token not in model_output:
146 + return ExtractedToolCallInformation(
147 + tools_called=False, tool_calls=[], content=model_output
148 + )
149 +
150 + try:
151 + tool_calls = []
152 +
153 + # Find all complete tool_call blocks
154 + for tool_call_match in self.tool_call_complete_regex.findall(model_output):
155 + # Find all invokes within this tool_call
156 + for invoke_name, invoke_content in self.invoke_complete_regex.findall(
157 + tool_call_match
158 + ):
159 + param_dict = self._parse_invoke_params(invoke_content)
160 + tool_calls.append(
161 + ToolCall(
162 + type="function",
163 + function=FunctionCall(
164 + name=invoke_name,
165 + arguments=json.dumps(param_dict, ensure_ascii=False),
166 + ),
167 + )
168 + )
169 +
170 + if not tool_calls:
171 + return ExtractedToolCallInformation(
172 + tools_called=False, tool_calls=[], content=model_output
173 + )
174 +
175 + # Extract content before first tool call
176 + first_tool_idx = model_output.find(self.tool_call_start_token)
177 + content = model_output[:first_tool_idx] if first_tool_idx > 0 else None
178 +
179 + return ExtractedToolCallInformation(
180 + tools_called=True, tool_calls=tool_calls, content=content
181 + )
182 +
183 + except Exception:
184 + logger.exception("Error extracting tool calls")
185 + return ExtractedToolCallInformation(
186 + tools_called=False, tool_calls=[], content=model_output
187 + )
188 +
189 + def _extract_name(self, name_str: str) -> str:
190 + """Extract name from quoted string."""
191 + name_str = name_str.strip()
192 + if (
193 + name_str.startswith('"')
194 + and name_str.endswith('"')
195 + or name_str.startswith("'")
196 + and name_str.endswith("'")
197 + ):
198 + return name_str[1:-1]
199 + return name_str
200 +
201 + def _extract_param_name(self, input_str: str) -> str:
202 + """Extract param name"""
203 + start = input_str.find('"') + 1
204 + end = input_str.find('"', start)
205 + return input_str[start:end] if start > 0 and end > start else input_str
206 +
207 + def _convert_param_value(self, value: str, param_type: str) -> Any:
208 + """Convert parameter value to the correct type."""
209 + if value.lower() == "null":
210 + return None
211 +
212 + param_type = param_type.lower()
213 + if param_type in ["string", "str", "text"]:
214 + return value
215 + elif param_type in ["integer", "int"]:
216 + try:
217 + return int(value)
218 + except (ValueError, TypeError):
219 + return value
220 + elif param_type in ["number", "float"]:
221 + try:
222 + val = float(value)
223 + return val if val != int(val) else int(val)
224 + except (ValueError, TypeError):
225 + return value
226 + elif param_type in ["boolean", "bool"]:
227 + return value.lower() in ["true", "1"]
228 + elif param_type in ["object", "array"]:
229 + try:
230 + return json.loads(value)
231 + except json.JSONDecodeError:
232 + return value
233 + else:
234 + # Try JSON parse first, fallback to string
235 + try:
236 + return json.loads(value)
237 + except json.JSONDecodeError:
238 + return value
239 +
240 + def extract_tool_calls_streaming(
241 + self,
242 + previous_text: str,
243 + current_text: str,
244 + delta_text: str,
245 + previous_token_ids: Sequence[int], # pylint: disable=unused-argument
246 + current_token_ids: Sequence[int], # pylint: disable=unused-argument
247 + delta_token_ids: Sequence[int],
248 + request: ChatCompletionRequest,
249 + ) -> DeltaMessage | None:
250 + """Extract tool calls from streaming model output."""
251 +
252 + # Store request for type conversion
253 + if not previous_text:
254 + self._reset_streaming_state()
255 + self.streaming_request = request
256 +
257 + # If no delta text, return None unless it's an EOS token after tools
258 + if not delta_text:
259 + # Check if this is an EOS token after all tool calls are complete
260 + if delta_token_ids:
261 + # Count complete tool calls
262 + complete_calls = len(
263 + self.tool_call_complete_regex.findall(current_text)
264 + )
265 +
266 + # If we have completed tool calls and populated prev_tool_call_arr
267 + if complete_calls > 0 and len(self.prev_tool_call_arr) > 0:
268 + # Check if all tool calls are closed
269 + open_calls = current_text.count(
270 + self.tool_call_start_token
271 + ) - current_text.count(self.tool_call_end_token)
272 + if open_calls == 0:
273 + # Return empty delta for finish_reason processing
274 + return DeltaMessage(content="")
275 + elif not self.is_tool_call_started and current_text:
276 + # This is a regular content response that's now complete
277 + return DeltaMessage(content="")
278 + return None
279 +
280 + # Check if we need to advance to next tool
281 + if self.json_closed and not self.in_function:
282 + # Check if this tool call has ended
283 + invoke_ends = current_text.count(self.invoke_end_token)
284 + if invoke_ends > self.current_tool_index:
285 + # This tool has ended, advance to next
286 + self.current_tool_index += 1
287 + self.header_sent = False
288 + self.param_count = 0
289 + self.json_started = False
290 + self.json_closed = False
291 + self.in_function = False # Now we can safely set this to False
292 + self.accumulated_params = {}
293 + # Continue processing next tool
294 + return None
295 +
296 + # Handle normal content before tool calls
297 + if not self.is_tool_call_started:
298 + # Check if tool call is starting
299 + if self.dsml_token in current_text:
300 + self.is_tool_call_started = True
301 + # Return any content before the tool call
302 + if self.dsml_start_check in delta_text:
303 + content_before = delta_text[
304 + : delta_text.index(self.dsml_start_check)
305 + ]
306 + if content_before:
307 + return DeltaMessage(content=content_before)
308 + return None
309 + else:
310 + # Check if we're between tool calls - skip whitespace
311 + if (
312 + current_text.rstrip().endswith(self.tool_call_end_token)
313 + and delta_text.strip() == ""
314 + ):
315 + # We just ended a tool call, skip whitespace
316 + return None
317 + # Normal content, no tool call
318 + if delta_text.endswith("<"):
319 + return DeltaMessage(content=delta_text[:-1])
320 + if previous_text and previous_text.endswith("<"):
321 + return DeltaMessage(content="<" + delta_text)
322 + return DeltaMessage(content=delta_text)
323 +
324 + # Check if we're between tool calls (waiting for next one)
325 + invoke_starts_count = current_text.count(self.invoke_start_prefix)
326 + if self.current_tool_index >= invoke_starts_count:
327 + # We're past all tool calls, shouldn't be here
328 + return None
329 +
330 + # Find the current tool call portion
331 + invoke_start_positions: list[int] = []
332 + idx = 0
333 + while True:
334 + idx = current_text.find(self.invoke_start_prefix, idx)
335 + if idx == -1:
336 + break
337 + invoke_start_positions.append(idx)
338 + idx += len(self.invoke_start_prefix)
339 +
340 + if self.current_tool_index >= len(invoke_start_positions):
341 + # No more tool calls to process yet
342 + return None
343 +
344 + invoke_start_idx = invoke_start_positions[self.current_tool_index]
345 + # Find where this tool call ends (or current position if not ended yet)
346 + invoke_end_idx = current_text.find(self.invoke_end_token, invoke_start_idx)
347 + if invoke_end_idx == -1:
348 + tool_text = current_text[invoke_start_idx:]
349 + else:
350 + tool_text = current_text[
351 + invoke_start_idx : invoke_end_idx + len(self.invoke_end_token)
352 + ]
353 +
354 + # Looking for function header
355 + if not self.header_sent:
356 + if self.invoke_start_prefix in tool_text:
357 + func_start = tool_text.find(self.invoke_start_prefix) + len(
358 + self.invoke_start_prefix
359 + )
360 + # Find the end quote for the function name
361 + func_end = tool_text.find(">", func_start)
362 +
363 + if func_end != -1:
364 + # Found complete function name
365 + function_name_raw = tool_text[func_start:func_end]
366 + self.current_function_name = self._extract_name(function_name_raw)
367 + self.current_tool_id = self._generate_tool_call_id()
368 + self.header_sent = True
369 + self.in_function = True
370 +
371 + # Add to prev_tool_call_arr immediately when we detect a tool call
372 + # Each tool call should be recorded regardless of function name
373 + # Ensure we don't add the same tool call index multiple times
374 + if len(self.prev_tool_call_arr) <= self.current_tool_index:
375 + self.prev_tool_call_arr.append(
376 + {
377 + "name": self.current_function_name,
378 + "arguments": "{}", # Placeholder, will be updated later
379 + }
380 + )
381 +
382 + # Send header with function info
383 + return DeltaMessage(
384 + tool_calls=[
385 + DeltaToolCall(
386 + index=self.current_tool_index,
387 + id=self.current_tool_id,
388 + function=DeltaFunctionCall(
389 + name=self.current_function_name, arguments=""
390 + ),
391 + type="function",
392 + )
393 + ]
394 + )
395 + return None
396 +
397 + # We've sent header, now handle function body
398 + if self.in_function:
399 + # Send opening brace if not sent yet
400 + if self.in_function and not self.json_started:
401 + self.json_started = True
402 + return DeltaMessage(
403 + tool_calls=[
404 + DeltaToolCall(
405 + index=self.current_tool_index,
406 + function=DeltaFunctionCall(arguments="{"),
407 + )
408 + ]
409 + )
410 +
411 + # Make sure json_started is set if we're processing parameters
412 + if not self.json_started:
413 + self.json_started = True
414 +
415 + # Check for function end in accumulated text
416 + if not self.json_closed and self.invoke_end_token in tool_text:
417 + # Count total parameters in the tool text
418 + total_param_count = tool_text.count(self.parameter_prefix)
419 +
420 + # Only close JSON if all parameters have been processed
421 + if self.param_count >= total_param_count:
422 + # Close JSON
423 + self.json_closed = True
424 +
425 + # Extract complete tool call
426 + # Find the invoke content
427 + invoke_start = tool_text.find(self.invoke_start_prefix) + len(
428 + self.invoke_start_prefix
429 + )
430 + invoke_content_end = tool_text.find(
431 + self.invoke_end_token, invoke_start
432 + )
433 + if invoke_content_end != -1:
434 + invoke_content = tool_text[invoke_start:invoke_content_end]
435 + # Parse to get the complete arguments
436 + try:
437 + invoke_params = self._parse_invoke_params(invoke_content)
438 + if invoke_params and self.current_tool_index < len(
439 + self.prev_tool_call_arr
440 + ):
441 + # Update existing entry in prev_tool_call_arr
442 + self.prev_tool_call_arr[self.current_tool_index][
443 + "arguments"
444 + ] = json.dumps(invoke_params, ensure_ascii=False)
445 + except Exception:
446 + pass # Ignore parsing errors during streaming
447 +
448 + result = DeltaMessage(
449 + tool_calls=[
450 + DeltaToolCall(
451 + index=self.current_tool_index,
452 + function=DeltaFunctionCall(arguments="}"),
453 + )
454 + ]
455 + )
456 +
457 + # Reset state for next tool
458 + self.json_closed = True
459 + self.in_function = False
460 + self.accumulated_params = {}
461 +
462 + logger.debug("[M2_STREAMING] Tool call completed")
463 +
464 + return result
465 + else:
466 + # Don't close JSON yet, continue processing parameters
467 + return None
468 +
469 + # Look for parameters
470 + # Find all parameter starts
471 + param_starts = []
472 + idx = 0
473 + while True:
474 + idx = tool_text.find(self.parameter_prefix, idx)
475 + if idx == -1:
476 + break
477 + param_starts.append(idx)
478 + idx += len(self.parameter_prefix)
479 +
480 + # Check if we should start a new parameter
481 + if (
482 + not self.in_param
483 + and self.param_count < len(param_starts)
484 + and len(param_starts) > self.param_count
485 + ):
486 + # Process the next parameter
487 + param_idx = param_starts[self.param_count]
488 + param_start = param_idx + len(self.parameter_prefix)
489 + remaining = tool_text[param_start:]
490 +
491 + if ">" in remaining:
492 + # We have the complete parameter name
493 + name_end = remaining.find(">")
494 + param_name_raw = remaining[:name_end]
495 + self.current_param_name = self._extract_param_name(param_name_raw)
496 +
497 + # Find the parameter value
498 + value_start = param_start + name_end + 1
499 + value_text = tool_text[value_start:]
500 + if value_text.startswith("\n"):
501 + value_text = value_text[1:]
502 +
503 + # Find where this parameter ends
504 + param_end_idx = value_text.find(self.parameter_end_token)
505 + if param_end_idx == -1:
506 + # No closing tag, look for next parameter or function end
507 + next_param_idx = value_text.find(self.parameter_prefix)
508 + func_end_idx = value_text.find(self.invoke_end_token)
509 +
510 + if next_param_idx != -1 and (
511 + func_end_idx == -1 or next_param_idx < func_end_idx
512 + ):
513 + param_end_idx = next_param_idx
514 + elif func_end_idx != -1:
515 + param_end_idx = func_end_idx
516 + else:
517 + # Neither found, check if tool call is complete
518 + if self.invoke_end_token in tool_text:
519 + # Tool call and parameter is complete
520 + param_end_idx = len(value_text)
521 + else:
522 + # Still streaming, wait for more content
523 + return None
524 +
525 + if param_end_idx != -1:
526 + # Complete parameter found
527 + param_value = value_text[:param_end_idx]
528 + if param_value.endswith("\n"):
529 + param_value = param_value[:-1]
530 +
531 + # Store raw value for later processing
532 + self.accumulated_params[self.current_param_name] = param_value
533 +
534 + # Get parameter configuration for type conversion
535 + param_config = {}
536 + if self.streaming_request and self.streaming_request.tools:
537 + for tool in self.streaming_request.tools:
538 + if (
539 + hasattr(tool, "function")
540 + and tool.function.name == self.current_function_name
541 + and hasattr(tool.function, "parameters")
542 + ):
543 + params = tool.function.parameters
544 + if (
545 + isinstance(params, dict)
546 + and "properties" in params
547 + ):
548 + param_config = params["properties"]
549 + break
550 +
551 + # Get parameter type
552 + param_type = "string"
553 + if (
554 + self.current_param_name in param_config
555 + and isinstance(param_config[self.current_param_name], dict)
556 + and "type" in param_config[self.current_param_name]
557 + ):
558 + param_type = param_config[self.current_param_name]["type"]
559 +
560 + # Convert param value to appropriate type
561 + converted_value = self._convert_param_value(
562 + param_value, param_type
563 + )
564 +
565 + # Build JSON fragment based on the converted type
566 + # Use json.dumps to properly serialize the value
567 + serialized_value = json.dumps(
568 + converted_value, ensure_ascii=False
569 + )
570 +
571 + if self.param_count == 0:
572 + json_fragment = (
573 + f'"{self.current_param_name}": {serialized_value}'
574 + )
575 + else:
576 + json_fragment = (
577 + f', "{self.current_param_name}": {serialized_value}'
578 + )
579 +
580 + self.param_count += 1
581 +
582 + return DeltaMessage(
583 + tool_calls=[
584 + DeltaToolCall(
585 + index=self.current_tool_index,
586 + function=DeltaFunctionCall(arguments=json_fragment),
587 + )
588 + ]
589 + )
590 +
591 + return None