main
py 561 lines 24.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # ruff: noqa
4 import json
5 from collections.abc import Sequence
6 from typing import Any, Optional, Union
7
8 import regex as re
9
10 from vllm.entrypoints.openai.chat_completion.protocol import (
11 ChatCompletionRequest,
12 )
13 from vllm.entrypoints.chat_utils import make_tool_call_id
14 from vllm.entrypoints.generate.base.protocol import (
15 DeltaFunctionCall,
16 DeltaMessage,
17 DeltaToolCall,
18 ExtractedToolCallInformation,
19 FunctionCall,
20 ToolCall,
21 )
22 from vllm.tool_parsers.abstract_tool_parser import (
23 Tool,
24 ToolParser,
25 )
26 from vllm.logger import init_logger
27 from vllm.tokenizers import TokenizerLike
28 from vllm.utils import random_uuid
29
30 logger = init_logger(__name__)
31
32
33 class xLAMToolParser(ToolParser):
34 def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
35 super().__init__(tokenizer, tools)
36
37 # Initialize state for streaming mode
38 self.prev_tool_calls: list[dict] = []
39 self.current_tool_id = -1
40 self.current_tool_name_sent = False
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.json_code_block_patterns = [
51 r"```(?:json)?\s*([\s\S]*?)```",
52 r"\[TOOL_CALLS\]([\s\S]*?)(?=\n|$)",
53 r"<tool_call>([\s\S]*?)</tool_call>",
54 ]
55 self.thinking_tag_pattern = r"</think>([\s\S]*)"
56
57 # Define streaming state type to be initialized later
58 self.streaming_state: dict[str, Any] = {
59 "current_tool_index": -1,
60 "tool_ids": [],
61 "sent_tools": [],
62 }
63
64 def preprocess_model_output(
65 self, model_output: str
66 ) -> tuple[Optional[str], Optional[str]]:
67 """
68 Preprocess the model output to extract content and potential tool calls.
69 Returns:
70 Tuple of (content, potential_tool_calls_json)
71 """
72 # Check for thinking tag
73 thinking_match = re.search(self.thinking_tag_pattern, model_output)
74 if thinking_match:
75 content = model_output[: thinking_match.start() + len("</think>")].strip()
76 thinking_content = thinking_match.group(1).strip()
77
78 # Try to parse the thinking content as JSON
79 try:
80 json.loads(thinking_content)
81 return content, thinking_content
82 except json.JSONDecodeError:
83 # If can't parse as JSON, look for JSON code blocks
84 for json_pattern in self.json_code_block_patterns:
85 json_matches = re.findall(json_pattern, thinking_content)
86 if json_matches:
87 for json_str in json_matches:
88 try:
89 json.loads(json_str)
90 return content, json_str
91 except json.JSONDecodeError:
92 continue
93
94 # Check for JSON code blocks in the entire output
95 for json_pattern in self.json_code_block_patterns:
96 json_matches = re.findall(json_pattern, model_output)
97 if json_matches:
98 for json_str in json_matches:
99 try:
100 json.loads(json_str)
101 # Extract content by removing the JSON code block
102 content = re.sub(json_pattern, "", model_output).strip()
103 return content, json_str
104 except json.JSONDecodeError:
105 continue
106
107 # If the entire output is a valid JSON array or looks like one, treat it as tool calls
108 if model_output.strip().startswith("["):
109 try:
110 json.loads(model_output)
111 return None, model_output
112 except json.JSONDecodeError:
113 # Even if it's not valid JSON yet, it might be a tool call in progress
114 if (
115 "{" in model_output
116 and "name" in model_output
117 and "arguments" in model_output
118 ):
119 return None, model_output
120
121 # If no tool calls found, return the original output as content
122 return model_output, None
123
124 def extract_tool_calls(
125 self, model_output: str, request: ChatCompletionRequest
126 ) -> ExtractedToolCallInformation:
127 """
128 Extract tool calls from a complete model output.
129 """
130 try:
131 # Preprocess the model output
132 content, potential_tool_calls = self.preprocess_model_output(model_output)
133
134 if not potential_tool_calls:
135 return ExtractedToolCallInformation(
136 tools_called=False, tool_calls=[], content=content
137 )
138
139 # Parse the potential tool calls as JSON
140 tool_calls_data = json.loads(potential_tool_calls)
141
142 # Ensure it's an array
143 if not isinstance(tool_calls_data, list):
144 logger.debug("Tool calls data is not an array")
145 return ExtractedToolCallInformation(
146 tools_called=False,
147 tool_calls=[],
148 content=content or model_output,
149 )
150
151 tool_calls: list[ToolCall] = []
152
153 for idx, call in enumerate(tool_calls_data):
154 if (
155 not isinstance(call, dict)
156 or "name" not in call
157 or "arguments" not in call
158 ):
159 logger.debug("Invalid tool call format at index %d", idx)
160 continue
161
162 tool_call = ToolCall(
163 id=f"call_{idx}_{random_uuid()}",
164 type="function",
165 function=FunctionCall(
166 name=call["name"],
167 arguments=(
168 json.dumps(call["arguments"], ensure_ascii=False)
169 if isinstance(call["arguments"], dict)
170 else call["arguments"]
171 ),
172 ),
173 )
174 tool_calls.append(tool_call)
175
176 return ExtractedToolCallInformation(
177 tools_called=len(tool_calls) > 0,
178 tool_calls=tool_calls,
179 content=content,
180 )
181
182 except Exception as e:
183 logger.exception("Error extracting tool calls: %s", str(e))
184 return ExtractedToolCallInformation(
185 tools_called=False, tool_calls=[], content=model_output
186 )
187
188 def extract_tool_calls_streaming(
189 self,
190 previous_text: str,
191 current_text: str,
192 delta_text: str,
193 previous_token_ids: Sequence[int],
194 current_token_ids: Sequence[int],
195 delta_token_ids: Sequence[int],
196 request: ChatCompletionRequest,
197 ) -> Union[DeltaMessage, None]:
198 """
199 Extract tool calls for streaming mode.
200 """
201 # First, check for a definitive start of a tool call block.
202 # This prevents premature parsing of incomplete output.
203 stripped_text = current_text.strip()
204 preprocessed_content, preprocessed_tool_calls = self.preprocess_model_output(
205 current_text
206 )
207
208 # For JSON code blocks, we need to detect them earlier, even if incomplete
209 has_potential_json_block = (
210 "```json" in current_text
211 or "```\n[" in current_text
212 or "[TOOL_CALLS]" in current_text
213 or "<tool_call>" in current_text
214 )
215
216 is_tool_call_block = (
217 stripped_text.startswith("[")
218 or stripped_text.startswith("<tool_call>")
219 or stripped_text.startswith("[TOOL_CALLS]")
220 or
221 # Check if we have thinking tags with JSON-like content following
222 ("</think>[" in current_text)
223 or
224 # Check if the text contains a JSON array after preprocessing
225 preprocessed_tool_calls is not None
226 or
227 # For JSON code blocks, detect early if we see enough structure
228 (
229 has_potential_json_block
230 and '"name"' in current_text
231 and '"arguments"' in current_text
232 )
233 )
234
235 if not is_tool_call_block:
236 return DeltaMessage(content=delta_text)
237
238 try:
239 # Initialize streaming state if not exists
240 if not hasattr(self, "streaming_state"):
241 self.streaming_state = {
242 "current_tool_index": -1,
243 "tool_ids": [],
244 "sent_tools": [], # Track complete state of each tool
245 }
246
247 # Try parsing as JSON to check for complete tool calls
248 try:
249 # Use preprocessed tool calls if available
250 tool_calls_text = (
251 preprocessed_tool_calls if preprocessed_tool_calls else current_text
252 )
253 parsed_tools = json.loads(tool_calls_text)
254 if isinstance(parsed_tools, list):
255 # Update our tool array for next time
256 self.prev_tool_call_arr = parsed_tools
257 except json.JSONDecodeError:
258 # Not complete JSON yet, use regex for partial parsing
259 pass
260
261 # Check for test-specific state setup (current_tools_sent)
262 # This handles the case where tests manually set current_tools_sent
263 if (
264 hasattr(self, "current_tools_sent") # type: ignore
265 and len(self.current_tools_sent) > 0
266 ):
267 # If current_tools_sent is set to [False], it means the test wants us to send the name
268 if (
269 len(self.current_tools_sent) == 1
270 and self.current_tools_sent[0] is False
271 ):
272 # Extract the function name using regex
273 name_pattern = r'"name"\s*:\s*"([^"]+)"'
274 name_match = re.search(name_pattern, current_text)
275 if name_match:
276 function_name = name_match.group(1)
277
278 # The test expects us to send just the name first
279 tool_id = make_tool_call_id()
280 delta = DeltaMessage(
281 tool_calls=[
282 DeltaToolCall(
283 index=0,
284 type="function",
285 id=tool_id,
286 function=DeltaFunctionCall(
287 name=function_name
288 ).model_dump(exclude_none=True), # type: ignore
289 )
290 ]
291 )
292 # Update state to reflect that we've sent the name
293 self.current_tools_sent = [True]
294 self.current_tool_id = 0
295 self.streaming_state["current_tool_index"] = 0
296 if len(self.streaming_state["sent_tools"]) == 0:
297 self.streaming_state["sent_tools"].append(
298 {
299 "sent_name": True,
300 "sent_arguments_prefix": False,
301 "sent_arguments": "",
302 }
303 )
304 else:
305 self.streaming_state["sent_tools"][0]["sent_name"] = True
306 self.current_tool_name_sent = True
307 return delta
308
309 # Use regex to identify tool calls in the output
310 # Use preprocessed tool calls text for better parsing, but also try to extract from incomplete JSON blocks
311 search_text = (
312 preprocessed_tool_calls if preprocessed_tool_calls else current_text
313 )
314
315 # For JSON code blocks that aren't complete yet, try to extract the JSON content
316 if not preprocessed_tool_calls and has_potential_json_block:
317 # Try to extract the JSON array from within the code block
318 json_match = re.search(
319 r"```(?:json)?\s*([\s\S]*?)(?:```|$)", current_text
320 )
321 if json_match:
322 potential_json = json_match.group(1).strip()
323 # Use this as search text even if it's incomplete
324 if potential_json.startswith("[") and (
325 '"name"' in potential_json and '"arguments"' in potential_json
326 ):
327 search_text = potential_json
328
329 # Try to find complete tool names first
330 name_pattern = r'"name"\s*:\s*"([^"]+)"'
331 name_matches = list(re.finditer(name_pattern, search_text))
332 tool_count = len(name_matches)
333
334 # If no complete tool names found, check for partial tool names
335 if tool_count == 0:
336 # Check if we're in the middle of parsing a tool name
337 partial_name_pattern = r'"name"\s*:\s*"([^"]*)'
338 partial_matches = list(re.finditer(partial_name_pattern, search_text))
339 if partial_matches:
340 # We have a partial tool name - not ready to emit yet
341 return None
342 else:
343 # No tools found at all
344 return None
345
346 # Ensure our state arrays are large enough
347 while len(self.streaming_state["sent_tools"]) < tool_count:
348 self.streaming_state["sent_tools"].append(
349 {
350 "sent_name": False,
351 "sent_arguments_prefix": False,
352 "sent_arguments": "",
353 }
354 )
355
356 while len(self.streaming_state["tool_ids"]) < tool_count:
357 self.streaming_state["tool_ids"].append(None)
358
359 # Determine if we need to move to a new tool
360 current_idx = self.streaming_state["current_tool_index"]
361
362 # If we haven't processed any tool yet or current tool is complete, move to next
363 if current_idx == -1 or current_idx < tool_count - 1:
364 next_idx = current_idx + 1
365
366 # If tool at next_idx has not been sent yet
367 if (
368 next_idx < tool_count
369 and not self.streaming_state["sent_tools"][next_idx]["sent_name"]
370 ):
371 # Update indexes
372 self.streaming_state["current_tool_index"] = next_idx
373 self.current_tool_id = next_idx # For backward compatibility
374 current_idx = next_idx
375
376 # Extract the tool name
377 tool_name = name_matches[current_idx].group(1)
378
379 # Generate ID and send tool name
380 tool_id = f"call_{current_idx}_{random_uuid()}"
381 self.streaming_state["tool_ids"][current_idx] = tool_id
382
383 delta = DeltaMessage(
384 tool_calls=[
385 DeltaToolCall(
386 index=current_idx,
387 type="function",
388 id=tool_id,
389 function=DeltaFunctionCall(name=tool_name).model_dump(
390 exclude_none=True
391 ), # type: ignore
392 )
393 ]
394 )
395 self.streaming_state["sent_tools"][current_idx]["sent_name"] = True
396 self.current_tool_name_sent = True # For backward compatibility
397
398 # Keep track of streamed args for backward compatibility
399 while len(self.streamed_args) <= current_idx:
400 self.streamed_args.append("")
401
402 return delta
403
404 # Process arguments for the current tool
405 if current_idx >= 0 and current_idx < tool_count:
406 # Support both regular and empty argument objects
407 # First, check for the empty arguments case: "arguments": {}
408 empty_args_pattern = (
409 r'"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{\s*\}'
410 )
411 empty_args_match = re.search(empty_args_pattern, search_text)
412
413 # Check if this tool has empty arguments
414 if empty_args_match and empty_args_match.start() > 0:
415 # Find which tool this empty arguments belongs to
416 empty_args_tool_idx = 0
417 for i in range(tool_count):
418 if i == current_idx:
419 # If this is our current tool and it has empty arguments
420 if not self.streaming_state["sent_tools"][current_idx][
421 "sent_arguments_prefix"
422 ]:
423 # Send empty object
424 self.streaming_state["sent_tools"][current_idx][
425 "sent_arguments_prefix"
426 ] = True
427 self.streaming_state["sent_tools"][current_idx][
428 "sent_arguments"
429 ] = "{}"
430
431 # Update streamed_args for backward compatibility
432 while len(self.streamed_args) <= current_idx:
433 self.streamed_args.append("")
434 self.streamed_args[current_idx] += "{}"
435
436 delta = DeltaMessage(
437 tool_calls=[
438 DeltaToolCall(
439 index=current_idx,
440 function=DeltaFunctionCall(
441 arguments="{}"
442 ).model_dump(exclude_none=True), # type: ignore
443 )
444 ]
445 )
446
447 # Move to next tool if available
448 if current_idx < tool_count - 1:
449 self.streaming_state["current_tool_index"] += 1
450 self.current_tool_id = self.streaming_state[
451 "current_tool_index"
452 ]
453
454 return delta
455
456 # Extract arguments for current tool using regex for non-empty arguments
457 args_pattern = r'"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*(\{(?:[^{}]|(?:\{[^{}]*\}))*\})'
458 args_matches = list(re.finditer(args_pattern, search_text))
459
460 if current_idx < len(args_matches):
461 args_text = args_matches[current_idx].group(1)
462
463 # Handle transition between tools
464 is_last_tool = current_idx == tool_count - 1
465
466 # For multiple tools, extract only the arguments for the current tool
467 if tool_count > 1:
468 # Parse the entire JSON structure to properly extract arguments for each tool
469 try:
470 parsed_tools = json.loads(search_text)
471 if isinstance(parsed_tools, list) and current_idx < len(
472 parsed_tools
473 ):
474 current_tool = parsed_tools[current_idx]
475 if isinstance(current_tool.get("arguments"), dict):
476 args_text = json.dumps(
477 current_tool["arguments"], ensure_ascii=False
478 )
479 else:
480 args_text = str(current_tool.get("arguments", "{}"))
481 except (json.JSONDecodeError, KeyError, IndexError):
482 # Fallback to regex-based extraction
483 pass
484
485 # If arguments haven't been sent yet
486 sent_args = self.streaming_state["sent_tools"][current_idx][
487 "sent_arguments"
488 ]
489
490 # If we haven't sent the opening bracket yet
491 if not self.streaming_state["sent_tools"][current_idx][
492 "sent_arguments_prefix"
493 ] and args_text.startswith("{"):
494 self.streaming_state["sent_tools"][current_idx][
495 "sent_arguments_prefix"
496 ] = True
497 self.streaming_state["sent_tools"][current_idx][
498 "sent_arguments"
499 ] = "{"
500
501 # Update streamed_args for backward compatibility
502 while len(self.streamed_args) <= current_idx:
503 self.streamed_args.append("")
504 self.streamed_args[current_idx] += "{"
505
506 delta = DeltaMessage(
507 tool_calls=[
508 DeltaToolCall(
509 index=current_idx,
510 function=DeltaFunctionCall(
511 arguments="{"
512 ).model_dump(exclude_none=True), # type: ignore
513 )
514 ]
515 )
516 return delta
517
518 # If we need to send more arguments
519 if args_text.startswith(sent_args):
520 # Calculate what part of arguments we need to send
521 args_diff = args_text[len(sent_args) :]
522
523 if args_diff:
524 # Update our state
525 self.streaming_state["sent_tools"][current_idx][
526 "sent_arguments"
527 ] = args_text
528
529 # Update streamed_args for backward compatibility
530 while len(self.streamed_args) <= current_idx:
531 self.streamed_args.append("")
532 self.streamed_args[current_idx] += args_diff
533
534 delta = DeltaMessage(
535 tool_calls=[
536 DeltaToolCall(
537 index=current_idx,
538 function=DeltaFunctionCall(
539 arguments=args_diff
540 ).model_dump(exclude_none=True), # type: ignore
541 )
542 ]
543 )
544 return delta
545
546 # If the tool's arguments are complete, check if we need to move to the next tool
547 if args_text.endswith("}") and args_text == sent_args:
548 # This tool is complete, move to the next one in the next iteration
549 if current_idx < tool_count - 1:
550 self.streaming_state["current_tool_index"] += 1
551 self.current_tool_id = self.streaming_state[
552 "current_tool_index"
553 ] # For compatibility
554
555 # If we got here, we couldn't determine what to stream next
556 return None
557
558 except Exception as e:
559 logger.exception(f"Error in streaming tool calls: {e}")
560 # If we encounter an error, just return the delta text as regular content
561 return DeltaMessage(content=delta_text)