main
py 415 lines 16.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # ruff: noqa: E501, SIM102
4
5 import json
6 from collections.abc import Sequence
7 from typing import Any
8
9 import regex as re
10
11 from vllm.entrypoints.generate.base.protocol import (
12 DeltaFunctionCall,
13 DeltaMessage,
14 DeltaToolCall,
15 ExtractedToolCallInformation,
16 FunctionCall,
17 ToolCall,
18 )
19 from vllm.entrypoints.openai.chat_completion.protocol import (
20 ChatCompletionRequest,
21 )
22 from vllm.logger import init_logger
23 from vllm.tokenizers import TokenizerLike
24 from vllm.tool_parsers.abstract_tool_parser import (
25 Tool,
26 ToolParser,
27 )
28 from vllm.tool_parsers.utils import consume_space
29 from vllm.utils import random_uuid
30
31 logger = init_logger(__name__)
32
33
34 class HunyuanA13BToolParser(ToolParser):
35 def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
36 super().__init__(tokenizer, tools)
37
38 # Initialize state for streaming mode
39 self.prev_tool_calls: list[dict] = []
40 self.current_tool_id = -1
41 self.streamed_args: list[str] = [] # Track arguments sent for each tool
42
43 # For backward compatibility with tests
44 self.current_tools_sent: list[bool] = []
45
46 # For backward compatibility with serving code
47 self.prev_tool_call_arr = []
48
49 # Regex patterns for preprocessing
50 self.answer_tool_calls_pattern = re.compile(
51 r"<tool_calls>([\s\S]*?)</tool_calls>", re.DOTALL
52 )
53
54 self.tool_name_reg = re.compile(r'"name"\s*:\s*"([^"]+)"')
55
56 self.tool_empty_arg_reg = re.compile(
57 r'"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{\s*\}'
58 )
59
60 # TODO: not support nested json object in fc arguments.
61 self.tool_non_empty_arg_reg = re.compile(
62 r'"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*(\{(?:[^{}]|(?:\{[^{}]*\}))*\})'
63 )
64
65 self.bot_string = "<tool_calls>"
66
67 # Define streaming state type to be initialized later
68 self.streaming_state: dict[str, Any] = {
69 "current_tool_index": -1,
70 "tool_ids": [],
71 "sent_tools": [],
72 }
73
74 def preprocess_model_output(
75 self, model_output: str
76 ) -> tuple[str | None, str | None]:
77 # find the location tool call
78 for match in self.answer_tool_calls_pattern.finditer(model_output):
79 start, end = match.span()
80 # check tool_calls whether in side of <think>
81 think_regions = [
82 (m.start(), m.end())
83 for m in re.finditer(
84 r"<think>(.*?)</think>", model_output, flags=re.DOTALL
85 )
86 ]
87 in_think = any(
88 start > t_start and end < t_end for t_start, t_end in think_regions
89 )
90 if not in_think:
91 content = model_output[:start]
92 tool_calls_content = match.group(1).strip()
93 try:
94 json.loads(tool_calls_content)
95 return content, tool_calls_content
96 except Exception:
97 continue
98 return model_output, None
99
100 def extract_tool_calls(
101 self, model_output: str, request: ChatCompletionRequest
102 ) -> ExtractedToolCallInformation:
103 """Extract tool calls from a complete model output."""
104 try:
105 # Preprocess the model output
106 content, potential_tool_calls = self.preprocess_model_output(model_output)
107
108 if not potential_tool_calls:
109 # some text should be filtered out for no function call
110 # this text is in a13b's chat template.
111 if content:
112 content = content.replace("助手:", "", 1)
113 return ExtractedToolCallInformation(
114 tools_called=False, tool_calls=[], content=content
115 )
116
117 # Parse the potential tool calls as JSON
118 tool_calls_data = json.loads(potential_tool_calls)
119
120 # Ensure it's an array
121 if not isinstance(tool_calls_data, list):
122 logger.debug("Tool calls data is not an array")
123 return ExtractedToolCallInformation(
124 tools_called=False,
125 tool_calls=[],
126 content=content or model_output,
127 )
128
129 tool_calls: list[ToolCall] = []
130
131 for idx, call in enumerate(tool_calls_data):
132 if (
133 not isinstance(call, dict)
134 or "name" not in call
135 or "arguments" not in call
136 ):
137 continue
138
139 tool_call = ToolCall(
140 id=f"call_{random_uuid()}",
141 type="function",
142 function=FunctionCall(
143 name=call["name"],
144 arguments=(
145 json.dumps(call["arguments"], ensure_ascii=False)
146 if isinstance(call["arguments"], dict)
147 else call["arguments"]
148 ),
149 ),
150 )
151 tool_calls.append(tool_call)
152
153 if not content or len(content.strip()) == 0:
154 # clear the whitespace content.
155 content = None
156
157 return ExtractedToolCallInformation(
158 tools_called=len(tool_calls) > 0,
159 tool_calls=tool_calls,
160 content=content,
161 )
162
163 except Exception:
164 return ExtractedToolCallInformation(
165 tools_called=False, tool_calls=[], content=model_output
166 )
167
168 def extract_tool_calls_streaming(
169 self,
170 previous_text: str,
171 current_text: str,
172 delta_text: str,
173 previous_token_ids: Sequence[int],
174 current_token_ids: Sequence[int],
175 delta_token_ids: Sequence[int],
176 request: ChatCompletionRequest,
177 ) -> DeltaMessage | None:
178 """Extract tool calls for streaming mode."""
179 start_idx = consume_space(0, current_text)
180 if current_text[start_idx:].startswith(self.bot_string):
181 start_idx = consume_space(start_idx + len(self.bot_string), current_text)
182 if (
183 not current_text
184 or start_idx >= len(current_text)
185 or current_text[start_idx] != "["
186 ):
187 return DeltaMessage(content=delta_text)
188
189 self._try_parse_json_tools(current_text[start_idx:])
190
191 test_delta = self._handle_test_compatibility(current_text)
192 if test_delta:
193 return test_delta
194
195 name_matches = list(self.tool_name_reg.finditer(current_text))
196 tool_count = len(name_matches)
197 if tool_count == 0:
198 return None
199 self._ensure_state_arrays(tool_count)
200 current_idx = self.streaming_state["current_tool_index"]
201
202 name_delta = self._handle_tool_name_streaming(
203 current_idx, tool_count, name_matches
204 )
205 if name_delta:
206 return name_delta
207
208 args_delta = self._handle_tool_args_streaming(
209 current_text, current_idx, tool_count
210 )
211 if args_delta:
212 return args_delta
213
214 return None
215
216 def _try_parse_json_tools(self, current_text: str):
217 try:
218 parsed_tools = json.loads(current_text)
219 if isinstance(parsed_tools, list):
220 self.prev_tool_call_arr = parsed_tools
221 except json.JSONDecodeError:
222 pass
223
224 def _handle_test_compatibility(self, current_text: str):
225 if len(self.current_tools_sent) > 0:
226 if (
227 len(self.current_tools_sent) == 1
228 and self.current_tools_sent[0] is False
229 ):
230 name_match = self.tool_name_reg.search(current_text)
231 if name_match:
232 function_name = name_match.group(1)
233 tool_id = f"chatcmpl-tool-{random_uuid()}"
234 delta = DeltaMessage(
235 tool_calls=[
236 DeltaToolCall(
237 index=0,
238 type="function",
239 id=tool_id,
240 function=DeltaFunctionCall(
241 name=function_name
242 ).model_dump(exclude_none=True),
243 )
244 ]
245 )
246 self.current_tools_sent = [True]
247 self.current_tool_id = 0
248 self.streaming_state["current_tool_index"] = 0
249 if len(self.streaming_state["sent_tools"]) == 0:
250 self.streaming_state["sent_tools"].append(
251 {
252 "sent_name": True,
253 "sent_arguments_prefix": False,
254 "sent_arguments": "",
255 }
256 )
257 else:
258 self.streaming_state["sent_tools"][0]["sent_name"] = True
259 return delta
260 return None
261
262 def _ensure_state_arrays(self, tool_count: int):
263 while len(self.streaming_state["sent_tools"]) < tool_count:
264 self.streaming_state["sent_tools"].append(
265 {
266 "sent_name": False,
267 "sent_arguments_prefix": False,
268 "sent_arguments": "",
269 }
270 )
271 while len(self.streaming_state["tool_ids"]) < tool_count:
272 self.streaming_state["tool_ids"].append(None)
273
274 def _handle_tool_name_streaming(
275 self, current_idx: int, tool_count: int, name_matches
276 ):
277 if current_idx == -1 or current_idx < tool_count - 1:
278 next_idx = current_idx + 1
279 if (
280 next_idx < tool_count
281 and not self.streaming_state["sent_tools"][next_idx]["sent_name"]
282 ):
283 self.streaming_state["current_tool_index"] = next_idx
284 self.current_tool_id = next_idx
285 current_idx = next_idx
286 tool_name = name_matches[current_idx].group(1)
287 tool_id = f"call_{current_idx}_{random_uuid()}"
288 self.streaming_state["tool_ids"][current_idx] = tool_id
289 delta = DeltaMessage(
290 tool_calls=[
291 DeltaToolCall(
292 index=current_idx,
293 type="function",
294 id=tool_id,
295 function=DeltaFunctionCall(name=tool_name).model_dump(
296 exclude_none=True
297 ),
298 )
299 ]
300 )
301 self.streaming_state["sent_tools"][current_idx]["sent_name"] = True
302 while len(self.streamed_args) <= current_idx:
303 self.streamed_args.append("")
304 return delta
305 return None
306
307 def _handle_tool_args_streaming(
308 self, current_text: str, current_idx: int, tool_count: int
309 ):
310 if current_idx >= 0 and current_idx < tool_count:
311 empty_args_match = self.tool_empty_arg_reg.search(current_text)
312 if empty_args_match and empty_args_match.start() > 0:
313 for i in range(tool_count):
314 if i == current_idx:
315 if not self.streaming_state["sent_tools"][current_idx][
316 "sent_arguments_prefix"
317 ]:
318 self.streaming_state["sent_tools"][current_idx][
319 "sent_arguments_prefix"
320 ] = True
321 self.streaming_state["sent_tools"][current_idx][
322 "sent_arguments"
323 ] = "{}"
324 while len(self.streamed_args) <= current_idx:
325 self.streamed_args.append("")
326 self.streamed_args[current_idx] += "{}"
327 delta = DeltaMessage(
328 tool_calls=[
329 DeltaToolCall(
330 index=current_idx,
331 function=DeltaFunctionCall(
332 arguments="{}"
333 ).model_dump(exclude_none=True),
334 )
335 ]
336 )
337 if current_idx < tool_count - 1:
338 self.streaming_state["current_tool_index"] += 1
339 self.current_tool_id = self.streaming_state[
340 "current_tool_index"
341 ]
342 return delta
343
344 args_matches = list(self.tool_non_empty_arg_reg.finditer(current_text))
345 if current_idx < len(args_matches):
346 args_text = args_matches[current_idx].group(1)
347 is_last_tool = current_idx == tool_count - 1
348 if not is_last_tool:
349 next_tool_pos = current_text.find(
350 "},{", args_matches[current_idx].start()
351 )
352 if next_tool_pos != -1:
353 args_end_pos = next_tool_pos + 1
354 args_text = (
355 current_text[
356 args_matches[current_idx].start() : args_end_pos
357 ]
358 .split('"arguments":')[1]
359 .strip()
360 )
361 sent_args = self.streaming_state["sent_tools"][current_idx][
362 "sent_arguments"
363 ]
364 if not self.streaming_state["sent_tools"][current_idx][
365 "sent_arguments_prefix"
366 ] and args_text.startswith("{"):
367 self.streaming_state["sent_tools"][current_idx][
368 "sent_arguments_prefix"
369 ] = True
370 self.streaming_state["sent_tools"][current_idx][
371 "sent_arguments"
372 ] = "{"
373 while len(self.streamed_args) <= current_idx:
374 self.streamed_args.append("")
375 self.streamed_args[current_idx] += "{"
376 delta = DeltaMessage(
377 tool_calls=[
378 DeltaToolCall(
379 index=current_idx,
380 function=DeltaFunctionCall(arguments="{").model_dump(
381 exclude_none=True
382 ),
383 )
384 ]
385 )
386 return delta
387
388 if args_text.startswith(sent_args):
389 args_diff = args_text[len(sent_args) :]
390 if args_diff:
391 self.streaming_state["sent_tools"][current_idx][
392 "sent_arguments"
393 ] = args_text
394 while len(self.streamed_args) <= current_idx:
395 self.streamed_args.append("")
396 self.streamed_args[current_idx] += args_diff
397 delta = DeltaMessage(
398 tool_calls=[
399 DeltaToolCall(
400 index=current_idx,
401 function=DeltaFunctionCall(
402 arguments=args_diff
403 ).model_dump(exclude_none=True),
404 )
405 ]
406 )
407 return delta
408
409 if args_text.endswith("}") and args_text == sent_args:
410 if current_idx < tool_count - 1:
411 self.streaming_state["current_tool_index"] += 1
412 self.current_tool_id = self.streaming_state[
413 "current_tool_index"
414 ]
415 return None