| 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 |