[PARSER][Mistral] unified engine-based parser for reasoning and tool calls (#48947)

Julien Denize committed Jul 30, 2026 at 15:38 UTC 1a20d23dab6eef0ce6ab97b7e03c944683cdf90b
30 files changed +2931 -1492
requirements/common.txt
+1 -1
@@ -31,7 +31,7 @@ partial-json-parser # used for parsing partial JSON outputs
31 jsonschema >= 4.23.0 # required for MiniMax M3 tool schema validation
32 pyzmq >= 25.0.0
33 msgspec
34 -mistral_common[image] >= 1.11.5
34 +mistral_common[image] >= 1.11.6
35 opencv-python-headless >= 4.13.0 # required for video IO
36 pyyaml
37 six>=1.16.0; python_version > '3.11' # transitive dependency of pandas that needs to be the latest version for python 3.12
requirements/test/cpu.txt
+1 -1
@@ -476,7 +476,7 @@ mcp==1.28.1
476 # via -r requirements/test/../common.txt
477 mdurl==0.1.2
478 # via markdown-it-py
479 -mistral-common==1.11.5
479 +mistral-common==1.11.6
480 # via
481 # -r requirements/test/../common.txt
482 # -r requirements/test/cuda.in
requirements/test/cuda.in
+1 -1
@@ -33,7 +33,7 @@ torchaudio==2.11.0
33 torchvision==0.28.0
34 transformers_stream_generator # required for qwen-vl test
35 matplotlib # required for qwen-vl test
36 -mistral_common[image,audio] >= 1.11.5 # required for voxtral test
36 +mistral_common[image,audio] >= 1.11.6 # required for voxtral test
37 num2words # required for smolvlm test
38 open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_common.py
39 datamodel_code_generator # required for minicpm3 test
requirements/test/cuda.txt
+1 -1
@@ -502,7 +502,7 @@ mcp==1.28.1
502 # via -r requirements/test/../common.txt
503 mdurl==0.1.2
504 # via markdown-it-py
505 -mistral-common==1.11.5
505 +mistral-common==1.11.6
506 # via
507 # -c requirements/common.txt
508 # -r requirements/test/../common.txt
requirements/test/nightly-torch.txt
+1 -1
@@ -23,7 +23,7 @@ jiwer # required for audio tests
23 timm # required for internvl test
24 transformers_stream_generator # required for qwen-vl test
25 matplotlib # required for qwen-vl test
26 -mistral_common[image,audio] >= 1.11.5 # required for voxtral test
26 +mistral_common[image,audio] >= 1.11.6 # required for voxtral test
27 num2words # required for smolvlm test
28 opencv-python-headless >= 4.13.0 # required for video test
29 datamodel_code_generator # required for minicpm3 test
requirements/test/rocm.in
+1 -1
@@ -29,7 +29,7 @@ tblib # for pickling test exceptions
29 timm>=1.0.17 # required for internvl and gemma3n-mm test
30 transformers_stream_generator # required for qwen-vl test
31 matplotlib # required for qwen-vl test
32 -mistral_common[image,audio]>=1.11.5 # required for voxtral test
32 +mistral_common[image,audio]>=1.11.6 # required for voxtral test
33 num2words # required for smolvlm test
34 open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_common.py
35 datamodel_code_generator # required for minicpm3 test
requirements/test/rocm.txt
+1 -1
@@ -497,7 +497,7 @@ mcp==1.27.0
497 # via -r requirements/test/../common.txt
498 mdurl==0.1.2
499 # via markdown-it-py
500 -mistral-common==1.11.5
500 +mistral-common==1.11.6
501 # via
502 # -c requirements/common.txt
503 # -r requirements/test/../common.txt
requirements/test/xpu.txt
+1 -1
@@ -373,7 +373,7 @@ mcp==1.28.1
373 # via -r requirements/test/../common.txt
374 mdurl==0.1.2
375 # via markdown-it-py
376 -mistral-common==1.11.5
376 +mistral-common==1.11.6
377 # via
378 # -c requirements/common.txt
379 # -r requirements/test/../common.txt
tests/models/language/generation/test_mistral.py
+2 -4
@@ -5,12 +5,10 @@ import json
5
6 import pytest
7
8 +from vllm.parser.mistral import MistralToolCall
9 from vllm.sampling_params import SamplingParams
10 from vllm.tokenizers.mistral import MistralTokenizer
10 -from vllm.tool_parsers.mistral_tool_parser import (
11 - MistralToolCall,
12 - MistralToolParser,
13 -)
11 +from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
12
13 from ...utils import check_logprobs_close
14
tests/parser/engine/test_delegating_replay.py
+6
@@ -40,6 +40,7 @@ from vllm.parser.engine.adapters import (
40 ParserEngineReasoningAdapter,
41 ParserEngineToolAdapter,
42 )
43 +from vllm.parser.mistral import MistralParser
44
45 _TOOLS_VALIDATOR = TypeAdapter(list[ChatCompletionToolsParam])
46
@@ -84,6 +85,11 @@ def _discover_pairings() -> list[_PairingInfo]:
85 for engine_cls, adapters in engines.items():
86 if "tool" not in adapters or "reasoning" not in adapters:
87 continue
88 + if engine_cls is MistralParser:
89 + # Mistral uses brace-balanced JSON tool args with no TOOL_END
90 + # token, so it does not fit this TOOL_END-based replay harness.
91 + # It is covered by tests/parser/mistral/ instead.
92 + continue
93 cfg = engine_cls(bare_tok, None).parser_engine_config
94 if cfg.name not in _BUILDERS:
95 missing_builders.append(f"{engine_cls.__name__} (config.name={cfg.name!r})")
tests/parser/engine/test_replay.py
+6
@@ -34,6 +34,7 @@ from tests.parser.engine.trace_builder import _BUILDERS, build_samples
34 from vllm.parser.engine import registered_adapters as _adapters_mod
35 from vllm.parser.engine.parser_engine import ParserEngine
36 from vllm.parser.engine.parser_engine_config import ParserState
37 +from vllm.parser.mistral import MistralParser
38
39 # ── Parser discovery ─────────────────────────────────────────────────
40
@@ -64,6 +65,11 @@ def _discover_parsers() -> list[_ParserInfo]:
65 and obj is not ParserEngine
66 ):
67 continue
68 + if obj is MistralParser:
69 + # Mistral uses brace-balanced JSON tool args with no TOOL_END
70 + # token, so it does not fit this TOOL_END-based replay harness.
71 + # It is covered by tests/parser/mistral/ instead.
72 + continue
73 cfg = obj(bare_tok, None).parser_engine_config
74 if cfg.name not in _BUILDERS:
75 missing_builders.append(f"{obj.__name__} (config.name={cfg.name!r})")
tests/parser/mistral/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
tests/parser/mistral/test_reasoning.py new
+599
@@ -0,0 +1,599 @@
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 +from mistral_common.protocol.instruct.messages import (
8 + AssistantMessage,
9 + ThinkChunk,
10 + ToolMessage,
11 + UserMessage,
12 +)
13 +from mistral_common.protocol.instruct.request import InstructRequest
14 +from mistral_common.protocol.instruct.tool_calls import FunctionCall, ToolCall
15 +
16 +from tests.reasoning.utils import run_reasoning_extraction_mistral
17 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
18 +from vllm.parser.parser_manager import ParserManager
19 +from vllm.reasoning import ReasoningParser, ReasoningParserManager
20 +from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
21 +from vllm.tokenizers.mistral import MistralTokenizer
22 +
23 +_PARSER_NAME = "mistral"
24 +_MODEL_V13 = "mistralai/Magistral-Small-2509"
25 +_MODEL_V11 = "mistralai/Magistral-Small-2506"
26 +
27 +_SAMPLE_TOOLS = [
28 + {
29 + "type": "function",
30 + "function": {
31 + "name": "get_weather",
32 + "description": "Get the current weather in a city.",
33 + "parameters": {
34 + "type": "object",
35 + "properties": {"city": {"type": "string"}},
36 + "required": ["city"],
37 + },
38 + },
39 + }
40 +]
41 +
42 +
43 +@pytest.fixture(scope="module")
44 +def mistral_tokenizer() -> MistralTokenizer:
45 + """v13 Magistral tokenizer with `[THINK]`/`[/THINK]` special tokens."""
46 + return MistralTokenizer.from_pretrained(_MODEL_V13)
47 +
48 +
49 +@pytest.fixture(scope="module")
50 +def mistral_v11_tokenizer() -> MistralTokenizer:
51 + """v11 Magistral tokenizer using plain-text `<think>`/`</think>` tags."""
52 + return MistralTokenizer.from_pretrained(_MODEL_V11)
53 +
54 +
55 +def _encode_v13(tokenizer: MistralTokenizer, output: str) -> list[int]:
56 + """Encode output string with `[THINK]`/`[/THINK]` markers into token IDs.
57 +
58 + `[THINK]` and `[/THINK]` placeholders in `output` are replaced by their
59 + special-token IDs; all surrounding text is encoded with the base tokenizer.
60 + Mirrors the encoding helper from the deleted
61 + `tests/reasoning/test_mistral_reasoning_parser.py`.
62 + """
63 + think_start = "[THINK]"
64 + think_end = "[/THINK]"
65 + len_start = len(think_start)
66 + len_end = len(think_end)
67 +
68 + index_start = output.find(think_start)
69 + index_end = output.find(think_end)
70 +
71 + out: list[int] = []
72 + if index_start != -1:
73 + out += tokenizer.tokenizer.encode(output[:index_start], False, False)
74 + out += [tokenizer.instruct.BEGIN_THINK]
75 +
76 + if index_end != -1:
77 + middle = output[index_start + len_start : index_end]
78 + suffix = output[index_end + len_end :]
79 + out += tokenizer.tokenizer.encode(middle, False, False)
80 + out += [tokenizer.instruct.END_THINK]
81 + out += tokenizer.tokenizer.encode(suffix, False, False)
82 + else:
83 + out += tokenizer.tokenizer.encode(
84 + output[index_start + len_start :], False, False
85 + )
86 + elif index_end != -1:
87 + out += tokenizer.tokenizer.encode(output[:index_end], False, False)
88 + out += [tokenizer.instruct.END_THINK]
89 + out += tokenizer.tokenizer.encode(output[index_end + len_end :], False, False)
90 + else:
91 + out += tokenizer.tokenizer.encode(output, False, False)
92 + return out
93 +
94 +
95 +_TEST_CASES = [
96 + # v13 special-token encoding: [THINK]…[/THINK] with trailing content.
97 + pytest.param(
98 + False,
99 + "[THINK]r[/THINK]c",
100 + "r",
101 + "c",
102 + id="v13_basic",
103 + ),
104 + pytest.param(
105 + True,
106 + "[THINK]r[/THINK]c",
107 + "r",
108 + "c",
109 + id="v13_basic_streaming",
110 + ),
111 + # No trailing content after reasoning ends.
112 + pytest.param(
113 + False,
114 + "[THINK]r[/THINK]",
115 + "r",
116 + None,
117 + id="v13_no_trailing_content",
118 + ),
119 + pytest.param(
120 + True,
121 + "[THINK]r[/THINK]",
122 + "r",
123 + None,
124 + id="v13_no_trailing_content_streaming",
125 + ),
126 + # Multi-line reasoning and multi-line content.
127 + pytest.param(
128 + False,
129 + "[THINK]a\nb[/THINK]c\nd",
130 + "a\nb",
131 + "c\nd",
132 + id="v13_multiline",
133 + ),
134 + pytest.param(
135 + True,
136 + "[THINK]a\nb[/THINK]c\nd",
137 + "a\nb",
138 + "c\nd",
139 + id="v13_multiline_streaming",
140 + ),
141 + # No reasoning at all: output is pure content.
142 + pytest.param(
143 + False,
144 + "hello world",
145 + None,
146 + "hello world",
147 + id="v13_no_reasoning",
148 + ),
149 + pytest.param(
150 + True,
151 + "hello world",
152 + None,
153 + "hello world",
154 + id="v13_no_reasoning_streaming",
155 + ),
156 + # [THINK] opened but never closed; reasoning accumulates, content is None.
157 + pytest.param(
158 + False,
159 + "[THINK]r",
160 + "r",
161 + None,
162 + id="v13_think_no_end",
163 + ),
164 + pytest.param(
165 + True,
166 + "[THINK]r",
167 + "r",
168 + None,
169 + id="v13_think_no_end_streaming",
170 + ),
171 + # Stray [/THINK] with no matching [THINK]: no reasoning, content follows.
172 + pytest.param(
173 + False,
174 + "[/THINK]This is the rest",
175 + None,
176 + "This is the rest",
177 + id="v13_stray_think_end",
178 + ),
179 + pytest.param(
180 + True,
181 + "[/THINK]This is the rest",
182 + None,
183 + "This is the rest",
184 + id="v13_stray_think_end_streaming",
185 + ),
186 + # Content before [THINK]: leading segment joins with trailing segment.
187 + pytest.param(
188 + False,
189 + "Before\n[THINK]r[/THINK]\nAfter",
190 + "r",
191 + "Before\n\nAfter",
192 + id="v13_leading_content",
193 + ),
194 + pytest.param(
195 + True,
196 + "Before\n[THINK]r[/THINK]\nAfter",
197 + "r",
198 + "Before\n\nAfter",
199 + id="v13_leading_content_streaming",
200 + ),
201 + # Empty output: no reasoning, no content.
202 + pytest.param(
203 + False,
204 + "",
205 + None,
206 + None,
207 + id="v13_empty",
208 + ),
209 + pytest.param(
210 + True,
211 + "",
212 + None,
213 + None,
214 + id="v13_empty_streaming",
215 + ),
216 +]
217 +
218 +
219 +@pytest.mark.parametrize(
220 + "streaming, output, expected_reasoning, expected_content",
221 + _TEST_CASES,
222 +)
223 +def test_mistral_reasoning_v13(
224 + streaming: bool,
225 + output: str,
226 + expected_reasoning: str | None,
227 + expected_content: str | None,
228 + mistral_tokenizer: MistralTokenizer,
229 +) -> None:
230 + output_tokens = _encode_v13(mistral_tokenizer, output)
231 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(_PARSER_NAME)(
232 + mistral_tokenizer
233 + )
234 + reasoning, content = run_reasoning_extraction_mistral(
235 + parser, output_tokens, streaming=streaming
236 + )
237 + assert reasoning == expected_reasoning
238 + assert content == expected_content
239 +
240 +
241 +def test_system_prompt_think_ignored(
242 + mistral_tokenizer: MistralTokenizer,
243 +) -> None:
244 + """Streaming content extraction must not
245 + include `[THINK]`/`[/THINK]` examples from the system prompt.
246 + """
247 + output = "[THINK]real[/THINK]answer"
248 + output_tokens = _encode_v13(mistral_tokenizer, output)
249 + for streaming in (False, True):
250 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(
251 + _PARSER_NAME
252 + )(mistral_tokenizer)
253 + reasoning, content = run_reasoning_extraction_mistral(
254 + parser, output_tokens, streaming=streaming
255 + )
256 + assert reasoning == "real"
257 + assert content == "answer"
258 +
259 +
260 +def test_reasoning_with_tool_calls(
261 + mistral_tokenizer: MistralTokenizer,
262 +) -> None:
263 + """Reasoning extraction with a tool call in the generated output."""
264 + tool_calls_token_id = mistral_tokenizer.get_vocab().get("[TOOL_CALLS]")
265 + if tool_calls_token_id is None:
266 + pytest.skip("Tokenizer does not have [TOOL_CALLS] in vocab")
267 +
268 + # Token sequence representing: [THINK]r[/THINK][TOOL_CALLS]f{"a":1}
269 + output_tokens = (
270 + [mistral_tokenizer.instruct.BEGIN_THINK]
271 + + mistral_tokenizer.tokenizer.encode("r", False, False)
272 + + [mistral_tokenizer.instruct.END_THINK]
273 + + [tool_calls_token_id]
274 + + mistral_tokenizer.tokenizer.encode('f{"a":1}', False, False)
275 + )
276 + for streaming in (False, True):
277 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(
278 + _PARSER_NAME
279 + )(mistral_tokenizer)
280 + reasoning, _content = run_reasoning_extraction_mistral(
281 + parser, output_tokens, streaming=streaming
282 + )
283 + assert reasoning == "r"
284 + assert "[TOOL_CALLS]" not in (reasoning or "")
285 +
286 +
287 +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstream", "stream"])
288 +def test_reasoning_v11_plain_text_think(
289 + mistral_v11_tokenizer: MistralTokenizer, streaming: bool
290 +) -> None:
291 + """v11 tokenizers use plain-text `<think>`/`</think>` (no special tokens)."""
292 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(_PARSER_NAME)(
293 + mistral_v11_tokenizer
294 + )
295 +
296 + tokens = mistral_v11_tokenizer.tokenizer.encode("<think>r</think>c", False, False)
297 + reasoning, content = run_reasoning_extraction_mistral(
298 + parser, tokens, streaming=streaming
299 + )
300 + assert reasoning == "r"
301 + assert content == "c"
302 +
303 +
304 +def test_is_reasoning_end(
305 + mistral_tokenizer: MistralTokenizer,
306 +) -> None:
307 + """is_reasoning_end returns True iff reasoning has finished."""
308 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(_PARSER_NAME)(
309 + mistral_tokenizer
310 + )
311 +
312 + # Complete reasoning: END_THINK present → True.
313 + complete_ids = _encode_v13(mistral_tokenizer, "[THINK]r[/THINK]c")
314 + assert parser.is_reasoning_end(complete_ids)
315 +
316 + # Open-only: no END_THINK, BEGIN_THINK present → False.
317 + open_ids = _encode_v13(mistral_tokenizer, "[THINK]r")
318 + assert not parser.is_reasoning_end(open_ids)
319 +
320 + # No reasoning markers at all → False.
321 + no_reasoning_ids = _encode_v13(mistral_tokenizer, "hello")
322 + assert not parser.is_reasoning_end(no_reasoning_ids)
323 +
324 + # Explicit END_THINK closes reasoning → True.
325 + explicit_end_ids = (
326 + [mistral_tokenizer.instruct.BEGIN_THINK]
327 + + mistral_tokenizer.tokenizer.encode("r", False, False)
328 + + [mistral_tokenizer.instruct.END_THINK]
329 + )
330 + assert parser.is_reasoning_end(explicit_end_ids)
331 +
332 + # [TOOL_CALLS] acts as implicit reasoning-end marker.
333 + tool_calls_token_id = mistral_tokenizer.get_vocab().get("[TOOL_CALLS]")
334 + if tool_calls_token_id is None:
335 + pytest.skip("Tokenizer does not have [TOOL_CALLS] in vocab")
336 +
337 + implicit_end_ids = (
338 + [mistral_tokenizer.instruct.BEGIN_THINK]
339 + + mistral_tokenizer.tokenizer.encode("r", False, False)
340 + + [tool_calls_token_id]
341 + )
342 + assert parser.is_reasoning_end(implicit_end_ids)
343 +
344 +
345 +def _stream_parse_delta(parser, tokenizer, gen_ids, request, prompt_ids):
346 + """Stream `gen_ids` through `parse_delta` and reconstruct reasoning/content."""
347 + reasoning = ""
348 + content = ""
349 + previous_tokens: list[str] | None = None
350 + prefix_offset = 0
351 + read_offset = 0
352 + for i, tok in enumerate(gen_ids):
353 + new_tokens, delta_text, prefix_offset, read_offset = detokenize_incrementally(
354 + tokenizer=tokenizer,
355 + all_input_ids=gen_ids[: i + 1],
356 + prev_tokens=previous_tokens,
357 + prefix_offset=prefix_offset,
358 + read_offset=read_offset,
359 + skip_special_tokens=True,
360 + spaces_between_special_tokens=True,
361 + )
362 + previous_tokens = (
363 + previous_tokens + new_tokens if previous_tokens else new_tokens
364 + )
365 + delta_message = parser.parse_delta(
366 + delta_text=delta_text,
367 + delta_token_ids=[tok],
368 + request=request,
369 + prompt_token_ids=prompt_ids,
370 + finished=(i == len(gen_ids) - 1),
371 + )
372 + if delta_message is not None:
373 + reasoning += delta_message.reasoning or ""
374 + content += delta_message.content or ""
375 + return reasoning, content
376 +
377 +
378 +def test_parse_delta_keeps_reasoning_open_after_closed_think_prompt(
379 + mistral_tokenizer: MistralTokenizer,
380 +) -> None:
381 + """A closed `[THINK]` block in the prompt must not finalize reasoning.
382 +
383 + Regression: reasoning system prompts end the prompt with a closed
384 + `[THINK]...[/THINK]` block. With a parser-injected grammar the generation's
385 + reasoning must still be captured as ``reasoning_content`` rather than
386 + leaking into ``content`` (the prompt-based reasoning-end heuristic must be
387 + skipped). This exercises ``DelegatingParser.parse_delta``'s prompt check,
388 + which the isolated reasoning-parser tests do not cover.
389 + """
390 + parser_cls = ParserManager.get_parser(
391 + tool_parser_name="mistral",
392 + reasoning_parser_name="mistral",
393 + enable_auto_tools=True,
394 + )
395 + parser = parser_cls(mistral_tokenizer, None)
396 + request = parser.adjust_request(
397 + ChatCompletionRequest(
398 + messages=[],
399 + model="test",
400 + tools=_SAMPLE_TOOLS,
401 + tool_choice="auto",
402 + )
403 + )
404 + assert getattr(request, "_grammar_from_parser", False)
405 +
406 + prompt_ids = _encode_v13(mistral_tokenizer, "[THINK]system reasoning[/THINK]")
407 + gen_ids = _encode_v13(
408 + mistral_tokenizer,
409 + "[THINK]because two plus two is four[/THINK]The answer is 4.",
410 + )
411 +
412 + reasoning, content = _stream_parse_delta(
413 + parser, mistral_tokenizer, gen_ids, request, prompt_ids
414 + )
415 +
416 + assert "because two plus two is four" in reasoning
417 + assert "because two plus two is four" not in content
418 + assert "The answer is 4." in content
419 +
420 +
421 +def _encode_gen(tokenizer: MistralTokenizer, text: str) -> list[int]:
422 + """Encode `text` mapping `[THINK]`, `[/THINK]`, `[TOOL_CALLS]`, `[ARGS]`
423 + to their special-token IDs; all other text uses the base tokenizer.
424 + """
425 + vocab = tokenizer.get_vocab()
426 + markers: dict[str, int] = {
427 + "[THINK]": tokenizer.instruct.BEGIN_THINK,
428 + "[/THINK]": tokenizer.instruct.END_THINK,
429 + "[TOOL_CALLS]": vocab["[TOOL_CALLS]"],
430 + "[ARGS]": vocab["[ARGS]"],
431 + }
432 + out: list[int] = []
433 + remaining = text
434 + while remaining:
435 + earliest: int | None = None
436 + earliest_marker: str | None = None
437 + for marker in markers:
438 + idx = remaining.find(marker)
439 + if idx != -1 and (earliest is None or idx < earliest):
440 + earliest = idx
441 + earliest_marker = marker
442 + if earliest_marker is None:
443 + out += tokenizer.tokenizer.encode(remaining, False, False)
444 + break
445 + assert earliest is not None # set together with earliest_marker
446 + if earliest > 0:
447 + out += tokenizer.tokenizer.encode(remaining[:earliest], False, False)
448 + out.append(markers[earliest_marker])
449 + remaining = remaining[earliest + len(earliest_marker) :]
450 + return out
451 +
452 +
453 +def _stream_turn(
454 + parser,
455 + tokenizer: MistralTokenizer,
456 + gen_ids: list[int],
457 + request,
458 + prompt_ids: list[int],
459 +) -> tuple[str, str, list[tuple[str, str]]]:
460 + """Stream `gen_ids` through `parse_delta`, returning `(reasoning, content,
461 + tool_calls)`.
462 +
463 + `tool_calls` is a list of `(name, arguments)` tuples ordered by
464 + `DeltaToolCall.index`. Arguments are concatenated across deltas.
465 + """
466 + reasoning = ""
467 + content = ""
468 + tool_call_acc: dict[int, dict[str, str]] = {}
469 + previous_tokens: list[str] | None = None
470 + prefix_offset = 0
471 + read_offset = 0
472 + for i, tok in enumerate(gen_ids):
473 + new_tokens, delta_text, prefix_offset, read_offset = detokenize_incrementally(
474 + tokenizer=tokenizer,
475 + all_input_ids=gen_ids[: i + 1],
476 + prev_tokens=previous_tokens,
477 + prefix_offset=prefix_offset,
478 + read_offset=read_offset,
479 + skip_special_tokens=True,
480 + spaces_between_special_tokens=True,
481 + )
482 + previous_tokens = (
483 + previous_tokens + new_tokens if previous_tokens else new_tokens
484 + )
485 + delta_message = parser.parse_delta(
486 + delta_text=delta_text,
487 + delta_token_ids=[tok],
488 + request=request,
489 + prompt_token_ids=prompt_ids,
490 + finished=(i == len(gen_ids) - 1),
491 + )
492 + if delta_message is not None:
493 + reasoning += delta_message.reasoning or ""
494 + content += delta_message.content or ""
495 + for tc in delta_message.tool_calls:
496 + if tc.index not in tool_call_acc:
497 + tool_call_acc[tc.index] = {"name": "", "args": ""}
498 + if tc.function:
499 + if tc.function.name:
500 + tool_call_acc[tc.index]["name"] = tc.function.name
501 + if tc.function.arguments:
502 + tool_call_acc[tc.index]["args"] += tc.function.arguments
503 + tool_calls = [
504 + (tool_call_acc[k]["name"], tool_call_acc[k]["args"])
505 + for k in sorted(tool_call_acc)
506 + ]
507 + return reasoning, content, tool_calls
508 +
509 +
510 +def test_parse_delta_multi_turn_reason_and_tool(
511 + mistral_tokenizer: MistralTokenizer,
512 +) -> None:
513 + """Two-turn loop: each assistant turn carries reasoning + a tool call.
514 +
515 + For each turn, the mistral-common conversation is encoded into `prompt_ids`
516 + before streaming the next generation. Turn 2's prompt therefore contains
517 + a `[THINK]`/`[/THINK]` block and `[TOOL_CALLS]` from turn 1 — the
518 + accumulated tool-call/result history that single-turn tests never exercise.
519 +
520 + Asserts that across both turns the parser correctly classifies think tokens
521 + as `reasoning_content` (never leaking into `content`), and that the tool
522 + call name/arguments are reconstructed correctly from the streaming deltas.
523 + """
524 + parser_cls = ParserManager.get_parser(
525 + tool_parser_name="mistral",
526 + reasoning_parser_name="mistral",
527 + enable_auto_tools=True,
528 + )
529 + request = parser_cls(mistral_tokenizer, None).adjust_request(
530 + ChatCompletionRequest(
531 + messages=[],
532 + model="test",
533 + tools=_SAMPLE_TOOLS,
534 + tool_choice="auto",
535 + )
536 + )
537 + assert getattr(request, "_grammar_from_parser", False)
538 +
539 + cities = ["Dallas", "Paris"]
540 + conversation: list = [UserMessage(content="What is the weather in Dallas?")]
541 +
542 + for i, city in enumerate(cities):
543 + # Fresh parser per turn: streaming state must not bleed across turns.
544 + parser = parser_cls(mistral_tokenizer, None)
545 +
546 + prompt_ids = mistral_tokenizer.instruct.encode_instruct(
547 + InstructRequest(messages=conversation)
548 + ).tokens
549 +
550 + gen_str = (
551 + f"[THINK]I should check {city} weather.[/THINK]"
552 + f'[TOOL_CALLS]get_weather[ARGS]{{"city": "{city}"}}'
553 + )
554 + gen_ids = _encode_gen(mistral_tokenizer, gen_str)
555 +
556 + reasoning, content, tool_calls = _stream_turn(
557 + parser, mistral_tokenizer, gen_ids, request, prompt_ids
558 + )
559 +
560 + assert f"check {city} weather" in reasoning, f"turn {i}: reasoning not captured"
561 + assert f"check {city} weather" not in content, (
562 + f"turn {i}: reasoning leaked into content"
563 + )
564 + assert "[THINK]" not in content, f"turn {i}: [THINK] leaked"
565 + assert "[TOOL_CALLS]" not in content, f"turn {i}: [TOOL_CALLS] leaked"
566 + assert len(tool_calls) == 1, f"turn {i}: expected 1 tool call"
567 + name, args = tool_calls[0]
568 + assert name == "get_weather", f"turn {i}: wrong tool name {name!r}"
569 + assert city in args, f"turn {i}: city not in args {args!r}"
570 +
571 + # Extend conversation with this turn's history for the next turn.
572 + tc_id = f"tc{i:07d}" # 9-char alphanumeric id: "tc0000000", "tc0000001"
573 + conversation.extend(
574 + [
575 + AssistantMessage(
576 + content=[
577 + ThinkChunk(
578 + thinking=f"I should check {city} weather.",
579 + closed=True,
580 + )
581 + ],
582 + tool_calls=[
583 + ToolCall(
584 + id=tc_id,
585 + function=FunctionCall(
586 + name="get_weather",
587 + arguments=json.dumps({"city": city}),
588 + ),
589 + )
590 + ],
591 + ),
592 + ToolMessage(
593 + content=f"{city}: 70F sunny",
594 + tool_call_id=tc_id,
595 + ),
596 + ]
597 + )
598 + if i < len(cities) - 1:
599 + conversation.append(UserMessage(content=f"And in {cities[i + 1]}?"))
tests/parser/mistral/test_tool_calls.py renamed
+1150 -116
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
7
8 import partial_json_parser
9 import pytest
10 +import regex as re
11 from mistral_common.protocol.instruct.messages import AssistantMessage
12 from mistral_common.protocol.instruct.request import InstructRequest
13 from mistral_common.protocol.instruct.tool_calls import (
@@ -28,21 +29,26 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
29 ChatCompletionRequest,
30 )
31 from vllm.entrypoints.openai.engine.protocol import (
32 + DeltaFunctionCall,
33 DeltaMessage,
34 DeltaToolCall,
35 ExtractedToolCallInformation,
36 StructuralTagResponseFormat,
37 )
38 +from vllm.parser.engine.events import EventType
39 +from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
40 +from vllm.parser.mistral import _DEFAULT_JSON_SCHEMA, MistralParser, mistral_config
41 +from vllm.parser.parser_manager import ParserManager
42 from vllm.sampling_params import StructuredOutputsParams
43 from vllm.tokenizers import TokenizerLike, get_tokenizer
44 from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
45 from vllm.tokenizers.mistral import MistralTokenizer
40 -from vllm.tool_parsers.mistral_tool_parser import (
41 - _DEFAULT_JSON_SCHEMA,
42 - MistralToolParser,
43 -)
46 +from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
47
48 _DUMMY_REQUEST = ChatCompletionRequest(messages=[], model="test")
49 +# Minimal request for the engine driver: tool_choice=None falls through to
50 +# auto extraction without triggering "tools must be set" validation.
51 +_ENGINE_REQUEST = ChatCompletionRequest(messages=[], model="test", tool_choice=None)
52
53
54 @pytest.fixture(scope="module")
@@ -71,6 +77,8 @@ def mistral_tool_parser(mistral_tokenizer):
77 def non_mistral_parser() -> MistralToolParser:
78 mock_tokenizer = MagicMock()
79 mock_tokenizer.get_vocab.return_value = {"[TOOL_CALLS]": 1}
80 + # Ensure the legacy (non-grammar) path is taken by MistralParser.
81 + mock_tokenizer.supports_grammar = False
82 return MistralToolParser(mock_tokenizer)
83
84
@@ -96,9 +104,18 @@ def assert_tool_calls(
104 assert actual_tool_call.function.name == expected_tool_call.function.name, (
105 f"got wrong function name:${actual_tool_call.function.name}"
106 )
99 - assert (
100 - actual_tool_call.function.arguments == expected_tool_call.function.arguments
101 - ), f"got wrong function argument:${actual_tool_call.function.arguments}"
107 + actual_args = actual_tool_call.function.arguments
108 + # Streaming deltas (DeltaToolCall) may have partial JSON because the
109 + # engine holds back the final closing brace until finish() is called.
110 + # Apply partial_json_parser so the comparison works for both the
111 + # legacy (complete JSON) and engine (potentially partial) paths.
112 + if isinstance(actual_tool_call, DeltaToolCall) and actual_args is not None:
113 + actual_args = partial_json_parser.ensure_json(
114 + actual_args, Allow.OBJ | Allow.STR
115 + )
116 + assert actual_args == expected_tool_call.function.arguments, (
117 + f"got wrong function argument:${actual_tool_call.function.arguments}"
118 + )
119
120
121 def fix_tool_call_tokenization(
@@ -106,45 +123,82 @@ def fix_tool_call_tokenization(
123 mistral_tool_parser: MistralToolParser,
124 mistral_tokenizer: TokenizerLike,
125 ):
109 - """
110 - Replaces the textual token sequence for [TOOL_CALLS]
111 - with its single special token ID.
112 - """
113 - textual_tool_call_token_ids = mistral_tokenizer.encode(
114 - text=mistral_tool_parser.bot_token,
115 - add_special_tokens=False,
116 - )
117 - # textual_tool_call_token_ids must not contain special tokens like bos, eos etc
118 - special_tool_call_token_ids = [mistral_tool_parser.bot_token_id]
126 + """Replace textual token sequences for the control markers ([TOOL_CALLS]
127 + and [ARGS]) with their single special token IDs.
128
120 - # If the input is too short to contain the sequence, no replacement is possible
121 - if not tokens or len(tokens) < len(textual_tool_call_token_ids):
122 - return tokens
129 + Real generation emits these as control tokens; encoding the marker *string*
130 + can instead yield literal text tokens. The engine detects markers by id, so
131 + the test stream must carry the special ids.
132 + """
133 + vocab = mistral_tokenizer.get_vocab()
134 + markers: list[tuple[str, int]] = [
135 + (
136 + mistral_tool_parser._parser_engine.bot_token,
137 + mistral_tool_parser._parser_engine.bot_token_id,
138 + )
139 + ]
140 + args_id = vocab.get("[ARGS]")
141 + if args_id is not None:
142 + markers.append(("[ARGS]", args_id))
143 +
144 + # (textual token sequence, special id) pairs to replace.
145 + replacements: list[tuple[list[int], int]] = []
146 + for text, special_id in markers:
147 + if special_id is None:
148 + continue
149 + textual = mistral_tokenizer.encode(text=text, add_special_tokens=False)
150 + if textual:
151 + replacements.append((textual, special_id))
152
124 - result_tokens = []
153 + result_tokens: list[int] = []
154 i = 0
126 - target_len = len(textual_tool_call_token_ids)
127 -
155 while i < len(tokens):
129 - # Check if the slice from the current position matches the target sequence
130 - if tokens[i : i + target_len] == textual_tool_call_token_ids:
131 - # If it matches, add the replacement and jump the index forward
132 - result_tokens.extend(special_tool_call_token_ids)
133 - i += target_len
156 + for textual, special_id in replacements:
157 + if tokens[i : i + len(textual)] == textual:
158 + result_tokens.append(special_id)
159 + i += len(textual)
160 + break
161 else:
135 - # Otherwise, just add the current token and move to the next one
162 result_tokens.append(tokens[i])
163 i += 1
164
165 return result_tokens
166
167
168 +def encode_mistral_output(tokenizer: MistralTokenizer, text: str) -> list[int]:
169 + """Encode generated output with control markers as their special-token ids.
170 +
171 + Real generation emits ``[TOOL_CALLS]``/``[ARGS]`` as single control tokens.
172 + Encoding the marker *strings* can instead glue them to neighbours (e.g.
173 + ``}[``) and yield literal text tokens, which the id-based engine never sees.
174 + Split on the markers and insert their special ids so the token stream
175 + matches real generation.
176 + """
177 + vocab = tokenizer.get_vocab()
178 + marker_ids = {
179 + "[TOOL_CALLS]": vocab.get("[TOOL_CALLS]"),
180 + "[ARGS]": vocab.get("[ARGS]"),
181 + }
182 + ids: list[int] = []
183 + for part in re.split(r"(\[TOOL_CALLS\]|\[ARGS\])", text):
184 + if part in marker_ids:
185 + # Fall back to text encoding here and the id-based engine path
186 + # silently stops being exercised while the tests still pass.
187 + assert marker_ids[part] is not None, f"{part} missing from vocab"
188 + ids.append(marker_ids[part])
189 + elif part:
190 + ids.extend(tokenizer.encode(part, add_special_tokens=False))
191 + return ids
192 +
193 +
194 def stream_delta_message_generator(
195 mistral_tool_parser: MistralToolParser,
196 mistral_tokenizer: TokenizerLike,
197 model_output: str | None,
198 tools: list[tuple[str, str]] | None,
199 chunk_size: int = 1,
200 + driver: str = "legacy",
201 + reasoning_parser: str | None = None,
202 ) -> Generator[DeltaMessage, None, None]:
203 if (
204 isinstance(mistral_tokenizer, MistralTokenizer)
@@ -179,6 +233,33 @@ def stream_delta_message_generator(
233 all_token_ids, mistral_tool_parser, mistral_tokenizer
234 )
235
236 + if isinstance(mistral_tokenizer, MistralTokenizer):
237 + # Real serving streams only generated tokens; encode_instruct adds
238 + # framing (BOS/EOS) the parser never receives. Strip them so the
239 + # id-based engine detection sees a realistic stream.
240 + if all_token_ids and all_token_ids[0] == mistral_tokenizer.bos_token_id:
241 + all_token_ids = all_token_ids[1:]
242 + if all_token_ids and all_token_ids[-1] == mistral_tokenizer.eos_token_id:
243 + all_token_ids = all_token_ids[:-1]
244 +
245 + if driver == "engine":
246 + _parser_cls = ParserManager.get_parser(
247 + tool_parser_name="mistral",
248 + reasoning_parser_name=reasoning_parser,
249 + enable_auto_tools=True,
250 + )
251 + _engine_parser = _parser_cls(mistral_tokenizer, None)
252 + if reasoning_parser is not None:
253 + _req_with_tools = ChatCompletionRequest(
254 + messages=[],
255 + model="test",
256 + tools=SAMPLE_TOOLS_DICTS,
257 + tool_choice="auto",
258 + )
259 + _engine_req = _engine_parser.adjust_request(_req_with_tools)
260 + else:
261 + _engine_req = _ENGINE_REQUEST
262 +
263 previous_text = ""
264 previous_tokens = None
265 prefix_offset = 0
@@ -215,15 +296,24 @@ def stream_delta_message_generator(
296 current_token_ids = all_token_ids[: i + 1]
297 current_text = previous_text + pending_text
298
218 - delta_message = mistral_tool_parser.extract_tool_calls_streaming(
219 - previous_text,
220 - current_text,
221 - pending_text,
222 - previous_token_ids,
223 - current_token_ids,
224 - pending_token_ids,
225 - request=_DUMMY_REQUEST,
226 - )
299 + if driver == "legacy":
300 + delta_message = mistral_tool_parser.extract_tool_calls_streaming(
301 + previous_text,
302 + current_text,
303 + pending_text,
304 + previous_token_ids,
305 + current_token_ids,
306 + pending_token_ids,
307 + request=_DUMMY_REQUEST,
308 + )
309 + else: # engine: per-delta windows, mirroring parse_delta behaviour
310 + delta_message = _engine_parser.parse_delta(
311 + delta_text=pending_text,
312 + delta_token_ids=list(pending_token_ids),
313 + request=_engine_req,
314 + prompt_token_ids=[1],
315 + finished=(i == len(all_token_ids) - 1),
316 + )
317 if delta_message:
318 yield delta_message
319
@@ -231,6 +321,14 @@ def stream_delta_message_generator(
321 pending_text = ""
322 pending_token_ids = []
323
324 + # For the legacy driver the engine holds back the final closing brace
325 + # in _args_buffer until finish_streaming() is called, mirroring how
326 + # the real serving layer flushes at end of stream.
327 + if driver == "legacy":
328 + flush_delta = mistral_tool_parser.finish_streaming()
329 + if flush_delta:
330 + yield flush_delta
331 +
332
333 @pytest.mark.parametrize(
334 "parser_fixture",
@@ -424,8 +522,14 @@ def test_extract_tool_calls_pre_v11_regex_fallback_fails(
522 "single_tool_add",
523 "single_tool_weather",
524 "multiple_tool_calls",
525 + "single_tool_add_args",
526 + "single_tool_weather_args",
527 + "multiple_tool_calls_args",
528 + "content_before_tool_args",
529 "complex",
530 "wrong_json",
531 + "trailing_text_after_json",
532 + "trailing_text_after_args_json",
533 ],
534 argnames=["model_output", "expected_tool_calls", "expected_content"],
535 argvalues=[
@@ -471,6 +575,60 @@ def test_extract_tool_calls_pre_v11_regex_fallback_fails(
575 ],
576 None,
577 ),
578 + (
579 + # v11+ emits an explicit [ARGS] separator between name and args.
580 + """[TOOL_CALLS]add_this_and_that[ARGS]{"a": 3.5, "b": 4}""", # noqa: E501
581 + [
582 + ToolCall(
583 + function=FunctionCall(
584 + name="add_this_and_that",
585 + arguments=json.dumps({"a": 3.5, "b": 4}),
586 + )
587 + )
588 + ],
589 + None,
590 + ),
591 + (
592 + """[TOOL_CALLS]get_current_weather[ARGS]{"city": "San Francisco", "state": "CA", "unit": "celsius"}""", # noqa: E501
593 + [
594 + ToolCall(
595 + function=FunctionCall(
596 + name="get_current_weather",
597 + arguments=json.dumps(
598 + {"city": "San Francisco", "state": "CA", "unit": "celsius"}
599 + ),
600 + )
601 + )
602 + ],
603 + None,
604 + ),
605 + (
606 + """[TOOL_CALLS]add[ARGS]{"a": 3.5, "b": 4}[TOOL_CALLS]multiply[ARGS]{"a": 3, "b": 6}""", # noqa: E501
607 + [
608 + ToolCall(
609 + function=FunctionCall(
610 + name="add", arguments=json.dumps({"a": 3.5, "b": 4})
611 + )
612 + ),
613 + ToolCall(
614 + function=FunctionCall(
615 + name="multiply", arguments=json.dumps({"a": 3, "b": 6})
616 + )
617 + ),
618 + ],
619 + None,
620 + ),
621 + (
622 + """hi[TOOL_CALLS]add[ARGS]{"a": 1, "b": 2}""", # noqa: E501
623 + [
624 + ToolCall(
625 + function=FunctionCall(
626 + name="add", arguments=json.dumps({"a": 1, "b": 2})
627 + )
628 + )
629 + ],
630 + "hi",
631 + ),
632 (
633 # Complex
634 """hi{hi[TOOL_CALLS]bash{"command": "print(\\"hello world!\\")\\nre.compile(r\'{}\')""", # noqa: E501
@@ -501,6 +659,33 @@ def test_extract_tool_calls_pre_v11_regex_fallback_fails(
659 ],
660 "hi{hi",
661 ),
662 + (
663 + # gh#48975: trailing text after the JSON object must not leak into
664 + # arguments (v11+ name{args} format has no terminator).
665 + """[TOOL_CALLS]get_current_weather{"city": "San Francisco"}Estimating the weather...""", # noqa: E501
666 + [
667 + ToolCall(
668 + function=FunctionCall(
669 + name="get_current_weather",
670 + arguments=json.dumps({"city": "San Francisco"}),
671 + )
672 + )
673 + ],
674 + None,
675 + ),
676 + (
677 + # gh#48975 with the explicit [ARGS] separator.
678 + """[TOOL_CALLS]get_current_weather[ARGS]{"city": "San Francisco"}Estimating the weather...""", # noqa: E501
679 + [
680 + ToolCall(
681 + function=FunctionCall(
682 + name="get_current_weather",
683 + arguments=json.dumps({"city": "San Francisco"}),
684 + )
685 + )
686 + ],
687 + None,
688 + ),
689 ],
690 )
691 def test_extract_tool_calls(
@@ -517,17 +702,128 @@ def test_extract_tool_calls(
702
703
704 def test_extract_tool_calls_v11_without_args_skipped(mistral_tool_parser):
705 + # Before Stage 2: the legacy path skipped tool calls with no arg separator,
706 + # returning tools_called=True with an empty list. After Stage 2 the engine
707 + # path is used for v11; it recognises the name and emits a tool call with
708 + # empty arguments ("{}"). The semantic result is equivalent — a valid zero-
709 + # argument call — and is strictly more correct than silently dropping it.
710 model_output = "[TOOL_CALLS]toolname_no_args"
711 result = mistral_tool_parser.extract_tool_calls(
712 model_output, request=_DUMMY_REQUEST
713 )
524 - assert result == ExtractedToolCallInformation(
525 - tools_called=True, tool_calls=[], content=None
714 + assert result.tools_called
715 + assert len(result.tool_calls) == 1
716 + assert result.tool_calls[0].function.name == "toolname_no_args"
717 + assert result.tool_calls[0].function.arguments == "{}"
718 + assert result.content is None
719 +
720 +
721 +def test_extract_tool_calls_malformed_name_before_marker_emits_empty_name(
722 + mistral_tool_parser,
723 +):
724 + model_output = 'bash[TOOL_CALLS]{"command": "cat file.py"}'
725 + result = mistral_tool_parser.extract_tool_calls(
726 + model_output, request=_DUMMY_REQUEST
727 + )
728 + assert result.tools_called
729 + assert len(result.tool_calls) == 1
730 + assert result.tool_calls[0].function.name == ""
731 + assert result.tool_calls[0].function.arguments == json.dumps(
732 + {"command": "cat file.py"}
733 + )
734 + assert result.content == "bash"
735 +
736 +
737 +def test_extract_tool_calls_well_formed_name_unaffected(mistral_tool_parser):
738 + model_output = '[TOOL_CALLS]bash[ARGS]{"command": "ls -la"}'
739 + result = mistral_tool_parser.extract_tool_calls(
740 + model_output, request=_DUMMY_REQUEST
741 + )
742 + assert result.tools_called
743 + assert len(result.tool_calls) == 1
744 + assert result.tool_calls[0].function.name == "bash"
745 + assert result.tool_calls[0].function.arguments == json.dumps({"command": "ls -la"})
746 + assert result.content is None
747 +
748 +
749 +def test_extract_tool_calls_empty_name_unparsable_args_no_crash(
750 + mistral_tool_parser,
751 +):
752 + # Empty name AND args that never form valid JSON: still emitted (raw
753 + # text as arguments) instead of raising.
754 + model_output = "bash[TOOL_CALLS]{not valid json at all"
755 + result = mistral_tool_parser.extract_tool_calls(
756 + model_output, request=_DUMMY_REQUEST
757 + )
758 + assert result.tools_called
759 + assert len(result.tool_calls) == 1
760 + assert result.tool_calls[0].function.name == ""
761 + assert result.tool_calls[0].function.arguments == "{not valid json at all"
762 + assert result.content == "bash"
763 +
764 +
765 +def test_extract_tool_calls_streaming_malformed_name_before_marker(
766 + mistral_tool_parser,
767 +):
768 + # Streaming counterpart: once the args start the slot can never go back to
769 + # naming, so the (empty) name is already final and streams immediately
770 + # alongside the args.
771 + model_output = 'bash[TOOL_CALLS]{"command": "cat file.py"}'
772 + mid_delta = mistral_tool_parser.extract_tool_calls_streaming(
773 + previous_text="",
774 + current_text=model_output,
775 + delta_text=model_output,
776 + previous_token_ids=[],
777 + current_token_ids=[],
778 + delta_token_ids=[],
779 + request=_DUMMY_REQUEST,
780 + )
781 + assert mid_delta is not None
782 + assert mid_delta.content == "bash"
783 + assert len(mid_delta.tool_calls) == 1
784 + assert mid_delta.tool_calls[0].function.name == ""
785 + assert mid_delta.tool_calls[0].function.arguments == '{"command": "cat file.py"'
786 +
787 + final_delta = mistral_tool_parser.finish_streaming()
788 + assert final_delta is not None
789 + assert len(final_delta.tool_calls) == 1
790 + assert final_delta.tool_calls[0].function.name is None
791 + assert final_delta.tool_calls[0].function.arguments == "}"
792 +
793 +
794 +def test_extract_tool_calls_parallel_malformed_then_well_formed(
795 + mistral_tool_parser,
796 +):
797 + # A malformed empty-name call followed by a well-formed one must not
798 + # regress into brace-splitting: nested braces in the second call's
799 + # arguments must stay intact, and both calls must be correctly separated.
800 + model_output = (
801 + 'bash[TOOL_CALLS]{"command": "ls"}'
802 + '[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}'
803 + )
804 + result = mistral_tool_parser.extract_tool_calls(
805 + model_output, request=_DUMMY_REQUEST
806 + )
807 + assert result.tools_called
808 + assert len(result.tool_calls) == 2
809 + assert result.tool_calls[0].function.name == ""
810 + assert result.tool_calls[0].function.arguments == json.dumps({"command": "ls"})
811 + assert result.tool_calls[1].function.name == "submit"
812 + assert result.tool_calls[1].function.arguments == json.dumps(
813 + {"answer": {"nested": 1}}
814 )
815 + assert result.content == "bash"
816
817
818 def _test_extract_tool_calls_streaming(
530 - tool_parser, tokenizer, model_output, tools, expected_tool_calls, expected_content
819 + tool_parser,
820 + tokenizer,
821 + model_output,
822 + tools,
823 + expected_tool_calls,
824 + expected_content,
825 + driver: str = "legacy",
826 + reasoning_parser: str | None = None,
827 ):
828 other_content: str = ""
829 function_names: list[str] = []
@@ -536,7 +832,12 @@ def _test_extract_tool_calls_streaming(
832 tool_call_ids: list[str | None] = []
833
834 for delta_message in stream_delta_message_generator(
539 - tool_parser, tokenizer, model_output, tools
835 + tool_parser,
836 + tokenizer,
837 + model_output,
838 + tools,
839 + driver=driver,
840 + reasoning_parser=reasoning_parser,
841 ):
842 # role should never be streamed from tool parser
843 assert not delta_message.role
@@ -547,12 +848,36 @@ def _test_extract_tool_calls_streaming(
848 streamed_tool_calls = delta_message.tool_calls
849
850 if streamed_tool_calls and len(streamed_tool_calls) > 0:
851 + # On the final finished=True delta the engine's _flush_engine_parsers
852 + # may append the flushed '}' as a separate DeltaToolCall for the
853 + # same index without coalescing. Merge entries sharing an index so
854 + # the "one diff per delta" invariant can be checked after merging.
855 + if len(streamed_tool_calls) > 1:
856 + assert len({tc.index for tc in streamed_tool_calls}) == 1, (
857 + "only within-chunk finish/flush of one tool is allowed; "
858 + "distinct indices in one delta indicate a regression: "
859 + f"{[tc.index for tc in streamed_tool_calls]}"
860 + )
861 + by_index: dict[int, DeltaToolCall] = {}
862 + for tc in streamed_tool_calls:
863 + existing = by_index.get(tc.index)
864 + if existing is None:
865 + by_index[tc.index] = tc
866 + else:
867 + if tc.id and not existing.id:
868 + existing.id = tc.id
869 + if tc.type and not existing.type:
870 + existing.type = tc.type
871 + if tc.function and existing.function and tc.function.arguments:
872 + existing.function.arguments = (
873 + existing.function.arguments or ""
874 + ) + tc.function.arguments
875 + streamed_tool_calls = list(by_index.values())
876 +
877 # make sure only one diff is present - correct even for parallel
878 assert len(streamed_tool_calls) == 1
879 tool_call = streamed_tool_calls[0]
880
554 - assert len(tool_parser.prev_tool_call_arr) > 0
555 -
881 # if a new tool is being called, set up empty arguments
882 if tool_call.index != tool_call_idx:
883 tool_call_idx = tool_call.index
@@ -595,34 +920,8 @@ def _test_extract_tool_calls_streaming(
920 ]
921 assert_tool_calls(actual_tool_calls, expected_tool_calls)
922
598 - if expected_tool_calls:
599 - assert len(tool_parser.streamed_args_for_tool) == len(expected_tool_calls)
600 - assert len(tool_parser.prev_tool_call_arr) == len(expected_tool_calls)
601 - for i in range(len(expected_tool_calls)):
602 - assert (
603 - tool_parser.prev_tool_call_arr[i]["arguments"]
604 - == tool_parser.streamed_args_for_tool[i]
605 - )
606 - assert tool_parser.streamed_args_for_tool[i] == function_args_strs[i]
607 - assert (
608 - tool_parser.prev_tool_call_arr[i]["name"]
609 - == expected_tool_calls[i].function.name
610 - )
611 -
612 - # Simulate the serving layer's unstreamed-args check
613 - index = len(tool_parser.prev_tool_call_arr) - 1
614 - args = tool_parser.prev_tool_call_arr[index].get("arguments", {})
615 - expected_call = (
616 - args if isinstance(args, str) else json.dumps(args, ensure_ascii=False)
617 - )
618 - actual_call = tool_parser.streamed_args_for_tool[index]
619 - remaining_call = expected_call.replace(actual_call, "", 1)
620 - assert remaining_call == ""
621 - else:
622 - assert len(tool_parser.streamed_args_for_tool) == 0
623 - assert len(tool_parser.prev_tool_call_arr) == 0
624 -
923
924 +@pytest.mark.parametrize("driver", ["legacy", "engine"])
925 @pytest.mark.parametrize(
926 ids=[
927 "no_tools",
@@ -748,6 +1047,7 @@ def test_extract_tool_calls_streaming_pre_v11_tokenizer(
1047 model_output,
1048 expected_tool_calls,
1049 expected_content,
1050 + driver,
1051 ):
1052 _test_extract_tool_calls_streaming(
1053 mistral_pre_v11_tool_parser,
@@ -756,9 +1056,15 @@ def test_extract_tool_calls_streaming_pre_v11_tokenizer(
1056 None,
1057 expected_tool_calls,
1058 expected_content,
1059 + driver=driver,
1060 )
1061
1062
1063 +@pytest.mark.parametrize(
1064 + "driver,reasoning_parser",
1065 + [("legacy", None), ("engine", None), ("engine", "mistral")],
1066 + ids=["legacy", "engine_no_reasoning", "engine_with_reasoning"],
1067 +)
1068 @pytest.mark.parametrize(
1069 ids=[
1070 "single_tool_add",
@@ -826,6 +1132,8 @@ def test_extract_tool_calls_streaming(
1132 tools,
1133 expected_tool_calls,
1134 expected_content,
1135 + driver,
1136 + reasoning_parser,
1137 ):
1138 _test_extract_tool_calls_streaming(
1139 mistral_tool_parser,
@@ -834,17 +1142,19 @@ def test_extract_tool_calls_streaming(
1142 tools,
1143 expected_tool_calls,
1144 expected_content,
1145 + driver=driver,
1146 + reasoning_parser=reasoning_parser,
1147 )
1148
1149
1150 +# Drives extract_tool_calls_streaming directly (bespoke detokenization loop),
1151 +# not via stream_delta_message_generator — driver parametrization not needed.
1152 def test_extract_tool_calls_streaming_v11_no_tools(
1153 mistral_tool_parser, mistral_tokenizer
1154 ):
1155 model_output = "This is a test"
844 - if isinstance(mistral_tokenizer, MistralTokenizer):
845 - all_token_ids = mistral_tokenizer.encode(model_output)
846 - else:
847 - all_token_ids = mistral_tokenizer.encode(model_output, add_special_tokens=False)
1156 + # add_special_tokens=False: real serving streams only generated tokens.
1157 + all_token_ids = mistral_tokenizer.encode(model_output, add_special_tokens=False)
1158 skip_special = isinstance(mistral_tokenizer, MistralTokenizer)
1159 collected_content = ""
1160 previous_text = ""
@@ -887,8 +1197,47 @@ def test_extract_tool_calls_streaming_v11_no_tools(
1197 previous_text = current_text
1198
1199 assert collected_content == model_output
890 - assert len(mistral_tool_parser.streamed_args_for_tool) == 0
891 - assert len(mistral_tool_parser.prev_tool_call_arr) == 0
1200 +
1201 +
1202 +def test_mistral_parser_drops_eos_from_output(mistral_tokenizer):
1203 + """EOS token must never surface as content/reasoning; literal EOS string
1204 + must be preserved when the EOS token id is absent.
1205 +
1206 + The engine drops special tokens by id, so:
1207 + Case A: the real EOS token id causes the text to be dropped.
1208 + Case B: the same EOS string with a non-EOS token id is preserved.
1209 + """
1210 + if not isinstance(mistral_tokenizer, MistralTokenizer):
1211 + pytest.skip("Requires MistralTokenizer")
1212 +
1213 + eos_id: int = mistral_tokenizer.eos_token_id
1214 + eos_text: str = mistral_tokenizer.decode([eos_id])
1215 + assert eos_text
1216 +
1217 + # Case A: real EOS token — text must be dropped.
1218 + parser_a = MistralParser(mistral_tokenizer)
1219 + parser_a.initialize_streaming()
1220 + events_a = parser_a._feed(eos_text, [eos_id])
1221 + events_a.extend(parser_a._engine.finish())
1222 + delta_a = parser_a._events_to_delta(events_a, finished=True)
1223 + content_a = (delta_a.content if delta_a else None) or ""
1224 + reasoning_a = (delta_a.reasoning if delta_a else None) or ""
1225 + assert eos_text not in content_a
1226 + assert eos_text not in reasoning_a
1227 +
1228 + # Case B: EOS text with a non-EOS token id — text must be preserved.
1229 + # This proves content equal to the EOS string is not silently dropped
1230 + # when it did not come from the real EOS token.
1231 + non_eos_ids = mistral_tokenizer.encode(text="hello", add_special_tokens=False)
1232 + non_eos_id = next(tid for tid in non_eos_ids if tid != eos_id)
1233 + parser_b = MistralParser(mistral_tokenizer)
1234 + parser_b.initialize_streaming()
1235 + events_b = parser_b._feed(eos_text, [non_eos_id])
1236 + events_b.extend(parser_b._engine.finish())
1237 + delta_b = parser_b._events_to_delta(events_b, finished=True)
1238 + content_b = (delta_b.content if delta_b else None) or ""
1239 + reasoning_b = (delta_b.reasoning if delta_b else None) or ""
1240 + assert eos_text in content_b or eos_text in reasoning_b
1241
1242
1243 @pytest.mark.parametrize(
@@ -961,6 +1310,55 @@ def test_extract_tool_calls_streaming_v11_no_tools(
1310 "bla",
1311 id="v11-content_before_tool",
1312 ),
1313 + pytest.param(
1314 + "mistral_tool_parser",
1315 + "mistral_tokenizer",
1316 + """[TOOL_CALLS]add_this_and_that[ARGS]{"a": 3.5, "b": 4}""", # noqa: E501
1317 + [
1318 + ToolCall(
1319 + function=FunctionCall(
1320 + name="add_this_and_that",
1321 + arguments=json.dumps({"a": 3.5, "b": 4}),
1322 + )
1323 + )
1324 + ],
1325 + "",
1326 + id="v11-single_tool_add_args",
1327 + ),
1328 + pytest.param(
1329 + "mistral_tool_parser",
1330 + "mistral_tokenizer",
1331 + """[TOOL_CALLS]add[ARGS]{"a": 3.5, "b": 4}[TOOL_CALLS]multiply[ARGS]{"a": 3, "b": 6}""", # noqa: E501
1332 + [
1333 + ToolCall(
1334 + function=FunctionCall(
1335 + name="add", arguments=json.dumps({"a": 3.5, "b": 4})
1336 + )
1337 + ),
1338 + ToolCall(
1339 + function=FunctionCall(
1340 + name="multiply", arguments=json.dumps({"a": 3, "b": 6})
1341 + )
1342 + ),
1343 + ],
1344 + "",
1345 + id="v11-multiple_tool_calls_args",
1346 + ),
1347 + pytest.param(
1348 + "mistral_tool_parser",
1349 + "mistral_tokenizer",
1350 + """bla[TOOL_CALLS]add_this_and_that[ARGS]{"a": 3.5, "b": 4}""", # noqa: E501
1351 + [
1352 + ToolCall(
1353 + function=FunctionCall(
1354 + name="add_this_and_that",
1355 + arguments=json.dumps({"a": 3.5, "b": 4}),
1356 + )
1357 + )
1358 + ],
1359 + "bla",
1360 + id="v11-content_before_tool_args",
1361 + ),
1362 pytest.param(
1363 "mistral_tool_parser",
1364 "mistral_tokenizer",
@@ -1116,11 +1514,15 @@ def test_extract_tool_calls_streaming_one_chunk(
1514 tool_parser = request.getfixturevalue(parser_fixture)
1515 tokenizer = request.getfixturevalue(tokenizer_fixture)
1516
1119 - if isinstance(tokenizer, MistralTokenizer):
1120 - all_token_ids = tokenizer.encode(model_output)
1517 + # Real serving streams only generated tokens (no BOS/EOS framing) with the
1518 + # control markers as special ids.
1519 + if isinstance(tokenizer, MistralTokenizer) and tokenizer.version >= 11:
1520 + all_token_ids = encode_mistral_output(tokenizer, model_output)
1521 else:
1522 all_token_ids = tokenizer.encode(model_output, add_special_tokens=False)
1123 - all_token_ids = fix_tool_call_tokenization(all_token_ids, tool_parser, tokenizer)
1523 + all_token_ids = fix_tool_call_tokenization(
1524 + all_token_ids, tool_parser, tokenizer
1525 + )
1526
1527 delta_message = tool_parser.extract_tool_calls_streaming(
1528 previous_text="",
@@ -1131,6 +1533,26 @@ def test_extract_tool_calls_streaming_one_chunk(
1533 delta_token_ids=all_token_ids,
1534 request=_DUMMY_REQUEST,
1535 )
1536 + # The engine buffers the final closing brace ('}') in _args_buffer until
1537 + # finish_streaming() is called, mirroring real serving. Flush it and
1538 + # merge the result so the asserted arguments are complete.
1539 + flush_delta = tool_parser.finish_streaming()
1540 + if flush_delta is not None and flush_delta.tool_calls:
1541 + if delta_message is not None and delta_message.tool_calls:
1542 + for flush_tc in flush_delta.tool_calls:
1543 + for existing_tc in delta_message.tool_calls:
1544 + if existing_tc.index == flush_tc.index and flush_tc.function:
1545 + existing_tc.function = (
1546 + existing_tc.function or DeltaFunctionCall()
1547 + )
1548 + existing_tc.function.arguments = (
1549 + existing_tc.function.arguments or ""
1550 + ) + (flush_tc.function.arguments or "")
1551 + elif delta_message is not None:
1552 + delta_message.tool_calls = flush_delta.tool_calls
1553 + else:
1554 + delta_message = flush_delta
1555 +
1556 assert isinstance(delta_message, DeltaMessage)
1557 assert len(delta_message.tool_calls) == len(expected_tool_calls)
1558
@@ -1145,13 +1567,6 @@ def test_extract_tool_calls_streaming_one_chunk(
1567 @pytest.mark.parametrize(
1568 "parser_fixture, model_output, fake_count, two_phase",
1569 [
1148 - pytest.param(
1149 - "mistral_tool_parser",
1150 - '[TOOL_CALLS]add{"a": 1, "b": 2}',
1151 - 20,
1152 - True,
1153 - id="v11",
1154 - ),
1570 pytest.param(
1571 "mistral_pre_v11_tool_parser",
1572 '[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]',
@@ -1164,7 +1579,11 @@ def test_extract_tool_calls_streaming_one_chunk(
1579 def test_fast_detokenization_text_detection(
1580 parser_fixture, model_output, fake_count, two_phase, request
1581 ):
1167 - """Regression: bot_token in text but not token_ids (PR #37209)."""
1582 + """Regression: bot_token in text but not token_ids (PR #37209).
1583 +
1584 + Only the pre-v11 legacy path detects the marker from text; v11+ routes
1585 + through the engine and detects the marker by token id.
1586 + """
1587 parser = request.getfixturevalue(parser_fixture)
1588 # Token IDs that do NOT contain bot_token_id.
1589 fake_token_ids = list(range(99, 99 + fake_count))
@@ -1210,34 +1629,24 @@ def test_fast_detokenization_text_detection(
1629 assert delta_message.tool_calls[0].function.name == "add"
1630
1631
1213 -@pytest.mark.parametrize(
1214 - "parser_fixture, patched_method, current_text",
1215 - [
1216 - (
1217 - "mistral_tool_parser",
1218 - "_extract_tool_calls_streaming",
1219 - "[TOOL_CALLS]add{}",
1220 - ),
1221 - (
1222 - "mistral_pre_v11_tool_parser",
1223 - "_extract_tool_calls_streaming_pre_v11_tokenizer",
1224 - '[TOOL_CALLS] [{"name":"a","arguments":{}}]',
1225 - ),
1226 - ],
1227 - ids=["v11", "pre_v11"],
1228 -)
1632 def test_extract_tool_calls_streaming_exception_returns_none(
1230 - parser_fixture, patched_method, current_text, request
1633 + mistral_pre_v11_tool_parser,
1634 ):
1232 - parser = request.getfixturevalue(parser_fixture)
1233 - with patch.object(parser, patched_method, side_effect=RuntimeError("boom")):
1635 + # v11 routes through the ParserEngine and does not swallow exceptions;
1636 + # only the pre-v11 legacy state-machine path has explicit exception handling.
1637 + parser = mistral_pre_v11_tool_parser
1638 + patched_method = "_extract_tool_calls_streaming_pre_v11_tokenizer"
1639 + current_text = '[TOOL_CALLS] [{"name":"a","arguments":{}}]'
1640 + with patch.object(
1641 + parser._parser_engine, patched_method, side_effect=RuntimeError("boom")
1642 + ):
1643 result = parser.extract_tool_calls_streaming(
1644 previous_text="",
1645 current_text=current_text,
1646 delta_text=current_text,
1647 previous_token_ids=[],
1239 - current_token_ids=[parser.bot_token_id],
1240 - delta_token_ids=[parser.bot_token_id],
1648 + current_token_ids=[parser._parser_engine.bot_token_id],
1649 + delta_token_ids=[parser._parser_engine.bot_token_id],
1650 request=_DUMMY_REQUEST,
1651 )
1652 assert result is None
@@ -1368,14 +1777,17 @@ def test_adjust_request_unsupported_grammar_for_tokenizer(mistral_tokenizer) ->
1777
1778 @pytest.mark.parametrize(
1779 "tool_choice,expected_skip",
1371 - [("auto", False), ("none", True)],
1372 - ids=["auto_skip_false", "none_skip_true"],
1780 + [("auto", False), ("none", False)],
1781 + ids=["auto_skip_false", "none_skip_false"],
1782 )
1783 def test_adjust_request_non_mistral_tokenizer(
1784 non_mistral_parser: MistralToolParser,
1785 tool_choice: str,
1786 expected_skip: bool,
1787 ) -> None:
1788 + # MistralParser (ParserEngine) always sets skip_special_tokens=False so
1789 + # that special tokens like [TOOL_CALLS] are visible to the parser even
1790 + # when tool_choice="none".
1791 request = _make_request(tool_choice=tool_choice)
1792 result = non_mistral_parser.adjust_request(request)
1793
@@ -1572,22 +1984,23 @@ def test_adjust_request_tool_choice_with_json_schema_factory_routing(
1984 assert len(result.structured_outputs.grammar) > 0
1985
1986
1575 -def test_grammar_from_tool_parser_default_false() -> None:
1987 +def test_grammar_from_parser_default_false() -> None:
1988 request = _make_request()
1577 - assert request._grammar_from_tool_parser is False
1989 + assert request._grammar_from_parser is False
1990
1991
1580 -def test_grammar_from_tool_parser_set_by_adjust_request(
1992 +def test_grammar_from_parser_set_by_adjust_request(
1993 mistral_tool_parser: MistralToolParser,
1994 ) -> None:
1995 request = _make_request()
1996 result = mistral_tool_parser.adjust_request(request)
1585 - assert result._grammar_from_tool_parser is True
1997 + assert result._grammar_from_parser is True
1998
1999
2000 +@pytest.mark.parametrize("driver", ["legacy", "engine"])
2001 @pytest.mark.parametrize("chunk_size", [2, 3, 4, 5])
2002 def test_streaming_pre_v11_parallel_calls_batched_deltas(
1590 - mistral_pre_v11_tool_parser, mistral_pre_v11_tokenizer, chunk_size
2003 + mistral_pre_v11_tool_parser, mistral_pre_v11_tokenizer, chunk_size, driver
2004 ):
2005 """A batched delta spanning the boundary between two parallel calls must
2006 keep them on distinct indices (the bug collapsed both onto index 0)."""
@@ -1605,6 +2018,7 @@ def test_streaming_pre_v11_parallel_calls_batched_deltas(
2018 model_output,
2019 tools=None,
2020 chunk_size=chunk_size,
2021 + driver=driver,
2022 ):
2023 for tool_call in delta_message.tool_calls or []:
2024 if tool_call.index != idx:
@@ -1619,3 +2033,623 @@ def test_streaming_pre_v11_parallel_calls_batched_deltas(
2033 assert len(args) == 2
2034 # trailing args of the final call are flushed by the serving layer
2035 assert json.loads(args[0]) == {"a": 3.5, "b": 4}
2036 +
2037 +
2038 +@pytest.mark.parametrize(
2039 + "reasoning_encoding",
2040 + ["text", "special_token"],
2041 + ids=["text_encoding", "special_token_encoding"],
2042 +)
2043 +def test_content_tool_calls_transition_emits_reasoning_end(
2044 + mistral_tokenizer,
2045 + reasoning_encoding,
2046 +):
2047 + """(CONTENT, TOOL_CALLS) must emit REASONING_END when reasoning is enabled."""
2048 +
2049 + cfg = mistral_config(reasoning_encoding=reasoning_encoding)
2050 + engine = StreamingParserEngine(
2051 + config=cfg, tokenizer=mistral_tokenizer, vocab=mistral_tokenizer.get_vocab()
2052 + )
2053 + engine.skip_tool_parsing = True
2054 +
2055 + bot_token_id = mistral_tokenizer.get_vocab().get("[TOOL_CALLS]")
2056 + assert bot_token_id is not None
2057 +
2058 + events = engine.feed(delta_text="[TOOL_CALLS]", delta_token_ids=[bot_token_id])
2059 + event_types = [e.type for e in events]
2060 +
2061 + assert EventType.REASONING_END in event_types, (
2062 + f"REASONING_END missing for reasoning_encoding={reasoning_encoding!r}; "
2063 + f"got events: {event_types}"
2064 + )
2065 + # Tool call start must NOT appear in skip_tool_parsing mode
2066 + assert EventType.TOOL_CALL_START not in event_types
2067 +
2068 +
2069 +@pytest.mark.parametrize("chunk_size", [1, 3])
2070 +@pytest.mark.parametrize(
2071 + "tools,expected_tool_calls,expected_content",
2072 + [
2073 + (
2074 + [("add", '{"a": 3, "b": 4}')],
2075 + [
2076 + ToolCall(
2077 + function=FunctionCall(
2078 + name="add", arguments=json.dumps({"a": 3, "b": 4})
2079 + )
2080 + )
2081 + ],
2082 + "",
2083 + ),
2084 + (
2085 + [
2086 + ("add", '{"a": 3.5, "b": 4}'),
2087 + (
2088 + "get_current_weather",
2089 + '{"city": "San Francisco", "state": "CA", "unit": "celsius"}',
2090 + ),
2091 + ],
2092 + [
2093 + ToolCall(
2094 + function=FunctionCall(
2095 + name="add", arguments=json.dumps({"a": 3.5, "b": 4})
2096 + )
2097 + ),
2098 + ToolCall(
2099 + function=FunctionCall(
2100 + name="get_current_weather",
2101 + arguments=json.dumps(
2102 + {
2103 + "city": "San Francisco",
2104 + "state": "CA",
2105 + "unit": "celsius",
2106 + }
2107 + ),
2108 + )
2109 + ),
2110 + ],
2111 + "",
2112 + ),
2113 + ],
2114 + ids=["single_tool_add", "parallel_tools"],
2115 +)
2116 +def test_reasoning_active_no_think_block_no_leak(
2117 + mistral_tool_parser,
2118 + mistral_tokenizer,
2119 + tools,
2120 + expected_tool_calls,
2121 + expected_content,
2122 + chunk_size,
2123 +):
2124 + """Tool call without a preceding think block must not leak as content
2125 + when the reasoning parser is active."""
2126 + accumulated_content = ""
2127 + function_names: list[str] = []
2128 + function_args_strs: list[str] = []
2129 + tool_call_idx = -1
2130 + tool_call_ids: list[str | None] = []
2131 +
2132 + for delta_message in stream_delta_message_generator(
2133 + mistral_tool_parser,
2134 + mistral_tokenizer,
2135 + model_output=None,
2136 + tools=tools,
2137 + chunk_size=chunk_size,
2138 + driver="engine",
2139 + reasoning_parser="mistral",
2140 + ):
2141 + if delta_message.content:
2142 + accumulated_content += delta_message.content
2143 +
2144 + for tool_call in delta_message.tool_calls or []:
2145 + if tool_call.index != tool_call_idx:
2146 + tool_call_idx = tool_call.index
2147 + function_args_strs.append("")
2148 + tool_call_ids.append(None)
2149 + if tool_call.id and not tool_call_ids[tool_call.index]:
2150 + tool_call_ids[tool_call.index] = tool_call.id
2151 + if tool_call.function:
2152 + if tool_call.function.name:
2153 + function_names.append(tool_call.function.name)
2154 + if tool_call.function.arguments:
2155 + function_args_strs[tool_call.index] += tool_call.function.arguments
2156 +
2157 + assert "[TOOL_CALLS]" not in accumulated_content
2158 + assert "[ARGS]" not in accumulated_content
2159 + assert accumulated_content == expected_content
2160 +
2161 + actual_tool_calls = [
2162 + ToolCall(
2163 + id=tc_id,
2164 + function=FunctionCall(
2165 + name=name,
2166 + arguments=partial_json_parser.ensure_json(args, Allow.OBJ | Allow.STR),
2167 + ),
2168 + )
2169 + for tc_id, name, args in zip(tool_call_ids, function_names, function_args_strs)
2170 + ]
2171 + assert_tool_calls(actual_tool_calls, expected_tool_calls)
2172 +
2173 +
2174 +# ---------------------------------------------------------------------------
2175 +# Pre-v11 guided schema injection and bare-array extraction tests
2176 +# ---------------------------------------------------------------------------
2177 +
2178 +
2179 +@pytest.mark.parametrize(
2180 + "tool_choice,expected_items_count",
2181 + [
2182 + ("required", 2), # all tools
2183 + (
2184 + {"type": "function", "function": {"name": "get_weather"}},
2185 + 1,
2186 + ), # named: single tool
2187 + ],
2188 + ids=["required", "named"],
2189 +)
2190 +def test_adjust_request_pre_v11_guided_schema_injected(
2191 + mistral_pre_v11_tool_parser: MistralToolParser,
2192 + tool_choice: object,
2193 + expected_items_count: int,
2194 +) -> None:
2195 + request = _make_request(tool_choice=tool_choice)
2196 + result = mistral_pre_v11_tool_parser.adjust_request(request)
2197 +
2198 + assert result.structured_outputs is not None
2199 + schema = result.structured_outputs.json
2200 + if isinstance(schema, str):
2201 + schema = json.loads(schema)
2202 + assert schema["type"] == "array"
2203 + assert schema["minItems"] == 1
2204 + items = schema["items"]
2205 + assert "anyOf" in items
2206 + assert len(items["anyOf"]) == expected_items_count
2207 + for entry in items["anyOf"]:
2208 + props = entry["properties"]
2209 + assert "name" in props
2210 + assert "arguments" in props
2211 +
2212 +
2213 +@pytest.mark.parametrize(
2214 + "response_format",
2215 + [{"type": "json_object"}, {"type": "json_schema", "json_schema": {"name": "s"}}],
2216 + ids=["json_object", "json_schema"],
2217 +)
2218 +def test_adjust_request_pre_v11_required_clears_response_format(
2219 + mistral_pre_v11_tool_parser: MistralToolParser,
2220 + response_format: dict,
2221 +) -> None:
2222 + """required + response_format must inject the tool schema and clear
2223 + response_format so the tool schema is the sole structured-output
2224 + constraint, matching the base ToolParser (otherwise the request either
2225 + hits the "multiple constraints" engine error or, with no constraint,
2226 + rambles to finish_reason='length')."""
2227 + request = _make_request(tool_choice="required", response_format=response_format)
2228 + result = mistral_pre_v11_tool_parser.adjust_request(request)
2229 +
2230 + assert result.response_format is None
2231 + assert result.structured_outputs is not None
2232 + schema = result.structured_outputs.json
2233 + if isinstance(schema, str):
2234 + schema = json.loads(schema)
2235 + assert schema["type"] == "array"
2236 +
2237 +
2238 +def test_adjust_request_non_mistral_tokenizer_required_injects_schema(
2239 + non_mistral_parser: MistralToolParser,
2240 +) -> None:
2241 + """The guided-schema injection must also fire for non-Mistral (e.g. HF-mode)
2242 + tokenizers driving the Mistral tool parser, mirroring the base ToolParser so
2243 + required + response_format does not fall back to an unconstrained ramble."""
2244 + request = _make_request(
2245 + tool_choice="required", response_format={"type": "json_object"}
2246 + )
2247 + result = non_mistral_parser.adjust_request(request)
2248 +
2249 + assert result.response_format is None
2250 + assert result.structured_outputs is not None
2251 + assert result.structured_outputs.json is not None
2252 +
2253 +
2254 +@pytest.mark.parametrize(
2255 + "tool_choice",
2256 + ["auto", "none"],
2257 + ids=["auto", "none"],
2258 +)
2259 +def test_adjust_request_pre_v11_no_injection_for_auto_none(
2260 + mistral_pre_v11_tool_parser: MistralToolParser,
2261 + tool_choice: str,
2262 +) -> None:
2263 + request = _make_request(tool_choice=tool_choice)
2264 + result = mistral_pre_v11_tool_parser.adjust_request(request)
2265 +
2266 + assert result.structured_outputs is None
2267 +
2268 +
2269 +def test_legacy_extract_tool_calls_guided_bare_array_required(
2270 + mistral_pre_v11_tool_parser: MistralToolParser,
2271 +) -> None:
2272 + model_output = '[{"name": "get_current_weather", "arguments": {"city": "Dallas"}}]'
2273 + request = _make_request(tool_choice="required")
2274 +
2275 + result = mistral_pre_v11_tool_parser.extract_tool_calls(
2276 + model_output, request=request
2277 + )
2278 +
2279 + assert result.tools_called
2280 + assert len(result.tool_calls) == 1
2281 + tc = result.tool_calls[0]
2282 + assert tc.function.name == "get_current_weather"
2283 + assert json.loads(tc.function.arguments) == {"city": "Dallas"}
2284 + assert isinstance(tc.id, str)
2285 + assert len(tc.id) == 9
2286 + assert result.content is None
2287 +
2288 +
2289 +def test_legacy_extract_tool_calls_none_with_tools(
2290 + mistral_pre_v11_tool_parser: MistralToolParser,
2291 +) -> None:
2292 + model_output = (
2293 + '[TOOL_CALLS] [{"name": "get_current_weather",'
2294 + ' "arguments": {"city": "Dallas"}}]'
2295 + )
2296 + request = _make_request(tool_choice="none")
2297 +
2298 + result = mistral_pre_v11_tool_parser.extract_tool_calls(
2299 + model_output, request=request
2300 + )
2301 +
2302 + assert not result.tools_called
2303 + assert result.tool_calls == []
2304 + assert result.content == model_output
2305 +
2306 +
2307 +def test_guided_streaming_required_pre_v11(
2308 + mistral_pre_v11_tool_parser: MistralToolParser,
2309 + mistral_pre_v11_tokenizer,
2310 +) -> None:
2311 + model_output = '[{"name": "get_current_weather", "arguments": {"city": "Dallas"}}]'
2312 + request = _make_request(tool_choice="required")
2313 + all_token_ids = mistral_pre_v11_tokenizer.encode(
2314 + model_output, add_special_tokens=False
2315 + )
2316 +
2317 + function_name: str | None = None
2318 + function_args = ""
2319 + tool_call_id: str | None = None
2320 + previous_text = ""
2321 + previous_tokens = None
2322 + prefix_offset = 0
2323 + read_offset = 0
2324 +
2325 + for i, token_id in enumerate(all_token_ids):
2326 + (new_tokens, delta_text, prefix_offset, read_offset) = detokenize_incrementally(
2327 + tokenizer=mistral_pre_v11_tokenizer,
2328 + all_input_ids=all_token_ids[: i + 1],
2329 + prev_tokens=previous_tokens,
2330 + prefix_offset=prefix_offset,
2331 + read_offset=read_offset,
2332 + skip_special_tokens=False,
2333 + spaces_between_special_tokens=True,
2334 + )
2335 + previous_tokens = (
2336 + (previous_tokens + new_tokens) if previous_tokens else new_tokens
2337 + )
2338 + current_text = previous_text + delta_text
2339 +
2340 + delta_message = mistral_pre_v11_tool_parser.extract_tool_calls_streaming(
2341 + previous_text=previous_text,
2342 + current_text=current_text,
2343 + delta_text=delta_text,
2344 + previous_token_ids=all_token_ids[:i],
2345 + current_token_ids=all_token_ids[: i + 1],
2346 + delta_token_ids=[token_id],
2347 + request=request,
2348 + )
2349 + previous_text = current_text
2350 +
2351 + if delta_message and delta_message.tool_calls:
2352 + for tc in delta_message.tool_calls:
2353 + if tc.id and not tool_call_id:
2354 + tool_call_id = tc.id
2355 + if tc.function:
2356 + if tc.function.name:
2357 + function_name = tc.function.name
2358 + if tc.function.arguments:
2359 + function_args += tc.function.arguments
2360 +
2361 + assert function_name == "get_current_weather"
2362 + assert tool_call_id is not None
2363 + assert len(tool_call_id) == 9
2364 + assert json.loads(function_args) == {"city": "Dallas"}
2365 +
2366 +
2367 +# ---------------------------------------------------------------------------
2368 +# Malformed-name-before-marker matrix: {malformed, well-formed, mixed
2369 +# parallel} x {streaming, non-streaming}.
2370 +#
2371 +# These tests drive a real MistralParser built from the module's
2372 +# `mistral_tokenizer` fixture (grammar-capable v11+ tokenizer, so the
2373 +# declarative ParserEngine path is exercised, not the pre-v11 legacy state
2374 +# machine) with actual bash/search_replace/submit tools, mirroring the tools
2375 +# present in the real captured traces this bug was found in.
2376 +# ---------------------------------------------------------------------------
2377 +
2378 +_BASH_SEARCH_SUBMIT_TOOLS_DICTS = [
2379 + {
2380 + "type": "function",
2381 + "function": {
2382 + "name": "bash",
2383 + "description": "Run a bash command",
2384 + "parameters": {
2385 + "type": "object",
2386 + "properties": {"command": {"type": "string"}},
2387 + "required": ["command"],
2388 + },
2389 + },
2390 + },
2391 + {
2392 + "type": "function",
2393 + "function": {
2394 + "name": "search_replace",
2395 + "description": "Search and replace text in a file",
2396 + "parameters": {
2397 + "type": "object",
2398 + "properties": {
2399 + "path": {"type": "string"},
2400 + "search": {"type": "string"},
2401 + "replace": {"type": "string"},
2402 + },
2403 + "required": ["path", "search", "replace"],
2404 + },
2405 + },
2406 + },
2407 + {
2408 + "type": "function",
2409 + "function": {
2410 + "name": "submit",
2411 + "description": "Submit the final answer",
2412 + "parameters": {
2413 + "type": "object",
2414 + "properties": {"answer": {"type": "object"}},
2415 + "required": ["answer"],
2416 + },
2417 + },
2418 + },
2419 +]
2420 +
2421 +
2422 +def _malformed_matrix_request() -> ChatCompletionRequest:
2423 + return ChatCompletionRequest(
2424 + messages=[],
2425 + model="test",
2426 + tools=_BASH_SEARCH_SUBMIT_TOOLS_DICTS,
2427 + tool_choice="auto",
2428 + )
2429 +
2430 +
2431 +def _stream_via_parse_delta(
2432 + parser: MistralParser,
2433 + tokenizer: MistralTokenizer,
2434 + model_output: str,
2435 + request: ChatCompletionRequest,
2436 +) -> tuple[str, list[str | None], list[str]]:
2437 + """Feed `model_output` through `parser.parse_delta` token-by-token.
2438 +
2439 + This is the exact API production serving drives on every generated
2440 + token (see `ParserEngine.parse_delta`), unlike
2441 + `extract_tool_calls_streaming` which some legacy call sites drive with
2442 + pre-computed previous/current text windows.
2443 + """
2444 + parser.initialize_streaming()
2445 + all_token_ids = encode_mistral_output(tokenizer, model_output)
2446 +
2447 + content = ""
2448 + tool_call_idx = -1
2449 + names: list[str | None] = []
2450 + args: list[str] = []
2451 +
2452 + previous_tokens = None
2453 + prefix_offset = 0
2454 + read_offset = 0
2455 + for i, token_id in enumerate(all_token_ids):
2456 + new_tokens, delta_text, prefix_offset, read_offset = detokenize_incrementally(
2457 + tokenizer=tokenizer,
2458 + all_input_ids=all_token_ids[: i + 1],
2459 + prev_tokens=previous_tokens,
2460 + prefix_offset=prefix_offset,
2461 + read_offset=read_offset,
2462 + skip_special_tokens=False,
2463 + spaces_between_special_tokens=True,
2464 + )
2465 + previous_tokens = (
2466 + previous_tokens + new_tokens if previous_tokens else new_tokens
2467 + )
2468 + finished = i == len(all_token_ids) - 1
2469 +
2470 + delta_message = parser.parse_delta(
2471 + delta_text=delta_text,
2472 + delta_token_ids=[token_id],
2473 + request=request,
2474 + prompt_token_ids=[1],
2475 + finished=finished,
2476 + )
2477 + if delta_message is None:
2478 + continue
2479 + if delta_message.content:
2480 + content += delta_message.content
2481 + for tool_call in delta_message.tool_calls or []:
2482 + if tool_call.index != tool_call_idx:
2483 + tool_call_idx = tool_call.index
2484 + names.append(None)
2485 + args.append("")
2486 + if tool_call.function:
2487 + if tool_call.function.name is not None:
2488 + names[tool_call.index] = tool_call.function.name
2489 + if tool_call.function.arguments:
2490 + args[tool_call.index] += tool_call.function.arguments
2491 +
2492 + return content, names, args
2493 +
2494 +
2495 +_MALFORMED_MATRIX_CASES = [
2496 + pytest.param(
2497 + # 2x2 corner: name present, `[ARGS]` token present.
2498 + '[TOOL_CALLS]bash[ARGS]{"command": "ls -la"}',
2499 + ["bash"],
2500 + [json.dumps({"command": "ls -la"})],
2501 + None,
2502 + id="wellformed_single",
2503 + ),
2504 + pytest.param(
2505 + # 2x2 corner: name present, no `[ARGS]` token.
2506 + '[TOOL_CALLS]bash{"command": "ls -la"}',
2507 + ["bash"],
2508 + [json.dumps({"command": "ls -la"})],
2509 + None,
2510 + id="wellformed_single_no_args_token",
2511 + ),
2512 + pytest.param(
2513 + # 2x2 corner: name skipped, no `[ARGS]` token. "bash" lands in
2514 + # content before the marker, then generation jumps straight to
2515 + # args (no NAME slot, no [ARGS]).
2516 + 'bash[TOOL_CALLS]{"command": "cat file.py"}',
2517 + [""],
2518 + [json.dumps({"command": "cat file.py"})],
2519 + "bash",
2520 + id="malformed_single",
2521 + ),
2522 + pytest.param(
2523 + # 2x2 corner: name skipped, `[ARGS]` token present.
2524 + 'bash[TOOL_CALLS][ARGS]{"command": "cat file.py"}',
2525 + [""],
2526 + [json.dumps({"command": "cat file.py"})],
2527 + "bash",
2528 + id="malformed_single_args_token",
2529 + ),
2530 + pytest.param(
2531 + # parallel well-formed x2; the second call's nested braces in its
2532 + # arguments must not confuse call splitting.
2533 + '[TOOL_CALLS]bash[ARGS]{"command": "ls"}'
2534 + '[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}',
2535 + ["bash", "submit"],
2536 + [json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
2537 + None,
2538 + id="parallel_wellformed",
2539 + ),
2540 + pytest.param(
2541 + # parallel malformed x2: both calls skip the NAME slot entirely
2542 + # (marker immediately followed by `{`), each ending with an empty
2543 + # name and correctly separated (nested-brace) arguments.
2544 + 'bash[TOOL_CALLS]{"command": "ls"}[TOOL_CALLS]{"answer": {"nested": 1}}',
2545 + ["", ""],
2546 + [json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
2547 + "bash",
2548 + id="parallel_malformed",
2549 + ),
2550 + pytest.param(
2551 + # parallel malformed x2, `[ARGS]` token variant: both calls skip
2552 + # the NAME slot but enter TOOL_ARGS via `[ARGS]` instead of `{`.
2553 + 'bash[TOOL_CALLS][ARGS]{"command": "ls"}'
2554 + '[TOOL_CALLS][ARGS]{"answer": {"nested": 1}}',
2555 + ["", ""],
2556 + [json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
2557 + "bash",
2558 + id="parallel_malformed_args_token",
2559 + ),
2560 + pytest.param(
2561 + # mixed: malformed first call, well-formed second call. The
2562 + # important case: proves calls are split on the `[TOOL_CALLS]`
2563 + # marker, not on braces — the first call's simple args and the
2564 + # second call's nested-brace args must not bleed into each other.
2565 + 'bash[TOOL_CALLS]{"command": "ls"}'
2566 + '[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}',
2567 + ["", "submit"],
2568 + [json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
2569 + "bash",
2570 + id="mixed_malformed_then_wellformed",
2571 + ),
2572 + pytest.param(
2573 + # mixed, `[ARGS]` token variant: malformed first call enters
2574 + # TOOL_ARGS via `[ARGS]` instead of `{`, second call well-formed.
2575 + 'bash[TOOL_CALLS][ARGS]{"command": "ls"}'
2576 + '[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}',
2577 + ["", "submit"],
2578 + [json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
2579 + "bash",
2580 + id="mixed_malformed_args_token_then_wellformed",
2581 + ),
2582 + pytest.param(
2583 + # mixed: well-formed first call, malformed second call (NAME slot
2584 + # skipped entirely for the second call).
2585 + '[TOOL_CALLS]bash[ARGS]{"command": "ls"}[TOOL_CALLS]{"answer": {"nested": 1}}',
2586 + ["bash", ""],
2587 + [json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
2588 + None,
2589 + id="mixed_wellformed_then_malformed",
2590 + ),
2591 +]
2592 +
2593 +
2594 +@pytest.mark.parametrize(
2595 + "model_output,expected_names,expected_args,expected_content",
2596 + _MALFORMED_MATRIX_CASES,
2597 +)
2598 +def test_malformed_tool_call_matrix_non_streaming(
2599 + mistral_tokenizer,
2600 + model_output,
2601 + expected_names,
2602 + expected_args,
2603 + expected_content,
2604 +):
2605 + parser = MistralParser(mistral_tokenizer)
2606 + request = _malformed_matrix_request()
2607 +
2608 + result = parser.extract_tool_calls_from_content(model_output, request)
2609 +
2610 + assert result.tools_called
2611 + assert [tc.function.name for tc in result.tool_calls] == expected_names
2612 + assert [tc.function.arguments for tc in result.tool_calls] == expected_args
2613 + assert result.content == expected_content
2614 +
2615 +
2616 +@pytest.mark.parametrize(
2617 + "model_output,expected_names,expected_args,expected_content",
2618 + _MALFORMED_MATRIX_CASES,
2619 +)
2620 +def test_malformed_tool_call_matrix_streaming(
2621 + mistral_tokenizer,
2622 + model_output,
2623 + expected_names,
2624 + expected_args,
2625 + expected_content,
2626 +):
2627 + parser = MistralParser(mistral_tokenizer)
2628 + request = _malformed_matrix_request()
2629 +
2630 + content, names, args = _stream_via_parse_delta(
2631 + parser, mistral_tokenizer, model_output, request
2632 + )
2633 +
2634 + assert content == (expected_content or "")
2635 + assert names == expected_names
2636 + assert args == expected_args
2637 +
2638 +
2639 +# Real payload captured from a production SWE-bench-agent.
2640 +_REAL_LEAK_CONTENT = 'bash[TOOL_CALLS]{"command": "cd /testbed && git diff xarray/core/weighted.py"}ometracediff --git a/xarray/core/weighted.py b/xarray/core/weighted.py\\nindex 5f2d138e..9c38b0b7 100644\\n--- a/xarray/core/weighted.py\\n+++ b/xarray/core/weighted.py\\n@@ -1,4 +1,6 @@\\n from typing import TYPE_CHECKING, Hashable, Iterable, Optional, Union, overload\\n+\\n+import numpy as np\\n \\n from .computation import dot\\n from .options import _get_keep_attrs\\n@@ -105,6 +107,10 @@ class Weighted:\\n )\\n \\n self.obj = obj\\n- self.weights = weights\\n+ # Ensure weights are numeric (float64) to avoid boolean arithmetic issues\\n+ # in dot products and weighted operations\\n+ if not np.issubdtype(weights.dtype, np.number):\\n+ weights = weights.astype(np.float64)\\n+ self.weights = weights\\n "}现在 hareview the changes we made:bash[ARGS]{"command": "cat /testbed/xarray/core/weighted.py | head -120 | tail -20"}' # noqa: E501
2641 +
2642 +
2643 +def test_extract_tool_calls_real_captured_malformed_output(mistral_tokenizer):
2644 + parser = MistralParser(mistral_tokenizer)
2645 + request = _malformed_matrix_request()
2646 +
2647 + result = parser.extract_tool_calls_from_content(_REAL_LEAK_CONTENT, request)
2648 +
2649 + assert result.tools_called
2650 + assert len(result.tool_calls) == 1
2651 + assert result.tool_calls[0].function.name == ""
2652 + assert result.tool_calls[0].function.arguments == json.dumps(
2653 + {"command": "cd /testbed && git diff xarray/core/weighted.py"}
2654 + )
2655 + assert result.content == "bash"
tests/reasoning/test_mistral_reasoning_parser.py deleted
-348
@@ -1,348 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -import pytest
5 -
6 -from tests.reasoning.utils import run_reasoning_extraction_mistral
7 -from vllm.reasoning import ReasoningParser, ReasoningParserManager
8 -from vllm.tokenizers.mistral import MistralTokenizer
9 -
10 -parser_name = "mistral"
11 -
12 -
13 -@pytest.fixture(scope="module")
14 -def mistral_tokenizer():
15 - mistral_tokenizer = MistralTokenizer.from_pretrained(
16 - "mistralai/Magistral-Small-2509"
17 - )
18 - return mistral_tokenizer
19 -
20 -
21 -INVALID_SIMPLE_REASONING = {
22 - "output": "This is a reasoning section[/THINK]This is the rest",
23 - "reasoning": None,
24 - "content": "This is a reasoning sectionThis is the rest",
25 - "is_reasoning_end": False,
26 -}
27 -INVALID_COMPLETE_REASONING = {
28 - "output": "This is a reasoning section[/THINK]",
29 - "reasoning": None,
30 - "content": "This is a reasoning section",
31 - "is_reasoning_end": False,
32 -}
33 -NO_CONTENT = {
34 - "output": "[THINK]This is reasoning",
35 - "reasoning": "This is reasoning",
36 - "content": None,
37 - "is_reasoning_end": False,
38 -}
39 -NO_REASONING = {
40 - "output": "This is content",
41 - "reasoning": None,
42 - "content": "This is content",
43 - "is_reasoning_end": False,
44 -}
45 -NO_REASONING_STREAMING = {
46 - "output": "This is a reasoning section",
47 - "reasoning": None,
48 - "content": "This is a reasoning section",
49 - "is_reasoning_end": False,
50 -}
51 -INVALID_MULTIPLE_LINES = {
52 - "output": "This\nThat[/THINK]This is the rest\nThat",
53 - "reasoning": None,
54 - "content": "This\nThatThis is the rest\nThat",
55 - "is_reasoning_end": False,
56 -}
57 -INVALID_SHORTEST_REASONING_NO_STREAMING = {
58 - "output": "[/THINK]This is the rest",
59 - "reasoning": None,
60 - "content": "This is the rest",
61 - "is_reasoning_end": False,
62 -}
63 -INVALID_SHORTEST_REASONING = {
64 - "output": "[/THINK]This is the rest",
65 - "reasoning": None,
66 - "content": "This is the rest",
67 - "is_reasoning_end": False,
68 -}
69 -REASONING_WITH_THINK = {
70 - "output": "[THINK]This is a reasoning section[/THINK]This is the rest",
71 - "reasoning": "This is a reasoning section",
72 - "content": "This is the rest",
73 - "is_reasoning_end": True,
74 -}
75 -COMPLETE_REASONING_WITH_THINK = {
76 - "output": "[THINK]This is a reasoning section[/THINK]",
77 - "reasoning": "This is a reasoning section",
78 - "content": None,
79 - "is_reasoning_end": True,
80 -}
81 -MULTIPLE_LINES_WITH_THINK = {
82 - "output": "[THINK]This\nThat[/THINK]This is the rest\nThat",
83 - "reasoning": "This\nThat",
84 - "content": "This is the rest\nThat",
85 - "is_reasoning_end": True,
86 -}
87 -INVALID_SHORTEST_REASONING_NO_STREAMING_WITH_THINK = {
88 - "output": "[/THINK]This is the rest",
89 - "reasoning": None,
90 - "content": "This is the rest",
91 - "is_reasoning_end": False,
92 -}
93 -INVALID_SHORTEST_REASONING_WITH_THINK = {
94 - "output": "[/THINK]This is the rest",
95 - "reasoning": None,
96 - "content": "This is the rest",
97 - "is_reasoning_end": False,
98 -}
99 -THINK_NO_END = {
100 - "output": "[THINK]This is a reasoning section",
101 - "reasoning": "This is a reasoning section",
102 - "content": None,
103 - "is_reasoning_end": False,
104 -}
105 -EMPTY = {
106 - "output": "",
107 - "reasoning": None,
108 - "content": "",
109 - "is_reasoning_end": False,
110 -}
111 -EMPTY_STREAMING = {
112 - "output": "",
113 - "reasoning": None,
114 - "content": None,
115 - "is_reasoning_end": False,
116 -}
117 -NEW_LINE = {
118 - "output": "Before\n[THINK]This is a reasoning section[/THINK]\nThis is the rest",
119 - "reasoning": "This is a reasoning section",
120 - "content": "Before\n\nThis is the rest",
121 - "is_reasoning_end": True,
122 -}
123 -NEW_LINE_STREAMING = {
124 - "output": "Before\n[THINK]This is a reasoning section[/THINK]\nThis is the rest",
125 - "reasoning": "This is a reasoning section",
126 - "content": "Before\n\nThis is the rest",
127 - "is_reasoning_end": True,
128 -}
129 -
130 -TEST_CASES = [
131 - pytest.param(
132 - False,
133 - INVALID_SIMPLE_REASONING,
134 - id="invalid_simple_reasoning",
135 - ),
136 - pytest.param(
137 - True,
138 - INVALID_SIMPLE_REASONING,
139 - id="invalid_simple_reasoning_streaming",
140 - ),
141 - pytest.param(
142 - False,
143 - INVALID_COMPLETE_REASONING,
144 - id="invalid_complete_reasoning",
145 - ),
146 - pytest.param(
147 - True,
148 - INVALID_COMPLETE_REASONING,
149 - id="invalid_complete_reasoning_streaming",
150 - ),
151 - pytest.param(
152 - False,
153 - NO_CONTENT,
154 - id="no_content",
155 - ),
156 - pytest.param(
157 - False,
158 - NO_REASONING,
159 - id="no_reasoning",
160 - ),
161 - pytest.param(
162 - True,
163 - NO_REASONING_STREAMING,
164 - id="no_reasoning_token_streaming",
165 - ),
166 - pytest.param(
167 - False,
168 - INVALID_MULTIPLE_LINES,
169 - id="invalid_multiple_lines",
170 - ),
171 - pytest.param(
172 - True,
173 - INVALID_MULTIPLE_LINES,
174 - id="invalid_multiple_lines_streaming",
175 - ),
176 - pytest.param(
177 - True,
178 - INVALID_SHORTEST_REASONING,
179 - id="invalid_shortest",
180 - ),
181 - pytest.param(
182 - False,
183 - INVALID_SHORTEST_REASONING_NO_STREAMING,
184 - id="invalid_shortest_streaming",
185 - ),
186 - pytest.param(
187 - False,
188 - REASONING_WITH_THINK,
189 - id="reasoning_with_think",
190 - ),
191 - pytest.param(
192 - True,
193 - REASONING_WITH_THINK,
194 - id="reasoning_with_think_streaming",
195 - ),
196 - pytest.param(
197 - False,
198 - COMPLETE_REASONING_WITH_THINK,
199 - id="complete_reasoning_with_think",
200 - ),
201 - pytest.param(
202 - True,
203 - COMPLETE_REASONING_WITH_THINK,
204 - id="complete_reasoning_with_think_streaming",
205 - ),
206 - pytest.param(
207 - False,
208 - MULTIPLE_LINES_WITH_THINK,
209 - id="multiple_lines_with_think",
210 - ),
211 - pytest.param(
212 - True,
213 - MULTIPLE_LINES_WITH_THINK,
214 - id="multiple_lines_with_think_streaming",
215 - ),
216 - pytest.param(
217 - False,
218 - INVALID_SHORTEST_REASONING_NO_STREAMING_WITH_THINK,
219 - id="invalid_shortest_with_think",
220 - ),
221 - pytest.param(
222 - True,
223 - INVALID_SHORTEST_REASONING_WITH_THINK,
224 - id="invalid_shortest_with_think_streaming",
225 - ),
226 - pytest.param(
227 - False,
228 - THINK_NO_END,
229 - id="think_no_end",
230 - ),
231 - pytest.param(
232 - True,
233 - THINK_NO_END,
234 - id="think_no_end_streaming",
235 - ),
236 - pytest.param(
237 - False,
238 - EMPTY,
239 - id="empty",
240 - ),
241 - pytest.param(
242 - True,
243 - EMPTY_STREAMING,
244 - id="empty_streaming",
245 - ),
246 - pytest.param(
247 - False,
248 - NEW_LINE,
249 - id="new_line",
250 - ),
251 - pytest.param(
252 - True,
253 - NEW_LINE_STREAMING,
254 - id="new_line_streaming",
255 - ),
256 -]
257 -
258 -
259 -@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
260 -def test_mistral_reasoning(
261 - streaming: bool,
262 - param_dict: dict,
263 - mistral_tokenizer: MistralTokenizer,
264 -):
265 - output = param_dict["output"]
266 -
267 - index_think = output.find("[THINK]")
268 - len_think = len("[THINK]")
269 - index_end_think = output.find("[/THINK]")
270 - len_end_think = len("[/THINK]")
271 -
272 - # encode everything to tokens ids
273 - output_tokens = []
274 - if index_think != -1:
275 - output_before_think = output[:index_think]
276 - output_tokens += mistral_tokenizer.tokenizer.encode(
277 - output_before_think, False, False
278 - )
279 - output_tokens += [mistral_tokenizer.instruct.BEGIN_THINK]
280 -
281 - if index_end_think != -1:
282 - output_middle = output[index_think + len_think : index_end_think]
283 - output_after_think = output[index_end_think + len_end_think :]
284 - output_tokens += mistral_tokenizer.tokenizer.encode(
285 - output_middle, False, False
286 - )
287 - output_tokens += [mistral_tokenizer.instruct.END_THINK]
288 - output_tokens += mistral_tokenizer.tokenizer.encode(
289 - output_after_think, False, False
290 - )
291 - else:
292 - output_middle = output[index_think + len_think :]
293 - output_tokens += mistral_tokenizer.tokenizer.encode(
294 - output_middle, False, False
295 - )
296 - elif index_end_think != -1:
297 - output_before_think = output[:index_end_think]
298 - output_after_think = output[index_end_think + len_end_think :]
299 - output_tokens += mistral_tokenizer.tokenizer.encode(
300 - output_before_think, False, False
301 - )
302 - output_tokens += [mistral_tokenizer.instruct.END_THINK]
303 - output_tokens += mistral_tokenizer.tokenizer.encode(
304 - output_after_think, False, False
305 - )
306 - else:
307 - output_tokens += mistral_tokenizer.tokenizer.encode(output, False, False)
308 -
309 - parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
310 - mistral_tokenizer
311 - )
312 -
313 - reasoning, content = run_reasoning_extraction_mistral(
314 - parser, output_tokens, streaming=streaming
315 - )
316 -
317 - assert reasoning == param_dict["reasoning"]
318 - assert content == param_dict["content"]
319 -
320 - # Test is_reasoning_end
321 - is_reasoning_end = parser.is_reasoning_end(output_tokens)
322 - assert is_reasoning_end == param_dict["is_reasoning_end"]
323 -
324 - # Test extract_content
325 - if param_dict["content"] is not None:
326 - # Handle the case where there are tokens outputted before Thinking.
327 - # This should not occur if the model is well trained and prompted.
328 - if "[THINK]" in param_dict["output"] and not param_dict["output"].startswith(
329 - "[THINK]"
330 - ):
331 - before_content = param_dict["output"].split("[THINK]")[0]
332 - before_token_ids = mistral_tokenizer.tokenizer.encode(
333 - before_content, bos=False, eos=False
334 - )
335 - left_to_encode = param_dict["content"][len(before_content) :]
336 - # Normal situation.
337 - else:
338 - before_token_ids = []
339 - left_to_encode = param_dict["content"]
340 -
341 - content_tokens = parser.extract_content_ids(output_tokens)
342 - expected_token_ids = before_token_ids + mistral_tokenizer.tokenizer.encode(
343 - left_to_encode, bos=False, eos=False
344 - )
345 - assert content_tokens == expected_token_ids
346 - else:
347 - content = parser.extract_content_ids(output_tokens)
348 - assert content == []
tests/tokenizers_/test_mistral.py
+144 -2
@@ -8,11 +8,13 @@ import llguidance
8 import pytest
9 from mistral_common.exceptions import InvalidMessageStructureException
10 from mistral_common.guidance.grammar_factory import GrammarFactory
11 -from mistral_common.tokens.tokenizers.base import SpecialTokenPolicy
11 +from mistral_common.tokens.tokenizers.base import SpecialTokenPolicy, SpecialTokens
12
13 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
14 from vllm.tokenizers.mistral import (
15 MistralTokenizer,
16 _validate_apply_chat_template_args,
17 + validate_request_params,
18 )
19
20
@@ -1247,7 +1249,9 @@ class TestMistralTokenizer:
1249
1250 expected_strings = (
1251 '[{"type": "function", "function": {"name": "get_weather", "description": "Gets the current weather in a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "The city name"}}, "required": ["city"]}}}] I am an AI\n\nHello world ![TOOL_CALLS][{"name": "get_weather", "arguments": {"city": "Paris"}, "id": "123456789"}] {"content": {"temperature": 20, "unit": "celsius"}, "call_id": "123456789"}', # noqa: E501
1250 - 'I am an AI[{"type": "function", "function": {"name": "get_weather", "description": "Gets the current weather in a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "The city name"}}, "required": ["city"]}}}]Hello world ![TOOL_CALLS]get_weather{"city": "Paris"}{"temperature": 20, "unit": "celsius"}', # noqa: E501
1252 + # v11+ tool-call decode emits the explicit [ARGS] separator between
1253 + # the function name and its JSON arguments (get_weather[ARGS]{...}).
1254 + 'I am an AI[{"type": "function", "function": {"name": "get_weather", "description": "Gets the current weather in a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "The city name"}}, "required": ["city"]}}}]Hello world ![TOOL_CALLS]get_weather[ARGS]{"city": "Paris"}{"temperature": 20, "unit": "celsius"}', # noqa: E501
1255 )
1256
1257 assert (
@@ -1498,6 +1502,7 @@ class TestMistralTokenizer:
1502 "get",
1503 "_",
1504 "weather",
1505 + "[ARGS]",
1506 '{"',
1507 "city",
1508 '":',
@@ -2234,3 +2239,140 @@ class TestMistralTokenizer:
2239 decoded = mistral_tokenizer.tokenizer.decode(output, SpecialTokenPolicy.KEEP)
2240
2241 assert "[THINK]2+2 equals 4[/THINK]" in decoded
2242 +
2243 +
2244 +def test_convert_ids_to_tokens_pre_args_tekken():
2245 + """convert_ids_to_tokens must not raise on pre-[ARGS] Tekken tokenizers.
2246 +
2247 + Tekken tokenizers before v11 (e.g. Ministral-8B-Instruct-2410) have no
2248 + [ARGS] special token. This guards the is_special([ARGS]) gate in
2249 + MistralTokenizer.convert_ids_to_tokens, which must skip [ARGS] rather than
2250 + call get_special_token(args) unconditionally (which raises on Tekken).
2251 + """
2252 + tokenizer = MistralTokenizer.from_pretrained("mistralai/Ministral-8B-Instruct-2410")
2253 + assert tokenizer.is_tekken
2254 + assert not tokenizer.tokenizer.is_special(SpecialTokens.args)
2255 +
2256 + ids = tokenizer.encode("Hello world !", add_special_tokens=False)
2257 + tokens = tokenizer.convert_ids_to_tokens(ids, skip_special_tokens=True)
2258 + assert len(tokens) > 0
2259 +
2260 +
2261 +# ---------------------------------------------------------------------------
2262 +# validate_request_params – reasoning_effort validation (no tokenizer needed)
2263 +# ---------------------------------------------------------------------------
2264 +
2265 +
2266 +@pytest.mark.parametrize("reasoning_effort", [None, "none", "high"])
2267 +def test_validate_request_params_valid_effort(reasoning_effort: str | None) -> None:
2268 + request = ChatCompletionRequest(
2269 + model="test-model",
2270 + messages=[],
2271 + reasoning_effort=reasoning_effort,
2272 + )
2273 + assert validate_request_params(request) is None
2274 +
2275 +
2276 +def test_validate_request_params_rejects_unsupported_effort() -> None:
2277 + request = ChatCompletionRequest(
2278 + model="test-model",
2279 + messages=[],
2280 + reasoning_effort="medium",
2281 + )
2282 + with pytest.raises(ValueError, match="reasoning_effort"):
2283 + validate_request_params(request)
2284 +
2285 +
2286 +# ---------------------------------------------------------------------------
2287 +# apply_chat_template reasoning_effort passthrough (v15 tokenizer)
2288 +# ---------------------------------------------------------------------------
2289 +
2290 +_PASSTHROUGH_MESSAGES = [{"role": "user", "content": "Hello"}]
2291 +
2292 +
2293 +@pytest.fixture(scope="module")
2294 +def v15_mistral_tokenizer() -> MistralTokenizer:
2295 + """Load the v15 Mistral tokenizer; skip if unavailable."""
2296 + try:
2297 + return MistralTokenizer.from_pretrained("mistralai/Mistral-Small-4-119B-2603")
2298 + except Exception:
2299 + pytest.skip("v15 tokenizer unavailable")
2300 +
2301 +
2302 +@pytest.fixture(scope="module")
2303 +def v13_mistral_tokenizer() -> MistralTokenizer:
2304 + """Load the v13 Mistral tokenizer; skip if unavailable."""
2305 + try:
2306 + return MistralTokenizer.from_pretrained("mistralai/Magistral-Small-2509")
2307 + except Exception:
2308 + pytest.skip("v13 tokenizer unavailable")
2309 +
2310 +
2311 +def test_v15_apply_chat_template_passes_reasoning_effort_high(
2312 + monkeypatch: pytest.MonkeyPatch,
2313 + v15_mistral_tokenizer: MistralTokenizer,
2314 +) -> None:
2315 + captured_kwargs: list[dict] = []
2316 +
2317 + def fake_apply_chat_template(**kwargs):
2318 + captured_kwargs.append(kwargs)
2319 + return [0]
2320 +
2321 + monkeypatch.setattr(
2322 + v15_mistral_tokenizer.transformers_tokenizer,
2323 + "apply_chat_template",
2324 + fake_apply_chat_template,
2325 + )
2326 +
2327 + v15_mistral_tokenizer.apply_chat_template(
2328 + messages=_PASSTHROUGH_MESSAGES,
2329 + reasoning_effort="high",
2330 + )
2331 +
2332 + assert captured_kwargs[-1]["reasoning_effort"] == "high"
2333 +
2334 +
2335 +def test_v15_apply_chat_template_passes_reasoning_effort_none_by_default(
2336 + monkeypatch: pytest.MonkeyPatch,
2337 + v15_mistral_tokenizer: MistralTokenizer,
2338 +) -> None:
2339 + captured_kwargs: list[dict] = []
2340 +
2341 + def fake_apply_chat_template(**kwargs):
2342 + captured_kwargs.append(kwargs)
2343 + return [0]
2344 +
2345 + monkeypatch.setattr(
2346 + v15_mistral_tokenizer.transformers_tokenizer,
2347 + "apply_chat_template",
2348 + fake_apply_chat_template,
2349 + )
2350 +
2351 + v15_mistral_tokenizer.apply_chat_template(messages=_PASSTHROUGH_MESSAGES)
2352 +
2353 + assert "reasoning_effort" in captured_kwargs[-1]
2354 + assert captured_kwargs[-1]["reasoning_effort"] is None
2355 +
2356 +
2357 +def test_pre_v15_apply_chat_template_omits_reasoning_effort(
2358 + monkeypatch: pytest.MonkeyPatch,
2359 + v13_mistral_tokenizer: MistralTokenizer,
2360 +) -> None:
2361 + captured_kwargs: list[dict] = []
2362 +
2363 + def fake_apply_chat_template(**kwargs):
2364 + captured_kwargs.append(kwargs)
2365 + return [0]
2366 +
2367 + monkeypatch.setattr(
2368 + v13_mistral_tokenizer.transformers_tokenizer,
2369 + "apply_chat_template",
2370 + fake_apply_chat_template,
2371 + )
2372 +
2373 + v13_mistral_tokenizer.apply_chat_template(
2374 + messages=_PASSTHROUGH_MESSAGES,
2375 + reasoning_effort="high",
2376 + )
2377 +
2378 + assert "reasoning_effort" not in captured_kwargs[-1]
tests/tool_use/mistral/test_mistral_tool_calls.py
+1 -1
@@ -219,7 +219,7 @@ _NOT_SET = object()
219 pytest.param(
220 [WEATHER_TOOL],
221 "required",
222 - 30,
222 + 9,
223 id="required",
224 ),
225 ],
vllm/entrypoints/openai/chat_completion/protocol.py
+2 -2
@@ -533,8 +533,8 @@ class ChatCompletionRequest(OpenAIBaseModel):
533 msg["tool_calls"] = list(tool_calls)
534 return self
535
536 - _grammar_from_tool_parser: bool = PrivateAttr(default=False)
537 - """CAUTION: Should only be set by ``ToolParser.adjust_request``."""
536 + _grammar_from_parser: bool = PrivateAttr(default=False)
537 + """CAUTION: Should only be set by the parser-engine adapter's adjust_request."""
538
539 def build_chat_params(
540 self,
vllm/entrypoints/openai/chat_completion/serving.py
+1 -11
@@ -65,7 +65,6 @@ from vllm.renderers.online_renderer import OnlineRenderer
65 from vllm.sampling_params import BeamSearchParams, SamplingParams
66 from vllm.tokenizers import TokenizerLike
67 from vllm.utils.collection_utils import as_list
68 -from vllm.utils.mistral import is_mistral_tool_parser
68
69 logger = init_logger(__name__)
70
@@ -157,15 +156,6 @@ class OpenAIServingChat(GenerateBaseServing):
156 model_name=self.model_config.model,
157 is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
158 )
160 - if (
161 - self.parser_cls is not None
162 - and is_mistral_tool_parser(self.parser_cls.tool_parser_cls)
163 - and self.parser_cls.reasoning_parser_cls is not None
164 - ):
165 - from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
166 -
167 - MistralToolParser.model_can_reason = True
168 -
159 self.exclude_tools_when_tool_choice_none = exclude_tools_when_tool_choice_none
160
161 self.enable_prompt_tokens_details = enable_prompt_tokens_details
@@ -350,7 +340,7 @@ class OpenAIServingChat(GenerateBaseServing):
340 else:
341 if not request.include_reasoning:
342 reasoning_ended = True
353 - elif request._grammar_from_tool_parser:
343 + elif request._grammar_from_parser:
344 # The Mistral grammar already includes an optional
345 # `think?` rule that handles both reasoning and
346 # non-reasoning outputs.
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+1 -1
@@ -284,7 +284,7 @@ class DerenderChatRequest(BaseModel):
284
285 Required by the parsing so that tool/reasoning parsers can receive the full
286 request context they expect (request.tools, request.tool_choice,
287 - request._grammar_from_tool_parser, etc.).
287 + request._grammar_from_parser, etc.).
288 """
289 # --8<-- [end:derender-chat-request]
290
vllm/parser/engine/parser_engine.py
+7 -4
@@ -396,6 +396,9 @@ class ParserEngine(Parser):
396 return True
397 return find_tool_name(self._tools, name)
398
399 + def _accept_tool_name(self, name: str) -> bool:
400 + return bool(name) and self._is_valid_tool_name(name)
401 +
402 # ── Private helpers ─────────────────────────────────────────────
403
404 def _check_skip_tool_parsing(
@@ -807,7 +810,7 @@ class ParserEngine(Parser):
810 deltas: list[DeltaToolCall],
811 name: str | None,
812 ) -> None:
810 - if not name or not self._is_valid_tool_name(name):
813 + if name is None or not self._accept_tool_name(name):
814 return
815 slot = self._tool_slots[idx]
816 slot.name = name
@@ -866,8 +869,8 @@ class ParserEngine(Parser):
869 slot = self._tool_slots[idx]
870
871 if not slot.name_sent:
869 - name = slot.name or self._try_extract_name(idx)
870 - if name and self._is_valid_tool_name(name):
872 + name = slot.name or self._try_extract_name(idx) or ""
873 + if self._accept_tool_name(name):
874 slot.name = name
875 slot.name_sent = True
876 slot.string_keys = self._streamable_string_keys(
@@ -1042,7 +1045,7 @@ class ParserEngine(Parser):
1045 else:
1046 args_json = "{}"
1047
1045 - if name and self._is_valid_tool_name(name):
1048 + if self._accept_tool_name(name):
1049 self._ensure_tool_id(slot, name)
1050 args_json = self._fix_arg_types(args_json, name)
1051 tool_calls.append(
vllm/parser/engine/registered_adapters.py
+6
@@ -15,6 +15,7 @@ from vllm.parser.glm47_moe import Glm47MoeParser
15 from vllm.parser.inkling import InklingParser
16 from vllm.parser.kimi_k2 import KimiK2Parser
17 from vllm.parser.minimax_m2 import MinimaxM2Parser
18 +from vllm.parser.mistral import MistralParser
19 from vllm.parser.nemotron_v3 import NemotronV3Parser
20 from vllm.parser.qwen3 import Qwen3Parser
21 from vllm.parser.seed_oss import SeedOssParser
@@ -68,3 +69,8 @@ from vllm.parser.seed_oss import SeedOssParser
69 InklingParserReasoningAdapter,
70 InklingParserToolAdapter,
71 ) = make_adapters(InklingParser)
72 +
73 +(
74 + MistralParserReasoningAdapter,
75 + MistralParserToolAdapter,
76 +) = make_adapters(MistralParser)
vllm/parser/mistral.py
+946 -54
@@ -3,80 +3,972 @@
3
4 from __future__ import annotations
5
6 +import functools
7 +import json
8 from collections.abc import Sequence
7 -from typing import TYPE_CHECKING
9 +from enum import Enum, auto
10 +from random import choices
11 +from string import ascii_letters, digits
12 +from typing import TYPE_CHECKING, Any, Literal
13
9 -from vllm.entrypoints.openai.engine.protocol import DeltaMessage, FunctionCall
10 -from vllm.parser.abstract_parser import DelegatingParser
14 +import ijson
15 +import regex as re
16 +from mistral_common.protocol.instruct.tool_calls import (
17 + NamedToolChoice as MistralNamedToolChoice,
18 +)
19 +from mistral_common.protocol.instruct.tool_calls import (
20 + Tool as MistralTool,
21 +)
22 +from mistral_common.protocol.instruct.tool_calls import (
23 + ToolChoice as MistralToolChoice,
24 +)
25 +from mistral_common.protocol.instruct.tool_calls import (
26 + ToolChoiceEnum as MistralToolChoiceEnum,
27 +)
28 +from mistral_common.tokens.tokenizers.base import SpecialTokens
29 +from pydantic import Field
30 +
31 +from vllm.entrypoints.openai.chat_completion.protocol import (
32 + ChatCompletionNamedToolChoiceParam,
33 +)
34 +from vllm.entrypoints.openai.engine.protocol import (
35 + DeltaFunctionCall,
36 + DeltaMessage,
37 + DeltaToolCall,
38 + ExtractedToolCallInformation,
39 + FunctionCall,
40 + ToolCall,
41 +)
42 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
43 +from vllm.logger import init_logger
44 +from vllm.parser.engine.events import EventType
45 +from vllm.parser.engine.parser_engine import ParserEngine
46 +from vllm.parser.engine.parser_engine_config import (
47 + ParserEngineConfig,
48 + ParserState,
49 + Transition,
50 +)
51 +from vllm.sampling_params import StructuredOutputsParams
52 +from vllm.utils.mistral import is_mistral_tokenizer
53
54 if TYPE_CHECKING:
55 from vllm.entrypoints.openai.chat_completion.protocol import (
56 ChatCompletionRequest,
57 )
16 - from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
58 + from vllm.parser.engine.events import SemanticEvent
59 + from vllm.parser.engine.parser_engine import ToolCallSlot
60 + from vllm.tokenizers import TokenizerLike
61 + from vllm.tool_parsers.abstract_tool_parser import Tool
62 +
63 +logger = init_logger(__name__)
64 +
65 +_ALPHANUMERIC = ascii_letters + digits
66 +_DEFAULT_JSON_SCHEMA: dict[str, Any] = {
67 + "anyOf": [{"type": "object"}, {"type": "array"}]
68 +}
69 +
70 +# Special token strings as emitted/decoded by MistralTokenizer.
71 +_TOOL_CALLS = SpecialTokens.tool_calls.value
72 +_ARGS = SpecialTokens.args.value
73 +_THINK_START_SPECIAL = SpecialTokens.begin_think.value
74 +_THINK_END_SPECIAL = SpecialTokens.end_think.value
75 +
76 +# Plain-text reasoning markers used by v11 tokenizers.
77 +_THINK_START_TEXT = "<think>"
78 +_THINK_END_TEXT = "</think>"
79 +
80 +_OPEN_BRACE = "{"
81 +
82 +
83 +class StreamingState(Enum):
84 + """Streaming parsing state for pre-v11 tool call extraction."""
85 +
86 + WAITING_FOR_TOOL_START = auto()
87 + WAITING_FOR_TOOL_KEY = auto()
88 + PARSING_NAME = auto()
89 + PARSING_NAME_COMPLETED = auto()
90 + WAITING_FOR_ARGUMENTS_START = auto()
91 + PARSING_ARGUMENTS = auto()
92 + PARSING_ARGUMENTS_COMPLETED = auto()
93 + TOOL_COMPLETE = auto()
94 + ALL_TOOLS_COMPLETE = auto()
95 +
96 +
97 +class MistralToolCall(ToolCall):
98 + """ToolCall with a Mistral-compatible random alphanumeric id."""
99 +
100 + id: str = Field(default_factory=lambda: MistralToolCall.generate_random_id())
101 +
102 + @staticmethod
103 + def generate_random_id() -> str:
104 + # Mistral Tool Call Ids must be alphanumeric with a length of 9.
105 + # https://github.com/mistralai/mistral-common/blob/21ee9f6cee3441e9bb1e6ed2d10173f90bd9b94b/src/mistral_common/protocol/instruct/validator.py#L299
106 + return "".join(choices(_ALPHANUMERIC, k=9))
107 +
108 + @staticmethod
109 + def is_valid_id(id: str) -> bool:
110 + return id.isalnum() and len(id) == 9
111 +
112 +
113 +def _is_pre_v11_tokeniser(model_tokenizer: TokenizerLike) -> bool:
114 + if is_mistral_tokenizer(model_tokenizer):
115 + return model_tokenizer.version < 11
116 + vocab: dict[str, int] = getattr(model_tokenizer, "get_vocab", lambda: {})()
117 + return _ARGS not in vocab
118 +
119 +
120 +@functools.cache
121 +def mistral_config(
122 + *,
123 + reasoning_encoding: Literal["special_token", "text", "none"],
124 + name: str = "mistral",
125 +) -> ParserEngineConfig:
126 + """Return a :class:`ParserEngineConfig` for the Mistral output format.
127 +
128 + Args:
129 + reasoning_encoding: Reasoning token format.
130 + ``"special_token"`` – v13+ ``[THINK]``/``[/THINK]`` special
131 + tokens placed in both ``terminals`` and ``token_id_terminals``.
132 + ``"text"`` – v11 ``<think>``/``</think>`` plain text placed in
133 + ``terminals`` only; no ``token_id_terminals`` for think tokens.
134 + ``"none"`` – no reasoning support.
135 + name: Name embedded in the returned config (used for debugging).
136 +
137 + Returns:
138 + A frozen :class:`ParserEngineConfig` with ``initial_state=CONTENT``.
139 + """
140 + if reasoning_encoding == "special_token":
141 + think_start = _THINK_START_SPECIAL
142 + think_end = _THINK_END_SPECIAL
143 + reasoning_terminals: dict[str, str] = {
144 + "THINK_START": think_start,
145 + "THINK_END": think_end,
146 + }
147 + reasoning_token_id_terminals: dict[str, str] = {
148 + "THINK_START": think_start,
149 + "THINK_END": think_end,
150 + }
151 + elif reasoning_encoding == "text":
152 + think_start = _THINK_START_TEXT
153 + think_end = _THINK_END_TEXT
154 + reasoning_terminals = {
155 + "THINK_START": think_start,
156 + "THINK_END": think_end,
157 + }
158 + # Text think markers have no token-id terminals.
159 + reasoning_token_id_terminals = {}
160 + else:
161 + reasoning_terminals = {}
162 + reasoning_token_id_terminals = {}
163 +
164 + if reasoning_encoding != "none":
165 + reasoning_transitions: dict[tuple[ParserState, str], Transition] = {
166 + (ParserState.CONTENT, "THINK_START"): Transition(
167 + ParserState.REASONING,
168 + (),
169 + ),
170 + # Absorb a duplicate/re-emitted THINK_START inside reasoning.
171 + (ParserState.REASONING, "THINK_START"): Transition(
172 + ParserState.REASONING,
173 + (),
174 + ),
175 + (ParserState.REASONING, "THINK_END"): Transition(
176 + ParserState.CONTENT,
177 + (EventType.REASONING_END,),
178 + ),
179 + # Absorb stray THINK_END that arrives after reasoning ended.
180 + (ParserState.CONTENT, "THINK_END"): Transition(
181 + ParserState.CONTENT,
182 + (),
183 + ),
184 + # [TOOL_CALLS] directly from reasoning implicitly ends it.
185 + (ParserState.REASONING, "TOOL_CALLS"): Transition(
186 + ParserState.TOOL_NAME,
187 + (EventType.REASONING_END, EventType.TOOL_CALL_START),
188 + ),
189 + }
190 + else:
191 + reasoning_transitions = {}
192 +
193 + return ParserEngineConfig(
194 + name=name,
195 + initial_state=ParserState.CONTENT,
196 + terminals={
197 + **reasoning_terminals,
198 + "TOOL_CALLS": _TOOL_CALLS,
199 + "ARGS": _ARGS,
200 + "OPEN_BRACE": _OPEN_BRACE,
201 + },
202 + token_id_terminals={
203 + **reasoning_token_id_terminals,
204 + "TOOL_CALLS": _TOOL_CALLS,
205 + "ARGS": _ARGS,
206 + },
207 + transitions={
208 + **reasoning_transitions,
209 + # A tool call from content implicitly ends reasoning when enabled.
210 + (ParserState.CONTENT, "TOOL_CALLS"): Transition(
211 + ParserState.TOOL_NAME,
212 + (EventType.REASONING_END, EventType.TOOL_CALL_START)
213 + if reasoning_encoding != "none"
214 + else (EventType.TOOL_CALL_START,),
215 + ),
216 + # NAME→ARGS via explicit [ARGS] separator (v11+): consumed, no events.
217 + (ParserState.TOOL_NAME, "ARGS"): Transition(
218 + ParserState.TOOL_ARGS,
219 + (),
220 + ),
221 + # NAME→ARGS via "{": carried as ARG_VALUE_CHUNK so the opening
222 + # brace lands in the JSON argument buffer (fallback for name{args}).
223 + (ParserState.TOOL_NAME, "OPEN_BRACE"): Transition(
224 + ParserState.TOOL_ARGS,
225 + (EventType.ARG_VALUE_CHUNK,),
226 + ),
227 + # Parallel tool calls: next [TOOL_CALLS] ends current call.
228 + (ParserState.TOOL_ARGS, "TOOL_CALLS"): Transition(
229 + ParserState.TOOL_NAME,
230 + (EventType.TOOL_CALL_END, EventType.TOOL_CALL_START),
231 + ),
232 + },
233 + stream_arg_deltas=True,
234 + tool_args_json=True,
235 + strip_trailing_reasoning_whitespace=True,
236 + drop_whitespace_only_content_before_tools=True,
237 + )
238 +
239 +
240 +class MistralParser(ParserEngine):
241 + """Mistral parser: engine-based reasoning + ``[TOOL_CALLS]`` tool calls.
242
243 + Reasoning encoding is auto-detected from the tokenizer:
244
19 -class MistralParser(DelegatingParser):
20 - def __init__(self, tokenizer, tools=None, *args, **kwargs):
21 - super().__init__(tokenizer, tools, *args, **kwargs)
22 - from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
245 + - ``"special_token"`` – ``[THINK]`` present in vocab (v13+).
246 + - ``"text"`` – tokenizer supports grammar but has no ``[THINK]`` (v11).
247 + - ``"none"`` – no grammar support; reasoning disabled.
248
24 - if not isinstance(self._tool_parser, MistralToolParser):
25 - raise ValueError(
26 - "MistralParser requires --tool-call-parser mistral, "
27 - f"got {self._tool_parser.__class__.__name__}."
249 + Tool calls use the ``[TOOL_CALLS]func_name{...}`` format. The opening
250 + ``{`` doubles as the NAME→ARGS separator and is included in the argument
251 + buffer via an ``ARG_VALUE_CHUNK`` event on the transition (mirrors
252 + kimi_k2, JSON args, `tool_args_json=True`).
253 +
254 + When the tokenizer does not support grammar (``_reasoning_encoding ==
255 + "none"``), the legacy ``[TOOL_CALLS]``-based extraction path is used
256 + instead of the declarative engine, handling both pre-v11 JSON-array and
257 + v11+ ``funcname{args}`` formats.
258 + """
259 +
260 + def __init__(
261 + self,
262 + tokenizer: TokenizerLike,
263 + tools: list[Tool] | None = None,
264 + **kwargs,
265 + ) -> None:
266 + vocab = tokenizer.get_vocab()
267 + self._reasoning_encoding: Literal["special_token", "text", "none"]
268 + if _THINK_START_SPECIAL in vocab:
269 + self._reasoning_encoding = "special_token"
270 + elif getattr(tokenizer, "supports_grammar", False):
271 + self._reasoning_encoding = "text"
272 + else:
273 + self._reasoning_encoding = "none"
274 +
275 + kwargs.setdefault(
276 + "parser_engine_config",
277 + mistral_config(reasoning_encoding=self._reasoning_encoding),
278 + )
279 + super().__init__(tokenizer, tools, **kwargs)
280 +
281 + self._tool_calls_token_id: int | None = self.vocab.get(_TOOL_CALLS)
282 +
283 + # Tool calls use the legacy parser for all tokenizer versions;
284 + # reasoning is handled by the engine.
285 + self.bot_token: str = _TOOL_CALLS
286 + self.bot_token_id: int | None = self._tool_calls_token_id
287 + if self.bot_token_id is None:
288 + raise RuntimeError(
289 + "Mistral parser could not locate the tool call token in the tokenizer!"
290 + )
291 +
292 + # Legacy tool-call streaming state.
293 + self.prev_tool_call_arr: list[dict[str, Any]] = []
294 + self.current_tool_id: int = -1
295 + self.streaming_state: StreamingState = StreamingState.WAITING_FOR_TOOL_START
296 + self.tool_call_started: bool = False
297 + self.current_tool_name: str | None = None
298 + self.current_tool_mistral_id: str | None = None
299 + self.starting_new_tool: bool = False
300 + self.streamed_args_for_tool: list[str] = []
301 + self._is_pre_v11: bool = _is_pre_v11_tokeniser(tokenizer)
302 + self.parse_coro = None
303 + if self._is_pre_v11:
304 + self.parse_coro = ijson.parse_coro(
305 + self.update_stream_state_pre_v11_tokenizer()
306 )
307 + self.tool_call_regex = re.compile(r"\[{.*}\]", re.DOTALL)
308
30 - def _maybe_force_auto_tool_parsing(
309 + def adjust_request(
310 self, request: ChatCompletionRequest | ResponsesRequest
32 - ) -> None:
33 - # When the Mistral grammar factory injected structured outputs,
34 - # the model emits v11+ format ([TOOL_CALLS]name{args}) that the
35 - # named/required parsers can't handle. Disable them so all
36 - # tool_choice modes fall back to auto tool parsing via
37 - # extract_tool_calls.
38 - if getattr(request, "_grammar_from_tool_parser", False):
39 - assert self._tool_parser is not None
40 - self._tool_parser.supports_required_and_named = False
41 -
42 - def parse(
311 + ) -> ChatCompletionRequest | ResponsesRequest:
312 + if not isinstance(request, ResponsesRequest) and request._grammar_from_parser:
313 + return request
314 + so_non_supported_attributes = [
315 + "regex",
316 + "choice",
317 + "grammar",
318 + # whitespace_pattern is not a constraint type but an option;
319 + # Mistral grammar factory does not support it.
320 + "whitespace_pattern",
321 + "structural_tag",
322 + ]
323 + any_so_non_supported_active = request.structured_outputs is not None and any(
324 + getattr(request.structured_outputs, attribute) is not None
325 + for attribute in so_non_supported_attributes
326 + )
327 + response_format_non_supported_active = (
328 + isinstance(request, ResponsesRequest)
329 + or request.response_format is not None
330 + and request.response_format.type == "structural_tag"
331 + )
332 +
333 + if (
334 + not is_mistral_tokenizer(self.model_tokenizer)
335 + or isinstance(request, ResponsesRequest)
336 + or not self.model_tokenizer.supports_grammar
337 + or any_so_non_supported_active
338 + or response_format_non_supported_active
339 + ):
340 + request = super().adjust_request(request)
341 + if request.tools and request.tool_choice != "none":
342 + # Keep special tokens so the [TOOL_CALLS] marker
343 + # survives for tool detection.
344 + request.skip_special_tokens = False
345 + # Inject a guided JSON schema for pre-v11 required/named tool choice
346 + # so the model emits a well-formed bare JSON array instead of
347 + # rambling. tool_choice forces a tool call, so a json_object /
348 + # json_schema response_format is cleared here (the tool schema is
349 + # the sole structured-output constraint), mirroring the base
350 + # ToolParser; a structural_tag response_format is left untouched.
351 + req_tool_choice = request.tool_choice
352 + is_required_or_named = req_tool_choice == "required" or isinstance(
353 + req_tool_choice, ChatCompletionNamedToolChoiceParam
354 + )
355 + response_format = getattr(request, "response_format", None)
356 + response_format_overridable = response_format is None or (
357 + response_format.type in ("text", "json_object", "json_schema")
358 + )
359 + # This runs only in the non-grammar (legacy) branch, so it covers
360 + # both pre-v11 Mistral tokenizers and non-Mistral (e.g. HF-mode)
361 + # tokenizers driving the Mistral tool parser. The remaining guards
362 + # keep it off when the user supplied their own structured output.
363 + if (
364 + not isinstance(request, ResponsesRequest)
365 + and request.tools
366 + and request.structured_outputs is None
367 + and is_required_or_named
368 + and response_format_overridable
369 + ):
370 + schema = self._build_guided_schema_pre_v11(request)
371 + if schema is not None:
372 + request.structured_outputs = StructuredOutputsParams(json=schema)
373 + request.response_format = None
374 + return request
375 +
376 + json_schema: dict[str, Any] | None = None
377 + if request.structured_outputs is not None:
378 + if request.structured_outputs.json_object is not None:
379 + json_schema = _DEFAULT_JSON_SCHEMA
380 + elif request.structured_outputs.json is not None:
381 + if isinstance(request.structured_outputs.json, str):
382 + json_schema = json.loads(request.structured_outputs.json)
383 + else:
384 + json_schema = request.structured_outputs.json
385 + else:
386 + raise ValueError(
387 + "Unsupported request.structured_outputs for MistralParser. "
388 + "Only `json` and `json_object` are supported."
389 + )
390 + elif (
391 + request.response_format is not None
392 + and request.response_format.type != "text"
393 + ):
394 + if request.response_format.type == "json_object":
395 + json_schema = _DEFAULT_JSON_SCHEMA
396 + elif request.response_format.type == "json_schema":
397 + if request.response_format.json_schema is not None:
398 + json_schema = request.response_format.json_schema.json_schema
399 + else:
400 + json_schema = _DEFAULT_JSON_SCHEMA
401 + else:
402 + raise ValueError(
403 + "MistralParser only accepts `text`, `json_object` or "
404 + f"`json_schema`, got {request.response_format=}"
405 + )
406 + request.response_format = None
407 +
408 + grammar_factory = self.model_tokenizer.grammar_factory
409 +
410 + # Rendering grammar is cached in mistral-common given tools, template and mode.
411 + template = grammar_factory.select_jinja_template()
412 +
413 + mistral_tools = (
414 + [MistralTool.from_openai(tool.model_dump()) for tool in request.tools]
415 + if request.tools is not None
416 + else None
417 + )
418 +
419 + tool_choice: MistralToolChoice
420 + match request.tool_choice:
421 + case "none" | "auto" | "required":
422 + tool_choice = MistralToolChoiceEnum(request.tool_choice)
423 + case None:
424 + tool_choice = MistralToolChoiceEnum.auto
425 + # _ == Named tool choice
426 + case _:
427 + tool_choice = MistralNamedToolChoice.model_validate(
428 + {
429 + "type": "function",
430 + "function": {"name": request.tool_choice.function.name},
431 + }
432 + )
433 +
434 + match tool_choice, json_schema is not None:
435 + case MistralToolChoiceEnum.none, True:
436 + lark_grammar = grammar_factory.get_lark_for_json_schema(
437 + template=template, json_schema=json_schema
438 + )
439 + case _, _:
440 + lark_grammar = grammar_factory.get_lark_from_jinja(
441 + template=template,
442 + mode=tool_choice,
443 + tools=mistral_tools,
444 + json_schema=json_schema,
445 + parallel_tool_calls=request.parallel_tool_calls,
446 + json_only=False,
447 + )
448 +
449 + request.structured_outputs = StructuredOutputsParams(grammar=lark_grammar)
450 + request._grammar_from_parser = True
451 + return request
452 +
453 + def _build_guided_schema_pre_v11(
454 + self,
455 + request: ChatCompletionRequest,
456 + ) -> dict[str, Any] | None:
457 + """Build a guided JSON schema for pre-v11 required/named tool choice.
458 +
459 + The schema enforces the Mistral-native array format
460 + ``[{"name": ..., "arguments": {...}}]`` so the model emits a parseable
461 + bare JSON array instead of free-form text.
462 +
463 + Args:
464 + request: The chat completion request carrying `tools` and
465 + `tool_choice`.
466 +
467 + Returns:
468 + A JSON Schema dict, or ``None`` if the named tool is not found.
469 + """
470 + tool_choice = request.tool_choice
471 + tools = request.tools or []
472 + extra: dict[str, Any] = {}
473 +
474 + if tool_choice == "required":
475 + applicable_tools = tools
476 + else:
477 + # Named tool choice — restrict to the single requested tool.
478 + assert isinstance(tool_choice, ChatCompletionNamedToolChoiceParam)
479 + chosen_name = tool_choice.function.name
480 + applicable_tools = [t for t in tools if t.function.name == chosen_name]
481 + if not applicable_tools:
482 + logger.warning(
483 + "Named tool %r not found in tools list; "
484 + "skipping guided schema injection.",
485 + chosen_name,
486 + )
487 + return None
488 + extra["maxItems"] = 1
489 +
490 + any_of = [
491 + {
492 + "type": "object",
493 + "properties": {
494 + "name": {"type": "string", "enum": [tool.function.name]},
495 + "arguments": tool.function.parameters or {"type": "object"},
496 + },
497 + "required": ["name", "arguments"],
498 + }
499 + for tool in applicable_tools
500 + ]
501 +
502 + return {
503 + "type": "array",
504 + "minItems": 1,
505 + **extra,
506 + "items": {
507 + "type": "object",
508 + "anyOf": any_of,
509 + },
510 + }
511 +
512 + def _ensure_tool_id(self, slot: ToolCallSlot, name: str) -> None:
513 + """Assign a Mistral-compatible 9-char alphanumeric id to `slot`."""
514 + if not slot.id:
515 + slot.id = MistralToolCall.generate_random_id()
516 +
517 + def extract_tool_calls_from_content(
518 + self,
519 + content: str,
520 + request: ChatCompletionRequest,
521 + ) -> ExtractedToolCallInformation:
522 + if self._is_pre_v11:
523 + return self._legacy_extract_tool_calls(content, request)
524 + return super().extract_tool_calls_from_content(content, request)
525 +
526 + def _legacy_extract_tool_calls(
527 self,
528 model_output: str,
529 + request: ChatCompletionRequest,
530 + ) -> ExtractedToolCallInformation:
531 + """Pre-v11 non-streaming extraction.
532 +
533 + Handles ``[TOOL_CALLS][{...}]`` and guided bare-array formats.
534 + """
535 + # tool_choice="none" with tools: never produce tool calls.
536 + if request.tool_choice == "none" and request.tools:
537 + return ExtractedToolCallInformation(
538 + tools_called=False, tool_calls=[], content=model_output
539 + )
540 +
541 + content: str | None = None
542 + if self.bot_token in model_output:
543 + content_and_raw_tool_calls = model_output.split(self.bot_token)
544 + content = content_and_raw_tool_calls[0]
545 + raw_tool_calls = content_and_raw_tool_calls[1:]
546 + # pre-v11: content[BOT] [{tool_call1},{tool_call2}]
547 + if len(raw_tool_calls) != 1:
548 + raise ValueError(
549 + "Only one BOT token should have been outputted, "
550 + f"but got {model_output}."
551 + )
552 + stringified_tool_calls = raw_tool_calls[0].strip()
553 + elif request.tool_choice == "required" or isinstance(
554 + request.tool_choice, ChatCompletionNamedToolChoiceParam
555 + ):
556 + # Guided bare-array output (no [TOOL_CALLS] marker).
557 + stringified_tool_calls = model_output.strip()
558 + else:
559 + return ExtractedToolCallInformation(
560 + tools_called=False, tool_calls=[], content=model_output
561 + )
562 +
563 + try:
564 + # Use raw_decode to parse the first valid JSON value,
565 + # ignoring trailing tokens the model may emit after
566 + # the tool call array.
567 + tool_calls, _ = json.JSONDecoder().raw_decode(stringified_tool_calls)
568 + except json.JSONDecodeError:
569 + try:
570 + raw_tool_call = self.tool_call_regex.findall(stringified_tool_calls)[0]
571 + tool_calls = json.loads(raw_tool_call)
572 + tool_calls = [
573 + {
574 + "name": tool_call["name"],
575 + "arguments": json.dumps(
576 + tool_call.get("arguments", {}),
577 + ensure_ascii=False,
578 + ),
579 + }
580 + for tool_call in tool_calls
581 + ]
582 + except (IndexError, json.JSONDecodeError):
583 + logger.exception("Error in extracting tool call from response.")
584 + return ExtractedToolCallInformation(
585 + tools_called=False,
586 + tool_calls=[],
587 + content=stringified_tool_calls,
588 + )
589 + else:
590 + tool_calls = [
591 + {
592 + "name": tool_call["name"],
593 + "arguments": json.dumps(
594 + tool_call.get("arguments", {}),
595 + ensure_ascii=False,
596 + ),
597 + }
598 + for tool_call in tool_calls
599 + ]
600 +
601 + mistral_tool_calls: list[MistralToolCall] = [
602 + MistralToolCall(
603 + type="function",
604 + function=FunctionCall(
605 + name=tool_call["name"],
606 + arguments=tool_call.get("arguments", "{}"),
607 + ),
608 + )
609 + for tool_call in tool_calls
610 + ]
611 +
612 + return ExtractedToolCallInformation(
613 + tools_called=True,
614 + tool_calls=mistral_tool_calls,
615 + content=content if content and content.strip() else None,
616 + )
617 +
618 + def extract_tool_calls_streaming(
619 + self,
620 + previous_text: str,
621 + current_text: str,
622 + delta_text: str,
623 + previous_token_ids: Sequence[int],
624 + current_token_ids: Sequence[int],
625 + delta_token_ids: Sequence[int],
626 request: ChatCompletionRequest | ResponsesRequest,
46 - enable_auto_tools: bool = False,
47 - model_output_token_ids: Sequence[int] = (),
48 - ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
49 - self._maybe_force_auto_tool_parsing(request)
50 - reasoning, content, tool_calls = super().parse(
51 - model_output,
52 - request,
53 - enable_auto_tools,
54 - model_output_token_ids,
627 + ) -> DeltaMessage | None:
628 + if not self._is_pre_v11:
629 + return super().extract_tool_calls_streaming(
630 + previous_text,
631 + current_text,
632 + delta_text,
633 + previous_token_ids,
634 + current_token_ids,
635 + delta_token_ids,
636 + request,
637 + )
638 + # Pre-v11: latch on [TOOL_CALLS] or on the first content of a guided
639 + # required/named request (bare JSON array, no special token).
640 + if self.bot_token_id in delta_token_ids or self.bot_token in delta_text:
641 + self.tool_call_started = True
642 + elif not self.tool_call_started and delta_text:
643 + is_guided = request.tool_choice == "required" or isinstance(
644 + request.tool_choice, ChatCompletionNamedToolChoiceParam
645 + )
646 + if is_guided:
647 + self.tool_call_started = True
648 + if not self.tool_call_started:
649 + return DeltaMessage(content=delta_text)
650 + try:
651 + return self._extract_tool_calls_streaming_pre_v11_tokenizer(
652 + delta_text=delta_text,
653 + delta_token_ids=delta_token_ids,
654 + )
655 + except Exception:
656 + logger.exception("Error trying to handle streaming tool call.")
657 + return None
658 +
659 + @ijson.coroutine
660 + def update_stream_state_pre_v11_tokenizer(self):
661 + while True:
662 + (prefix, event, value) = yield
663 +
664 + if prefix == "item" and event == "start_map":
665 + self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
666 + self.starting_new_tool = True
667 + if prefix == "item" and event == "map_key" and value == "name":
668 + self.streaming_state = StreamingState.PARSING_NAME
669 + if prefix == "item.name" and event == "string":
670 + self.current_tool_name = value
671 + self.streaming_state = StreamingState.PARSING_NAME_COMPLETED
672 + if prefix == "item" and event == "map_key" and value == "arguments":
673 + self.streaming_state = StreamingState.WAITING_FOR_ARGUMENTS_START
674 + if prefix == "item.arguments" and event == "start_map":
675 + self.streaming_state = StreamingState.PARSING_ARGUMENTS
676 + if prefix == "item.arguments" and event == "end_map":
677 + self.streaming_state = StreamingState.PARSING_ARGUMENTS_COMPLETED
678 + if prefix == "item" and event == "end_map":
679 + self.streaming_state = StreamingState.TOOL_COMPLETE
680 + if prefix == "" and event == "end_array":
681 + self.streaming_state = StreamingState.ALL_TOOLS_COMPLETE
682 +
683 + def _extract_tool_calls_streaming_pre_v11_tokenizer(
684 + self,
685 + delta_text: str,
686 + delta_token_ids: Sequence[int],
687 + ) -> DeltaMessage | None:
688 + """Extract tool calls for pre-v11 Mistral models.
689 +
690 + Handles ``[TOOL_CALLS][{"name": "add", "arguments":{"a": 3.5}}]``.
691 + """
692 + assert self.parse_coro is not None
693 + content = None
694 + delta_tool_calls: list[DeltaToolCall] = []
695 + current_tool_call: DeltaToolCall = DeltaToolCall(
696 + index=self.current_tool_id, type="function"
697 )
56 - if tool_calls:
57 - from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
698 + current_tool_call_modified = False
699 + if self.bot_token_id in delta_token_ids or self.bot_token in delta_text:
700 + # this is the first tool call
701 + if not delta_text.startswith(self.bot_token):
702 + content = delta_text.split(self.bot_token)[0]
703 + delta_text = "".join(delta_text.split(self.bot_token)[1:])
704 +
705 + # ijson gives no text index per event, so split the delta manually
706 + # to know where each event is emitted from.
707 + while len(delta_text) > 0:
708 + streaming_state_before_parse = self.streaming_state
709 +
710 + if self.streaming_state == StreamingState.WAITING_FOR_TOOL_START:
711 + delta_to_be_parsed, delta_text = self._split_delta(
712 + delta_text=delta_text,
713 + stop_after_opening_curly_braces=1,
714 + )
715 + elif self.streaming_state == StreamingState.WAITING_FOR_TOOL_KEY:
716 + delta_to_be_parsed, delta_text = self._split_delta(
717 + delta_text=delta_text,
718 + stop_after_colon=1,
719 + stop_after_opening_curly_braces=1,
720 + )
721 + elif self.streaming_state == StreamingState.PARSING_NAME:
722 + delta_to_be_parsed, delta_text = self._split_delta(
723 + delta_text=delta_text,
724 + stop_after_comma=1,
725 + stop_after_closing_brackets=1,
726 + )
727 + elif self.streaming_state == StreamingState.WAITING_FOR_ARGUMENTS_START:
728 + delta_to_be_parsed, delta_text = self._split_delta(
729 + delta_text=delta_text,
730 + stop_after_opening_curly_braces=1,
731 + )
732 + elif self.streaming_state == StreamingState.PARSING_ARGUMENTS:
733 + delta_to_be_parsed, delta_text = self._split_delta(
734 + delta_text=delta_text,
735 + stop_after_closing_curly_braces=1,
736 + )
737 + elif self.streaming_state in [
738 + StreamingState.PARSING_ARGUMENTS_COMPLETED,
739 + StreamingState.PARSING_NAME_COMPLETED,
740 + ]:
741 + delta_to_be_parsed, delta_text = self._split_delta(
742 + delta_text=delta_text,
743 + stop_after_closing_curly_braces=1,
744 + stop_after_closing_brackets=1,
745 + )
746 + elif self.streaming_state == StreamingState.TOOL_COMPLETE:
747 + delta_to_be_parsed, delta_text = self._split_delta(
748 + delta_text=delta_text,
749 + stop_after_opening_curly_braces=1,
750 + stop_after_closing_brackets=1,
751 + )
752 + elif self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
753 + content = delta_text
754 + delta_text = ""
755 + else:
756 + delta_to_be_parsed = delta_text
757 + delta_text = ""
758 +
759 + if self.streaming_state != StreamingState.ALL_TOOLS_COMPLETE:
760 + self.parse_coro.send(delta_to_be_parsed.encode("utf-8"))
761 +
762 + # start_map is the authoritative new-tool signal and survives
763 + # batched deltas, unlike comparing pre/post streaming states.
764 + if self.starting_new_tool:
765 + self.starting_new_tool = False
766 + if current_tool_call_modified:
767 + if self.current_tool_mistral_id is not None:
768 + current_tool_call.id = self.current_tool_mistral_id
769 + self.current_tool_mistral_id = None
770 + self._track_streamed_args_pre_v11(current_tool_call)
771 + delta_tool_calls.append(current_tool_call)
772 + current_tool_call_modified = False
773 + self.current_tool_id += 1
774 + self.streamed_args_for_tool.append("")
775 + self.prev_tool_call_arr.append({})
776 + self.current_tool_mistral_id = MistralToolCall.generate_random_id()
777 + current_tool_call = DeltaToolCall(
778 + index=self.current_tool_id,
779 + type="function",
780 + )
781 + if current_tool_call.function is None:
782 + current_tool_call.function = DeltaFunctionCall()
783 +
784 + if self.current_tool_name is not None:
785 + current_tool_call_modified = True
786 + current_tool_call.function.name = self.current_tool_name
787 + self.prev_tool_call_arr[self.current_tool_id]["name"] = (
788 + self.current_tool_name
789 + )
790 + self.current_tool_name = None
791 + if self.streaming_state == StreamingState.PARSING_NAME_COMPLETED:
792 + self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
793 + if self.streaming_state in [
794 + StreamingState.PARSING_ARGUMENTS,
795 + StreamingState.PARSING_ARGUMENTS_COMPLETED,
796 + ]:
797 + if self.streaming_state == StreamingState.PARSING_ARGUMENTS_COMPLETED:
798 + self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
799 + current_tool_call_modified = True
800 + if current_tool_call.function.arguments is None:
801 + current_tool_call.function.arguments = delta_to_be_parsed
802 + else:
803 + current_tool_call.function.arguments += delta_to_be_parsed
804 + if streaming_state_before_parse != StreamingState.PARSING_ARGUMENTS:
805 + # It's the first chunk of arg. let's lstrip it
806 + current_tool_call.function.arguments = (
807 + current_tool_call.function.arguments.lstrip()
808 + )
809
59 - # Named/required tool_choice builds FunctionCalls without
60 - # ID, backfill with Mistral-format IDs.
61 - for tc in tool_calls:
62 - if not tc.id:
63 - tc.id = MistralToolCall.generate_random_id()
64 - return reasoning, content, tool_calls
810 + if current_tool_call_modified:
811 + if self.current_tool_mistral_id is not None:
812 + current_tool_call.id = self.current_tool_mistral_id
813 + self.current_tool_mistral_id = None
814 + self._track_streamed_args_pre_v11(current_tool_call)
815 + delta_tool_calls.append(current_tool_call)
816
66 - def parse_delta(
817 + if content or len(delta_tool_calls) > 0:
818 + delta_message = DeltaMessage()
819 + if content:
820 + delta_message.content = content
821 + if len(delta_tool_calls) > 0:
822 + delta_message.tool_calls = delta_tool_calls
823 + return delta_message
824 + else:
825 + if self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
826 + return DeltaMessage()
827 + else:
828 + return None
829 +
830 + def _track_streamed_args_pre_v11(self, tool_call: DeltaToolCall) -> None:
831 + r"""Accumulate `tool_call` arguments into the streaming state."""
832 + if tool_call.function is not None and tool_call.function.arguments is not None:
833 + self.streamed_args_for_tool[self.current_tool_id] += (
834 + tool_call.function.arguments
835 + )
836 + self.prev_tool_call_arr[self.current_tool_id]["arguments"] = (
837 + self.streamed_args_for_tool[self.current_tool_id]
838 + )
839 +
840 + def _split_delta(
841 self,
842 delta_text: str,
69 - delta_token_ids: list[int],
843 + stop_after_quotes: int = -1,
844 + stop_after_opening_curly_braces: int = -1,
845 + stop_after_closing_curly_braces: int = -1,
846 + stop_after_closing_brackets: int = -1,
847 + stop_after_colon: int = -1,
848 + stop_after_comma: int = -1,
849 + ) -> tuple[str, str]:
850 + delta_to_be_parsed = ""
851 + for i, c in enumerate(delta_text):
852 + if c in ['"', "'"]:
853 + delta_to_be_parsed += c
854 + stop_after_quotes -= 1
855 + if stop_after_quotes == 0:
856 + return (delta_to_be_parsed, delta_text[i + 1 :])
857 + elif c == "{":
858 + delta_to_be_parsed += c
859 + stop_after_opening_curly_braces -= 1
860 + if stop_after_opening_curly_braces == 0:
861 + return (delta_to_be_parsed, delta_text[i + 1 :])
862 + elif c == "}":
863 + delta_to_be_parsed += c
864 + stop_after_closing_curly_braces -= 1
865 + if stop_after_closing_curly_braces == 0:
866 + return (delta_to_be_parsed, delta_text[i + 1 :])
867 + elif c == "]":
868 + delta_to_be_parsed += c
869 + stop_after_closing_brackets -= 1
870 + if stop_after_closing_brackets == 0:
871 + return (delta_to_be_parsed, delta_text[i + 1 :])
872 + elif c == ":":
873 + delta_to_be_parsed += c
874 + stop_after_colon -= 1
875 + if stop_after_colon == 0:
876 + return (delta_to_be_parsed, delta_text[i + 1 :])
877 + elif c == ",":
878 + delta_to_be_parsed += c
879 + stop_after_comma -= 1
880 + if stop_after_comma == 0:
881 + return (delta_to_be_parsed, delta_text[i + 1 :])
882 + else:
883 + delta_to_be_parsed += c
884 +
885 + return (delta_to_be_parsed, "")
886 +
887 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
888 + if self._reasoning_encoding == "none":
889 + return True
890 + if super().is_reasoning_end(input_ids):
891 + return True
892 + # [TOOL_CALLS] acts as an implicit reasoning-end marker
893 + if self.bot_token_id is not None:
894 + reasoning_start_id = self._reasoning_start_token_id
895 + for i in range(len(input_ids) - 1, -1, -1):
896 + if (
897 + reasoning_start_id is not None
898 + and input_ids[i] == reasoning_start_id
899 + ):
900 + return False
901 + if input_ids[i] == self.bot_token_id:
902 + return True
903 + return False
904 +
905 + def extract_reasoning(
906 + self,
907 + model_output: str,
908 request: ChatCompletionRequest | ResponsesRequest,
71 - prompt_token_ids: list[int] | None = None,
72 - *,
73 - finished: bool,
74 - ) -> DeltaMessage | None:
75 - self._maybe_force_auto_tool_parsing(request)
76 - return super().parse_delta(
77 - delta_text,
78 - delta_token_ids,
79 - request,
80 - prompt_token_ids,
81 - finished=finished,
909 + ) -> tuple[str | None, str | None]:
910 + if self._reasoning_encoding == "none":
911 + return None, model_output
912 + return super().extract_reasoning(model_output, request)
913 +
914 + def _accept_tool_name(self, name: str) -> bool:
915 + # Once `[ARGS]` or the opening `{` has moved the slot past its name,
916 + # that name is final, empty included. Emitting the call with ""
917 + # surfaces the malformed generation instead of dropping it.
918 + return self._is_valid_tool_name(name)
919 +
920 + def _try_extract_name(self, idx: int) -> str | None:
921 + # No JSON-embedded "name" key in this format; the slot name is final.
922 + return self._tool_slots[idx].name
923 +
924 + def _extract_name_and_args(self, raw_body: str) -> tuple[str, str]:
925 + # Never a {"name": ..., "arguments": ...} envelope -- a literal "name"
926 + # argument key must not be mistaken for the tool name.
927 + return "", self._extract_args_json(raw_body, "")
928 +
929 + def _handle_arg_chunk(
930 + self,
931 + event: SemanticEvent,
932 + deltas: list[DeltaToolCall],
933 + ) -> None:
934 + """Emit the opening ``{`` as an arg delta when the name is first sent.
935 +
936 + When the TOOL_NAME→TOOL_ARGS transition fires, ``{`` arrives as an
937 + ARG_VALUE_CHUNK before ``name_sent`` is True. The parent emits the
938 + name delta but not the ``{`` arg delta. This override re-emits the
939 + current chunk so streaming clients receive a valid JSON prefix.
940 + """
941 + idx = event.tool_index
942 + name_sent_before = (
943 + 0 <= idx < len(self._tool_slots) and self._tool_slots[idx].name_sent
944 )
945 + super()._handle_arg_chunk(event, deltas)
946 + if (
947 + event.value
948 + and not name_sent_before
949 + and 0 <= idx < len(self._tool_slots)
950 + and self._tool_slots[idx].name_sent
951 + ):
952 + deltas.append(
953 + DeltaToolCall(
954 + index=idx,
955 + function=DeltaFunctionCall(arguments=event.value),
956 + )
957 + )
958 +
959 + def _extract_args_json(self, raw_args: str, func_name: str) -> str:
960 + """Return the first complete JSON value in ``raw_args``.
961 +
962 + v11+ tool calls are emitted as ``name{args}`` with no terminator, so a
963 + model may append ordinary text after the closing brace. Parse the first
964 + JSON value with ``raw_decode`` and drop any trailing output, mirroring
965 + the pre-v11 path (fixes gh#48975).
966 + """
967 + stripped = raw_args.strip()
968 + if not stripped:
969 + return "{}"
970 + try:
971 + _, end = json.JSONDecoder().raw_decode(stripped)
972 + except json.JSONDecodeError:
973 + return stripped
974 + return stripped[:end]
vllm/parser/parser_manager.py
-9
@@ -109,8 +109,6 @@ class ParserManager:
109 if reasoning_parser_cls is None and tool_parser_cls is None:
110 return None
111
112 - from vllm.utils.mistral import is_mistral_tool_parser
113 -
112 if is_harmony:
113 from vllm.parser.harmony import HarmonyParser
114
@@ -118,13 +116,6 @@ class ParserManager:
116 HarmonyParser.tool_parser_cls = tool_parser_cls
117 return HarmonyParser
118
121 - if is_mistral_tool_parser(tool_parser_cls):
122 - from vllm.parser.mistral import MistralParser
123 -
124 - MistralParser.reasoning_parser_cls = reasoning_parser_cls
125 - MistralParser.tool_parser_cls = tool_parser_cls
126 - return MistralParser
127 -
119 if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
120 from vllm.parser.kimi_k3 import KimiK3Parser
121
vllm/reasoning/__init__.py
+1 -1
@@ -106,7 +106,7 @@ _REASONING_PARSERS_TO_REGISTER = {
106 ),
107 "mistral": (
108 "mistral_reasoning_parser",
109 - "MistralReasoningParser",
109 + "MistralParserReasoningAdapter",
110 ),
111 "nemotron_v3": (
112 "nemotron_v3_engine_reasoning_parser",
vllm/reasoning/mistral_reasoning_parser.py
+2 -158
@@ -1,162 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -from collections.abc import Iterable, Sequence
5 -from functools import cached_property
6 -from typing import TYPE_CHECKING
4 +from vllm.parser.engine.registered_adapters import MistralParserReasoningAdapter
5
8 -from vllm.reasoning import ReasoningParser
9 -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
10 -from vllm.tokenizers.mistral import MistralTokenizer
11 -
12 -if TYPE_CHECKING:
13 - from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
14 - from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
15 -
16 -
17 -class MistralReasoningParser(BaseThinkingReasoningParser):
18 - """
19 - Reasoning parser for Mistral models.
20 -
21 - The Mistral models uses `[THINK]`...`[/THINK]` tokens to denote reasoning
22 - text. This parser extracts the reasoning content from the model output.
23 -
24 - A valid reasoning trace should always start with a `[THINK]` token and end with
25 - a `[/THINK]` token.
26 -
27 - If `[THINK]` token is not generated, then this parser only returns content.
28 - """
29 -
30 - def __init__(self, tokenizer: MistralTokenizer, *args, **kwargs):
31 - if not isinstance(tokenizer, MistralTokenizer):
32 - raise ValueError("The tokenizer must be an instance of MistralTokenizer.")
33 -
34 - ReasoningParser.__init__(self, tokenizer, *args, **kwargs)
35 -
36 - if not self.model_tokenizer:
37 - raise ValueError(
38 - "The model tokenizer must be passed to the ReasoningParser "
39 - "constructor during construction."
40 - )
41 -
42 - self.start_token_id = tokenizer.tokenizer.get_special_token(self.start_token)
43 - self.end_token_id = tokenizer.tokenizer.get_special_token(self.end_token)
44 -
45 - if self.start_token_id is None or self.end_token_id is None:
46 - raise RuntimeError(
47 - "Mistral reasoning parser could not locate think start/end "
48 - "tokens in the tokenizer!"
49 - )
50 -
51 - @cached_property
52 - def start_token(self) -> str:
53 - """The token that starts reasoning content."""
54 - from mistral_common.tokens.tokenizers.base import SpecialTokens
55 -
56 - return SpecialTokens.begin_think
57 -
58 - @cached_property
59 - def end_token(self) -> str:
60 - """The token that ends reasoning content."""
61 - from mistral_common.tokens.tokenizers.base import SpecialTokens
62 -
63 - return SpecialTokens.end_think
64 -
65 - def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
66 - has_eot_token = False
67 -
68 - for id in reversed(input_ids):
69 - if id == self.start_token_id:
70 - # Reasoning ends only if a BOT token is found before a EOT token.
71 - return has_eot_token
72 - elif id == self.end_token_id:
73 - has_eot_token = True
74 - return False
75 -
76 - def is_reasoning_end_streaming(
77 - self, input_ids: Sequence[int], delta_ids: Iterable[int]
78 - ) -> bool:
79 - if self.end_token_id in delta_ids:
80 - return True
81 - # Grammar's think? is optional — if [THINK] was never generated,
82 - # reasoning was skipped entirely.
83 - return self.start_token_id not in input_ids
84 -
85 - def extract_content_ids(self, input_ids: list[int]) -> list[int]:
86 - """
87 - Extract the content
88 - """
89 - has_bot_token = False
90 - has_eot_token = False
91 - bot_token_index = -1
92 - eot_token_index = -1
93 - # One for loop instead of multiple lookups
94 - for i, token_id in enumerate(input_ids):
95 - # We filter that we have multiple BOT tokens which should not
96 - # happen for a well prompted trained model
97 - if token_id == self.start_token_id and not has_bot_token:
98 - has_bot_token = True
99 - bot_token_index = i
100 - elif token_id == self.end_token_id:
101 - has_eot_token = True
102 - eot_token_index = i
103 - break
104 -
105 - # 1. Only BOT has been outputted
106 - if has_bot_token and not has_eot_token:
107 - # Should be = [] if model is well prompted and trained.
108 - return input_ids[:bot_token_index]
109 - # 2. Neither BOT or EOT have been outputted
110 - elif not has_bot_token and not has_eot_token:
111 - return input_ids
112 - # 3. Both BOT and EOT have been outputted.
113 - elif has_bot_token and has_eot_token:
114 - return input_ids[:bot_token_index] + input_ids[eot_token_index + 1 :]
115 - # 4. Only EOT has been outputted => this should not have occurred for a model
116 - # well prompted and trained.
117 - else:
118 - return input_ids[:eot_token_index] + input_ids[eot_token_index + 1 :]
119 -
120 - def extract_reasoning(
121 - self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest"
122 - ) -> tuple[str | None, str | None]:
123 - """
124 - Extract reasoning content from the model output.
125 - """
126 - if not model_output:
127 - return (None, "")
128 -
129 - # Check if the start token is present in the model output, remove it
130 - # if it is present.
131 - prev_bot_token, bot_token, post_bot_token = model_output.partition(
132 - self.start_token
133 - )
134 -
135 - has_bot_token = bool(bot_token)
136 - # Valid EOT tokens should follow BOT token
137 - has_valid_eot_token = has_bot_token and self.end_token in post_bot_token
138 -
139 - # 1. If there is BOT token followed by EOT token
140 - if has_bot_token and has_valid_eot_token:
141 - prev_eot_token, _, post_eot_token = post_bot_token.partition(self.end_token)
142 - # If model is well prompted and trained prev_bot_token should be ""
143 - content = prev_bot_token + post_eot_token
144 - return prev_eot_token, content if content else None
145 - # 2. Only BOT token
146 - elif has_bot_token:
147 - # If model is well prompted and trained prev_bot_token should be ""
148 - return post_bot_token, prev_bot_token if prev_bot_token else None
149 - # 3. EOT token has been outputted without BOT or neither has been outputted
150 - else:
151 - has_non_valid_eot_token = self.end_token in prev_bot_token
152 - # 3.a EOT token has been outputted without BOT
153 - # If model is well prompted and trained `has_non_valid_eot_token` should
154 - # be `False` and the parser outputs all tokens as 'content'
155 - if has_non_valid_eot_token:
156 - prev_eot_token, _, post_eot_token = prev_bot_token.partition(
157 - self.end_token
158 - )
159 - return None, prev_eot_token + post_eot_token
160 - # 3.b neither BOT or EOT have been outputted
161 - else:
162 - return None, prev_bot_token
6 +__all__ = ["MistralParserReasoningAdapter"]
vllm/tokenizers/mistral.py
+15 -6
@@ -21,7 +21,6 @@ from mistral_common.tokens.tokenizers.base import (
21 )
22 from mistral_common.tokens.tokenizers.instruct import (
23 InstructTokenizerBase,
24 - InstructTokenizerV13,
24 )
25 from mistral_common.tokens.tokenizers.mistral import (
26 MistralTokenizer as MistralCommonTokenizer,
@@ -461,6 +460,7 @@ class MistralTokenizer(TokenizerLike):
460 def convert_tokens_to_string(self, tokens: list[str]) -> str:
461 to_decode_special_tokens = {
462 SpecialTokens.tool_calls,
463 + SpecialTokens.args,
464 SpecialTokens.begin_think,
465 SpecialTokens.end_think,
466 }
@@ -517,11 +517,20 @@ class MistralTokenizer(TokenizerLike):
517 non_skip_special_tokens_ids = {
518 self.tokenizer.get_special_token(SpecialTokens.tool_calls),
519 }
520 - if isinstance(self.instruct, InstructTokenizerV13):
521 - if self.instruct.BEGIN_THINK:
522 - non_skip_special_tokens_ids.add(self.instruct.BEGIN_THINK)
523 - if self.instruct.END_THINK:
524 - non_skip_special_tokens_ids.add(self.instruct.END_THINK)
520 + # [ARGS] only exists in v11+ tool-call tokenizers; older tokenizers
521 + # raise (Tekken) or return unk (SPM) for it.
522 + if self.tokenizer.is_special(SpecialTokens.args):
523 + non_skip_special_tokens_ids.add(
524 + self.tokenizer.get_special_token(SpecialTokens.args)
525 + )
526 + # [THINK]/[/THINK] only exist in v13+ reasoning tokenizers; use the
527 + # same is_special gate as [ARGS] above so newer versions are covered
528 + # without an isinstance check on the instruct tokenizer.
529 + for think_token in (SpecialTokens.begin_think, SpecialTokens.end_think):
530 + if self.tokenizer.is_special(think_token):
531 + non_skip_special_tokens_ids.add(
532 + self.tokenizer.get_special_token(think_token)
533 + )
534
535 ids_kept = [
536 i
vllm/tool_parsers/mistral_tool_parser.py
+26 -756
@@ -3,767 +3,37 @@
3
4 from __future__ import annotations
5
6 -import json
7 -from collections.abc import Sequence
8 -from enum import Enum, auto
9 -from random import choices
10 -from string import ascii_letters, digits
11 -from typing import Any
6 +from typing import TYPE_CHECKING
7
13 -import ijson
14 -import regex as re
15 -from mistral_common.protocol.instruct.tool_calls import (
16 - NamedToolChoice as MistralNamedToolChoice,
17 -)
18 -from mistral_common.protocol.instruct.tool_calls import (
19 - Tool as MistralTool,
20 -)
21 -from mistral_common.protocol.instruct.tool_calls import (
22 - ToolChoice as MistralToolChoice,
23 -)
24 -from mistral_common.protocol.instruct.tool_calls import (
25 - ToolChoiceEnum as MistralToolChoiceEnum,
26 -)
27 -from pydantic import Field
8 +from vllm.parser.engine.registered_adapters import MistralParserToolAdapter
9
29 -from vllm.entrypoints.openai.chat_completion.protocol import (
30 - ChatCompletionRequest,
31 -)
32 -from vllm.entrypoints.openai.engine.protocol import (
33 - DeltaFunctionCall,
34 - DeltaMessage,
35 - DeltaToolCall,
36 - ExtractedToolCallInformation,
37 - FunctionCall,
38 - ToolCall,
39 -)
40 -from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
41 -from vllm.logger import init_logger
42 -from vllm.sampling_params import StructuredOutputsParams
43 -from vllm.tokenizers import TokenizerLike
44 -from vllm.tool_parsers.abstract_tool_parser import (
45 - Tool,
46 - ToolParser,
47 -)
48 -from vllm.utils.mistral import is_mistral_tokenizer
10 +if TYPE_CHECKING:
11 + from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
12 + from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
13
50 -logger = init_logger(__name__)
14
52 -ALPHANUMERIC = ascii_letters + digits
15 +class MistralToolParser(MistralParserToolAdapter): # type: ignore[valid-type, misc]
16 + # Marked so is_mistral_tool_parser() in vllm.utils.mistral recognises
17 + # this adapter and lets adjust_request() fire for tool_choice="none"
18 + # on grammar-capable Mistral tokenizers.
19 + IS_MISTRAL_TOOL_PARSER = True
20
54 -_DEFAULT_JSON_SCHEMA = {"anyOf": [{"type": "object"}, {"type": "array"}]}
55 -
56 -
57 -class StreamingState(Enum):
58 - """Enum for tracking the current streaming parsing state."""
59 -
60 - WAITING_FOR_TOOL_START = auto()
61 - WAITING_FOR_TOOL_KEY = (
62 - auto()
63 - ) # waiting for the "name" or "arguments" key to be complete
64 - PARSING_NAME = auto()
65 - PARSING_NAME_COMPLETED = auto()
66 - WAITING_FOR_ARGUMENTS_START = auto()
67 - PARSING_ARGUMENTS = auto()
68 - PARSING_ARGUMENTS_COMPLETED = auto()
69 - TOOL_COMPLETE = auto()
70 - ALL_TOOLS_COMPLETE = auto()
71 -
72 -
73 -class MistralToolCall(ToolCall):
74 - id: str = Field(default_factory=lambda: MistralToolCall.generate_random_id())
75 -
76 - @staticmethod
77 - def generate_random_id():
78 - # Mistral Tool Call Ids must be alphanumeric with a length of 9.
79 - # https://github.com/mistralai/mistral-common/blob/21ee9f6cee3441e9bb1e6ed2d10173f90bd9b94b/src/mistral_common/protocol/instruct/validator.py#L299
80 - return "".join(choices(ALPHANUMERIC, k=9))
81 -
82 - @staticmethod
83 - def is_valid_id(id: str) -> bool:
84 - return id.isalnum() and len(id) == 9
85 -
86 -
87 -def _is_pre_v11_tokeniser(model_tokenizer: TokenizerLike) -> bool:
88 - if is_mistral_tokenizer(model_tokenizer):
89 - return model_tokenizer.version < 11
90 - # For HF tokenizers, check if [ARGS] token exists in vocab
91 - # which indicates a v11+ equivalent tokenizer
92 - vocab: dict[str, int] = getattr(model_tokenizer, "get_vocab", lambda: {})()
93 - return "[ARGS]" not in vocab
94 -
95 -
96 -class MistralToolParser(ToolParser):
97 - r"""Tool call parser for Mistral models, intended for use with either:
98 -
99 - - `mistral_common <https://github.com/mistralai/mistral-common/>`_
100 - (recommended)
101 - - the `examples/tool_chat_template_mistral.jinja` template.
102 -
103 - Used when `--enable-auto-tool-choice --tool-call-parser mistral` are all
104 - set.
105 - """
106 -
107 - IS_MISTRAL_TOOL_PARSER = True # used by vllm.utils.mistral
108 -
109 - # Used to generate correct grammar in `adjust_request`
110 - model_can_reason: bool = False
111 -
112 - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
113 - super().__init__(tokenizer, tools)
114 -
115 - if not is_mistral_tokenizer(self.model_tokenizer):
116 - logger.info("Non-Mistral tokenizer detected when using a Mistral model...")
117 -
118 - # initialize properties used for state when parsing tool calls in
119 - # streaming mode
120 - self.prev_tool_call_arr: list[dict[str, Any]] = []
121 - self.current_tool_id: int = -1
122 - self.streaming_state: StreamingState = StreamingState.WAITING_FOR_TOOL_START
123 -
124 - # For streaming pre v11 tokenizer tool calls
125 - self.current_tool_name: str | None = None
126 - self.current_tool_mistral_id: str | None = None
127 - self.starting_new_tool = False
128 - self._is_pre_v11 = _is_pre_v11_tokeniser(self.model_tokenizer)
129 - if self._is_pre_v11:
130 - self.parse_coro = ijson.parse_coro(
131 - self.update_stream_state_pre_v11_tokenizer()
132 - )
133 -
134 - self.bot_token = "[TOOL_CALLS]"
135 - self.bot_token_id = self.vocab.get(self.bot_token)
136 - self.tool_call_regex = re.compile(r"\[{.*}\]", re.DOTALL)
137 -
138 - if self.bot_token_id is None:
139 - raise RuntimeError(
140 - "Mistral Tool Parser could not locate the tool call token in "
141 - "the tokenizer!"
142 - )
21 + # Mistral emits tool calls in the [TOOL_CALLS]name[ARGS]{...} format, not the
22 + # plain JSON array / bare-args that the serving layer's generic required and
23 + # named handlers expect. Route required/named through this parser's own
24 + # extraction (treated like "auto") so the [TOOL_CALLS] format is parsed
25 + # correctly instead of crashing or dumping the raw envelope into arguments.
26 + supports_required_and_named = False
27
28 def adjust_request(
145 - self, request: ChatCompletionRequest | ResponsesRequest
146 - ) -> ChatCompletionRequest | ResponsesRequest:
147 - so_non_supported_attributes = [
148 - "regex",
149 - "choice",
150 - "grammar",
151 - # whitespace_pattern is not a constraint type but an option;
152 - # Mistral grammar factory does not support it.
153 - "whitespace_pattern",
154 - "structural_tag",
155 - ]
156 - any_so_non_supported_active = request.structured_outputs is not None and any(
157 - getattr(request.structured_outputs, attribute) is not None
158 - for attribute in so_non_supported_attributes
159 - )
160 - response_format_non_supported_active = (
161 - isinstance(request, ResponsesRequest)
162 - or request.response_format is not None
163 - and request.response_format.type == "structural_tag"
164 - )
165 -
166 - if (
167 - not is_mistral_tokenizer(self.model_tokenizer)
168 - or isinstance(request, ResponsesRequest)
169 - or not self.model_tokenizer.supports_grammar
170 - or any_so_non_supported_active
171 - or response_format_non_supported_active
172 - ):
173 - request = super().adjust_request(request)
174 - if request.tools and request.tool_choice != "none":
175 - # Do not skip special tokens when using chat template
176 - # with Mistral parser as TOOL_CALL token is needed
177 - # for tool detection.
178 - # Note: we don't want skip_special_tokens=False
179 - # with MistralTokenizer as it is incompatible
180 - request.skip_special_tokens = False
181 - return request
182 -
183 - json_schema: dict[str, Any] | None = None
184 - if request.structured_outputs is not None:
185 - if request.structured_outputs.json_object is not None:
186 - json_schema = _DEFAULT_JSON_SCHEMA
187 - elif request.structured_outputs.json is not None:
188 - if isinstance(request.structured_outputs.json, str):
189 - json_schema = json.loads(request.structured_outputs.json)
190 - else:
191 - json_schema = request.structured_outputs.json
192 - else:
193 - raise ValueError(
194 - "Unsupported request.structured_outputs for MistralToolParser. "
195 - "Only `json` and `json_object` are supported."
196 - )
197 - elif (
198 - request.response_format is not None
199 - and request.response_format.type != "text"
200 - ):
201 - if request.response_format.type == "json_object":
202 - json_schema = _DEFAULT_JSON_SCHEMA
203 - elif request.response_format.type == "json_schema":
204 - if request.response_format.json_schema is not None:
205 - json_schema = request.response_format.json_schema.json_schema
206 - else:
207 - json_schema = _DEFAULT_JSON_SCHEMA
208 - else:
209 - raise ValueError(
210 - "MistralToolParser only accepts `text`, `json_object` or "
211 - f"`json_schema`, got {request.response_format=}"
212 - )
213 - # Structured Outputs will be defined.
214 - request.response_format = None
215 -
216 - grammar_factory = self.model_tokenizer.grammar_factory
217 -
218 - # TODO: Once unified parser, improve this.
219 - # The issue is figuring out when a model is a reasoning one or not.
220 - template = grammar_factory.select_jinja_template(
221 - reasoning=self.model_can_reason
222 - )
223 -
224 - mistral_tools = (
225 - [MistralTool.from_openai(tool.model_dump()) for tool in request.tools]
226 - if request.tools is not None
227 - else None
228 - )
229 -
230 - tool_choice: MistralToolChoice
231 - match request.tool_choice:
232 - case "none" | "auto" | "required":
233 - tool_choice = MistralToolChoiceEnum(request.tool_choice)
234 - case None:
235 - tool_choice = MistralToolChoiceEnum.auto
236 - # _ == Named tool choice
237 - case _:
238 - tool_choice = MistralNamedToolChoice.model_validate(
239 - {
240 - "type": "function",
241 - "function": {"name": request.tool_choice.function.name},
242 - }
243 - )
244 -
245 - # Rendering grammar is cached in mistral-common given tools, template and mode.
246 - match tool_choice, json_schema is not None:
247 - case MistralToolChoiceEnum.none, True:
248 - lark_grammar = grammar_factory.get_lark_for_json_schema(
249 - template=template, json_schema=json_schema
250 - )
251 - case _, _:
252 - lark_grammar = grammar_factory.get_lark_from_jinja(
253 - template=template,
254 - mode=tool_choice,
255 - tools=mistral_tools,
256 - json_schema=json_schema,
257 - parallel_tool_calls=request.parallel_tool_calls,
258 - json_only=False,
259 - )
260 -
261 - request.structured_outputs = StructuredOutputsParams(grammar=lark_grammar)
262 - request._grammar_from_tool_parser = True
263 - return request
264 -
265 - def extract_tool_calls(
266 - self,
267 - model_output: str,
268 - request: ChatCompletionRequest,
269 - ) -> ExtractedToolCallInformation:
270 - """
271 - Extract the tool calls from a complete model response.
272 -
273 - Content and tool calls formatting depends on the Mistral's tokenizer version
274 - used to train the model:
275 -
276 - - < v11: `content[BOT] [{tool_call1},{tool_call2}]`
277 - - >= v11: `content[BOT]tool_name1{args_call1}[BOT]tool_name2{args_call2}`
278 -
279 - with [BOT] the tool call token.
280 -
281 - Note:
282 - For tokenizer versions >= v11, tool calls with arguments wrongly formatted
283 - are still returned as tool calls. This is to allow the model to know it
284 - tried to make a tool call. It reduces chance of another failure and
285 - prevents that the context is filled with tool calls wrongly placed in
286 - assistant message contents.
287 - """
288 -
289 - # If the tool call token is not present, return a text response
290 - if self.bot_token not in model_output:
291 - return ExtractedToolCallInformation(
292 - tools_called=False, tool_calls=[], content=model_output
293 - )
294 -
295 - content_and_raw_tool_calls = model_output.split(self.bot_token)
296 - content = content_and_raw_tool_calls[0]
297 - raw_tool_calls = content_and_raw_tool_calls[1:]
298 -
299 - # >= v11: content[BOT]tool_name1{args_call1}[BOT]tool_name2{args_call2}
300 - if not self._is_pre_v11:
301 - tool_calls = []
302 - for raw_tool_call in raw_tool_calls:
303 - if "{" not in raw_tool_call:
304 - continue
305 -
306 - end_name = raw_tool_call.find("{")
307 - tool_name, args = (
308 - raw_tool_call[:end_name],
309 - raw_tool_call[end_name:],
310 - )
311 -
312 - # HF tokenizers may include [ARGS] in the text
313 - tool_name = tool_name.replace("[ARGS]", "")
314 - tool_calls.append({"name": tool_name, "arguments": args})
315 -
316 - # < v11: content[BOT] [{tool_call1},{tool_call2}]
317 - else:
318 - if len(raw_tool_calls) != 1:
319 - raise ValueError(
320 - "Only one BOT token should have been outputted, "
321 - f"but got {model_output}."
322 - )
323 - stringified_tool_calls = raw_tool_calls[0].strip()
324 - try:
325 - # Use raw_decode to parse the first valid JSON value,
326 - # ignoring trailing tokens the model may emit after
327 - # the tool call array.
328 - tool_calls, _ = json.JSONDecoder().raw_decode(stringified_tool_calls)
329 - except json.JSONDecodeError:
330 - try:
331 - raw_tool_call = self.tool_call_regex.findall(
332 - stringified_tool_calls
333 - )[0]
334 - tool_calls = json.loads(raw_tool_call)
335 - tool_calls = [
336 - {
337 - "name": tool_call["name"],
338 - "arguments": json.dumps(
339 - tool_call.get("arguments", {}),
340 - ensure_ascii=False,
341 - ),
342 - }
343 - for tool_call in tool_calls
344 - ]
345 - except (IndexError, json.JSONDecodeError):
346 - logger.exception("Error in extracting tool call from response.")
347 - return ExtractedToolCallInformation(
348 - tools_called=False,
349 - tool_calls=[],
350 - content=stringified_tool_calls,
351 - )
352 - else:
353 - tool_calls = [
354 - {
355 - "name": tool_call["name"],
356 - "arguments": json.dumps(
357 - tool_call.get("arguments", {}),
358 - ensure_ascii=False,
359 - ),
360 - }
361 - for tool_call in tool_calls
362 - ]
363 -
364 - mistral_tool_calls: list[MistralToolCall] = [
365 - MistralToolCall(
366 - type="function",
367 - function=FunctionCall(
368 - name=tool_call["name"],
369 - arguments=tool_call.get("arguments", "{}"),
370 - ),
371 - )
372 - for tool_call in tool_calls
373 - ]
374 -
375 - return ExtractedToolCallInformation(
376 - tools_called=True,
377 - tool_calls=mistral_tool_calls,
378 - content=content if content.strip() else None,
379 - )
380 -
381 - def extract_tool_calls_streaming(
382 - self,
383 - previous_text: str,
384 - current_text: str,
385 - delta_text: str,
386 - previous_token_ids: Sequence[int],
387 - current_token_ids: Sequence[int],
388 - delta_token_ids: Sequence[int],
389 - request: ChatCompletionRequest,
390 - ) -> DeltaMessage | None:
391 - has_bot_token = (
392 - self.bot_token_id in current_token_ids or self.bot_token in current_text
393 - )
394 - if not has_bot_token:
395 - # if the tool call token is not in the tokens generated so far,
396 - # append output to contents since it's not a tool
397 - return DeltaMessage(content=delta_text)
398 -
399 - # if the tool call token IS in the tokens generated so far, that
400 - # means we're parsing as tool calls now
401 - try:
402 - if self._is_pre_v11:
403 - return self._extract_tool_calls_streaming_pre_v11_tokenizer(
404 - delta_text=delta_text,
405 - delta_token_ids=delta_token_ids,
406 - )
407 - else:
408 - return self._extract_tool_calls_streaming(
409 - delta_text=delta_text, delta_token_ids=delta_token_ids
410 - )
411 - except Exception:
412 - logger.exception("Error trying to handle streaming tool call.")
413 - return None
414 -
415 - def _extract_tool_calls_streaming(
416 - self,
417 - delta_text: str,
418 - delta_token_ids: Sequence[int],
419 - ) -> DeltaMessage | None:
420 - """
421 - Extracts tool calls for Mistral models
422 - doing tool calls of the following format:
423 - `[TOOL_CALLS]add{"a": 3.5, "b": 4}`
424 - """
425 - additional_content: str = ""
426 - if self.streaming_state == StreamingState.WAITING_FOR_TOOL_START:
427 - # this is the first tool call
428 - if self.bot_token not in delta_text:
429 - return DeltaMessage(content=delta_text)
430 - if not delta_text.startswith(self.bot_token):
431 - additional_content += delta_text.split(self.bot_token)[0]
432 - delta_text = self.bot_token + "".join(
433 - delta_text.split(self.bot_token)[1:]
434 - )
435 -
436 - delta_tool_calls = self._generate_delta_tool_call(delta_text)
437 - if not additional_content and len(delta_tool_calls) == 0:
438 - if self.streaming_state in [
439 - StreamingState.PARSING_ARGUMENTS,
440 - StreamingState.PARSING_ARGUMENTS_COMPLETED,
441 - StreamingState.TOOL_COMPLETE,
442 - StreamingState.ALL_TOOLS_COMPLETE,
443 - ]:
444 - # Return an empty DeltaMessage once the tool calls are all done
445 - # so that finish_reason gets set.
446 - return DeltaMessage()
447 - else:
448 - # return None when the tool is not likely to be finished
449 - # This can occur when the name is being parsed for example
450 - # and we wait for the name to be complete
451 - # before sending the function name
452 - return None
453 -
454 - delta = DeltaMessage()
455 - if additional_content:
456 - delta.content = additional_content
457 - if len(delta_tool_calls) > 0:
458 - delta.tool_calls = delta_tool_calls
459 -
460 - return delta
461 -
462 - def _generate_delta_tool_call(self, delta_text: str) -> list[DeltaToolCall]:
463 - if delta_text == "" or delta_text is None:
464 - return []
465 - delta_function_name = None
466 - tool_id = None
467 - if self.streaming_state not in [
468 - StreamingState.PARSING_NAME,
469 - StreamingState.PARSING_ARGUMENTS,
470 - ] and delta_text.startswith(self.bot_token):
471 - self.current_tool_id += 1
472 - self.streamed_args_for_tool.append("")
473 - self.prev_tool_call_arr.append({})
474 - self.streaming_state = StreamingState.PARSING_NAME
475 - delta_text = delta_text.replace(self.bot_token, "", 1)
476 - if self.streaming_state == StreamingState.PARSING_NAME:
477 - if self.current_tool_name is None:
478 - self.current_tool_name = ""
479 - # The name stops where the arguments start
480 - # And the arguments start with the `{` char
481 - if "{" in delta_text:
482 - tool_id = MistralToolCall.generate_random_id()
483 - delta_function_name = delta_text.split("{")[0]
484 - self.current_tool_name += delta_function_name
485 - # HF tokenizers may include [ARGS] in the text
486 - self.current_tool_name = self.current_tool_name.replace("[ARGS]", "")
487 - self.prev_tool_call_arr[self.current_tool_id]["name"] = (
488 - self.current_tool_name
489 - )
490 - delta_text = delta_text[len(delta_function_name) :]
491 - self.streaming_state = StreamingState.PARSING_ARGUMENTS
492 - else:
493 - # we want to send the tool name once it's complete
494 - self.current_tool_name += delta_text
495 - return []
496 - if self.streaming_state == StreamingState.PARSING_ARGUMENTS:
497 - next_function_text = None
498 - if self.bot_token in delta_text:
499 - # current tool call is over
500 - delta_arguments = ""
501 - delta_arguments += delta_text.split(self.bot_token)[0]
502 - next_function_text = delta_text[len(delta_arguments) :]
503 - self.streaming_state = StreamingState.TOOL_COMPLETE
504 - else:
505 - delta_arguments = delta_text
506 - self.streamed_args_for_tool[self.current_tool_id] += delta_arguments
507 - self.prev_tool_call_arr[self.current_tool_id]["arguments"] = (
508 - self.streamed_args_for_tool[self.current_tool_id]
509 - )
510 - ret = []
511 - if self.current_tool_name or delta_arguments:
512 - ret += [
513 - DeltaToolCall(
514 - index=self.current_tool_id,
515 - type="function",
516 - id=tool_id,
517 - function=DeltaFunctionCall(
518 - name=self.current_tool_name, arguments=delta_arguments
519 - ).model_dump(exclude_none=True),
520 - )
521 - ]
522 - self.current_tool_name = None
523 - if next_function_text:
524 - ret += self._generate_delta_tool_call(next_function_text)
525 - return ret
526 - # Should not happen
527 - return []
528 -
529 - @ijson.coroutine
530 - def update_stream_state_pre_v11_tokenizer(self):
531 - while True:
532 - (prefix, event, value) = yield
533 -
534 - if prefix == "item" and event == "start_map":
535 - self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
536 - self.starting_new_tool = True
537 - if prefix == "item" and event == "map_key" and value == "name":
538 - self.streaming_state = StreamingState.PARSING_NAME
539 - if prefix == "item.name" and event == "string":
540 - self.current_tool_name = value
541 - self.streaming_state = StreamingState.PARSING_NAME_COMPLETED
542 - if prefix == "item" and event == "map_key" and value == "arguments":
543 - self.streaming_state = StreamingState.WAITING_FOR_ARGUMENTS_START
544 - if prefix == "item.arguments" and event == "start_map":
545 - self.streaming_state = StreamingState.PARSING_ARGUMENTS
546 - if prefix == "item.arguments" and event == "end_map":
547 - self.streaming_state = StreamingState.PARSING_ARGUMENTS_COMPLETED
548 - if prefix == "item" and event == "end_map":
549 - self.streaming_state = StreamingState.TOOL_COMPLETE
550 - if prefix == "" and event == "end_array":
551 - self.streaming_state = StreamingState.ALL_TOOLS_COMPLETE
552 -
553 - def _extract_tool_calls_streaming_pre_v11_tokenizer(
29 self,
555 - delta_text: str,
556 - delta_token_ids: Sequence[int],
557 - ) -> DeltaMessage | None:
558 - """
559 - Extracts tool calls for Mistral models
560 - doing tool calls of the following format:
561 - `[TOOL_CALLS][{"name": "add", "arguments":{"a": 3.5, "b": 4}}`
562 - """
563 - assert self.parse_coro is not None
564 - content = None
565 - delta_tool_calls: list[DeltaToolCall] = []
566 - current_tool_call: DeltaToolCall = DeltaToolCall(
567 - index=self.current_tool_id, type="function"
568 - )
569 - current_tool_call_modified = False
570 - if self.bot_token_id in delta_token_ids or self.bot_token in delta_text:
571 - # this is the first tool call
572 - if not delta_text.startswith(self.bot_token):
573 - content = delta_text.split(self.bot_token)[0]
574 - delta_text = "".join(delta_text.split(self.bot_token)[1:])
575 -
576 - # Cut smartly the delta text to catch the ijson events
577 - # as ijson does not give us the index in the text at each event.
578 - # We need to cut so that we know
579 - # where in the text the events are emitted from.
580 - while len(delta_text) > 0:
581 - streaming_state_before_parse = self.streaming_state
582 -
583 - if self.streaming_state == StreamingState.WAITING_FOR_TOOL_START:
584 - delta_to_be_parsed, delta_text = self._split_delta(
585 - delta_text=delta_text,
586 - stop_after_opening_curly_braces=1,
587 - )
588 - elif self.streaming_state == StreamingState.WAITING_FOR_TOOL_KEY:
589 - # Wait until another key is sent
590 - # or the current tool is completed
591 - delta_to_be_parsed, delta_text = self._split_delta(
592 - delta_text=delta_text,
593 - stop_after_colon=1,
594 - stop_after_opening_curly_braces=1,
595 - # if the tool ends, we want to separate
596 - # at the start of the next tool
597 - )
598 - elif self.streaming_state == StreamingState.PARSING_NAME:
599 - delta_to_be_parsed, delta_text = self._split_delta(
600 - delta_text=delta_text,
601 - stop_after_comma=1,
602 - stop_after_closing_brackets=1,
603 - )
604 - elif self.streaming_state == StreamingState.WAITING_FOR_ARGUMENTS_START:
605 - delta_to_be_parsed, delta_text = self._split_delta(
606 - delta_text=delta_text,
607 - stop_after_opening_curly_braces=1,
608 - )
609 - elif self.streaming_state == StreamingState.PARSING_ARGUMENTS:
610 - delta_to_be_parsed, delta_text = self._split_delta(
611 - delta_text=delta_text,
612 - stop_after_closing_curly_braces=1,
613 - # we could be more clever
614 - # by listening to item.arguments.* start_map events
615 - # and know how many curly braces we can allow
616 - )
617 - elif self.streaming_state in [
618 - StreamingState.PARSING_ARGUMENTS_COMPLETED,
619 - StreamingState.PARSING_NAME_COMPLETED,
620 - ]:
621 - delta_to_be_parsed, delta_text = self._split_delta(
622 - delta_text=delta_text,
623 - stop_after_closing_curly_braces=1,
624 - stop_after_closing_brackets=1,
625 - )
626 - elif self.streaming_state == StreamingState.TOOL_COMPLETE:
627 - delta_to_be_parsed, delta_text = self._split_delta(
628 - delta_text=delta_text,
629 - stop_after_opening_curly_braces=1,
630 - stop_after_closing_brackets=1,
631 - )
632 - elif self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
633 - content = delta_text
634 - delta_text = ""
635 - else:
636 - delta_to_be_parsed = delta_text
637 - delta_text = ""
638 -
639 - if self.streaming_state != StreamingState.ALL_TOOLS_COMPLETE:
640 - self.parse_coro.send(delta_to_be_parsed.encode("utf-8"))
641 -
642 - # Given the parsed text and the possible streaming state change,
643 - # let's add to the tool delta
644 - # start_map is the authoritative new-tool signal and survives
645 - # batched deltas, unlike comparing pre/post streaming states
646 - if self.starting_new_tool:
647 - self.starting_new_tool = False
648 - if current_tool_call_modified:
649 - if self.current_tool_mistral_id is not None:
650 - current_tool_call.id = self.current_tool_mistral_id
651 - self.current_tool_mistral_id = None
652 - self._track_streamed_args_pre_v11(current_tool_call)
653 - delta_tool_calls.append(current_tool_call)
654 - current_tool_call_modified = False
655 - self.current_tool_id += 1
656 - self.streamed_args_for_tool.append("")
657 - self.prev_tool_call_arr.append({})
658 - self.current_tool_mistral_id = MistralToolCall.generate_random_id()
659 - current_tool_call = DeltaToolCall(
660 - index=self.current_tool_id,
661 - type="function",
662 - )
663 - if current_tool_call.function is None:
664 - current_tool_call.function = DeltaFunctionCall()
665 -
666 - if self.current_tool_name is not None:
667 - # we have the complete tool name
668 - current_tool_call_modified = True
669 - current_tool_call.function.name = self.current_tool_name
670 - self.prev_tool_call_arr[self.current_tool_id]["name"] = (
671 - self.current_tool_name
672 - )
673 - self.current_tool_name = None
674 - if self.streaming_state == StreamingState.PARSING_NAME_COMPLETED:
675 - self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
676 - if self.streaming_state in [
677 - StreamingState.PARSING_ARGUMENTS,
678 - StreamingState.PARSING_ARGUMENTS_COMPLETED,
679 - ]:
680 - if self.streaming_state == StreamingState.PARSING_ARGUMENTS_COMPLETED:
681 - self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
682 - # the delta_to_be_parsed is part of arguments.
683 - current_tool_call_modified = True
684 - if current_tool_call.function.arguments is None:
685 - current_tool_call.function.arguments = delta_to_be_parsed
686 - else:
687 - current_tool_call.function.arguments += delta_to_be_parsed
688 - if streaming_state_before_parse != StreamingState.PARSING_ARGUMENTS:
689 - # It's the first chunk of arg. let's lstrip it
690 - current_tool_call.function.arguments = (
691 - current_tool_call.function.arguments.lstrip()
692 - )
693 -
694 - if current_tool_call_modified:
695 - if self.current_tool_mistral_id is not None:
696 - current_tool_call.id = self.current_tool_mistral_id
697 - self.current_tool_mistral_id = None
698 - self._track_streamed_args_pre_v11(current_tool_call)
699 - delta_tool_calls.append(current_tool_call)
700 -
701 - if content or len(delta_tool_calls) > 0:
702 - delta_message = DeltaMessage()
703 - if content:
704 - delta_message.content = content
705 - if len(delta_tool_calls) > 0:
706 - delta_message.tool_calls = delta_tool_calls
707 - return delta_message
708 - else:
709 - if self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
710 - return DeltaMessage()
711 - else:
712 - return None
713 -
714 - def _track_streamed_args_pre_v11(self, tool_call: DeltaToolCall) -> None:
715 - r"""Accumulate `tool_call` arguments into the streaming state."""
716 - if tool_call.function is not None and tool_call.function.arguments is not None:
717 - self.streamed_args_for_tool[self.current_tool_id] += (
718 - tool_call.function.arguments
719 - )
720 - self.prev_tool_call_arr[self.current_tool_id]["arguments"] = (
721 - self.streamed_args_for_tool[self.current_tool_id]
722 - )
723 -
724 - def _split_delta(
725 - self,
726 - delta_text: str,
727 - stop_after_quotes: int = -1,
728 - stop_after_opening_curly_braces: int = -1,
729 - stop_after_closing_curly_braces: int = -1,
730 - stop_after_closing_brackets: int = -1,
731 - stop_after_colon: int = -1,
732 - stop_after_comma=-1,
733 - ) -> tuple[str, str]:
734 - delta_to_be_parsed = ""
735 - for i, c in enumerate(delta_text):
736 - if c in ['"', "'"]:
737 - delta_to_be_parsed += c
738 - stop_after_quotes -= 1
739 - if stop_after_quotes == 0:
740 - return (delta_to_be_parsed, delta_text[i + 1 :])
741 - elif c == "{":
742 - delta_to_be_parsed += c
743 - stop_after_opening_curly_braces -= 1
744 - if stop_after_opening_curly_braces == 0:
745 - return (delta_to_be_parsed, delta_text[i + 1 :])
746 - elif c == "}":
747 - delta_to_be_parsed += c
748 - stop_after_closing_curly_braces -= 1
749 - if stop_after_closing_curly_braces == 0:
750 - return (delta_to_be_parsed, delta_text[i + 1 :])
751 - elif c == "]":
752 - delta_to_be_parsed += c
753 - stop_after_closing_brackets -= 1
754 - if stop_after_closing_brackets == 0:
755 - return (delta_to_be_parsed, delta_text[i + 1 :])
756 - elif c == ":":
757 - delta_to_be_parsed += c
758 - stop_after_colon -= 1
759 - if stop_after_colon == 0:
760 - return (delta_to_be_parsed, delta_text[i + 1 :])
761 - elif c == ",":
762 - delta_to_be_parsed += c
763 - stop_after_comma -= 1
764 - if stop_after_comma == 0:
765 - return (delta_to_be_parsed, delta_text[i + 1 :])
766 - else:
767 - delta_to_be_parsed += c
768 -
769 - return (delta_to_be_parsed, "")
30 + request: ChatCompletionRequest | ResponsesRequest,
31 + ) -> ChatCompletionRequest | ResponsesRequest:
32 + # Skip the base ToolParser tool_choice -> structured_outputs
33 + # conversion. Mistral enforces tool_choice via the grammar ``mode``
34 + # (auto/none/required/named); forwarding a tool-derived json_schema
35 + # would be unioned into the grammar by mistral-common, allowing raw
36 + # JSON in place of a real [TOOL_CALLS] call. Genuine user-provided
37 + # structured output on the request is still honored by the engine's
38 + # adjust_request.
39 + return self._parser_engine.adjust_request(request)
vllm/tool_parsers/streaming.py
+1 -1
@@ -99,7 +99,7 @@ def extract_named_tool_call_streaming(
99 else:
100 if is_mistral_tokenizer(tokenizer):
101 # Import mistral_common only if we need it.
102 - from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
102 + from vllm.parser.mistral import MistralToolCall
103
104 tool_call_id = MistralToolCall.generate_random_id()
105 else:
vllm/utils/mistral.py
+5 -10
@@ -12,10 +12,8 @@ from vllm.utils.import_utils import LazyLoader
12 if TYPE_CHECKING:
13 # if type checking, eagerly import the module
14 import vllm.tokenizers.mistral as mt
15 - import vllm.tool_parsers.mistral_tool_parser as mtp
15 else:
16 mt = LazyLoader("mt", globals(), "vllm.tokenizers.mistral")
18 - mtp = LazyLoader("mtp", globals(), "vllm.tool_parsers.mistral_tool_parser")
17
18
19 def is_mistral_tokenizer(obj: TokenizerLike | None) -> TypeGuard[mt.MistralTokenizer]:
@@ -31,13 +29,10 @@ def is_mistral_tokenizer(obj: TokenizerLike | None) -> TypeGuard[mt.MistralToken
29
30
31 def is_mistral_tool_parser(cls: type | None) -> bool:
34 - """Return true if *cls* is (a subclass of) MistralToolParser.
32 + """Return true if *cls* carries the ``IS_MISTRAL_TOOL_PARSER`` marker.
33
36 - Uses a class attribute check so that importing
37 - ``vllm.tool_parsers.mistral_tool_parser`` — and transitively
38 - ``mistral_common`` — is not required.
34 + The marker is set on the engine-adapter subclass registered under the
35 + ``"mistral"`` tool-parser key. No import of the parser module is
36 + required.
37 """
40 - return bool(
41 - getattr(cls, "IS_MISTRAL_TOOL_PARSER", False)
42 - and issubclass(cls, mtp.MistralToolParser) # type: ignore[arg-type]
43 - )
38 + return bool(getattr(cls, "IS_MISTRAL_TOOL_PARSER", False))