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,