main
py 501 lines 20.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """ATEM tool-call parser for MuseGlimmer.
4
5 Faithful port of the MuseGlimmer ``response_schema`` tool-call contract from the
6 HuggingFace MuseGlimmer export (``convert_muse_glimmer_weights_to_hf.py``:
7 ``MUSE_GLIMMER_RESPONSE_SCHEMA``).
8
9 MuseGlimmer emits tool calls in an XML-ish ATEM format inside channel-scoped messages:
10
11 <|start|>assistant to=self<|message|>...reasoning...<|eom|>
12 <|start|>assistant to=<tool>.<fn><|message|>
13 <atem:function_calls>
14 <atem:invoke name="tool.fn">
15 <atem:parameter name="arg">value</atem:parameter>
16 </atem:invoke>
17 </atem:function_calls><|eom|> # non-final call
18 <|start|>assistant to=user<|message|>...final answer...<|eot|>
19
20 Channel scoping is essential: an ``<atem:invoke>`` echoed inside a ``to=self``
21 reasoning block or a ``to=user`` final answer must NOT be parsed as a real tool
22 call.
23
24 Rather than *subtracting* reasoning/answer spans with regex substitutions (the
25 approach the HF ``response_schema`` uses, which is safe only on a complete,
26 well-formed turn), this parser *segments* the output into messages and then
27 *selects* the tool-channel bodies. On a complete turn the two are equivalent;
28 on a truncated or damaged turn, subtraction can delete a valid tool call
29 (an unterminated ``to=self`` block makes the non-greedy strip run to the next
30 ``<|eom|>``, which belongs to the tool-call message) whereas selection cannot.
31
32 Usage: ``--enable-auto-tool-choice --tool-call-parser muse_glimmer``
33 """
34
35 from __future__ import annotations
36
37 import json
38 from collections.abc import Iterator, Sequence
39
40 import regex as re
41 from openai.types.responses import ToolChoiceFunction
42 from transformers import PreTrainedTokenizerBase
43
44 from vllm.entrypoints.chat_utils import make_tool_call_id
45 from vllm.entrypoints.generate.base.protocol import (
46 DeltaFunctionCall,
47 DeltaMessage,
48 DeltaToolCall,
49 ExtractedToolCallInformation,
50 FunctionCall,
51 ToolCall,
52 )
53 from vllm.entrypoints.openai.chat_completion.protocol import (
54 ChatCompletionNamedToolChoiceParam,
55 ChatCompletionRequest,
56 )
57 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
58 from vllm.logger import init_logger
59 from vllm.tool_parsers.abstract_tool_parser import (
60 Tool,
61 ToolParser,
62 )
63
64 logger = init_logger(__name__)
65
66 # --- Message framing -------------------------------------------------------
67 # An assistant message header. All three parts are optional except the
68 # <|message|> terminator:
69 # "<|start|>assistant to=get_weather<|message|>" -- after an <|eom|> boundary
70 # " to=self<|message|>" -- first message of a turn
71 # (the prompt already ended
72 # with "<|start|>assistant")
73 # "<|message|>" -- bare recipient (public CoT
74 # / untagged content)
75 _MSG_HEADER_RE = re.compile(
76 r"(?:<\|start\|>\s*assistant)?[^\S\n]*(?:to=(?P<rcpt>[A-Za-z0-9_.\-]+))?<\|message\|>"
77 )
78 _MSG_END_RE = re.compile(r"<\|eom\|>|<\|eot\|>")
79
80 # Recipients whose bodies are NOT tool calls.
81 _REASONING_RECIPIENT = "self"
82 _USER_RECIPIENT = "user"
83
84 # Structural markers that must never reach the client. A streamed body is held
85 # back by up to len(marker)-1 characters so a marker split across two chunks is
86 # not emitted as content.
87 _STRUCTURAL_MARKERS = ("<|eom|>", "<|eot|>", "<|start|>", "<|message|>")
88 _MAX_MARKER_LEN = max(len(m) for m in _STRUCTURAL_MARKERS)
89 # A trailing " to=NAME" that could still grow into a bare message header (the
90 # first message of a turn has no <|start|> prefix -- the prompt ends with
91 # "<|start|>assistant", so the model's first emitted text is " to=self<|message|>").
92 _OPEN_TAIL_TO_RE = re.compile(r"[^\S\n]+to=[A-Za-z0-9_.\-]*$")
93
94 # --- Tool-call extraction (unchanged from MUSE_GLIMMER_RESPONSE_SCHEMA) -------------
95 _INVOKE_RE = re.compile(r"(<atem:invoke\b.*?</atem:invoke>)", re.DOTALL)
96 _NAME_RE = re.compile(r'<atem:invoke\b[^>]*?\bname="([^"]+)"')
97 _PARAM_RE = re.compile(
98 r'<atem:parameter\b[^>]*?\bname="(?P<key>[^"]+)"[^>]*?>(?P<value>.*?)</atem:parameter>',
99 re.DOTALL,
100 )
101 _FUNCTION_CALLS_OPEN = "<atem:function_calls>"
102
103
104 def _decode_value(raw: str):
105 """JSON-decode a parameter value when possible, else keep the raw string.
106
107 Mirrors the schema's ``x-parser: json`` with ``allow_non_json: True``.
108 """
109 try:
110 return json.loads(raw)
111 except (json.JSONDecodeError, ValueError):
112 return raw
113
114
115 def _iter_messages(text: str) -> Iterator[tuple[str | None, str, bool]]:
116 """Segment *text* into assistant messages.
117
118 Yields ``(recipient, body, closed)`` per message, where ``recipient`` is
119 ``None`` for a bare ``<|message|>`` header and ``closed`` is False for a
120 message that has not (yet) seen ``<|eom|>`` / ``<|eot|>``.
121
122 A message is also terminated by the start of the NEXT header. Without that,
123 a reasoning block whose ``<|eom|>`` is missing (truncation, or a chunk
124 dropped at the reasoning -> tool transition) would absorb the tool-call
125 message that follows it and the call would be lost -- the same defect the
126 subtractive regexes have.
127 """
128 pos = 0
129 while pos < len(text):
130 header = _MSG_HEADER_RE.search(text, pos)
131 if header is None:
132 return
133 body_start = header.end()
134 end = _MSG_END_RE.search(text, body_start)
135 nxt = _MSG_HEADER_RE.search(text, body_start)
136 body_end = end.start() if end is not None else len(text)
137 closed = end is not None
138 if nxt is not None and nxt.start() < body_end:
139 body_end = nxt.start()
140 closed = False
141 next_pos = nxt.start()
142 else:
143 next_pos = end.end() if end is not None else len(text)
144 body = text[body_start:body_end]
145 # A body can never legitimately contain <|start|>. Seeing one means the
146 # next header is only partially generated (its <|message|> has not
147 # arrived), so the regex above could not recognise it yet. Cut there,
148 # otherwise the streamed body would grow to include the next header and
149 # then shrink back once it completes.
150 start_tok = body.find("<|start|>")
151 if start_tok != -1:
152 body = body[:start_tok]
153 closed = False
154 yield header.group("rcpt"), body, closed
155 pos = next_pos
156
157
158 def _trailing_partial_marker_len(text: str) -> int:
159 """Length of the longest suffix of *text* that prefixes a structural marker."""
160 max_overlap = min(len(text), _MAX_MARKER_LEN - 1)
161 for overlap in range(max_overlap, 0, -1):
162 suffix = text[-overlap:]
163 if any(marker.startswith(suffix) for marker in _STRUCTURAL_MARKERS):
164 return overlap
165 return 0
166
167
168 def _safe_open_body(body: str) -> str:
169 """Trim the tail of a still-growing body to what is safe to emit now.
170
171 Holds back anything that could still turn out to be structural, so the
172 emitted prefix only ever grows. Chunks under speculative decoding are large
173 enough that markers routinely straddle them.
174 """
175 tail_to = _OPEN_TAIL_TO_RE.search(body)
176 if tail_to is not None:
177 return body[: tail_to.start()]
178 partial = _trailing_partial_marker_len(body)
179 return body[: len(body) - partial] if partial else body
180
181
182 class MuseGlimmerToolParser(ToolParser):
183 # MuseGlimmer emits ATEM markup around tool-call arguments. The generic
184 # named/required tool_choice path in vllm/parser/abstract_parser.py assigns
185 # the raw model text straight to FunctionCall.arguments, which leaks that
186 # framing and yields invalid JSON; the "required" branch then silently
187 # swallows the ValidationError and returns tool_calls=null. Opting out
188 # routes named/required through extract_tool_calls /
189 # extract_tool_calls_streaming -- the same path "auto" already uses.
190 supports_required_and_named = False
191
192 def __init__(
193 self,
194 tokenizer: PreTrainedTokenizerBase,
195 tools: list[Tool] | None = None,
196 ) -> None:
197 super().__init__(tokenizer, tools)
198 # Streaming cursors. vLLM constructs one ToolParser per request, so
199 # instance state is per-stream.
200 self._streamed_content_len: int = 0
201 self._streamed_reasoning_len: int = 0
202 self._emitted_tool_calls: int = 0
203
204 def adjust_request(
205 self, request: ChatCompletionRequest | ResponsesRequest
206 ) -> ChatCompletionRequest | ResponsesRequest:
207 """Force special tokens through, and keep JSON guided decoding off.
208
209 ``skip_special_tokens`` defaults to True on both ChatCompletionRequest
210 and ResponsesRequest. Every rule in this parser keys off ``<|message|>``
211 / ``<|eom|>`` / ``<|eot|>`` / ``<|start|>``, so with the default the
212 channel framing is stripped before we see it: message segmentation
213 finds nothing, reasoning-channel invokes are indistinguishable from real
214 ones, and the raw ATEM markup falls through to the client as content.
215 Set it unconditionally and FIRST -- not only for tools requests, and not
216 relying on the reasoning parser to have set it.
217
218 For required/named ``tool_choice`` the base hook installs a JSON schema
219 constraint (ToolParser.adjust_request -> get_json_schema_from_tools).
220 MuseGlimmer emits ATEM XML, so under that constraint it writes JSON *inside*
221 the tool channel -- ``<atem:function_calls>[{"name": ...`` -- with no
222 ``<atem:invoke>`` for this parser to find. Skip the base hook so those
223 choices decode natively, the same as "auto".
224 """
225 request.skip_special_tokens = False
226
227 tool_choice = getattr(request, "tool_choice", None)
228 if request.tools and (
229 tool_choice == "required"
230 or isinstance(
231 tool_choice, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction)
232 )
233 ):
234 return request
235 return super().adjust_request(request)
236
237 # ---------------- channel selection ----------------
238
239 @classmethod
240 def _tool_channel_text(cls, text: str) -> str:
241 """Concatenate the bodies of messages addressed to a tool.
242
243 Falls back to the whole text when no message header is present at all --
244 that means the framing never reached us (``skip_special_tokens`` was on,
245 or the chunk carrying the header was dropped upstream), and scanning
246 everything is strictly better than returning nothing.
247 """
248 bodies = [
249 body
250 for rcpt, body, _closed in _iter_messages(text)
251 if rcpt is not None
252 and rcpt != _REASONING_RECIPIENT
253 and rcpt != _USER_RECIPIENT
254 ]
255 if bodies:
256 return "\n".join(bodies)
257 if _MSG_HEADER_RE.search(text) is None and (
258 _FUNCTION_CALLS_OPEN in text or "<atem:invoke" in text
259 ):
260 logger.warning(
261 "MuseGlimmer: ATEM markup with no channel framing; "
262 "is skip_special_tokens enabled upstream?"
263 )
264 return text
265 return ""
266
267 @classmethod
268 def _visible_channels(cls, text: str) -> tuple[str, str, bool, bool]:
269 """Return ``(content, reasoning, content_open, reasoning_open)``.
270
271 The ``*_open`` flags say whether that channel's LAST message is still
272 being generated; only then must the caller hold back a partial
273 structural marker. Tracking them per channel matters: a closed
274 reasoning block whose text happens to end in ``<`` would otherwise stay
275 permanently truncated while a later content message is open.
276 """
277 content_parts: list[str] = []
278 reasoning_parts: list[str] = []
279 content_open = False
280 reasoning_open = False
281 for rcpt, body, closed in _iter_messages(text):
282 if rcpt == _REASONING_RECIPIENT:
283 reasoning_parts.append(body)
284 reasoning_open = not closed
285 elif rcpt is None or rcpt == _USER_RECIPIENT:
286 content_parts.append(body)
287 content_open = not closed
288 return (
289 "".join(content_parts),
290 "".join(reasoning_parts),
291 content_open,
292 reasoning_open,
293 )
294
295 # ---------------- tool name binding ----------------
296
297 @staticmethod
298 def _registered_names(request: ChatCompletionRequest | None) -> set[str]:
299 """Names of the tools the client registered on this request."""
300 names: set[str] = set()
301 tools = getattr(request, "tools", None) if request is not None else None
302 for t in tools or []:
303 fn = getattr(t, "function", None) or t
304 name = getattr(fn, "name", None)
305 if name is None and isinstance(fn, dict):
306 name = fn.get("name")
307 if name:
308 names.add(name)
309 return names
310
311 @staticmethod
312 def _normalize_name(emitted: str, registered: set[str]) -> str:
313 """Map an emitted ATEM invoke name back to a registered tool name.
314
315 When a client registers a BARE name (e.g. ``get_weather``) the shipped
316 chat template renders the valid recipient as ``"get_weather.*"``, and
317 the model duly emits ``get_weather.get_weather``. Collapsing that
318 doubled form is safe: head and tail are identical and the collapsed
319 name is registered.
320
321 Anything else is passed through unchanged. Matching on the trailing
322 segment alone is NOT safe -- an emitted ``weather.get`` against a
323 registered ``{calendar.get}`` has a unique leaf match and would silently
324 dispatch the wrong tool.
325 """
326 if not registered or emitted in registered:
327 return emitted
328 head, sep, tail = emitted.partition(".")
329 if sep and head == tail and head in registered:
330 return head
331 logger.warning(
332 "MuseGlimmer: emitted tool name %r does not match any registered tool; "
333 "passing through unchanged.",
334 emitted,
335 )
336 return emitted
337
338 @classmethod
339 def _parse_tool_calls(
340 cls, text: str, registered: set[str] | None = None
341 ) -> list[ToolCall]:
342 registered = registered or set()
343 scoped = cls._tool_channel_text(text)
344 tool_calls: list[ToolCall] = []
345 for invoke in _INVOKE_RE.findall(scoped):
346 name_m = _NAME_RE.search(invoke)
347 if not name_m:
348 continue
349 name = cls._normalize_name(name_m.group(1), registered)
350 args: dict = {}
351 for pm in _PARAM_RE.finditer(invoke):
352 args[pm.group("key")] = _decode_value(pm.group("value"))
353 tool_calls.append(
354 ToolCall(
355 function=FunctionCall(
356 name=name,
357 arguments=json.dumps(args, ensure_ascii=False),
358 )
359 )
360 )
361 return tool_calls
362
363 @classmethod
364 def _extract_content(cls, text: str) -> str | None:
365 """Return the user-facing body, or the raw text when unframed."""
366 content, reasoning, _c_open, _r_open = cls._visible_channels(text)
367 if content:
368 return content
369 # No framing at all -> the whole thing is plain content.
370 if not reasoning and _MSG_HEADER_RE.search(text) is None:
371 return text or None
372 return None
373
374 # ---------------- non-streaming ----------------
375
376 def extract_tool_calls(
377 self, model_output: str, request: ChatCompletionRequest
378 ) -> ExtractedToolCallInformation:
379 if (
380 _FUNCTION_CALLS_OPEN not in model_output
381 and "<atem:invoke" not in model_output
382 ):
383 return ExtractedToolCallInformation(
384 tools_called=False,
385 tool_calls=[],
386 content=self._extract_content(model_output),
387 )
388 try:
389 registered = self._registered_names(request)
390 tool_calls = self._parse_tool_calls(model_output, registered)
391 if not tool_calls:
392 # A tool block was opened but no COMPLETE
393 # <atem:invoke>...</atem:invoke> parsed -- typically a truncated
394 # call (finish_reason='length'/abort). Log it: silently
395 # returning "no tool call" here is indistinguishable from the
396 # model choosing not to call one, which makes this failure mode
397 # invisible in production.
398 logger.warning(
399 "MuseGlimmer: tool channel opened but no complete <atem:invoke> "
400 "parsed (truncated tool call?); returning content only."
401 )
402 return ExtractedToolCallInformation(
403 tools_called=False,
404 tool_calls=[],
405 content=self._extract_content(model_output),
406 )
407 return ExtractedToolCallInformation(
408 tools_called=True,
409 tool_calls=tool_calls,
410 content=self._extract_content(model_output),
411 )
412 except Exception:
413 logger.exception("Error extracting MuseGlimmer ATEM tool calls.")
414 return ExtractedToolCallInformation(
415 tools_called=False, tool_calls=[], content=model_output
416 )
417
418 # ---------------- streaming ----------------
419
420 def extract_tool_calls_streaming(
421 self,
422 previous_text: str,
423 current_text: str,
424 delta_text: str,
425 previous_token_ids: Sequence[int],
426 current_token_ids: Sequence[int],
427 delta_token_ids: Sequence[int],
428 request: ChatCompletionRequest,
429 ) -> DeltaMessage | None:
430 """Incremental ATEM streaming for tool calls AND content.
431
432 This parser owns every delta once reasoning has ended: in
433 vllm/parser/abstract_parser.py::parse_delta the "pass through as
434 content" fallback is guarded by ``not self._in_tool_call_phase(state)``,
435 and ``_in_tool_call_phase`` is simply ``tool_parser is not None and
436 state.reasoning_ended``. So with a tool parser loaded that fallback is
437 dead code, and returning None here DISCARDS the delta. Anything we do
438 not emit -- including the ``to=user`` final answer -- never reaches the
439 client. Hence content is emitted here, not left to the reasoning parser.
440
441 Tool calls are surfaced only when an ``<atem:invoke>`` block becomes
442 complete: the XML is opaque until closed and MuseGlimmer parameters are not
443 incremental JSON, so there is nothing meaningful to stream before then.
444 """
445 if not previous_text:
446 # First delta of the tool phase (parse_delta resets previous_text
447 # to "" when it hands the stream over). Reset the cursors.
448 self._streamed_content_len = 0
449 self._streamed_reasoning_len = 0
450 self._emitted_tool_calls = 0
451
452 try:
453 registered = self._registered_names(request)
454 calls = self._parse_tool_calls(current_text, registered)
455 content, reasoning, content_open, reasoning_open = self._visible_channels(
456 current_text
457 )
458
459 # Trim the tail of a channel that is still growing, so the emitted
460 # prefix never shrinks between deltas.
461 if content_open:
462 content = _safe_open_body(content)
463 if reasoning_open:
464 reasoning = _safe_open_body(reasoning)
465
466 content_delta = content[self._streamed_content_len :]
467 reasoning_delta = reasoning[self._streamed_reasoning_len :]
468
469 tool_deltas: list[DeltaToolCall] = []
470 for i in range(self._emitted_tool_calls, len(calls)):
471 fn = calls[i].function
472 tool_deltas.append(
473 DeltaToolCall(
474 index=i,
475 type="function",
476 id=make_tool_call_id(),
477 function=DeltaFunctionCall(
478 name=fn.name,
479 arguments=fn.arguments,
480 ).model_dump(exclude_none=True),
481 )
482 )
483
484 if not content_delta and not reasoning_delta and not tool_deltas:
485 return None
486
487 self._streamed_content_len = len(content)
488 self._streamed_reasoning_len = len(reasoning)
489 self._emitted_tool_calls = len(calls)
490
491 message = DeltaMessage()
492 if content_delta:
493 message.content = content_delta
494 if reasoning_delta:
495 message.reasoning = reasoning_delta
496 if tool_deltas:
497 message.tool_calls = tool_deltas
498 return message
499 except Exception:
500 logger.exception("Error extracting MuseGlimmer ATEM streaming tool calls.")
501 return None