main
py 434 lines 18.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import ast
5 import json
6 from collections.abc import Sequence
7
8 import regex as re
9
10 import vllm.envs as envs
11 from vllm.entrypoints.generate.base.protocol import (
12 DeltaMessage,
13 ExtractedToolCallInformation,
14 )
15 from vllm.entrypoints.openai.chat_completion.protocol import (
16 ChatCompletionRequest,
17 )
18 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
19 from vllm.logger import init_logger
20 from vllm.tokenizers import TokenizerLike
21 from vllm.tool_parsers.abstract_tool_parser import (
22 Tool,
23 ToolParser,
24 )
25 from vllm.tool_parsers.utils import (
26 UnexpectedAstError,
27 compute_tool_delta,
28 contains_broken_string_literal,
29 escape_ctrl_chars_in_strings,
30 escape_nested_quotes_in_strings,
31 handle_single_tool,
32 make_valid_python,
33 normalize_leading_zero_ints,
34 rename_reserved_kwargs,
35 restore_reserved_kwarg_names,
36 )
37
38 logger = init_logger(__name__)
39
40 TOOL_CALL_START = "<|tool_call_start|>"
41 TOOL_CALL_END = "<|tool_call_end|>"
42
43
44 class Lfm2ToolParser(ToolParser):
45 """Tool call parser for LiquidAI LFM2/LFM2.5 models that produce pythonic
46 tool calls wrapped in <|tool_call_start|> and <|tool_call_end|> tokens.
47
48 Example model output:
49 <|tool_call_start|>[get_weather(location="Paris")]<|tool_call_end|>
50 The weather in Paris is sunny.
51
52 Used when --enable-auto-tool-choice --tool-call-parser lfm2 are all set.
53 """
54
55 TOOL_CALL_REGEX = re.compile(r"\[.*\]$", re.DOTALL)
56
57 def __init__(
58 self,
59 tokenizer: TokenizerLike,
60 tools: list[Tool] | None = None,
61 ):
62 super().__init__(tokenizer, tools)
63
64 self.tool_call_start_token_id = self.vocab.get(TOOL_CALL_START)
65 self.tool_call_end_token_id = self.vocab.get(TOOL_CALL_END)
66
67 if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
68 raise RuntimeError(
69 "LFM2 tool parser could not locate "
70 "<|tool_call_start|>/<|tool_call_end|> tokens in the "
71 "tokenizer!"
72 )
73
74 # Trailing content already emitted to the client. Used by the
75 # streaming path to suppress LFM2's frequent echo of the tool
76 # call body after the first <|tool_call_end|> while still
77 # allowing legitimate post-call prose through.
78 self._trailing_emitted: str = ""
79
80 def adjust_request(
81 self, request: ChatCompletionRequest | ResponsesRequest
82 ) -> ChatCompletionRequest | ResponsesRequest:
83 request = super().adjust_request(request)
84 if request.tools and request.tool_choice != "none":
85 # The <|tool_call_start|>/<|tool_call_end|> sentinels are
86 # registered as special tokens in the LFM2/LFM2.5 tokenizer.
87 # With the default ``skip_special_tokens=True`` they are
88 # stripped from the decoded text before reaching this parser,
89 # so the tool block becomes invisible. Force the engine to
90 # preserve them when tool calling is enabled.
91 request.skip_special_tokens = False
92 return request
93
94 # Rename for readability. This is NOT a tool id.
95 @property
96 def current_tool_index(self) -> int:
97 return self.current_tool_id
98
99 @current_tool_index.setter
100 def current_tool_index(self, value: int) -> None:
101 self.current_tool_id = value
102
103 @staticmethod
104 def _restore_reserved(tool_call):
105 """Restore parameter names renamed by ``rename_reserved_kwargs``."""
106 arguments = json.loads(tool_call.function.arguments)
107 restored = restore_reserved_kwarg_names(arguments)
108 if restored != arguments:
109 tool_call.function.arguments = json.dumps(restored, ensure_ascii=False)
110 return tool_call
111
112 @staticmethod
113 def _strip_echo(raw_after: str) -> str:
114 """Drop any orphan <|tool_call_end|> (and the preceding text) from
115 trailing content. LFM2 occasionally echoes the call body after the
116 first end token and caps it with a second end token; everything
117 through the last such orphan is model garbage, not user content."""
118 last_orphan = raw_after.rfind(TOOL_CALL_END)
119 if last_orphan != -1:
120 return raw_after[last_orphan + len(TOOL_CALL_END) :]
121 return raw_after
122
123 @classmethod
124 def _extract_tool_call_text(
125 cls, model_output: str
126 ) -> tuple[str | None, str | None]:
127 """Extract the pythonic call text and surrounding content.
128
129 Returns (tool_text, content) where tool_text is the text between
130 the sentinel tokens and content is everything outside them.
131 """
132 start_idx = model_output.find(TOOL_CALL_START)
133 if start_idx == -1:
134 return None, model_output
135
136 end_idx = model_output.find(TOOL_CALL_END, start_idx)
137 if end_idx == -1:
138 # Incomplete — treat entire text after start as tool call
139 tool_text = model_output[start_idx + len(TOOL_CALL_START) :]
140 content_before = model_output[:start_idx].strip()
141 content = content_before or None
142 return tool_text, content
143
144 tool_text = model_output[start_idx + len(TOOL_CALL_START) : end_idx]
145 content_before = model_output[:start_idx].strip()
146 content_after = cls._strip_echo(
147 model_output[end_idx + len(TOOL_CALL_END) :]
148 ).strip()
149
150 content_parts = []
151 if content_before:
152 content_parts.append(content_before)
153 if content_after:
154 content_parts.append(content_after)
155 content = "\n".join(content_parts) if content_parts else None
156
157 return tool_text, content
158
159 def extract_tool_calls(
160 self, model_output: str, request: ChatCompletionRequest
161 ) -> ExtractedToolCallInformation:
162 tool_text, content = self._extract_tool_call_text(model_output)
163
164 if tool_text is None:
165 return ExtractedToolCallInformation(
166 tools_called=False, tool_calls=[], content=model_output
167 )
168
169 tool_text = tool_text.strip()
170
171 is_tool_call_pattern = False
172 try:
173 is_tool_call_pattern = (
174 self.TOOL_CALL_REGEX.match(
175 tool_text,
176 timeout=envs.VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS,
177 )
178 is not None
179 )
180 except TimeoutError:
181 logger.warning("Regex timeout occurred when matching tool call pattern.")
182
183 if not is_tool_call_pattern:
184 return ExtractedToolCallInformation(
185 tools_called=False, tool_calls=[], content=model_output
186 )
187
188 try:
189 kw_renamed = False
190 try:
191 module = ast.parse(tool_text)
192 except (SyntaxError, ValueError):
193 # Progressive rewrites, each a no-op on already-valid text:
194 # escape raw control chars / NUL bytes inside string literals,
195 # strip leading zeros from int literals (month=07), close
196 # unambiguous nested quotes (command='sed -n '1,9p' f.py'),
197 # and rename reserved-keyword parameters (from=1; restored
198 # below). The first rewrite whose result parses wins.
199 escaped = escape_ctrl_chars_in_strings(
200 normalize_leading_zero_ints(tool_text)
201 )
202 candidates = [escaped]
203 requoted, requote_changed = escape_nested_quotes_in_strings(escaped)
204 if requote_changed:
205 # Requoting can move raw control chars (newlines beyond
206 # the phantom close) inside the string; escape again.
207 candidates.append(escape_ctrl_chars_in_strings(requoted))
208 renamed, kw_renamed = rename_reserved_kwargs(candidates[-1])
209 if kw_renamed:
210 candidates.append(renamed)
211 for candidate in candidates:
212 try:
213 module = ast.parse(candidate)
214 break
215 except (SyntaxError, ValueError):
216 continue
217 else:
218 raise
219 parsed = getattr(module.body[0], "value", None)
220 # An empty block ([]) must not report tools_called=True with zero
221 # calls, so require at least one element.
222 if (
223 isinstance(parsed, ast.List)
224 and parsed.elts
225 and all(isinstance(e, ast.Call) for e in parsed.elts)
226 ):
227 tool_calls = [
228 handle_single_tool(e) # type: ignore
229 for e in parsed.elts
230 ]
231 if kw_renamed:
232 tool_calls = [self._restore_reserved(tc) for tc in tool_calls]
233 return ExtractedToolCallInformation(
234 tools_called=True,
235 tool_calls=tool_calls,
236 content=content,
237 )
238 else:
239 raise UnexpectedAstError("Tool output must be a list of function calls")
240 except Exception:
241 logger.exception("Error in extracting tool call from response.")
242 return ExtractedToolCallInformation(
243 tools_called=False, tool_calls=[], content=model_output
244 )
245
246 def extract_tool_calls_streaming(
247 self,
248 previous_text: str,
249 current_text: str,
250 delta_text: str,
251 previous_token_ids: Sequence[int],
252 current_token_ids: Sequence[int],
253 delta_token_ids: Sequence[int],
254 request: ChatCompletionRequest,
255 ) -> DeltaMessage | None:
256 # If the tool call start token hasn't appeared yet, stream as content.
257 if TOOL_CALL_START not in current_text:
258 return DeltaMessage(content=delta_text)
259
260 # Compute leading content (before <|tool_call_start|>) that arrived
261 # in this delta and hasn't been streamed yet. Without this, when the
262 # prefix and the start token land in the same delta the prefix is
263 # silently dropped — token-by-token streaming masked the bug because
264 # the prefix tokens always arrived in earlier deltas.
265 leading_content = ""
266 if TOOL_CALL_START not in previous_text:
267 start_idx = current_text.find(TOOL_CALL_START)
268 # previous_text contained no start token, so it has already been
269 # streamed via the no-start-token branch above.
270 leading_content = current_text[len(previous_text) : start_idx]
271
272 has_end_in_current = TOOL_CALL_END in current_text
273 has_end_in_previous = TOOL_CALL_END in previous_text
274
275 # Compute trailing content (after <|tool_call_end|>) not yet
276 # streamed. LFM2 frequently echoes the tool call body again
277 # after the first end token, capped with a second end token.
278 # Suppress that echo:
279 # - If a second <|tool_call_end|> has appeared, treat
280 # everything through the last one as garbage.
281 # - If the trailing starts with `[` or `<` (potential echo
282 # body or another sentinel) and no second end token has
283 # arrived yet, buffer it instead of emitting.
284 trailing_content = ""
285 if has_end_in_current:
286 end_idx = current_text.find(TOOL_CALL_END) + len(TOOL_CALL_END)
287 full_trailing = current_text[end_idx:]
288 stripped_trailing = self._strip_echo(full_trailing)
289 if stripped_trailing == full_trailing:
290 # No second end token yet — possibly mid-echo.
291 lstripped = full_trailing.lstrip()
292 if lstripped.startswith("[") or lstripped.startswith("<"):
293 # Suspect echo; hold off until resolved.
294 final_trailing = self._trailing_emitted
295 else:
296 final_trailing = full_trailing
297 else:
298 final_trailing = stripped_trailing
299 if final_trailing.startswith(self._trailing_emitted):
300 trailing_content = final_trailing[len(self._trailing_emitted) :]
301 self._trailing_emitted = final_trailing
302
303 # If tools were already parsed in a prior delta, just stream any
304 # newly arrived trailing content.
305 if has_end_in_current and self.prev_tool_call_arr and has_end_in_previous:
306 if trailing_content:
307 return DeltaMessage(content=trailing_content)
308 return DeltaMessage(content="")
309
310 # Extract the pythonic text between start and end tokens.
311 tool_text = current_text.split(TOOL_CALL_START, 1)[1]
312 # Strip the end token if present (entire call arrived at once).
313 if TOOL_CALL_END in tool_text:
314 tool_text = tool_text.split(TOOL_CALL_END, 1)[0]
315 # Leading whitespace after the start token would make every completion
316 # candidate an IndentationError (the non-streaming path strips it too).
317 tool_text = tool_text.lstrip()
318
319 def _content_only_or_none() -> DeltaMessage | None:
320 """Return a content-only delta if any content arrived in this
321 chunk, otherwise None. Used on incremental-parse failure paths
322 so leading/trailing content is never silently dropped.
323 """
324 combined = leading_content + trailing_content
325 return DeltaMessage(content=combined) if combined else None
326
327 try:
328 # A raw control char inside a string argument would make every
329 # completion candidate a SyntaxError; escape them here rather than
330 # inside make_valid_python so the shared helper keeps its upstream
331 # behavior for the other pythonic parsers. Leading zeros in int
332 # literals (month=07) and parameters named after Python keywords
333 # (`from=1`) can never parse; rewrite those too. All rewrites are
334 # deterministic, so successive chunks stay consistent; keyword
335 # names are restored after decoding.
336 tool_text = escape_ctrl_chars_in_strings(
337 normalize_leading_zero_ints(tool_text)
338 )
339 if has_end_in_current:
340 # Nested-quote recovery needs the final text (which quote
341 # closes a partial string is not stable across chunks) and
342 # must never touch text that already parses.
343 try:
344 ast.parse(tool_text)
345 except (SyntaxError, ValueError):
346 requoted_text, requote_changed = escape_nested_quotes_in_strings(
347 tool_text
348 )
349 if requote_changed:
350 tool_text = escape_ctrl_chars_in_strings(requoted_text)
351 renamed_tool_text, kw_renamed = rename_reserved_kwargs(tool_text)
352 if kw_renamed:
353 tool_text = renamed_tool_text
354
355 # A broken string (its first closing quote cannot close it —
356 # nested-quote text mid-arrival) makes every completion-based
357 # partial parse an implicit-concatenation misreading whose
358 # streamed prefix could never be retracted. Withhold deltas
359 # until the requote recovery above has produced sane text.
360 if contains_broken_string_literal(tool_text):
361 return _content_only_or_none()
362
363 valid_and_added_text = make_valid_python(tool_text)
364 if valid_and_added_text is None:
365 return _content_only_or_none()
366 valid_text, added_text = valid_and_added_text
367
368 module = ast.parse(valid_text)
369 parsed = getattr(module.body[0], "value", None)
370 if not isinstance(parsed, ast.List) or not all(
371 isinstance(e, ast.Call) for e in parsed.elts
372 ):
373 raise UnexpectedAstError("Tool output must be a list of function calls")
374 tool_calls = [
375 handle_single_tool(e) # type: ignore
376 for e in parsed.elts
377 ]
378 if kw_renamed:
379 tool_calls = [self._restore_reserved(tc) for tc in tool_calls]
380
381 tool_deltas = []
382 for index, new_call in enumerate(tool_calls):
383 if index < self.current_tool_index:
384 continue
385
386 self.current_tool_index = index
387 if len(self.streamed_args_for_tool) == index:
388 self.streamed_args_for_tool.append("")
389
390 new_call_complete = (
391 index < len(tool_calls) - 1 or ")]" not in added_text
392 )
393 if new_call_complete:
394 self.current_tool_index += 1
395
396 withheld_suffix = added_text[:-2] if not new_call_complete else ""
397 if not new_call_complete and added_text[-2] == ")":
398 withheld_suffix = withheld_suffix + "}"
399 withheld_suffix = withheld_suffix.replace("'", '"')
400 delta = compute_tool_delta(
401 self.streamed_args_for_tool[index],
402 new_call,
403 index,
404 withheld_suffix,
405 )
406
407 if delta is not None:
408 tool_deltas.append(delta)
409 if (
410 delta.function is not None
411 and delta.function.arguments is not None
412 ):
413 self.streamed_args_for_tool[index] += delta.function.arguments
414
415 if tool_deltas and not self.prev_tool_call_arr:
416 self.prev_tool_call_arr = [{"arguments": {}}]
417
418 combined_content = leading_content + trailing_content
419
420 if tool_deltas or combined_content:
421 return DeltaMessage(
422 content=combined_content if combined_content else None,
423 tool_calls=tool_deltas,
424 )
425 elif not added_text and self.current_tool_id > 0:
426 return DeltaMessage(content="")
427 else:
428 return None
429 except Exception:
430 logger.exception("Error trying to handle streaming tool call.")
431 logger.debug(
432 "Skipping chunk as a result of tool streaming extraction error"
433 )
434 return _content_only_or_none()