main
py 334 lines 13.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import Sequence
6
7 import partial_json_parser
8 import regex as re
9 from partial_json_parser.core.options import Allow
10 from transformers import PreTrainedTokenizerBase
11
12 import vllm.envs as envs
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.entrypoints.openai.chat_completion.protocol import (
23 ChatCompletionRequest,
24 )
25 from vllm.logger import init_logger
26 from vllm.tool_parsers.abstract_tool_parser import (
27 Tool,
28 ToolParser,
29 )
30 from vllm.tool_parsers.utils import (
31 find_common_prefix,
32 is_complete_json,
33 partial_json_loads,
34 )
35
36 logger = init_logger(__name__)
37
38
39 class Llama3JsonToolParser(ToolParser):
40 """Tool call parser for Llama 3.x and 4 models intended for use with the
41 examples/tool_chat_template_llama.jinja template.
42
43 Used when --enable-auto-tool-choice --tool-call-parser llama3_json or
44 llama4_json are set.
45 """
46
47 bot_token: str = "<|python_tag|>"
48 structural_tag_model = "llama"
49 # Simple regex to find opening braces - we'll use JSON decoder for parsing
50 # This handles arbitrary nesting depth correctly
51 tool_call_start_regex: re.Pattern = re.compile(r"\{")
52 json_decoder: json.JSONDecoder = json.JSONDecoder()
53
54 def __init__(
55 self,
56 tokenizer: PreTrainedTokenizerBase,
57 tools: list[Tool] | None = None,
58 ):
59 super().__init__(tokenizer, tools)
60
61 # initialize properties used for state when parsing tool calls in
62 # streaming mode
63 self.prev_tool_call_arr: list[dict] = []
64 self.current_tool_id: int = -1
65 self.current_tool_name_sent: bool = False
66 self.streamed_args_for_tool: list[
67 str
68 ] = [] # map what has been streamed for each tool so far to a list
69 self.bot_token_id = self.vocab.get(self.bot_token)
70 if self.bot_token_id is None:
71 raise RuntimeError(
72 "Llama3JsonToolParser could not locate the bot token "
73 f"'{self.bot_token}' in the tokenizer."
74 )
75
76 def extract_tool_calls(
77 self, model_output: str, request: ChatCompletionRequest
78 ) -> ExtractedToolCallInformation:
79 """Extract the tool calls from a complete model response.
80 Only extracts JSON content and ignores any surrounding plain text.
81 Supports both single JSON and multiple JSONs separated by semicolons.
82 """
83 # Quick check before running regex
84 if not (self.bot_token in model_output or "{" in model_output):
85 return ExtractedToolCallInformation(
86 tools_called=False, tool_calls=[], content=model_output
87 )
88
89 # Keep track of the end index of the last parsed JSON object
90 # so we don't parse inner brackets
91 end_index = -1
92 tool_calls: list[ToolCall] = []
93
94 try:
95 for match in self.tool_call_start_regex.finditer(
96 model_output, timeout=envs.VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS
97 ):
98 start_index = match.start()
99 # Skip if this brace is inside a previously parsed JSON object
100 if start_index <= end_index:
101 continue
102
103 try:
104 obj, json_end_index = self.json_decoder.raw_decode(
105 model_output[start_index:]
106 )
107 end_index = start_index + json_end_index
108
109 # raise KeyError if missing
110 name = obj["name"]
111 arguments_or_params = (
112 obj["arguments"] if "arguments" in obj else obj["parameters"]
113 )
114
115 tool_calls.append(
116 ToolCall(
117 type="function",
118 function=FunctionCall(
119 name=name,
120 # function call args are JSON but as a string
121 arguments=json.dumps(
122 arguments_or_params, ensure_ascii=False
123 ),
124 ),
125 )
126 )
127 except KeyError as e:
128 # Missing required key
129 missing_key = str(e).strip("'\"")
130 logger.exception(
131 "Couldn't extract tool call from JSON response. "
132 "Required key '%s' not present. "
133 "Returning output in content with empty tool calls.",
134 missing_key,
135 )
136 return ExtractedToolCallInformation(
137 tools_called=False, tool_calls=[], content=model_output
138 )
139 except Exception:
140 # Any other error during parsing
141 logger.exception(
142 "Error in extracting tool call from response. "
143 "Returning output in content with empty tool calls"
144 )
145 return ExtractedToolCallInformation(
146 tools_called=False, tool_calls=[], content=model_output
147 )
148 except TimeoutError:
149 logger.warning("Regex timeout occurred when matching tool call pattern.")
150 logger.debug(
151 "Regex timeout occurred when matching user input: %s", model_output
152 )
153 return ExtractedToolCallInformation(
154 tools_called=False, tool_calls=[], content=model_output
155 )
156
157 # If we have valid tool calls, return them normally
158 if tool_calls:
159 return ExtractedToolCallInformation(
160 tools_called=True, tool_calls=tool_calls, content=None
161 )
162
163 # No valid tool calls found
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 if not (
179 current_text.startswith(self.bot_token) or current_text.startswith("{")
180 ):
181 return DeltaMessage(content=delta_text)
182
183 # bit mask flags for partial JSON parsing. If the name hasn't been
184 # sent yet, don't allow sending
185 # an incomplete string since OpenAI only ever (as far as I have
186 # seen) allows sending the entire tool/ function name at once.
187 flags = Allow.ALL if self.current_tool_name_sent else Allow.ALL & ~Allow.STR
188 try:
189 tool_call_arr = []
190 is_complete = []
191 try:
192 # depending on the prompt format the Llama model may or may not
193 # prefix the output with the <|python_tag|> token
194 start_idx = (
195 len(self.bot_token)
196 if current_text.startswith(self.bot_token)
197 else 0
198 )
199 while start_idx < len(current_text):
200 (obj, end_idx) = partial_json_loads(current_text[start_idx:], flags)
201 is_complete.append(
202 is_complete_json(current_text[start_idx : start_idx + end_idx])
203 )
204 start_idx += end_idx + len("; ")
205 # depending on the prompt Llama can use
206 # either arguments or parameters
207 if "parameters" in obj:
208 assert "arguments" not in obj, (
209 "model generated both parameters and arguments"
210 )
211 obj["arguments"] = obj["parameters"]
212 tool_call_arr.append(obj)
213 except partial_json_parser.core.exceptions.MalformedJSON:
214 logger.debug("not enough tokens to parse into JSON yet")
215 return None
216
217 # select as the current tool call the one we're on the state at
218 current_tool_call: dict = (
219 tool_call_arr[self.current_tool_id] if len(tool_call_arr) > 0 else {}
220 )
221
222 # case -- if no tokens have been streamed for the tool, e.g.
223 # only the array brackets, stream nothing
224 if len(tool_call_arr) == 0:
225 return None
226
227 # case: we are starting a new tool in the array
228 # -> array has > 0 length AND length has moved past cursor
229 elif (
230 len(tool_call_arr) > 0 and len(tool_call_arr) > self.current_tool_id + 1
231 ):
232 # if we're moving on to a new call, first make sure we
233 # haven't missed anything in the previous one that was
234 # auto-generated due to JSON completions, but wasn't
235 # streamed to the client yet.
236 if self.current_tool_id >= 0:
237 cur_arguments = current_tool_call.get("arguments")
238 if cur_arguments:
239 cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
240 sent = len(self.streamed_args_for_tool[self.current_tool_id])
241 argument_diff = cur_args_json[sent:]
242
243 logger.debug("got arguments diff: %s", argument_diff)
244 delta = DeltaMessage(
245 tool_calls=[
246 DeltaToolCall(
247 index=self.current_tool_id,
248 function=DeltaFunctionCall(
249 arguments=argument_diff
250 ).model_dump(exclude_none=True),
251 )
252 ]
253 )
254 self.streamed_args_for_tool[self.current_tool_id] += (
255 argument_diff
256 )
257 else:
258 delta = None
259 else:
260 delta = None
261 # re-set stuff pertaining to progress in the current tool
262 self.current_tool_id = len(tool_call_arr) - 1
263 self.current_tool_name_sent = False
264 self.streamed_args_for_tool.append("")
265 logger.debug("starting on new tool %d", self.current_tool_id)
266 return delta
267
268 # if the current tool name hasn't been sent, send if available
269 # - otherwise send nothing
270 elif not self.current_tool_name_sent:
271 function_name = current_tool_call.get("name")
272 if function_name:
273 delta = DeltaMessage(
274 tool_calls=[
275 DeltaToolCall(
276 index=self.current_tool_id,
277 type="function",
278 id=make_tool_call_id(),
279 function=DeltaFunctionCall(
280 name=function_name
281 ).model_dump(exclude_none=True),
282 )
283 ]
284 )
285 self.current_tool_name_sent = True
286 else:
287 delta = None
288
289 # now we know we're on the same tool call and we're streaming
290 # arguments
291 else:
292 cur_arguments = current_tool_call.get("arguments")
293 delta = None
294
295 if cur_arguments:
296 sent = len(self.streamed_args_for_tool[self.current_tool_id])
297 cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
298 prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get(
299 "arguments"
300 )
301
302 argument_diff = None
303 if is_complete[self.current_tool_id]:
304 argument_diff = cur_args_json[sent:]
305 elif prev_arguments:
306 prev_args_json = json.dumps(prev_arguments, ensure_ascii=False)
307 if cur_args_json != prev_args_json:
308 prefix = find_common_prefix(prev_args_json, cur_args_json)
309 argument_diff = prefix[sent:]
310
311 if argument_diff is not None:
312 delta = DeltaMessage(
313 tool_calls=[
314 DeltaToolCall(
315 index=self.current_tool_id,
316 function=DeltaFunctionCall(
317 arguments=argument_diff
318 ).model_dump(exclude_none=True),
319 )
320 ]
321 )
322 self.streamed_args_for_tool[self.current_tool_id] += (
323 argument_diff
324 )
325
326 self.prev_tool_call_arr = tool_call_arr
327 return delta
328
329 except Exception:
330 logger.exception("Error trying to handle streaming tool call.")
331 logger.debug(
332 "Skipping chunk as a result of tool streaming extraction error"
333 )
334 return None