@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 68dafcca75
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 2, 06:00 UTC

[Refactor] Unify reasoning + tool-call parsing behind Parser.parse() (#44267)

Signed-off-by: sfeng33 <4florafeng@gmail.com>

Flora Feng committed Jun 2, 2026 at 03:11 UTC 68dafcca7551990dfc6942a4f315a225f0c7b0f7
5 files changed +409 -152
tests/entrypoints/openai/test_tool_choice_content_none.py
+4 -10
@@ -4,7 +4,6 @@
4 import pytest
5
6 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
7 -from vllm.entrypoints.openai.engine.serving import OpenAIServing
7 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
8 from vllm.parser.abstract_parser import DelegatingParser
9
@@ -32,11 +31,8 @@ class _DummyDelegatingParser(DelegatingParser):
31 ):
32 return None
33
35 - def extract_tool_calls(self, model_output: str, request):
36 - return None
37 -
34
39 -def test_parse_tool_calls_from_content_allows_named_tool_choice_with_none_content():
35 +def test_chat_completion_named_tool_choice_with_none_content():
36 request = ChatCompletionRequest.model_validate(
37 {
38 "model": "test-model",
@@ -53,17 +49,15 @@ def test_parse_tool_calls_from_content_allows_named_tool_choice_with_none_conten
49 "tool_choice": {"type": "function", "function": {"name": "get_weather"}},
50 }
51 )
52 + parser = _DummyDelegatingParser(tokenizer=None)
53
57 - tool_calls, content = OpenAIServing._parse_tool_calls_from_content(
54 + tool_calls, content = parser._extract_tool_calls(
55 + content=None,
56 request=request,
59 - tokenizer=None,
57 enable_auto_tools=True,
61 - tool_parser_cls=None,
62 - content=None,
58 )
59
60 assert content is None
66 - assert tool_calls is not None
61 assert tool_calls == []
62
63
tests/parser/test_parse.py new
+262
@@ -0,0 +1,262 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import json
5 +
6 +import pytest
7 +
8 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
9 +from vllm.parser.abstract_parser import _WrappedParser
10 +from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
11 +from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
12 +
13 +
14 +class ThinkReasoningParser(BaseThinkingReasoningParser):
15 + @property
16 + def start_token(self) -> str:
17 + return "<think>"
18 +
19 + @property
20 + def end_token(self) -> str:
21 + return "</think>"
22 +
23 +
24 +MODEL_OUTPUT = (
25 + "<think>let me think about this</think>"
26 + '<tool_call>\n{"name": "get_weather", '
27 + '"arguments": {"city": "Dallas"}}\n</tool_call>'
28 +)
29 +
30 +PLAIN_TEXT = "The weather in Dallas is sunny and 75°F."
31 +
32 +TOOL_CALL_ONLY = (
33 + '<tool_call>\n{"name": "get_weather", '
34 + '"arguments": {"city": "Dallas"}}\n</tool_call>'
35 +)
36 +
37 +TOOL_ARGUMENTS = '{"city": "Dallas"}'
38 +
39 +
40 +@pytest.fixture(scope="module")
41 +def tokenizer():
42 + from vllm.tokenizers import get_tokenizer
43 +
44 + return get_tokenizer("Qwen/Qwen3-32B")
45 +
46 +
47 +def make_request(**overrides):
48 + base = {
49 + "model": "test-model",
50 + "messages": [{"role": "user", "content": "hi"}],
51 + }
52 + base.update(overrides)
53 + return ChatCompletionRequest.model_validate(base)
54 +
55 +
56 +TOOLS = [
57 + {
58 + "type": "function",
59 + "function": {
60 + "name": "get_weather",
61 + "parameters": {"type": "object", "properties": {}},
62 + },
63 + }
64 +]
65 +
66 +
67 +def make_parser(tokenizer, reasoning=False, tool=False):
68 + _WrappedParser.reasoning_parser_cls = ThinkReasoningParser if reasoning else None
69 + _WrappedParser.tool_parser_cls = Hermes2ProToolParser if tool else None
70 + return _WrappedParser(tokenizer)
71 +
72 +
73 +@pytest.mark.parametrize(
74 + "reasoning,tool",
75 + [(False, False), (False, True)],
76 + ids=["neither", "tool-only"],
77 +)
78 +def test_parse_plain_text_no_reasoning_parser(tokenizer, reasoning, tool):
79 + parser = make_parser(tokenizer, reasoning=reasoning, tool=tool)
80 + request = make_request()
81 + r, content, tool_calls = parser.parse(PLAIN_TEXT, request)
82 +
83 + assert r is None
84 + assert content == PLAIN_TEXT
85 + assert tool_calls is not None
86 + assert len(tool_calls) == 0
87 +
88 +
89 +@pytest.mark.parametrize(
90 + "reasoning,tool",
91 + [(True, False), (True, True)],
92 + ids=["reasoning-only", "both"],
93 +)
94 +def test_parse_plain_text_with_reasoning_parser(tokenizer, reasoning, tool):
95 + parser = make_parser(tokenizer, reasoning=reasoning, tool=tool)
96 + request = make_request()
97 + r, content, tool_calls = parser.parse(PLAIN_TEXT, request)
98 +
99 + assert r == PLAIN_TEXT
100 + assert content is None
101 + assert tool_calls is not None
102 + assert len(tool_calls) == 0
103 +
104 +
105 +def test_parse_both_parsers(tokenizer):
106 + parser = make_parser(tokenizer, reasoning=True, tool=True)
107 + request = make_request(tools=TOOLS)
108 + reasoning, content, tool_calls = parser.parse(
109 + MODEL_OUTPUT, request, enable_auto_tools=True
110 + )
111 +
112 + assert reasoning is not None
113 + assert "let me think about this" in reasoning
114 + assert tool_calls is not None
115 + assert len(tool_calls) == 1
116 + assert tool_calls[0].name == "get_weather"
117 + assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}
118 + assert not content or content.strip() == ""
119 +
120 +
121 +def test_parse_reasoning_only(tokenizer):
122 + parser = make_parser(tokenizer, reasoning=True, tool=False)
123 + request = make_request()
124 + reasoning, content, tool_calls = parser.parse(MODEL_OUTPUT, request)
125 +
126 + assert reasoning is not None
127 + assert "let me think about this" in reasoning
128 + assert content is not None
129 + assert "<tool_call>" in content
130 + assert "get_weather" in content
131 + assert tool_calls is not None
132 + assert len(tool_calls) == 0
133 +
134 +
135 +def test_parse_tool_only(tokenizer):
136 + parser = make_parser(tokenizer, reasoning=False, tool=True)
137 + request = make_request(tools=TOOLS)
138 + reasoning, content, tool_calls = parser.parse(
139 + MODEL_OUTPUT, request, enable_auto_tools=True
140 + )
141 +
142 + assert reasoning is None
143 + assert tool_calls is not None
144 + assert len(tool_calls) == 1
145 + assert tool_calls[0].name == "get_weather"
146 + assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}
147 +
148 +
149 +def test_parse_named_tool_choice(tokenizer):
150 + parser = make_parser(tokenizer, reasoning=False, tool=True)
151 + request = make_request(
152 + tools=TOOLS,
153 + tool_choice={
154 + "type": "function",
155 + "function": {"name": "get_weather"},
156 + },
157 + )
158 + reasoning, content, tool_calls = parser.parse(
159 + TOOL_ARGUMENTS, request, enable_auto_tools=True
160 + )
161 +
162 + assert reasoning is None
163 + assert content is None
164 + assert tool_calls is not None
165 + assert len(tool_calls) == 1
166 + assert tool_calls[0].name == "get_weather"
167 + assert tool_calls[0].arguments == TOOL_ARGUMENTS
168 +
169 +
170 +def test_parse_named_tool_choice_with_reasoning(tokenizer):
171 + parser = make_parser(tokenizer, reasoning=True, tool=True)
172 + model_output = f"<think>thinking</think>{TOOL_ARGUMENTS}"
173 + request = make_request(
174 + tools=TOOLS,
175 + tool_choice={
176 + "type": "function",
177 + "function": {"name": "get_weather"},
178 + },
179 + )
180 + reasoning, content, tool_calls = parser.parse(
181 + model_output, request, enable_auto_tools=True
182 + )
183 +
184 + assert reasoning is not None
185 + assert "thinking" in reasoning
186 + assert content is None
187 + assert len(tool_calls) == 1
188 + assert tool_calls[0].name == "get_weather"
189 + assert tool_calls[0].arguments == TOOL_ARGUMENTS
190 +
191 +
192 +def test_parse_required_tool_choice(tokenizer):
193 + parser = make_parser(tokenizer, reasoning=False, tool=True)
194 + functions_json = json.dumps(
195 + [
196 + {"name": "get_weather", "parameters": {"city": "Dallas"}},
197 + {"name": "get_time", "parameters": {"timezone": "UTC"}},
198 + ]
199 + )
200 + request = make_request(tools=TOOLS, tool_choice="required")
201 + reasoning, content, tool_calls = parser.parse(
202 + functions_json, request, enable_auto_tools=True
203 + )
204 +
205 + assert reasoning is None
206 + assert content is None
207 + assert tool_calls is not None
208 + assert len(tool_calls) == 2
209 + assert tool_calls[0].name == "get_weather"
210 + assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}
211 + assert tool_calls[1].name == "get_time"
212 + assert json.loads(tool_calls[1].arguments) == {"timezone": "UTC"}
213 +
214 +
215 +def test_parse_named_tool_choice_content_none(tokenizer):
216 + parser = make_parser(tokenizer, reasoning=False, tool=True)
217 + request = make_request(
218 + tools=TOOLS,
219 + tool_choice={
220 + "type": "function",
221 + "function": {"name": "get_weather"},
222 + },
223 + )
224 + reasoning, content, tool_calls = parser.parse("", request, enable_auto_tools=True)
225 + assert reasoning is None
226 + assert content is None
227 + assert tool_calls is not None
228 +
229 +
230 +def test_parse_required_tool_choice_content_none(tokenizer):
231 + parser = make_parser(tokenizer, reasoning=False, tool=True)
232 + request = make_request(tools=TOOLS, tool_choice="required")
233 + reasoning, content, tool_calls = parser.parse("", request, enable_auto_tools=True)
234 + assert reasoning is None
235 + assert content is None
236 + assert tool_calls is not None
237 + assert len(tool_calls) == 0
238 +
239 +
240 +def test_parse_auto_tools_no_parser(tokenizer):
241 + parser = make_parser(tokenizer, reasoning=False, tool=False)
242 + request = make_request()
243 + reasoning, content, tool_calls = parser.parse(
244 + TOOL_CALL_ONLY, request, enable_auto_tools=True
245 + )
246 +
247 + assert reasoning is None
248 + assert content == TOOL_CALL_ONLY
249 + assert tool_calls is not None
250 + assert len(tool_calls) == 0
251 +
252 +
253 +def test_parse_auto_tools_no_calls_returns_none(tokenizer):
254 + parser = make_parser(tokenizer, reasoning=False, tool=True)
255 + request = make_request(tools=TOOLS)
256 + reasoning, content, tool_calls = parser.parse(
257 + PLAIN_TEXT, request, enable_auto_tools=True
258 + )
259 +
260 + assert reasoning is None
261 + assert content == PLAIN_TEXT
262 + assert tool_calls is None
vllm/entrypoints/openai/chat_completion/serving.py
+16 -16
@@ -357,6 +357,14 @@ class OpenAIServingChat(OpenAIServing):
357 assert len(generators) == 1
358 (result_generator,) = generators
359
360 + parser: Parser | None = None
361 + if self.parser_cls is not None:
362 + parser = self.parser_cls(
363 + tokenizer,
364 + request.tools,
365 + chat_template_kwargs=chat_template_kwargs,
366 + )
367 +
368 if request.stream:
369 return self.chat_completion_stream_generator(
370 request,
@@ -378,7 +386,7 @@ class OpenAIServingChat(OpenAIServing):
386 conversation,
387 tokenizer,
388 request_metadata,
381 - reasoning_parser,
389 + parser,
390 )
391
392 def get_chat_request_role(self, request: ChatCompletionRequest) -> str:
@@ -934,7 +942,7 @@ class OpenAIServingChat(OpenAIServing):
942 conversation: list[ConversationMessage],
943 tokenizer: TokenizerLike,
944 request_metadata: RequestResponseMetadata,
937 - reasoning_parser: ReasoningParser | None = None,
945 + parser: Parser | None = None,
946 ) -> ErrorResponse | ChatCompletionResponse:
947 created_time = int(time.time())
948 final_res: RequestOutput | None = None
@@ -1043,28 +1051,20 @@ class OpenAIServingChat(OpenAIServing):
1051 choices.append(choice_data)
1052 continue
1053
1046 - if reasoning_parser:
1047 - # If the reasoning parser is enabled,
1048 - # tool calls are extracted exclusively from the content.
1049 - reasoning, content = reasoning_parser.extract_reasoning(
1050 - output.text, request=request
1054 + if parser is not None:
1055 + reasoning, content, tool_calls = parser.parse(
1056 + output.text,
1057 + request,
1058 + enable_auto_tools=self.enable_auto_tools,
1059 )
1060 if not request.include_reasoning:
1061 reasoning = None
1062 else:
1063 reasoning = None
1064 content = output.text
1065 + tool_calls = []
1066
1067 auto_tools_called = False
1059 - # if auto tools are not enabled, and a named tool choice using
1060 - # outlines is not being used
1061 - tool_calls, content = self._parse_tool_calls_from_content(
1062 - request=request,
1063 - tokenizer=tokenizer,
1064 - content=content,
1065 - enable_auto_tools=self.enable_auto_tools,
1066 - tool_parser_cls=self.tool_parser,
1067 - )
1068 if is_mistral_tokenizer(tokenizer):
1069 from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
1070
vllm/entrypoints/openai/engine/serving.py
+1 -126
@@ -1,6 +1,5 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -import contextlib
3 import json
4 import time
5 from collections.abc import Awaitable, Mapping
@@ -9,8 +8,7 @@ from http import HTTPStatus
8 from typing import Any, ClassVar, Generic, Protocol, TypeAlias, TypeVar
9
10 from fastapi import Request
12 -from openai.types.responses import ToolChoiceFunction
13 -from pydantic import ConfigDict, TypeAdapter, ValidationError
11 +from pydantic import ConfigDict
12 from starlette.datastructures import Headers
13
14 import vllm.envs as envs
@@ -21,7 +19,6 @@ from vllm.entrypoints.generate.beam_search.online import BeamSearchOnlineMixin
19 from vllm.entrypoints.logger import RequestLogger
20 from vllm.entrypoints.openai.chat_completion.protocol import (
21 BatchChatCompletionRequest,
24 - ChatCompletionNamedToolChoiceParam,
22 ChatCompletionRequest,
23 ChatCompletionResponse,
24 )
@@ -31,8 +28,6 @@ from vllm.entrypoints.openai.completion.protocol import (
28 )
29 from vllm.entrypoints.openai.engine.protocol import (
30 ErrorResponse,
34 - FunctionCall,
35 - FunctionDefinition,
31 GenerationError,
32 )
33 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
@@ -61,14 +56,12 @@ from vllm.renderers.inputs.preprocess import (
56 )
57 from vllm.sampling_params import BeamSearchParams, SamplingParams
58 from vllm.tokenizers import TokenizerLike
64 -from vllm.tool_parsers import ToolParser
59 from vllm.tracing import (
60 contains_trace_headers,
61 extract_trace_headers,
62 log_tracing_disabled_warning,
63 )
64 from vllm.utils import random_uuid
71 -from vllm.utils.mistral import is_mistral_tool_parser
65
66 logger = init_logger(__name__)
67
@@ -451,124 +444,6 @@ class OpenAIServing(BeamSearchOnlineMixin):
444 exc_info=True,
445 )
446
454 - @staticmethod
455 - def _parse_tool_calls_from_content(
456 - request: ResponsesRequest | ChatCompletionRequest,
457 - tokenizer: TokenizerLike | None,
458 - enable_auto_tools: bool,
459 - tool_parser_cls: type[ToolParser] | None,
460 - content: str | None = None,
461 - ) -> tuple[list[FunctionCall] | None, str | None]:
462 - # When the Mistral grammar factory injected structured outputs,
463 - # let the parser handle the output.
464 - use_mistral_tool_parser = (
465 - isinstance(request, ChatCompletionRequest)
466 - and is_mistral_tool_parser(tool_parser_cls)
467 - and request._grammar_from_tool_parser
468 - )
469 -
470 - function_calls = list[FunctionCall]()
471 - if (
472 - not use_mistral_tool_parser
473 - and request.tool_choice
474 - and isinstance(request.tool_choice, ToolChoiceFunction)
475 - ):
476 - # Forced Function Call (Responses API)
477 - if content is None:
478 - return [], None
479 - function_calls.append(
480 - FunctionCall(name=request.tool_choice.name, arguments=content)
481 - )
482 - content = None # Clear content since tool is called.
483 - elif (
484 - not use_mistral_tool_parser
485 - and request.tool_choice
486 - and isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
487 - and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
488 - ):
489 - # Named function with standard JSON-based parsing
490 - if content is None:
491 - return [], None
492 - function_calls.append(
493 - FunctionCall(name=request.tool_choice.function.name, arguments=content)
494 - )
495 - content = None # Clear content since tool is called.
496 - elif (
497 - not use_mistral_tool_parser
498 - and request.tool_choice == "required"
499 - and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
500 - ):
501 - # "required" with standard JSON-based parsing
502 - tool_calls = []
503 - with contextlib.suppress(ValidationError):
504 - content = content or ""
505 - tool_calls = TypeAdapter(list[FunctionDefinition]).validate_json(
506 - content
507 - )
508 - for tool_call in tool_calls:
509 - function_calls.append(
510 - FunctionCall(
511 - name=tool_call.name,
512 - arguments=json.dumps(tool_call.parameters, ensure_ascii=False),
513 - )
514 - )
515 - content = None # Clear content since tool is called.
516 - elif tool_parser_cls and (
517 - use_mistral_tool_parser
518 - or (
519 - enable_auto_tools
520 - and (
521 - request.tool_choice == "auto"
522 - or request.tool_choice is None
523 - or (
524 - not tool_parser_cls.supports_required_and_named
525 - and request.tools
526 - and (
527 - request.tool_choice == "required"
528 - or isinstance(
529 - request.tool_choice,
530 - ChatCompletionNamedToolChoiceParam,
531 - )
532 - )
533 - )
534 - )
535 - )
536 - ):
537 - # Automatic Tool Call Parsing (also used as fallback for
538 - # required/named when supports_required_and_named=False)
539 - if tokenizer is None:
540 - raise ValueError(
541 - "Tokenizer not available when `skip_tokenizer_init=True`"
542 - )
543 -
544 - try:
545 - tool_parser = tool_parser_cls(tokenizer, request.tools)
546 - except RuntimeError as e:
547 - logger.exception("Error in tool parser creation.")
548 - raise e
549 - tool_call_info = tool_parser.extract_tool_calls(
550 - content if content is not None else "",
551 - request=request, # type: ignore
552 - )
553 - if tool_call_info is not None and tool_call_info.tools_called:
554 - # extract_tool_calls() returns a list of tool calls.
555 - function_calls.extend(
556 - FunctionCall(
557 - id=tool_call.id,
558 - name=tool_call.function.name,
559 - arguments=tool_call.function.arguments,
560 - )
561 - for tool_call in tool_call_info.tool_calls
562 - )
563 - content = tool_call_info.content
564 - if content and content.strip() == "":
565 - content = None
566 - else:
567 - # No tool calls.
568 - return None, content
569 -
570 - return function_calls, content
571 -
447 @staticmethod
448 def _get_decoded_token(
449 logprob: Logprob,
vllm/parser/abstract_parser.py
+126
@@ -44,6 +44,7 @@ from vllm.tool_parsers.streaming import (
44 )
45 from vllm.tool_parsers.utils import Tool
46 from vllm.utils import random_uuid
47 +from vllm.utils.mistral import is_mistral_tool_parser
48
49 logger = init_logger(__name__)
50
@@ -313,6 +314,24 @@ class Parser:
314 A DeltaMessage with tool_calls field, or None.
315 """
316
317 + @abstractmethod
318 + def parse(
319 + self,
320 + model_output: str,
321 + request: ChatCompletionRequest | ResponsesRequest,
322 + enable_auto_tools: bool = False,
323 + ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
324 + """Parse a complete model output, extracting reasoning and tool calls.
325 +
326 + Args:
327 + model_output: The complete model-generated string.
328 + request: The request object used to generate the output.
329 + enable_auto_tools: Whether to enable automatic tool call parsing.
330 +
331 + Returns:
332 + A tuple of (reasoning, content, tool_calls).
333 + """
334 +
335 @abstractmethod
336 def parse_delta(
337 self,
@@ -511,6 +530,99 @@ class DelegatingParser(Parser):
530 # No tool calls
531 return [], content
532
533 + def _extract_tool_calls(
534 + self,
535 + content: str | None,
536 + request: ChatCompletionRequest | ResponsesRequest,
537 + enable_auto_tools: bool = False,
538 + ) -> tuple[list[FunctionCall] | None, str | None]:
539 + tool_parser = self._tool_parser
540 + if tool_parser is None:
541 + return [], content
542 +
543 + # When the Mistral grammar factory injected structured outputs,
544 + # let the parser handle the output.
545 + use_mistral_tool_parser = (
546 + is_mistral_tool_parser(type(tool_parser))
547 + and isinstance(request, ChatCompletionRequest)
548 + and request._grammar_from_tool_parser
549 + )
550 +
551 + supports_required_and_named = tool_parser.supports_required_and_named
552 + is_named_tool_choice = request.tool_choice and isinstance(
553 + request.tool_choice,
554 + (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam),
555 + )
556 + is_required_tool_choice = request.tool_choice == "required"
557 + is_auto_tool_choice = enable_auto_tools and (
558 + request.tool_choice == "auto"
559 + or request.tool_choice is None
560 + or (
561 + not supports_required_and_named
562 + and (is_named_tool_choice or is_required_tool_choice)
563 + )
564 + )
565 +
566 + tool_calls = list[FunctionCall]()
567 + if (
568 + is_named_tool_choice
569 + and supports_required_and_named
570 + and not use_mistral_tool_parser
571 + ):
572 + if content is None:
573 + return [], None
574 + tool_calls.append(
575 + FunctionCall(
576 + name=self._get_function_name(request),
577 + arguments=content,
578 + )
579 + )
580 + content = None
581 + elif (
582 + is_required_tool_choice
583 + and supports_required_and_named
584 + and not use_mistral_tool_parser
585 + ):
586 + # "required" with standard JSON-based parsing
587 + parsed_calls = []
588 + with contextlib.suppress(ValidationError):
589 + content = content or ""
590 + parsed_calls = TypeAdapter(list[FunctionDefinition]).validate_json(
591 + content
592 + )
593 + for tc in parsed_calls:
594 + tool_calls.append(
595 + FunctionCall(
596 + name=tc.name,
597 + arguments=json.dumps(tc.parameters, ensure_ascii=False),
598 + )
599 + )
600 + content = None
601 + elif is_auto_tool_choice or use_mistral_tool_parser:
602 + # Automatic Tool Call Parsing (also used as fallback for
603 + # required/named when supports_required_and_named=False)
604 + tool_call_info = tool_parser.extract_tool_calls(
605 + content if content is not None else "",
606 + request=request, # type: ignore
607 + )
608 + if tool_call_info is not None and tool_call_info.tools_called:
609 + tool_calls.extend(
610 + FunctionCall(
611 + id=tc.id,
612 + name=tc.function.name,
613 + arguments=tc.function.arguments,
614 + )
615 + for tc in tool_call_info.tool_calls
616 + )
617 + content = tool_call_info.content
618 + if content and content.strip() == "":
619 + content = None
620 + else:
621 + # No tool calls.
622 + return None, content
623 +
624 + return tool_calls, content
625 +
626 def adjust_request(
627 self, request: ChatCompletionRequest | ResponsesRequest
628 ) -> ChatCompletionRequest | ResponsesRequest:
@@ -672,6 +784,20 @@ class DelegatingParser(Parser):
784 last_tc.function.arguments or ""
785 ) + self._tool_parser.get_remaining_unstreamed_args()
786
787 + def parse(
788 + self,
789 + model_output: str,
790 + request: ChatCompletionRequest | ResponsesRequest,
791 + enable_auto_tools: bool = False,
792 + ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
793 + reasoning, content = self.extract_reasoning(model_output, request)
794 + tool_calls, content = self._extract_tool_calls(
795 + content=content,
796 + request=request,
797 + enable_auto_tools=enable_auto_tools,
798 + )
799 + return reasoning, content, tool_calls
800 +
801 def parse_delta(
802 self,
803 delta_text: str,