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

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

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

Synced Nov 5, 02:15 UTC

[Mistral Grammar] Fix tool and reasoning parsing (#39217)

Signed-off-by: juliendenize <julien.denize@mistral.ai>

Julien Denize committed Apr 16, 2026 at 06:05 UTC c0722f22de7159c7ed91fa8268bcecd87b35fe9e
10 files changed +1601 -266
tests/tool_parsers/test_mistral_tool_parser.py
+752 -180
@@ -3,6 +3,7 @@
3
4 import json
5 from collections.abc import Generator
6 +from typing import Any
7 from unittest.mock import MagicMock, patch
8
9 import partial_json_parser
@@ -23,24 +24,33 @@ from mistral_common.protocol.instruct.tool_calls import (
24 ToolChoiceEnum as MistralToolChoiceEnum,
25 )
26 from partial_json_parser.core.options import Allow
27 +from pydantic import ValidationError
28
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 StructuralTagResponseFormat,
38 )
39 +from vllm.entrypoints.openai.engine.protocol import FunctionCall as VllmFunctionCall
40 +from vllm.reasoning.mistral_reasoning_parser import MistralReasoningParser
41 from vllm.sampling_params import StructuredOutputsParams
42 from vllm.tokenizers import TokenizerLike, get_tokenizer
43 from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
44 from vllm.tokenizers.mistral import MistralTokenizer
45 from vllm.tool_parsers.mistral_tool_parser import (
46 _DEFAULT_JSON_SCHEMA,
47 + MistralStreamingResult,
48 + MistralToolCall,
49 MistralToolParser,
50 )
51
52 +_DUMMY_REQUEST = ChatCompletionRequest(messages=[], model="test")
53 +
54
55 @pytest.fixture(scope="module")
56 def mistral_pre_v11_tokenizer():
@@ -205,7 +215,7 @@ def stream_delta_message_generator(
215 previous_token_ids,
216 current_token_ids,
217 delta_token_ids,
208 - request=None, # type: ignore[arg-type]
218 + request=_DUMMY_REQUEST,
219 )
220 if delta_message:
221 yield delta_message
@@ -218,14 +228,18 @@ def stream_delta_message_generator(
228 read_offset = new_read_offset
229
230
221 -def test_extract_tool_calls_no_tools(mistral_pre_v11_tool_parser):
231 +@pytest.mark.parametrize(
232 + "parser_fixture",
233 + ["mistral_pre_v11_tool_parser", "mistral_tool_parser"],
234 + ids=["pre_v11", "v11"],
235 +)
236 +def test_extract_tool_calls_no_tools(parser_fixture, request):
237 + parser = request.getfixturevalue(parser_fixture)
238 model_output = "This is a test"
223 - extracted_tool_calls = mistral_pre_v11_tool_parser.extract_tool_calls(
224 - model_output, request=None
225 - ) # type: ignore[arg-type]
226 - assert not extracted_tool_calls.tools_called
227 - assert extracted_tool_calls.tool_calls == []
228 - assert extracted_tool_calls.content == model_output
239 + result = parser.extract_tool_calls(model_output, request=_DUMMY_REQUEST)
240 + assert result == ExtractedToolCallInformation(
241 + tools_called=False, tool_calls=[], content=model_output
242 + )
243
244
245 @pytest.mark.parametrize(
@@ -234,6 +248,8 @@ def test_extract_tool_calls_no_tools(mistral_pre_v11_tool_parser):
248 "single_tool_weather",
249 "argument_before_name",
250 "argument_before_name_and_name_in_argument",
251 + "multiple_tools",
252 + "content_before_tool",
253 ],
254 argnames=["model_output", "expected_tool_calls", "expected_content"],
255 argvalues=[
@@ -292,14 +308,44 @@ def test_extract_tool_calls_no_tools(mistral_pre_v11_tool_parser):
308 ],
309 None,
310 ),
311 + (
312 + """[TOOL_CALLS] [{"name": "add", "arguments": {"a": 3.5, "b": 4}}, {"name": "get_current_weather", "arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
313 + [
314 + ToolCall(
315 + function=FunctionCall(
316 + name="add", arguments=json.dumps({"a": 3.5, "b": 4})
317 + )
318 + ),
319 + ToolCall(
320 + function=FunctionCall(
321 + name="get_current_weather",
322 + arguments=json.dumps(
323 + {"city": "San Francisco", "state": "CA", "unit": "celsius"}
324 + ),
325 + )
326 + ),
327 + ],
328 + None,
329 + ),
330 + (
331 + """Hello[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]""", # noqa: E501
332 + [
333 + ToolCall(
334 + function=FunctionCall(
335 + name="add", arguments=json.dumps({"a": 1, "b": 2})
336 + )
337 + )
338 + ],
339 + "Hello",
340 + ),
341 ],
342 )
343 def test_extract_tool_calls_pre_v11_tokenizer(
344 mistral_pre_v11_tool_parser, model_output, expected_tool_calls, expected_content
345 ):
346 extracted_tool_calls = mistral_pre_v11_tool_parser.extract_tool_calls(
301 - model_output, request=None
302 - ) # type: ignore[arg-type]
347 + model_output, request=_DUMMY_REQUEST
348 + )
349 assert extracted_tool_calls.tools_called
350
351 assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
@@ -307,6 +353,46 @@ def test_extract_tool_calls_pre_v11_tokenizer(
353 assert extracted_tool_calls.content == expected_content
354
355
356 +def test_extract_tool_calls_pre_v11_multiple_bot_tokens_raises(
357 + mistral_pre_v11_tool_parser,
358 +):
359 + model_output = (
360 + '[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1}}]'
361 + '[TOOL_CALLS] [{"name": "sub", "arguments":{"b": 2}}]'
362 + )
363 + with pytest.raises(ValueError, match="Only one BOT token"):
364 + mistral_pre_v11_tool_parser.extract_tool_calls(
365 + model_output, request=_DUMMY_REQUEST
366 + )
367 +
368 +
369 +def test_extract_tool_calls_pre_v11_regex_fallback_raises(
370 + mistral_pre_v11_tool_parser,
371 +):
372 + """The regex fallback path finds valid JSON but does not re-serialize
373 + the `arguments` dict to a string, causing a Pydantic
374 + `ValidationError` when constructing `FunctionCall`."""
375 + model_output = (
376 + '[TOOL_CALLS] junk [{"name": "add", "arguments":{"a": 1, "b": 2}}] trail'
377 + )
378 + with pytest.raises(ValidationError):
379 + mistral_pre_v11_tool_parser.extract_tool_calls(
380 + model_output, request=_DUMMY_REQUEST
381 + )
382 +
383 +
384 +def test_extract_tool_calls_pre_v11_regex_fallback_fails(
385 + mistral_pre_v11_tool_parser,
386 +):
387 + model_output = "[TOOL_CALLS] not json at all"
388 + result = mistral_pre_v11_tool_parser.extract_tool_calls(
389 + model_output, request=_DUMMY_REQUEST
390 + )
391 + assert result == ExtractedToolCallInformation(
392 + tools_called=False, tool_calls=[], content="not json at all"
393 + )
394 +
395 +
396 @pytest.mark.parametrize(
397 ids=[
398 "single_tool_add",
@@ -395,8 +481,8 @@ def test_extract_tool_calls(
481 mistral_tool_parser, model_output, expected_tool_calls, expected_content
482 ):
483 extracted_tool_calls = mistral_tool_parser.extract_tool_calls(
398 - model_output, request=None
399 - ) # type: ignore[arg-type]
484 + model_output, request=_DUMMY_REQUEST
485 + )
486 assert extracted_tool_calls.tools_called
487
488 assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
@@ -404,6 +490,16 @@ def test_extract_tool_calls(
490 assert extracted_tool_calls.content == expected_content
491
492
493 +def test_extract_tool_calls_v11_without_args_skipped(mistral_tool_parser):
494 + model_output = "[TOOL_CALLS]toolname_no_args"
495 + result = mistral_tool_parser.extract_tool_calls(
496 + model_output, request=_DUMMY_REQUEST
497 + )
498 + assert result == ExtractedToolCallInformation(
499 + tools_called=True, tool_calls=[], content=None
500 + )
501 +
502 +
503 def _test_extract_tool_calls_streaming(
504 tool_parser, tokenizer, model_output, tools, expected_tool_calls, expected_content
505 ):
@@ -669,17 +765,65 @@ def test_extract_tool_calls_streaming(
765 )
766
767
768 +def test_extract_tool_calls_streaming_v11_no_tools(
769 + mistral_tool_parser, mistral_tokenizer
770 +):
771 + model_output = "This is a test"
772 + if isinstance(mistral_tokenizer, MistralTokenizer):
773 + all_token_ids = mistral_tokenizer.encode(model_output)
774 + else:
775 + all_token_ids = mistral_tokenizer.encode(model_output, add_special_tokens=False)
776 + skip_special = isinstance(mistral_tokenizer, MistralTokenizer)
777 + collected_content = ""
778 + previous_text = ""
779 + previous_tokens = None
780 + prefix_offset = 0
781 + read_offset = 0
782 + for i in range(len(all_token_ids)):
783 + current_token_ids = all_token_ids[: i + 1]
784 + previous_token_ids = all_token_ids[:i]
785 + delta_token_ids = [all_token_ids[i]]
786 +
787 + new_tokens, delta_text, prefix_offset, read_offset = detokenize_incrementally(
788 + tokenizer=mistral_tokenizer,
789 + all_input_ids=current_token_ids,
790 + prev_tokens=previous_tokens,
791 + prefix_offset=prefix_offset,
792 + read_offset=read_offset,
793 + skip_special_tokens=skip_special,
794 + spaces_between_special_tokens=True,
795 + )
796 + current_text = previous_text + delta_text
797 + previous_tokens = (
798 + previous_tokens + new_tokens if previous_tokens else new_tokens
799 + )
800 +
801 + delta_message = mistral_tool_parser.extract_tool_calls_streaming(
802 + previous_text=previous_text,
803 + current_text=current_text,
804 + delta_text=delta_text,
805 + previous_token_ids=previous_token_ids,
806 + current_token_ids=current_token_ids,
807 + delta_token_ids=delta_token_ids,
808 + request=_DUMMY_REQUEST,
809 + )
810 + if delta_message and delta_message.content:
811 + collected_content += delta_message.content
812 + if delta_message:
813 + assert not delta_message.tool_calls
814 +
815 + previous_text = current_text
816 +
817 + assert collected_content == model_output
818 +
819 +
820 @pytest.mark.parametrize(
673 - ids=[
674 - "single_tool_add",
675 - "single_tool_weather",
676 - "multiple_tool_calls",
677 - "content_before_tool",
678 - "complex",
679 - ],
680 - argnames=["model_output", "expected_tool_calls", "expected_content"],
681 - argvalues=[
682 - (
821 + "parser_fixture, tokenizer_fixture, model_output,"
822 + " expected_tool_calls, expected_content",
823 + [
824 + pytest.param(
825 + "mistral_tool_parser",
826 + "mistral_tokenizer",
827 """[TOOL_CALLS]add_this_and_that{"a": 3.5, "b": 4}""", # noqa: E501
828 [
829 ToolCall(
@@ -690,8 +834,11 @@ def test_extract_tool_calls_streaming(
834 )
835 ],
836 "",
837 + id="v11-single_tool_add",
838 ),
694 - (
839 + pytest.param(
840 + "mistral_tool_parser",
841 + "mistral_tokenizer",
842 """[TOOL_CALLS]get_current_weather{"city": "San Francisco", "state": "CA", "unit": "celsius"}""", # noqa: E501
843 [
844 ToolCall(
@@ -704,8 +851,11 @@ def test_extract_tool_calls_streaming(
851 )
852 ],
853 "",
854 + id="v11-single_tool_weather",
855 ),
708 - (
856 + pytest.param(
857 + "mistral_tool_parser",
858 + "mistral_tokenizer",
859 """[TOOL_CALLS]add{"a": 3.5, "b": 4}[TOOL_CALLS]multiply{"a": 3, "b": 6}""", # noqa: E501
860 [
861 ToolCall(
@@ -720,9 +870,11 @@ def test_extract_tool_calls_streaming(
870 ),
871 ],
872 "",
873 + id="v11-multiple_tool_calls",
874 ),
724 - (
725 - # Additional content should not be after the tool calls
875 + pytest.param(
876 + "mistral_tool_parser",
877 + "mistral_tokenizer",
878 """bla[TOOL_CALLS]add_this_and_that{"a": 3.5, "b": 4}""", # noqa: E501
879 [
880 ToolCall(
@@ -733,9 +885,11 @@ def test_extract_tool_calls_streaming(
885 )
886 ],
887 "bla",
888 + id="v11-content_before_tool",
889 ),
737 - (
738 - # Complex
890 + pytest.param(
891 + "mistral_tool_parser",
892 + "mistral_tokenizer",
893 """hi{hi[TOOL_CALLS]bash{"command": "print(\\"hello world!\\")\\nre.compile(r\'{}\')"}""", # noqa: E501
894 [
895 ToolCall(
@@ -748,58 +902,19 @@ def test_extract_tool_calls_streaming(
902 )
903 ],
904 "hi{hi",
905 + id="v11-complex",
906 ),
752 - ],
753 -)
754 -def test_extract_tool_calls_streaming_one_chunk(
755 - mistral_tool_parser,
756 - mistral_tokenizer,
757 - model_output,
758 - expected_tool_calls,
759 - expected_content,
760 -):
761 - if isinstance(mistral_tokenizer, MistralTokenizer):
762 - all_token_ids = mistral_tokenizer.encode(model_output)
763 - else:
764 - all_token_ids = mistral_tokenizer.encode(model_output, add_special_tokens=False)
765 - all_token_ids = fix_tool_call_tokenization(
766 - all_token_ids, mistral_tool_parser, mistral_tokenizer
767 - )
768 -
769 - delta_message = mistral_tool_parser.extract_tool_calls_streaming(
770 - previous_text="",
771 - current_text=model_output,
772 - delta_text=model_output,
773 - previous_token_ids=[],
774 - current_token_ids=all_token_ids,
775 - delta_token_ids=all_token_ids,
776 - request=None,
777 - ) # type: ignore[arg-type]
778 - assert isinstance(delta_message, DeltaMessage)
779 - assert len(delta_message.tool_calls) == len(expected_tool_calls)
780 -
781 - assert_tool_calls(delta_message.tool_calls, expected_tool_calls)
782 -
783 - if delta_message.content is None:
784 - assert expected_content == ""
785 - else:
786 - assert delta_message.content == expected_content
787 -
788 -
789 -@pytest.mark.parametrize(
790 - ids=[
791 - "no_tools",
792 - "single_tool_add",
793 - "single_tool_add_strings",
794 - "single_tool_weather",
795 - "argument_before_name",
796 - "argument_before_name_and_name_in_argument",
797 - "multiple_tools",
798 - ],
799 - argnames=["model_output", "expected_tool_calls", "expected_content"],
800 - argvalues=[
801 - ("""This is a test""", [], """This is a test"""),
802 - (
907 + pytest.param(
908 + "mistral_pre_v11_tool_parser",
909 + "mistral_pre_v11_tokenizer",
910 + """This is a test""",
911 + [],
912 + """This is a test""",
913 + id="pre_v11-no_tools",
914 + ),
915 + pytest.param(
916 + "mistral_pre_v11_tool_parser",
917 + "mistral_pre_v11_tokenizer",
918 """[TOOL_CALLS] [ {"name":"add" , "arguments" : {"a": 3, "b": 4} } ]""", # noqa: E501
919 [
920 ToolCall(
@@ -809,8 +924,11 @@ def test_extract_tool_calls_streaming_one_chunk(
924 )
925 ],
926 "",
927 + id="pre_v11-single_tool_add",
928 ),
813 - (
929 + pytest.param(
930 + "mistral_pre_v11_tool_parser",
931 + "mistral_pre_v11_tokenizer",
932 """[TOOL_CALLS] [{"name": "add", "arguments":{"a": "3", "b": "4"}}]""", # noqa: E501
933 [
934 ToolCall(
@@ -820,8 +938,11 @@ def test_extract_tool_calls_streaming_one_chunk(
938 )
939 ],
940 "",
941 + id="pre_v11-single_tool_add_strings",
942 ),
824 - (
943 + pytest.param(
944 + "mistral_pre_v11_tool_parser",
945 + "mistral_pre_v11_tokenizer",
946 """[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
947 [
948 ToolCall(
@@ -834,8 +955,11 @@ def test_extract_tool_calls_streaming_one_chunk(
955 )
956 ],
957 "",
958 + id="pre_v11-single_tool_weather",
959 ),
838 - (
960 + pytest.param(
961 + "mistral_pre_v11_tool_parser",
962 + "mistral_pre_v11_tokenizer",
963 """[TOOL_CALLS] [{"arguments": {"city": "San Francisco", "state": "CA", "unit": "celsius"}, "name": "get_current_weather"}]""", # noqa: E501
964 [
965 ToolCall(
@@ -848,8 +972,11 @@ def test_extract_tool_calls_streaming_one_chunk(
972 )
973 ],
974 "",
975 + id="pre_v11-argument_before_name",
976 ),
852 - (
977 + pytest.param(
978 + "mistral_pre_v11_tool_parser",
979 + "mistral_pre_v11_tokenizer",
980 """[TOOL_CALLS] [{"arguments": {"name": "John Doe"}, "name": "get_age"}]""", # noqa: E501
981 [
982 ToolCall(
@@ -864,8 +991,11 @@ def test_extract_tool_calls_streaming_one_chunk(
991 )
992 ],
993 "",
994 + id="pre_v11-argument_before_name_and_name_in_argument",
995 ),
868 - (
996 + pytest.param(
997 + "mistral_pre_v11_tool_parser",
998 + "mistral_pre_v11_tokenizer",
999 """[TOOL_CALLS] [{"arguments": {"a": 3.5, "b": 4}, "name": "add"}, {"arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}, "name": "get_current_weather"}]""", # noqa: E501
1000 [
1001 ToolCall(
@@ -883,35 +1013,50 @@ def test_extract_tool_calls_streaming_one_chunk(
1013 ),
1014 ],
1015 "",
1016 + id="pre_v11-multiple_tools",
1017 + ),
1018 + pytest.param(
1019 + "mistral_pre_v11_tool_parser",
1020 + "mistral_pre_v11_tokenizer",
1021 + """Some text[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]""", # noqa: E501
1022 + [
1023 + ToolCall(
1024 + function=FunctionCall(
1025 + name="add", arguments=json.dumps({"a": 1, "b": 2})
1026 + )
1027 + )
1028 + ],
1029 + "Some text",
1030 + id="pre_v11-content_before_tool",
1031 ),
1032 ],
1033 )
889 -def test_extract_tool_calls_streaming_pre_v11_tokenizer_one_chunk(
890 - mistral_pre_v11_tool_parser,
891 - mistral_pre_v11_tokenizer,
1034 +def test_extract_tool_calls_streaming_one_chunk(
1035 + parser_fixture,
1036 + tokenizer_fixture,
1037 model_output,
1038 expected_tool_calls,
1039 expected_content,
1040 + request,
1041 ):
896 - if isinstance(mistral_pre_v11_tokenizer, MistralTokenizer):
897 - all_token_ids = mistral_pre_v11_tokenizer.encode(model_output)
1042 + tool_parser = request.getfixturevalue(parser_fixture)
1043 + tokenizer = request.getfixturevalue(tokenizer_fixture)
1044 +
1045 + if isinstance(tokenizer, MistralTokenizer):
1046 + all_token_ids = tokenizer.encode(model_output)
1047 else:
899 - all_token_ids = mistral_pre_v11_tokenizer.encode(
900 - model_output, add_special_tokens=False
901 - )
902 - all_token_ids = fix_tool_call_tokenization(
903 - all_token_ids, mistral_pre_v11_tool_parser, mistral_pre_v11_tokenizer
904 - )
1048 + all_token_ids = tokenizer.encode(model_output, add_special_tokens=False)
1049 + all_token_ids = fix_tool_call_tokenization(all_token_ids, tool_parser, tokenizer)
1050
906 - delta_message = mistral_pre_v11_tool_parser.extract_tool_calls_streaming(
1051 + delta_message = tool_parser.extract_tool_calls_streaming(
1052 previous_text="",
1053 current_text=model_output,
1054 delta_text=model_output,
1055 previous_token_ids=[],
1056 current_token_ids=all_token_ids,
1057 delta_token_ids=all_token_ids,
913 - request=None,
914 - ) # type: ignore[arg-type]
1058 + request=_DUMMY_REQUEST,
1059 + )
1060 assert isinstance(delta_message, DeltaMessage)
1061 assert len(delta_message.tool_calls) == len(expected_tool_calls)
1062
@@ -923,65 +1068,105 @@ def test_extract_tool_calls_streaming_pre_v11_tokenizer_one_chunk(
1068 assert delta_message.content == expected_content
1069
1070
926 -def test_fast_detokenization_text_detection(mistral_tool_parser):
1071 +@pytest.mark.parametrize(
1072 + "parser_fixture, model_output, fake_count, two_phase",
1073 + [
1074 + pytest.param(
1075 + "mistral_tool_parser",
1076 + '[TOOL_CALLS]add{"a": 1, "b": 2}',
1077 + 20,
1078 + True,
1079 + id="v11",
1080 + ),
1081 + pytest.param(
1082 + "mistral_pre_v11_tool_parser",
1083 + '[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]',
1084 + 30,
1085 + False,
1086 + id="pre_v11",
1087 + ),
1088 + ],
1089 +)
1090 +def test_fast_detokenization_text_detection(
1091 + parser_fixture, model_output, fake_count, two_phase, request
1092 +):
1093 """Regression: bot_token in text but not token_ids (PR #37209)."""
928 - model_output = '[TOOL_CALLS]add{"a": 1, "b": 2}'
1094 + parser = request.getfixturevalue(parser_fixture)
1095 # Token IDs that do NOT contain bot_token_id.
930 - fake_token_ids = list(range(99, 99 + 20))
931 -
932 - # First delta: pure content, no bot token yet
933 - delta_message_before = mistral_tool_parser.extract_tool_calls_streaming(
934 - previous_text="",
935 - current_text="Hello",
936 - delta_text="Hello",
937 - previous_token_ids=[],
938 - current_token_ids=[99],
939 - delta_token_ids=[99],
940 - request=None,
941 - )
942 - assert delta_message_before is not None
943 - assert delta_message_before.content == "Hello"
944 - assert not delta_message_before.tool_calls
945 -
946 - # Second delta: bot token in text but NOT in token_ids
947 - delta_message = mistral_tool_parser.extract_tool_calls_streaming(
948 - previous_text="Hello",
949 - current_text="Hello" + model_output,
1096 + fake_token_ids = list(range(99, 99 + fake_count))
1097 +
1098 + if two_phase:
1099 + # First delta: pure content, no bot token yet
1100 + delta_message_before = parser.extract_tool_calls_streaming(
1101 + previous_text="",
1102 + current_text="Hello",
1103 + delta_text="Hello",
1104 + previous_token_ids=[],
1105 + current_token_ids=[99],
1106 + delta_token_ids=[99],
1107 + request=_DUMMY_REQUEST,
1108 + )
1109 + assert delta_message_before is not None
1110 + assert delta_message_before.content == "Hello"
1111 + assert not delta_message_before.tool_calls
1112 +
1113 + previous_text = "Hello"
1114 + current_text = "Hello" + model_output
1115 + previous_token_ids = [99]
1116 + delta_token_ids = fake_token_ids[1:]
1117 + else:
1118 + previous_text = ""
1119 + current_text = model_output
1120 + previous_token_ids = []
1121 + delta_token_ids = fake_token_ids
1122 +
1123 + delta_message = parser.extract_tool_calls_streaming(
1124 + previous_text=previous_text,
1125 + current_text=current_text,
1126 delta_text=model_output,
951 - previous_token_ids=[99],
1127 + previous_token_ids=previous_token_ids,
1128 current_token_ids=fake_token_ids,
953 - delta_token_ids=fake_token_ids[1:],
954 - request=None,
1129 + delta_token_ids=delta_token_ids,
1130 + request=_DUMMY_REQUEST,
1131 )
1132 assert delta_message is not None
1133 assert delta_message.tool_calls is not None
958 - assert len(delta_message.tool_calls) > 0
1134 + assert len(delta_message.tool_calls) == 1
1135 assert delta_message.tool_calls[0].function is not None
1136 assert delta_message.tool_calls[0].function.name == "add"
1137
1138
963 -def test_fast_detokenization_text_detection_pre_v11(
964 - mistral_pre_v11_tool_parser,
1139 +@pytest.mark.parametrize(
1140 + "parser_fixture, patched_method, current_text",
1141 + [
1142 + (
1143 + "mistral_tool_parser",
1144 + "_extract_tool_calls_streaming",
1145 + "[TOOL_CALLS]add{}",
1146 + ),
1147 + (
1148 + "mistral_pre_v11_tool_parser",
1149 + "_extract_tool_calls_streaming_pre_v11_tokenizer",
1150 + '[TOOL_CALLS] [{"name":"a","arguments":{}}]',
1151 + ),
1152 + ],
1153 + ids=["v11", "pre_v11"],
1154 +)
1155 +def test_extract_tool_calls_streaming_exception_returns_none(
1156 + parser_fixture, patched_method, current_text, request
1157 ):
966 - """Regression: bot_token text detection for pre-v11 tokenizer (PR #37209)."""
967 - model_output = '[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]'
968 -
969 - fake_token_ids = list(range(99, 99 + 30))
970 -
971 - delta_message = mistral_pre_v11_tool_parser.extract_tool_calls_streaming(
972 - previous_text="",
973 - current_text=model_output,
974 - delta_text=model_output,
975 - previous_token_ids=[],
976 - current_token_ids=fake_token_ids,
977 - delta_token_ids=fake_token_ids,
978 - request=None,
979 - )
980 - assert delta_message is not None
981 - assert delta_message.tool_calls is not None
982 - assert len(delta_message.tool_calls) > 0
983 - assert delta_message.tool_calls[0].function is not None
984 - assert delta_message.tool_calls[0].function.name == "add"
1158 + parser = request.getfixturevalue(parser_fixture)
1159 + with patch.object(parser, patched_method, side_effect=RuntimeError("boom")):
1160 + result = parser.extract_tool_calls_streaming(
1161 + previous_text="",
1162 + current_text=current_text,
1163 + delta_text=current_text,
1164 + previous_token_ids=[],
1165 + current_token_ids=[parser.bot_token_id],
1166 + delta_token_ids=[parser.bot_token_id],
1167 + request=_DUMMY_REQUEST,
1168 + )
1169 + assert result is None
1170
1171
1172 SAMPLE_TOOLS_DICTS = [
@@ -1238,57 +1423,444 @@ def test_adjust_request_response_format_generates_grammar(
1423 assert len(result.structured_outputs.grammar) > 0
1424
1425
1241 -def test_adjust_request_tool_choice_none_with_json_schema_uses_json_schema_factory(
1426 +@pytest.mark.parametrize(
1427 + "tool_choice, expected_method, not_called_method",
1428 + [
1429 + ("none", "get_lark_for_json_schema", None),
1430 + ("auto", "get_lark_from_jinja", "get_lark_for_json_schema"),
1431 + ],
1432 + ids=["none_uses_json_schema_factory", "auto_uses_jinja_factory"],
1433 +)
1434 +def test_adjust_request_tool_choice_with_json_schema_factory_routing(
1435 mistral_tool_parser: MistralToolParser,
1436 + tool_choice: str,
1437 + expected_method: str,
1438 + not_called_method: str | None,
1439 ) -> None:
1440 request = _make_request(
1245 - tool_choice="none",
1441 + tool_choice=tool_choice,
1442 structured_outputs=StructuredOutputsParams(json='{"type": "object"}'),
1443 )
1444 factory = mistral_tool_parser.model_tokenizer.grammar_factory
1445
1250 - with patch.object(
1251 - factory,
1252 - "get_lark_for_json_schema",
1253 - wraps=factory.get_lark_for_json_schema,
1254 - ) as mock_json_schema:
1255 - result = mistral_tool_parser.adjust_request(request)
1446 + patches = {
1447 + expected_method: patch.object(
1448 + factory,
1449 + expected_method,
1450 + wraps=getattr(factory, expected_method),
1451 + ),
1452 + }
1453 + if not_called_method:
1454 + patches[not_called_method] = patch.object(
1455 + factory,
1456 + not_called_method,
1457 + wraps=getattr(factory, not_called_method),
1458 + )
1459 +
1460 + with patches[expected_method] as mock_expected:
1461 + ctx = patches[not_called_method] if not_called_method else None
1462 + if ctx:
1463 + with ctx as mock_not_called:
1464 + result = mistral_tool_parser.adjust_request(request)
1465 + mock_not_called.assert_not_called()
1466 + else:
1467 + result = mistral_tool_parser.adjust_request(request)
1468
1257 - mock_json_schema.assert_called_once()
1258 - assert mock_json_schema.call_args.kwargs["json_schema"] == {"type": "object"}
1469 + mock_expected.assert_called_once()
1470 + assert mock_expected.call_args.kwargs["json_schema"] == {"type": "object"}
1471
1472 assert result.structured_outputs is not None
1473 assert isinstance(result.structured_outputs.grammar, str)
1474 assert len(result.structured_outputs.grammar) > 0
1475
1476
1265 -def test_adjust_request_tool_choice_auto_with_json_schema_uses_jinja_factory(
1477 +def test_grammar_from_tool_parser_default_false() -> None:
1478 + request = _make_request()
1479 + assert request._grammar_from_tool_parser is False
1480 +
1481 +
1482 +def test_grammar_from_tool_parser_set_by_adjust_request(
1483 mistral_tool_parser: MistralToolParser,
1484 ) -> None:
1268 - request = _make_request(
1269 - tool_choice="auto",
1270 - structured_outputs=StructuredOutputsParams(json='{"type": "object"}'),
1271 - )
1272 - factory = mistral_tool_parser.model_tokenizer.grammar_factory
1485 + request = _make_request()
1486 + result = mistral_tool_parser.adjust_request(request)
1487 + assert result._grammar_from_tool_parser is True
1488
1274 - with (
1275 - patch.object(
1276 - factory,
1277 - "get_lark_for_json_schema",
1278 - wraps=factory.get_lark_for_json_schema,
1279 - ) as mock_json_schema,
1280 - patch.object(
1281 - factory,
1282 - "get_lark_from_jinja",
1283 - wraps=factory.get_lark_from_jinja,
1284 - ) as mock_jinja,
1285 - ):
1286 - result = mistral_tool_parser.adjust_request(request)
1489
1288 - mock_jinja.assert_called_once()
1289 - assert mock_jinja.call_args.kwargs["json_schema"] == {"type": "object"}
1290 - mock_json_schema.assert_not_called()
1490 +@pytest.mark.parametrize(
1491 + "tool_calls, expected_len",
1492 + [
1493 + (None, 0),
1494 + ([], 0),
1495 + ([VllmFunctionCall(id="abc123xyz", name="f", arguments="{}")], 1),
1496 + ([VllmFunctionCall(name="f", arguments="{}")], 1),
1497 + (
1498 + [
1499 + VllmFunctionCall(id="fixed1234", name="a", arguments='{"x": 1}'),
1500 + VllmFunctionCall(name="b", arguments='{"y": 2}'),
1501 + ],
1502 + 2,
1503 + ),
1504 + ],
1505 + ids=["none", "empty", "with_id", "without_id", "mixed"],
1506 +)
1507 +def test_build_non_streaming_tool_calls(
1508 + tool_calls: list[VllmFunctionCall] | None,
1509 + expected_len: int,
1510 +) -> None:
1511 + result = MistralToolParser.build_non_streaming_tool_calls(tool_calls)
1512 + assert len(result) == expected_len
1513
1292 - assert result.structured_outputs is not None
1293 - assert isinstance(result.structured_outputs.grammar, str)
1294 - assert len(result.structured_outputs.grammar) > 0
1514 + if tool_calls is None:
1515 + return
1516 +
1517 + for i, tc in enumerate(result):
1518 + assert isinstance(tc, MistralToolCall)
1519 + assert tc.type == "function"
1520 +
1521 + input_tc = tool_calls[i]
1522 + if input_tc.id:
1523 + assert tc.id == input_tc.id
1524 + else:
1525 + assert len(tc.id) == 9
1526 + assert tc.id.isalnum()
1527 +
1528 + assert tc.function.name == input_tc.name
1529 + assert tc.function.arguments == input_tc.arguments
1530 +
1531 +
1532 +class TestExtractMaybeReasoningAndToolStreaming:
1533 + r"""Tests for `MistralToolParser.extract_maybe_reasoning_and_tool_streaming`."""
1534 +
1535 + @pytest.fixture
1536 + def parser(self) -> MistralToolParser:
1537 + mock_tokenizer = MagicMock()
1538 + mock_tokenizer.get_vocab.return_value = {"[TOOL_CALLS]": 1}
1539 + return MistralToolParser(mock_tokenizer)
1540 +
1541 + @pytest.fixture
1542 + def request_obj(self) -> ChatCompletionRequest:
1543 + return _make_request()
1544 +
1545 + @staticmethod
1546 + def _call(
1547 + parser: MistralToolParser,
1548 + request: ChatCompletionRequest,
1549 + *,
1550 + reasoning_parser: Any = None,
1551 + previous_text: str = "",
1552 + current_text: str = "hello",
1553 + delta_text: str = "hello",
1554 + previous_token_ids: list[int] | None = None,
1555 + current_token_ids: list[int] | None = None,
1556 + output_token_ids: list[int] | None = None,
1557 + reasoning_ended: bool = False,
1558 + prompt_is_reasoning_end: bool | None = None,
1559 + ) -> MistralStreamingResult:
1560 + return parser.extract_maybe_reasoning_and_tool_streaming(
1561 + reasoning_parser=reasoning_parser,
1562 + previous_text=previous_text,
1563 + current_text=current_text,
1564 + delta_text=delta_text,
1565 + previous_token_ids=previous_token_ids or [],
1566 + current_token_ids=current_token_ids or [1, 2, 3],
1567 + output_token_ids=output_token_ids or [1, 2, 3],
1568 + reasoning_ended=reasoning_ended,
1569 + prompt_is_reasoning_end=prompt_is_reasoning_end,
1570 + request=request,
1571 + )
1572 +
1573 + def test_no_reasoning_tools_called(
1574 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1575 + ) -> None:
1576 + tool_delta = DeltaMessage(
1577 + tool_calls=[
1578 + DeltaToolCall(
1579 + index=0,
1580 + function=DeltaFunctionCall(name="f", arguments="{}"),
1581 + )
1582 + ]
1583 + )
1584 + with patch.object(
1585 + parser, "extract_tool_calls_streaming", return_value=tool_delta
1586 + ):
1587 + result = self._call(parser, request_obj, reasoning_parser=None)
1588 +
1589 + assert result == MistralStreamingResult(
1590 + delta_message=tool_delta,
1591 + reasoning_ended=False,
1592 + tools_called=True,
1593 + current_text="hello",
1594 + current_token_ids=[1, 2, 3],
1595 + )
1596 +
1597 + def test_no_reasoning_no_tools(
1598 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1599 + ) -> None:
1600 + content_delta = DeltaMessage(content="hello")
1601 + with patch.object(
1602 + parser, "extract_tool_calls_streaming", return_value=content_delta
1603 + ):
1604 + result = self._call(parser, request_obj, reasoning_parser=None)
1605 +
1606 + assert result == MistralStreamingResult(
1607 + delta_message=content_delta,
1608 + reasoning_ended=False,
1609 + tools_called=False,
1610 + current_text="hello",
1611 + current_token_ids=[1, 2, 3],
1612 + )
1613 +
1614 + def test_mistral_reasoning_parser_no_think_token(
1615 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1616 + ) -> None:
1617 + mock_rp = MagicMock(spec=MistralReasoningParser)
1618 + mock_rp.start_token_id = 999
1619 + content_delta = DeltaMessage(content="direct")
1620 + with patch.object(
1621 + parser, "extract_tool_calls_streaming", return_value=content_delta
1622 + ):
1623 + result = self._call(
1624 + parser,
1625 + request_obj,
1626 + reasoning_parser=mock_rp,
1627 + reasoning_ended=False,
1628 + current_token_ids=[1, 2, 3],
1629 + )
1630 +
1631 + mock_rp.extract_reasoning_streaming.assert_not_called()
1632 + assert result == MistralStreamingResult(
1633 + delta_message=content_delta,
1634 + reasoning_ended=False,
1635 + tools_called=False,
1636 + current_text="hello",
1637 + current_token_ids=[1, 2, 3],
1638 + )
1639 +
1640 + def test_mistral_reasoning_parser_with_think_token(
1641 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1642 + ) -> None:
1643 + mock_rp = MagicMock(spec=MistralReasoningParser)
1644 + mock_rp.start_token_id = 999
1645 + mock_rp.extract_reasoning_streaming.return_value = DeltaMessage(
1646 + reasoning="thinking..."
1647 + )
1648 + mock_rp.is_reasoning_end_streaming.return_value = False
1649 +
1650 + result = self._call(
1651 + parser,
1652 + request_obj,
1653 + reasoning_parser=mock_rp,
1654 + reasoning_ended=False,
1655 + current_token_ids=[1, 999, 3],
1656 + )
1657 +
1658 + mock_rp.extract_reasoning_streaming.assert_called_once()
1659 + assert result == MistralStreamingResult(
1660 + delta_message=DeltaMessage(reasoning="thinking..."),
1661 + reasoning_ended=False,
1662 + tools_called=False,
1663 + current_text="hello",
1664 + current_token_ids=[1, 999, 3],
1665 + )
1666 +
1667 + def test_non_mistral_reasoning_parser_always_expects_thinking(
1668 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1669 + ) -> None:
1670 + mock_rp = MagicMock()
1671 + mock_rp.start_token_id = 999
1672 + mock_rp.extract_reasoning_streaming.return_value = DeltaMessage(
1673 + reasoning="thinking..."
1674 + )
1675 + mock_rp.is_reasoning_end_streaming.return_value = False
1676 +
1677 + result = self._call(
1678 + parser,
1679 + request_obj,
1680 + reasoning_parser=mock_rp,
1681 + reasoning_ended=False,
1682 + current_token_ids=[1, 2, 3],
1683 + )
1684 +
1685 + mock_rp.extract_reasoning_streaming.assert_called_once()
1686 + assert result == MistralStreamingResult(
1687 + delta_message=DeltaMessage(reasoning="thinking..."),
1688 + reasoning_ended=False,
1689 + tools_called=False,
1690 + current_text="hello",
1691 + current_token_ids=[1, 2, 3],
1692 + )
1693 +
1694 + def test_reasoning_already_ended_no_reset(
1695 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1696 + ) -> None:
1697 + content_delta = DeltaMessage(content="content")
1698 + with patch.object(
1699 + parser, "extract_tool_calls_streaming", return_value=content_delta
1700 + ) as mock_extract:
1701 + result = self._call(
1702 + parser,
1703 + request_obj,
1704 + reasoning_parser=MagicMock(),
1705 + reasoning_ended=True,
1706 + previous_text="prior_tool_text",
1707 + previous_token_ids=[10, 20],
1708 + current_text="prior_tool_texthello",
1709 + current_token_ids=[10, 20, 1, 2, 3],
1710 + )
1711 +
1712 + _, call_kwargs = mock_extract.call_args
1713 + assert call_kwargs["previous_text"] == "prior_tool_text"
1714 + assert call_kwargs["previous_token_ids"] == [10, 20]
1715 +
1716 + assert result == MistralStreamingResult(
1717 + delta_message=content_delta,
1718 + reasoning_ended=True,
1719 + tools_called=False,
1720 + current_text="prior_tool_texthello",
1721 + current_token_ids=[10, 20, 1, 2, 3],
1722 + )
1723 +
1724 + def test_pre_v15_ignores_prompt_reasoning_end(
1725 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1726 + ) -> None:
1727 + mock_tokenizer = MagicMock(spec=MistralTokenizer)
1728 + mock_tokenizer.version = 13
1729 + parser.model_tokenizer = mock_tokenizer
1730 +
1731 + mock_rp = MagicMock(spec=MistralReasoningParser)
1732 + mock_rp.start_token_id = 999
1733 + mock_rp.extract_reasoning_streaming.return_value = DeltaMessage(
1734 + reasoning="thinking..."
1735 + )
1736 + mock_rp.is_reasoning_end_streaming.return_value = False
1737 +
1738 + result = self._call(
1739 + parser,
1740 + request_obj,
1741 + reasoning_parser=mock_rp,
1742 + reasoning_ended=False,
1743 + prompt_is_reasoning_end=True,
1744 + current_token_ids=[999, 1, 2],
1745 + )
1746 +
1747 + mock_rp.extract_reasoning_streaming.assert_called_once()
1748 + assert result == MistralStreamingResult(
1749 + delta_message=DeltaMessage(reasoning="thinking..."),
1750 + reasoning_ended=False,
1751 + tools_called=False,
1752 + current_text="hello",
1753 + current_token_ids=[999, 1, 2],
1754 + )
1755 +
1756 + def test_non_pre_v15_prompt_reasoning_end(
1757 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1758 + ) -> None:
1759 + mock_tokenizer = MagicMock(spec=MistralTokenizer)
1760 + mock_tokenizer.version = 15
1761 + parser.model_tokenizer = mock_tokenizer
1762 +
1763 + mock_rp = MagicMock(spec=MistralReasoningParser)
1764 + mock_rp.start_token_id = 999
1765 +
1766 + content_delta = DeltaMessage(content="after reasoning")
1767 + with patch.object(
1768 + parser, "extract_tool_calls_streaming", return_value=content_delta
1769 + ):
1770 + result = self._call(
1771 + parser,
1772 + request_obj,
1773 + reasoning_parser=mock_rp,
1774 + reasoning_ended=False,
1775 + prompt_is_reasoning_end=True,
1776 + current_token_ids=[999, 1, 2],
1777 + output_token_ids=[10, 20, 30],
1778 + )
1779 +
1780 + mock_rp.extract_reasoning_streaming.assert_not_called()
1781 + assert result == MistralStreamingResult(
1782 + delta_message=content_delta,
1783 + reasoning_ended=True,
1784 + tools_called=False,
1785 + current_text="hello",
1786 + current_token_ids=[10, 20, 30],
1787 + )
1788 +
1789 + def test_reasoning_end_transition_with_content(
1790 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1791 + ) -> None:
1792 + """When reasoning ends and the delta has content, that content is
1793 + cleared from delta_message and used as current_text for tool parsing."""
1794 + mock_rp = MagicMock()
1795 + mock_rp.start_token_id = 999
1796 + mock_rp.extract_reasoning_streaming.return_value = DeltaMessage(
1797 + reasoning="think", content="leftover"
1798 + )
1799 + mock_rp.is_reasoning_end_streaming.return_value = True
1800 + mock_rp.extract_content_ids.return_value = [50, 51]
1801 +
1802 + content_delta = DeltaMessage(content="leftover")
1803 + with patch.object(
1804 + parser, "extract_tool_calls_streaming", return_value=content_delta
1805 + ) as mock_extract:
1806 + result = self._call(
1807 + parser,
1808 + request_obj,
1809 + reasoning_parser=mock_rp,
1810 + reasoning_ended=False,
1811 + current_token_ids=[999, 1, 2],
1812 + output_token_ids=[10, 20, 30],
1813 + )
1814 +
1815 + mock_rp.extract_content_ids.assert_called_once_with([10, 20, 30])
1816 + _, call_kwargs = mock_extract.call_args
1817 + assert call_kwargs["previous_text"] == ""
1818 + assert call_kwargs["previous_token_ids"] == []
1819 + assert call_kwargs["delta_text"] == "leftover"
1820 + assert call_kwargs["current_token_ids"] == [50, 51]
1821 +
1822 + assert result == MistralStreamingResult(
1823 + delta_message=content_delta,
1824 + reasoning_ended=True,
1825 + tools_called=False,
1826 + current_text="leftover",
1827 + current_token_ids=[50, 51],
1828 + )
1829 +
1830 + def test_reasoning_end_transition_without_content(
1831 + self, parser: MistralToolParser, request_obj: ChatCompletionRequest
1832 + ) -> None:
1833 + """When reasoning ends but the delta has no content, current_text
1834 + is set to empty string."""
1835 + mock_rp = MagicMock()
1836 + mock_rp.start_token_id = 999
1837 + mock_rp.extract_reasoning_streaming.return_value = DeltaMessage(
1838 + reasoning="think"
1839 + )
1840 + mock_rp.is_reasoning_end_streaming.return_value = True
1841 + mock_rp.extract_content_ids.return_value = [50, 51]
1842 +
1843 + empty_delta = DeltaMessage(content="")
1844 + with patch.object(
1845 + parser, "extract_tool_calls_streaming", return_value=empty_delta
1846 + ) as mock_extract:
1847 + result = self._call(
1848 + parser,
1849 + request_obj,
1850 + reasoning_parser=mock_rp,
1851 + reasoning_ended=False,
1852 + current_token_ids=[999, 1, 2],
1853 + output_token_ids=[10, 20, 30],
1854 + )
1855 +
1856 + _, call_kwargs = mock_extract.call_args
1857 + assert call_kwargs["delta_text"] == ""
1858 + assert call_kwargs["current_token_ids"] == [50, 51]
1859 +
1860 + assert result == MistralStreamingResult(
1861 + delta_message=empty_delta,
1862 + reasoning_ended=True,
1863 + tools_called=False,
1864 + current_text="",
1865 + current_token_ids=[50, 51],
1866 + )
tests/tool_use/mistral/test_mistral_tool_calls.py
+480 -3
@@ -1,25 +1,198 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import json
5 +from dataclasses import dataclass, field
6 +
7 import openai
8 import pytest
9
7 -from tests.tool_use.utils import MESSAGES_ASKING_FOR_TOOLS, WEATHER_TOOL
10 +from tests.tool_use.utils import (
11 + MESSAGES_ASKING_FOR_PARALLEL_TOOLS,
12 + MESSAGES_ASKING_FOR_TOOLS,
13 + MESSAGES_WITH_TOOL_RESPONSE,
14 + MESSAGES_WITHOUT_TOOLS,
15 + SEARCH_TOOL,
16 + SEED,
17 + WEATHER_TOOL,
18 + ensure_system_prompt,
19 +)
20 +
21 +from .utils import ServerConfig
22 +
23 +
24 +def _requires_tool_parser(server_config: ServerConfig) -> None:
25 + r"""Skip test if server was not started with --tool-call-parser."""
26 + if "--tool-call-parser" not in server_config.get("arguments", []):
27 + pytest.skip(
28 + f"Skipping: {server_config['model']} not configured with --tool-call-parser"
29 + )
30 +
31 +
32 +def _is_pre_v11(server_config: ServerConfig) -> bool:
33 + r"""Pre-v11 Mistral models lack grammar-based tool call enforcement."""
34 + return "7B" in server_config.get("model", "")
35 +
36 +
37 +@dataclass
38 +class StreamedToolCallResult:
39 + r"""Accumulated result from streaming a single tool call."""
40 +
41 + function_name: str | None = None
42 + function_args_str: str = ""
43 + tool_call_id: str | None = None
44 + role_name: str | None = None
45 + finish_reason_count: int = 0
46 + finish_reason: str | None = None
47 +
48 +
49 +async def _collect_streamed_tool_call(
50 + stream: openai.AsyncStream,
51 + *,
52 + expected_finish_reason: str = "tool_calls",
53 +) -> StreamedToolCallResult:
54 + result = StreamedToolCallResult()
55 +
56 + async for chunk in stream:
57 + if chunk.choices[0].finish_reason:
58 + result.finish_reason_count += 1
59 + result.finish_reason = chunk.choices[0].finish_reason
60 + assert chunk.choices[0].finish_reason == expected_finish_reason
61 +
62 + if chunk.choices[0].delta.role:
63 + assert not result.role_name or result.role_name == "assistant"
64 + result.role_name = "assistant"
65 +
66 + streamed_tool_calls = chunk.choices[0].delta.tool_calls
67 + if streamed_tool_calls and len(streamed_tool_calls) > 0:
68 + assert len(streamed_tool_calls) == 1
69 + tool_call = streamed_tool_calls[0]
70 +
71 + if tool_call.id:
72 + assert not result.tool_call_id
73 + result.tool_call_id = tool_call.id
74 +
75 + if tool_call.function:
76 + if tool_call.function.name:
77 + assert result.function_name is None
78 + result.function_name = tool_call.function.name
79 + if tool_call.function.arguments:
80 + result.function_args_str += tool_call.function.arguments
81 +
82 + return result
83 +
84 +
85 +@dataclass
86 +class StreamedContentResult:
87 + r"""Accumulated result from streaming a content-only response."""
88 +
89 + chunks: list[str] = field(default_factory=list)
90 + finish_reason_count: int = 0
91 + finish_reason: str | None = None
92 + role_sent: bool = False
93 +
94 +
95 +async def _collect_streamed_content(
96 + stream: openai.AsyncStream,
97 + *,
98 + expected_finish_reason: str | None = None,
99 + no_tool_calls: bool = True,
100 +) -> StreamedContentResult:
101 + r"""Consume a streaming response and collect text content."""
102 + result = StreamedContentResult()
103 +
104 + async for chunk in stream:
105 + delta = chunk.choices[0].delta
106 +
107 + if delta.role:
108 + assert not result.role_sent
109 + assert delta.role == "assistant"
110 + result.role_sent = True
111 +
112 + if delta.content:
113 + result.chunks.append(delta.content)
114 +
115 + if chunk.choices[0].finish_reason is not None:
116 + result.finish_reason_count += 1
117 + result.finish_reason = chunk.choices[0].finish_reason
118 + if expected_finish_reason is not None:
119 + assert result.finish_reason == expected_finish_reason
120 +
121 + if no_tool_calls:
122 + assert not delta.tool_calls or len(delta.tool_calls) == 0
123 +
124 + return result
125 +
126 +
127 +@dataclass
128 +class StreamedParallelToolCallResult:
129 + r"""Accumulated result from streaming parallel tool calls."""
130 +
131 + function_names: list[str] = field(default_factory=list)
132 + function_args_strs: list[str] = field(default_factory=list)
133 + tool_call_ids: list[str] = field(default_factory=list)
134 + role_name: str | None = None
135 + finish_reason_count: int = 0
136 +
137 +
138 +async def _collect_streamed_parallel_tool_calls(
139 + stream: openai.AsyncStream,
140 +) -> StreamedParallelToolCallResult:
141 + r"""Consume a streaming response and collect parallel tool calls."""
142 + result = StreamedParallelToolCallResult()
143 + tool_call_idx: int = -1
144 +
145 + async for chunk in stream:
146 + if chunk.choices[0].finish_reason:
147 + result.finish_reason_count += 1
148 + assert chunk.choices[0].finish_reason == "tool_calls"
149 +
150 + if chunk.choices[0].delta.role:
151 + assert not result.role_name or result.role_name == "assistant"
152 + result.role_name = "assistant"
153 +
154 + streamed_tool_calls = chunk.choices[0].delta.tool_calls
155 + if streamed_tool_calls and len(streamed_tool_calls) > 0:
156 + assert len(streamed_tool_calls) == 1
157 + tool_call = streamed_tool_calls[0]
158 +
159 + if tool_call.index != tool_call_idx:
160 + tool_call_idx = tool_call.index
161 + result.function_args_strs.append("")
162 + result.tool_call_ids.append("")
163 +
164 + if tool_call.id:
165 + result.tool_call_ids[tool_call.index] = tool_call.id
166 +
167 + if tool_call.function:
168 + if tool_call.function.name:
169 + result.function_names.append(tool_call.function.name)
170 + if tool_call.function.arguments:
171 + result.function_args_strs[tool_call.index] += (
172 + tool_call.function.arguments
173 + )
174 +
175 + return result
176
177
178 # test: a tool_choice with mistral-tokenizer results in an ID of length 9
179 @pytest.mark.asyncio
12 -async def test_tool_call_with_tool_choice(client: openai.AsyncOpenAI):
180 +async def test_tool_call_with_tool_choice(
181 + client: openai.AsyncOpenAI, server_config: ServerConfig
182 +) -> None:
183 + _requires_tool_parser(server_config)
184 +
185 models = await client.models.list()
186 model_name: str = models.data[0].id
187 chat_completion = await client.chat.completions.create(
16 - messages=MESSAGES_ASKING_FOR_TOOLS,
188 + messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
189 temperature=0,
190 max_completion_tokens=100,
191 model=model_name,
192 tools=[WEATHER_TOOL],
193 tool_choice=WEATHER_TOOL,
194 logprobs=False,
195 + seed=SEED,
196 )
197
198 choice = chat_completion.choices[0]
@@ -28,3 +201,307 @@ async def test_tool_call_with_tool_choice(client: openai.AsyncOpenAI):
201 assert choice.message.role == "assistant"
202 assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 1
203 assert len(choice.message.tool_calls[0].id) == 9 # length of 9 for mistral
204 +
205 +
206 +_NOT_SET = object()
207 +
208 +
209 +@pytest.mark.asyncio
210 +@pytest.mark.parametrize(
211 + "tools, tool_choice, streaming_id_len_pre_v11",
212 + [
213 + pytest.param(
214 + [WEATHER_TOOL, SEARCH_TOOL],
215 + _NOT_SET,
216 + 9,
217 + id="auto",
218 + ),
219 + pytest.param(
220 + [WEATHER_TOOL],
221 + "required",
222 + 30,
223 + id="required",
224 + ),
225 + ],
226 +)
227 +async def test_tool_call_auto_or_required(
228 + client: openai.AsyncOpenAI,
229 + server_config: ServerConfig,
230 + tools: list,
231 + tool_choice: object,
232 + streaming_id_len_pre_v11: int,
233 +) -> None:
234 + _requires_tool_parser(server_config)
235 +
236 + models = await client.models.list()
237 + model_name: str = models.data[0].id
238 +
239 + create_kwargs: dict = {
240 + "messages": ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
241 + "temperature": 0,
242 + "max_completion_tokens": 100,
243 + "model": model_name,
244 + "tools": tools,
245 + "logprobs": False,
246 + "seed": SEED,
247 + }
248 + if tool_choice is not _NOT_SET:
249 + create_kwargs["tool_choice"] = tool_choice
250 +
251 + # --- non-streaming ---
252 + chat_completion = await client.chat.completions.create(**create_kwargs)
253 +
254 + choice = chat_completion.choices[0]
255 + tool_calls = choice.message.tool_calls
256 +
257 + assert choice.finish_reason == "tool_calls"
258 + assert tool_calls is not None and len(tool_calls) >= 1
259 + assert tool_calls[0].function.name == "get_current_weather"
260 + parsed_arguments = json.loads(tool_calls[0].function.arguments)
261 + assert "city" in parsed_arguments
262 + assert len(tool_calls[0].id) == 9
263 +
264 + # --- streaming ---
265 + stream = await client.chat.completions.create(**create_kwargs, stream=True)
266 +
267 + result = await _collect_streamed_tool_call(stream)
268 +
269 + assert result.finish_reason_count == 1
270 + assert result.role_name == "assistant"
271 + assert result.function_name == "get_current_weather"
272 + streamed_args = json.loads(result.function_args_str)
273 + assert isinstance(result.tool_call_id, str)
274 + if _is_pre_v11(server_config):
275 + assert len(result.tool_call_id) == streaming_id_len_pre_v11
276 + else:
277 + assert len(result.tool_call_id) == 9
278 + assert parsed_arguments == streamed_args
279 +
280 +
281 +@pytest.mark.asyncio
282 +async def test_tool_call_none_with_tools(
283 + client: openai.AsyncOpenAI, server_config: ServerConfig
284 +) -> None:
285 + _requires_tool_parser(server_config)
286 +
287 + models = await client.models.list()
288 + model_name: str = models.data[0].id
289 +
290 + # --- non-streaming ---
291 + chat_completion = await client.chat.completions.create(
292 + messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
293 + temperature=0,
294 + max_completion_tokens=100,
295 + model=model_name,
296 + tools=[WEATHER_TOOL],
297 + tool_choice="none",
298 + logprobs=False,
299 + seed=SEED,
300 + )
301 +
302 + choice = chat_completion.choices[0]
303 +
304 + assert choice.finish_reason != "tool_calls"
305 + assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
306 + assert choice.message.content is not None
307 + # Without grammar enforcement, pre-v11 models may still emit [TOOL_CALLS]
308 + if not _is_pre_v11(server_config):
309 + assert "[TOOL_CALLS]" not in choice.message.content
310 +
311 + non_streaming_content = choice.message.content
312 +
313 + # --- streaming ---
314 + stream = await client.chat.completions.create(
315 + messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
316 + temperature=0,
317 + max_completion_tokens=100,
318 + model=model_name,
319 + tools=[WEATHER_TOOL],
320 + tool_choice="none",
321 + logprobs=False,
322 + seed=SEED,
323 + stream=True,
324 + )
325 +
326 + # Pre-v11 models lack grammar enforcement, so the model may still
327 + # emit tool calls even with tool_choice="none".
328 + pre_v11 = _is_pre_v11(server_config)
329 + result = await _collect_streamed_content(stream, no_tool_calls=not pre_v11)
330 +
331 + assert result.finish_reason_count == 1
332 + if not pre_v11:
333 + assert result.finish_reason != "tool_calls"
334 + streamed_content = "".join(result.chunks)
335 + if not pre_v11:
336 + assert "[TOOL_CALLS]" not in streamed_content
337 + assert streamed_content == non_streaming_content
338 +
339 +
340 +@pytest.mark.asyncio
341 +async def test_chat_without_tools(
342 + client: openai.AsyncOpenAI, server_config: ServerConfig
343 +) -> None:
344 + models = await client.models.list()
345 + model_name: str = models.data[0].id
346 +
347 + # --- non-streaming ---
348 + chat_completion = await client.chat.completions.create(
349 + messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
350 + temperature=0,
351 + max_completion_tokens=150,
352 + model=model_name,
353 + logprobs=False,
354 + seed=SEED,
355 + )
356 +
357 + choice = chat_completion.choices[0]
358 + output_text = choice.message.content
359 +
360 + assert output_text is not None and len(output_text) > 0
361 + assert choice.finish_reason != "tool_calls"
362 + assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
363 +
364 + # --- streaming ---
365 + stream = await client.chat.completions.create(
366 + messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
367 + temperature=0,
368 + max_completion_tokens=150,
369 + model=model_name,
370 + logprobs=False,
371 + seed=SEED,
372 + stream=True,
373 + )
374 +
375 + result = await _collect_streamed_content(
376 + stream, expected_finish_reason=choice.finish_reason
377 + )
378 +
379 + assert result.role_sent
380 + assert result.finish_reason_count == 1
381 + assert len(result.chunks)
382 + assert "".join(result.chunks) == output_text
383 +
384 +
385 +@pytest.mark.asyncio
386 +async def test_tool_call_with_results(
387 + client: openai.AsyncOpenAI, server_config: ServerConfig
388 +) -> None:
389 + _requires_tool_parser(server_config)
390 +
391 + models = await client.models.list()
392 + model_name: str = models.data[0].id
393 +
394 + # --- non-streaming ---
395 + chat_completion = await client.chat.completions.create(
396 + messages=ensure_system_prompt(MESSAGES_WITH_TOOL_RESPONSE, server_config),
397 + temperature=0,
398 + max_completion_tokens=100,
399 + model=model_name,
400 + tools=[WEATHER_TOOL, SEARCH_TOOL],
401 + logprobs=False,
402 + seed=SEED,
403 + )
404 +
405 + choice = chat_completion.choices[0]
406 +
407 + assert choice.finish_reason != "tool_calls"
408 + assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
409 + assert choice.message.content is not None
410 + assert "98" in choice.message.content
411 +
412 + # --- streaming ---
413 + stream = await client.chat.completions.create(
414 + messages=ensure_system_prompt(MESSAGES_WITH_TOOL_RESPONSE, server_config),
415 + temperature=0,
416 + max_completion_tokens=100,
417 + model=model_name,
418 + tools=[WEATHER_TOOL, SEARCH_TOOL],
419 + logprobs=False,
420 + seed=SEED,
421 + stream=True,
422 + )
423 +
424 + result = await _collect_streamed_content(
425 + stream, expected_finish_reason=choice.finish_reason
426 + )
427 +
428 + assert result.role_sent
429 + assert result.finish_reason_count == 1
430 + assert len(result.chunks)
431 + assert "".join(result.chunks) == choice.message.content
432 +
433 +
434 +def _requires_parallel(server_config: ServerConfig) -> None:
435 + r"""Skip test if the model does not support parallel tool calls."""
436 + if not server_config.get("supports_parallel"):
437 + pytest.skip(
438 + f"Skipping: {server_config['model']} does not support parallel tool calls"
439 + )
440 +
441 +
442 +@pytest.mark.asyncio
443 +async def test_tool_call_parallel(
444 + client: openai.AsyncOpenAI, server_config: ServerConfig
445 +) -> None:
446 + _requires_tool_parser(server_config)
447 + _requires_parallel(server_config)
448 +
449 + models = await client.models.list()
450 + model_name: str = models.data[0].id
451 +
452 + # --- non-streaming ---
453 + chat_completion = await client.chat.completions.create(
454 + messages=ensure_system_prompt(
455 + MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
456 + ),
457 + temperature=0,
458 + max_completion_tokens=200,
459 + model=model_name,
460 + tools=[WEATHER_TOOL],
461 + logprobs=False,
462 + seed=SEED,
463 + )
464 +
465 + choice = chat_completion.choices[0]
466 + tool_calls = choice.message.tool_calls
467 +
468 + assert choice.finish_reason == "tool_calls"
469 + assert tool_calls is not None and len(tool_calls) >= 2
470 + for tc in tool_calls:
471 + assert tc.type == "function"
472 + assert tc.function.name == "get_current_weather"
473 + assert isinstance(tc.function.arguments, str)
474 + parsed = json.loads(tc.function.arguments)
475 + assert "city" in parsed
476 + assert len(tc.id) == 9
477 +
478 + non_streaming_tool_calls = tool_calls
479 +
480 + # --- streaming ---
481 + stream = await client.chat.completions.create(
482 + messages=ensure_system_prompt(
483 + MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
484 + ),
485 + temperature=0,
486 + max_completion_tokens=200,
487 + model=model_name,
488 + tools=[WEATHER_TOOL],
489 + logprobs=False,
490 + seed=SEED,
491 + stream=True,
492 + )
493 +
494 + result = await _collect_streamed_parallel_tool_calls(stream)
495 +
496 + assert result.finish_reason_count == 1
497 + assert result.role_name == "assistant"
498 + assert len(result.function_names) >= 2
499 + assert all(name == "get_current_weather" for name in result.function_names)
500 + assert len(result.tool_call_ids) >= 2
501 + assert all(isinstance(tid, str) and len(tid) == 9 for tid in result.tool_call_ids)
502 +
503 + for args_str in result.function_args_strs:
504 + streamed_args = json.loads(args_str)
505 + assert "city" in streamed_args
506 +
507 + assert len(result.function_names) == len(non_streaming_tool_calls)
tests/tool_use/mistral/utils.py
+24 -10
@@ -2,16 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
5 -from typing_extensions import TypedDict
6 -
7 -
8 -class ServerConfig(TypedDict, total=False):
9 - model: str
10 - arguments: list[str]
11 - system_prompt: str | None
12 - supports_parallel: bool | None
13 - supports_rocm: bool | None
14 -
5 +from tests.tool_use.utils import ServerConfig
6
7 ARGS: list[str] = ["--max-model-len", "1024"]
8
@@ -21,6 +12,11 @@ CONFIGS: dict[str, ServerConfig] = {
12 "arguments": [
13 "--tokenizer-mode",
14 "mistral",
15 + "--tool-call-parser",
16 + "mistral",
17 + "--enable-auto-tool-choice",
18 + "--enforce-eager",
19 + "--no-enable-prefix-caching",
20 '--ignore-patterns="consolidated.safetensors"',
21 ],
22 "system_prompt": "You are a helpful assistant with access to tools. If a tool"
@@ -29,4 +25,22 @@ CONFIGS: dict[str, ServerConfig] = {
25 "without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
26 "to the user's question - just respond to it normally.",
27 },
28 + "ministral-3b": {
29 + "model": "mistralai/Ministral-3-3B-Instruct-2512",
30 + "arguments": [
31 + "--tokenizer-mode",
32 + "mistral",
33 + "--tool-call-parser",
34 + "mistral",
35 + "--enable-auto-tool-choice",
36 + "--enforce-eager",
37 + "--no-enable-prefix-caching",
38 + ],
39 + "system_prompt": "You are a helpful assistant with access to tools. If a tool"
40 + " that you have would be helpful to answer a user query, "
41 + "call the tool. Otherwise, answer the user's query directly "
42 + "without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
43 + "to the user's question - just respond to it normally.",
44 + "supports_parallel": True,
45 + },
46 }
vllm/entrypoints/openai/chat_completion/protocol.py
+4 -8
@@ -11,7 +11,7 @@ from openai.types.chat.chat_completion_audio import (
11 ChatCompletionAudio as OpenAIChatCompletionAudio,
12 )
13 from openai.types.chat.chat_completion_message import Annotation as OpenAIAnnotation
14 -from pydantic import Field, model_validator
14 +from pydantic import Field, PrivateAttr, model_validator
15
16 from vllm.config import ModelConfig
17 from vllm.config.utils import replace
@@ -398,6 +398,9 @@ class ChatCompletionRequest(OpenAIBaseModel):
398 msg["tool_calls"] = list(tool_calls)
399 return self
400
401 + _grammar_from_tool_parser: bool = PrivateAttr(default=False)
402 + """CAUTION: Should only be set by ``ToolParser.adjust_request``."""
403 +
404 def build_chat_params(
405 self,
406 default_template: str | None,
@@ -822,13 +825,6 @@ class ChatCompletionRequest(OpenAIBaseModel):
825
826 return data
827
825 - @model_validator(mode="before")
826 - @classmethod
827 - def set_include_reasoning_for_none_effort(cls, data: Any) -> Any:
828 - if data.get("reasoning_effort") == "none":
829 - data["include_reasoning"] = False
830 - return data
831 -
828
829 class BatchChatCompletionRequest(OpenAIBaseModel):
830 """Request model for the /v1/chat/completions/batch endpoint.
vllm/entrypoints/openai/chat_completion/serving.py
+64 -5
@@ -73,7 +73,10 @@ from vllm.reasoning import ReasoningParser
73 from vllm.renderers import ChatParams
74 from vllm.sampling_params import BeamSearchParams, SamplingParams
75 from vllm.tokenizers import TokenizerLike
76 -from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
76 +from vllm.tool_parsers.mistral_tool_parser import (
77 + MistralToolCall,
78 + MistralToolParser,
79 +)
80 from vllm.tool_parsers.utils import partial_json_loads
81 from vllm.utils.collection_utils import as_list
82 from vllm.utils.mistral import is_mistral_tokenizer
@@ -140,6 +143,12 @@ class OpenAIServingChat(OpenAIServing):
143 enable_auto_tools=enable_auto_tools,
144 model_name=self.model_config.model,
145 )
146 + _is_mistral_tool_parser = self.tool_parser is not None and issubclass(
147 + self.tool_parser, MistralToolParser
148 + )
149 + if _is_mistral_tool_parser and self.reasoning_parser_cls is not None:
150 + MistralToolParser.model_can_reason = True
151 +
152 self.exclude_tools_when_tool_choice_none = exclude_tools_when_tool_choice_none
153
154 self.enable_prompt_tokens_details = enable_prompt_tokens_details
@@ -310,6 +319,11 @@ class OpenAIServingChat(OpenAIServing):
319 else:
320 if not request.include_reasoning:
321 reasoning_ended = True
322 + elif request._grammar_from_tool_parser:
323 + # The Mistral grammar already includes an optional
324 + # `think?` rule that handles both reasoning and
325 + # non-reasoning outputs.
326 + reasoning_ended = True
327 elif reasoning_parser:
328 reasoning_ended = reasoning_parser.is_reasoning_end(
329 prompt_token_ids or []
@@ -530,6 +544,8 @@ class OpenAIServingChat(OpenAIServing):
544 harmony_tools_streamed = [False] * num_choices
545 tools_streamed = [False] * num_choices
546
547 + is_mistral_grammar_path = request._grammar_from_tool_parser
548 +
549 if isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam):
550 tool_choice_function_name = request.tool_choice.function.name
551 else:
@@ -553,7 +569,7 @@ class OpenAIServingChat(OpenAIServing):
569
570 # Only one of these will be used, thus previous_texts and
571 # all_previous_token_ids will not be used twice in the same iteration.
556 - if tool_choice_auto or reasoning_parser:
572 + if is_mistral_grammar_path or tool_choice_auto or reasoning_parser:
573 # These are only required in "auto" tool choice case
574 all_previous_token_ids = [[] for _ in range(num_choices)]
575 reasoning_end_arr = [False] * num_choices
@@ -748,7 +764,7 @@ class OpenAIServingChat(OpenAIServing):
764 delta_message: DeltaMessage | None
765
766 # just update previous_texts and previous_token_ids
751 - if tool_choice_auto or reasoning_parser:
767 + if is_mistral_grammar_path or tool_choice_auto or reasoning_parser:
768 assert previous_texts is not None
769 assert all_previous_token_ids is not None
770 previous_text = previous_texts[i]
@@ -772,6 +788,30 @@ class OpenAIServingChat(OpenAIServing):
788 )
789 )
790 harmony_tools_streamed[i] |= tools_streamed_flag
791 + # Mistral grammar path: combined reasoning + tool streaming
792 + elif is_mistral_grammar_path:
793 + assert tool_parser is not None
794 + assert isinstance(tool_parser, MistralToolParser)
795 + assert reasoning_end_arr is not None
796 + output_token_ids = as_list(output.token_ids)
797 + result = tool_parser.extract_maybe_reasoning_and_tool_streaming(
798 + reasoning_parser=reasoning_parser,
799 + previous_text=previous_text,
800 + current_text=current_text,
801 + delta_text=delta_text,
802 + previous_token_ids=previous_token_ids,
803 + current_token_ids=current_token_ids,
804 + output_token_ids=output_token_ids,
805 + reasoning_ended=reasoning_end_arr[i],
806 + prompt_is_reasoning_end=(prompt_is_reasoning_end_arr[i]),
807 + request=request,
808 + )
809 + delta_message = result.delta_message
810 + reasoning_end_arr[i] = result.reasoning_ended
811 + current_text = result.current_text
812 + current_token_ids = result.current_token_ids
813 + if result.tools_called:
814 + tools_streamed[i] = True
815 # handle streaming deltas for tools with named tool_choice
816 elif tool_choice_function_name:
817 # When encountering think end id in prompt_token_ids
@@ -925,7 +965,9 @@ class OpenAIServingChat(OpenAIServing):
965 delta_message = DeltaMessage(content=delta_text)
966
967 # update the previous values for the next iteration
928 - if (tool_choice_auto or reasoning_parser) and not self.use_harmony:
968 + if (
969 + is_mistral_grammar_path or tool_choice_auto or reasoning_parser
970 + ) and not self.use_harmony:
971 assert previous_texts is not None
972 assert all_previous_token_ids is not None
973 previous_texts[i] = current_text
@@ -1312,7 +1354,24 @@ class OpenAIServingChat(OpenAIServing):
1354 tool_call_class = (
1355 MistralToolCall if is_mistral_tokenizer(tokenizer) else ToolCall
1356 )
1315 - if (not self.enable_auto_tools or not self.tool_parser) and (
1357 +
1358 + use_mistral_tool_parser = request._grammar_from_tool_parser
1359 + if use_mistral_tool_parser:
1360 + tool_call_items = MistralToolParser.build_non_streaming_tool_calls(
1361 + tool_calls
1362 + )
1363 + if tool_call_items:
1364 + auto_tools_called = (
1365 + request.tool_choice is None or request.tool_choice == "auto"
1366 + )
1367 + message = ChatMessage(
1368 + role=role,
1369 + reasoning=reasoning,
1370 + content=content,
1371 + tool_calls=tool_call_items,
1372 + )
1373 +
1374 + elif (not self.enable_auto_tools or not self.tool_parser) and (
1375 not isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
1376 and request.tool_choice != "required"
1377 ):
vllm/entrypoints/openai/engine/serving.py
+26 -8
@@ -65,6 +65,7 @@ from vllm.renderers.inputs.preprocess import (
65 from vllm.sampling_params import BeamSearchParams, SamplingParams
66 from vllm.tokenizers import TokenizerLike
67 from vllm.tool_parsers import ToolParser
68 +from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
69 from vllm.tracing import (
70 contains_trace_headers,
71 extract_trace_headers,
@@ -610,16 +611,31 @@ class OpenAIServing:
611 tool_parser_cls: type[ToolParser] | None,
612 content: str | None = None,
613 ) -> tuple[list[FunctionCall] | None, str | None]:
614 + # When the Mistral grammar factory injected structured outputs,
615 + # let the parser handle the output.
616 + use_mistral_tool_parser = (
617 + isinstance(request, ChatCompletionRequest)
618 + and tool_parser_cls is not None
619 + and issubclass(tool_parser_cls, MistralToolParser)
620 + and request._grammar_from_tool_parser
621 + )
622 +
623 function_calls = list[FunctionCall]()
614 - if request.tool_choice and isinstance(request.tool_choice, ToolChoiceFunction):
624 + if (
625 + not use_mistral_tool_parser
626 + and request.tool_choice
627 + and isinstance(request.tool_choice, ToolChoiceFunction)
628 + ):
629 assert content is not None
630 # Forced Function Call
631 function_calls.append(
632 FunctionCall(name=request.tool_choice.name, arguments=content)
633 )
634 content = None # Clear content since tool is called.
621 - elif request.tool_choice and isinstance(
622 - request.tool_choice, ChatCompletionNamedToolChoiceParam
635 + elif (
636 + not use_mistral_tool_parser
637 + and request.tool_choice
638 + and isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
639 ):
640 assert content is not None
641 # Forced Function Call
@@ -627,7 +643,7 @@ class OpenAIServing:
643 FunctionCall(name=request.tool_choice.function.name, arguments=content)
644 )
645 content = None # Clear content since tool is called.
630 - elif request.tool_choice == "required":
646 + elif not use_mistral_tool_parser and request.tool_choice == "required":
647 tool_calls = []
648 with contextlib.suppress(ValidationError):
649 content = content or ""
@@ -642,10 +658,12 @@ class OpenAIServing:
658 )
659 )
660 content = None # Clear content since tool is called.
645 - elif (
646 - tool_parser_cls
647 - and enable_auto_tools
648 - and (request.tool_choice == "auto" or request.tool_choice is None)
661 + elif tool_parser_cls and (
662 + use_mistral_tool_parser
663 + or (
664 + enable_auto_tools
665 + and (request.tool_choice == "auto" or request.tool_choice is None)
666 + )
667 ):
668 if tokenizer is None:
669 raise ValueError(
vllm/entrypoints/serve/render/serving.py
+12 -2
@@ -53,6 +53,7 @@ from vllm.renderers.inputs.preprocess import (
53 prompt_to_seq,
54 )
55 from vllm.tool_parsers import ToolParser
56 +from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
57 from vllm.utils import random_uuid
58 from vllm.utils.mistral import is_mistral_tokenizer
59 from vllm.utils.mistral import mt as _mt
@@ -555,9 +556,19 @@ class OpenAIServingRender:
556 # tool parsing is done only if a tool_parser has been set and if
557 # tool_choice is not "none" (if tool_choice is "none" but a tool_parser
558 # is set, we want to prevent parsing a tool_call hallucinated by the LLM
559 + #
560 + # Exception: Mistral grammar-capable tokenizers always call
561 + # adjust_request — even for tool_choice="none" — so that the grammar
562 + # factory can prevent special-token leakage.
563 if tool_parser is not None:
564 tool_choice = getattr(request, "tool_choice", "none")
560 - if tool_choice != "none":
565 + tokenizer = renderer.get_tokenizer()
566 + is_mistral_grammar_eligible = (
567 + issubclass(tool_parser, MistralToolParser)
568 + and is_mistral_tokenizer(tokenizer)
569 + and tokenizer.supports_grammar
570 + )
571 + if tool_choice != "none" or is_mistral_grammar_eligible:
572 if not isinstance(request, ChatCompletionRequest | ResponsesRequest):
573 msg = (
574 "Tool usage is only supported "
@@ -565,7 +576,6 @@ class OpenAIServingRender:
576 f"but got {type(request).__name__}"
577 )
578 raise NotImplementedError(msg)
568 - tokenizer = renderer.get_tokenizer()
579 request = tool_parser(tokenizer, request.tools).adjust_request(
580 request=request
581 )
vllm/sampling_params.py
+12 -2
@@ -157,6 +157,10 @@ def _is_non_tekken_mistral(tokenizer: TokenizerLike) -> bool:
157 return is_mistral_tokenizer(tokenizer) and not tokenizer.is_tekken
158
159
160 +def _get_llg_tokenizer(tokenizer: TokenizerLike) -> Any:
161 + return tokenizer.llg_tokenizer if is_mistral_tokenizer(tokenizer) else None
162 +
163 +
164 class SamplingParams(
165 PydanticMsgspecMixin,
166 msgspec.Struct,
@@ -816,7 +820,10 @@ class SamplingParams(
820 # allows <|special_token|> and similar, see
821 # https://github.com/guidance-ai/llguidance/blob/main/docs/syntax.md#special-tokens
822 # Without tokenizer these are disallowed in grammars.
819 - validate_guidance_grammar(self, tokenizer=None)
823 + validate_guidance_grammar(
824 + self,
825 + tokenizer=_get_llg_tokenizer(tokenizer),
826 + )
827 elif backend == "outlines":
828 # outlines backend
829 validate_structured_output_request_outlines(self)
@@ -862,7 +869,10 @@ class SamplingParams(
869 self.structured_outputs._backend = "outlines"
870 else:
871 # Fall back to guidance by default.
865 - validate_guidance_grammar(self, tokenizer=None)
872 + validate_guidance_grammar(
873 + self,
874 + tokenizer=_get_llg_tokenizer(tokenizer),
875 + )
876 self.structured_outputs._backend = "guidance"
877 # Remember that this backend was set automatically
878 self.structured_outputs._backend_was_auto = True
vllm/tokenizers/mistral.py
+49 -38
@@ -54,6 +54,50 @@ if TYPE_CHECKING:
54 logger = init_logger(__name__)
55
56
57 +def _pop_unallowed_keys_and_warn(
58 + dictionary: dict[str, Any], allowed_keys: set[str], err_dict_name: str
59 +):
60 + keys = list(dictionary.keys())
61 + for key in keys:
62 + if key not in allowed_keys:
63 + dictionary.pop(key)
64 + logger.warning_once(
65 + f"'{key=}' is not supported by mistral-common "
66 + f"for {err_dict_name}. It has been popped from the "
67 + "object."
68 + )
69 +
70 +
71 +# TODO(juliendenize): remove this once OpenAI API is better supported by
72 +# `mistral-common`.
73 +def adapt_inplace_to_mistral_tool(
74 + tool: dict[str, Any],
75 +) -> dict[str, Any]:
76 + tools_fields = set(Tool.model_fields.keys())
77 + function_fields = set(Function.model_fields.keys())
78 +
79 + # The Mistral client, in comparison to the OpenAI client, requires the
80 + # "parameters" dict and the "description" string to be present
81 + # even if they are empty.
82 + if function := tool.get("function"):
83 + if function.get("parameters") is None:
84 + function["parameters"] = {}
85 + if function.get("description") is None:
86 + function["description"] = ""
87 +
88 + _pop_unallowed_keys_and_warn(
89 + dictionary=function,
90 + allowed_keys=function_fields,
91 + err_dict_name="function",
92 + )
93 +
94 + _pop_unallowed_keys_and_warn(
95 + dictionary=tool, allowed_keys=tools_fields, err_dict_name="tools"
96 + )
97 +
98 + return tool
99 +
100 +
101 def maybe_serialize_tool_calls(request: "MistralChatCompletionRequest"):
102 # SEE: https://github.com/vllm-project/vllm/pull/9951
103 # Credits go to: @gcalmettes
@@ -159,44 +203,11 @@ def _prepare_apply_chat_template_tools_and_messages(
203 # Remove reasoning as unsupported by Mistral
204 _ = message.pop("reasoning", None) # type: ignore
205
162 - # The Mistral client, in comparison to the OpenAI client, requires the
163 - # "parameters" dict and the "description" string to be present
164 - # even if they are empty.
165 - if tools:
166 - for function in [
167 - tool["function"] for tool in tools if tool["type"] == "function"
168 - ]:
169 - if function.get("parameters") is None:
170 - function["parameters"] = {}
171 - if function.get("description") is None:
172 - function["description"] = ""
173 -
174 - # We filter not supported arguments to avoid throwing an error.
175 - # TODO(juliendenize): remove this once OpenAI API is better supported by
176 - # `mistral-common`.
177 - tools_fields = set(Tool.model_fields.keys())
178 - function_fields = set(Function.model_fields.keys())
179 - for tool in tools:
180 - tool_keys = list(tool.keys())
181 - for tool_key in tool_keys:
182 - if tool_key not in tools_fields:
183 - tool.pop(tool_key)
184 - logger.warning_once(
185 - f"'{tool_key}' is not supported by mistral-common for tools. "
186 - "It has been popped from the tool definition."
187 - )
188 - if tool["type"] == "function":
189 - function_keys = list(tool["function"].keys())
190 - for function_key in function_keys:
191 - if function_key not in function_fields:
192 - tool["function"].pop(function_key)
193 - logger.warning_once(
194 - f"'{function_key}' is not supported by mistral-common "
195 - "for function tools. It has been popped from the "
196 - "function definition."
197 - )
198 - else:
199 - raise ValueError("mistral-common only supports function tools.")
206 + tools = (
207 + [adapt_inplace_to_mistral_tool(tool=tool) for tool in tools]
208 + if tools is not None
209 + else None
210 + )
211
212 return messages, tools
213
vllm/tool_parsers/mistral_tool_parser.py
+178 -10
@@ -1,12 +1,15 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from __future__ import annotations
5 +
6 import json
7 from collections.abc import Sequence
8 +from dataclasses import dataclass
9 from enum import Enum, auto
10 from random import choices
11 from string import ascii_letters, digits
9 -from typing import Any
12 +from typing import TYPE_CHECKING, Any
13
14 import ijson
15 import regex as re
@@ -37,14 +40,19 @@ from vllm.entrypoints.openai.engine.protocol import (
40 )
41 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
42 from vllm.logger import init_logger
43 +from vllm.reasoning.mistral_reasoning_parser import MistralReasoningParser
44 from vllm.sampling_params import StructuredOutputsParams
45 from vllm.tokenizers import TokenizerLike
46 +from vllm.tokenizers.mistral import MistralTokenizer, adapt_inplace_to_mistral_tool
47 from vllm.tool_parsers.abstract_tool_parser import (
48 Tool,
49 ToolParser,
50 )
51 from vllm.utils.mistral import is_mistral_tokenizer
52
53 +if TYPE_CHECKING:
54 + from vllm.reasoning import ReasoningParser
55 +
56 logger = init_logger(__name__)
57
58 ALPHANUMERIC = ascii_letters + digits
@@ -86,13 +94,28 @@ def _is_pre_v11_tokeniser(model_tokenizer: TokenizerLike) -> bool:
94 return not (is_mistral_tokenizer(model_tokenizer) and model_tokenizer.version >= 11)
95
96
89 -class MistralToolParser(ToolParser):
97 +@dataclass
98 +class MistralStreamingResult:
99 + r"""Encapsulates the mutable state returned from
100 + `MistralToolParser.extract_maybe_reasoning_and_tool_streaming`.
101 """
91 - Tool call parser for Mistral 7B Instruct v0.3, intended for use with
92 - - [`mistral_common`](https://github.com/mistralai/mistral-common/)
93 - - the examples/tool_chat_template_mistral.jinja template.
102
95 - Used when --enable-auto-tool-choice --tool-call-parser mistral are all set
103 + delta_message: DeltaMessage | None
104 + reasoning_ended: bool
105 + tools_called: bool
106 + current_text: str
107 + current_token_ids: list[int]
108 +
109 +
110 +class MistralToolParser(ToolParser):
111 + r"""Tool call parser for Mistral models, intended for use with either:
112 +
113 + - `mistral_common <https://github.com/mistralai/mistral-common/>`_
114 + (recommended)
115 + - the `examples/tool_chat_template_mistral.jinja` template.
116 +
117 + Used when `--enable-auto-tool-choice --tool-call-parser mistral` are all
118 + set.
119 """
120
121 # Used to generate correct grammar in `adjust_request`
@@ -210,9 +233,11 @@ class MistralToolParser(ToolParser):
233 reasoning=self.model_can_reason
234 )
235
213 - tools = (
236 + mistral_tools = (
237 [
215 - MistralTool.from_openai(openai_tool=tool.model_dump())
238 + MistralTool.model_validate(
239 + adapt_inplace_to_mistral_tool(tool.model_dump())
240 + )
241 for tool in request.tools
242 ]
243 if request.tools is not None
@@ -244,15 +269,158 @@ class MistralToolParser(ToolParser):
269 lark_grammar = grammar_factory.get_lark_from_jinja(
270 template=template,
271 mode=tool_choice,
247 - tools=tools,
272 + tools=mistral_tools,
273 json_schema=json_schema,
274 parallel_tool_calls=request.parallel_tool_calls,
275 json_only=False,
276 )
277
278 request.structured_outputs = StructuredOutputsParams(grammar=lark_grammar)
279 + request._grammar_from_tool_parser = True
280 return request
281
282 + def extract_maybe_reasoning_and_tool_streaming(
283 + self,
284 + *,
285 + reasoning_parser: ReasoningParser | None,
286 + previous_text: str,
287 + current_text: str,
288 + delta_text: str,
289 + previous_token_ids: list[int],
290 + current_token_ids: list[int],
291 + output_token_ids: Sequence[int],
292 + reasoning_ended: bool,
293 + prompt_is_reasoning_end: bool | None,
294 + request: ChatCompletionRequest,
295 + ) -> MistralStreamingResult:
296 + r"""Streaming extraction with reasoning followed by tool-call parsing.
297 +
298 + This method encapsulates the combined reasoning extraction and
299 + tool-call streaming logic so that the serving layer only needs a
300 + thin routing branch.
301 +
302 + The flow is:
303 +
304 + 1. If a *reasoning_parser* is present and reasoning has **not** ended,
305 + extract reasoning tokens. Pre-v15 models may have pre-filled
306 + `[THINK]...[/THINK]` in system prompts, so we skip the
307 + prompt-level reasoning-end check for those.
308 + 2. Once reasoning ends (or if there is no reasoning parser), delegate
309 + to `extract_tool_calls_streaming` and track whether tools were
310 + called.
311 +
312 + Args:
313 + reasoning_parser: Optional reasoning parser instance.
314 + previous_text: Accumulated text from prior chunks.
315 + current_text: Full accumulated text including current chunk.
316 + delta_text: New text in this chunk.
317 + previous_token_ids: Token ids from prior chunks.
318 + current_token_ids: Full token ids including current chunk.
319 + output_token_ids: Raw output token ids from the engine.
320 + reasoning_ended: Whether reasoning has already ended.
321 + prompt_is_reasoning_end: Whether the prompt itself ends reasoning.
322 + request: The originating chat completion request.
323 + """
324 + delta_message: DeltaMessage | None = None
325 + tools_called = False
326 + reasoning_ended_at_entry = reasoning_ended
327 +
328 + # For MistralReasoningParser, only enter the reasoning block when
329 + # the model has actually emitted a [THINK] token. Other reasoning
330 + # parsers always expect thinking to be present.
331 + expect_thinking = (
332 + not isinstance(reasoning_parser, MistralReasoningParser)
333 + or reasoning_parser.start_token_id in current_token_ids
334 + )
335 + if reasoning_parser is not None and not reasoning_ended and expect_thinking:
336 + # Pre-v15 models may have pre-filled [THINK]...[/THINK] in
337 + # system prompts, so skip the prompt-level reasoning-end
338 + # check and wait for the output's own end-of-think.
339 + is_pre_v15 = (
340 + isinstance(self.model_tokenizer, MistralTokenizer)
341 + and self.model_tokenizer.version < 15
342 + )
343 +
344 + if not is_pre_v15 and prompt_is_reasoning_end:
345 + reasoning_ended = True
346 + current_token_ids = list(output_token_ids)
347 + else:
348 + delta_message = reasoning_parser.extract_reasoning_streaming(
349 + previous_text,
350 + current_text,
351 + delta_text,
352 + previous_token_ids,
353 + current_token_ids,
354 + output_token_ids,
355 + )
356 + if reasoning_parser.is_reasoning_end_streaming(
357 + current_token_ids, output_token_ids
358 + ):
359 + reasoning_ended = True
360 + current_token_ids = reasoning_parser.extract_content_ids(
361 + list(output_token_ids)
362 + )
363 + if delta_message and delta_message.content:
364 + current_text = delta_message.content
365 + delta_message.content = None
366 + else:
367 + current_text = ""
368 +
369 + if not reasoning_ended:
370 + return MistralStreamingResult(
371 + delta_message=delta_message,
372 + reasoning_ended=False,
373 + tools_called=False,
374 + current_text=current_text,
375 + current_token_ids=current_token_ids,
376 + )
377 +
378 + delta_token_ids = list(output_token_ids)
379 +
380 + # On the iteration where reasoning just ended, reset the text/token
381 + # state so the tool parser sees a clean history instead of the
382 + # accumulated reasoning text.
383 + if not reasoning_ended_at_entry and reasoning_ended:
384 + previous_text = ""
385 + previous_token_ids = []
386 + delta_text = current_text
387 + delta_token_ids = current_token_ids
388 +
389 + delta_message = self.extract_tool_calls_streaming(
390 + previous_text=previous_text,
391 + current_text=current_text,
392 + delta_text=delta_text,
393 + previous_token_ids=previous_token_ids,
394 + current_token_ids=current_token_ids,
395 + delta_token_ids=delta_token_ids,
396 + request=request,
397 + )
398 + if delta_message and delta_message.tool_calls:
399 + tools_called = True
400 +
401 + return MistralStreamingResult(
402 + delta_message=delta_message,
403 + reasoning_ended=reasoning_ended,
404 + tools_called=tools_called,
405 + current_text=current_text,
406 + current_token_ids=current_token_ids,
407 + )
408 +
409 + @staticmethod
410 + def build_non_streaming_tool_calls(
411 + tool_calls: list[FunctionCall] | None,
412 + ) -> list[ToolCall]:
413 + r"""Build `MistralToolCall` items for non-streaming responses."""
414 + if not tool_calls:
415 + return []
416 +
417 + return [
418 + MistralToolCall(id=tc.id, function=tc)
419 + if tc.id
420 + else MistralToolCall(function=tc)
421 + for tc in tool_calls
422 + ]
423 +
424 def extract_tool_calls(
425 self,
426 model_output: str,
@@ -323,7 +491,7 @@ class MistralToolParser(ToolParser):
491 )[0]
492 tool_calls = json.loads(raw_tool_call)
493 except (IndexError, json.JSONDecodeError):
326 - logger.exception("Error in extracting tool call from response: {e}")
494 + logger.exception("Error in extracting tool call from response.")
495 # If raw decoding and decoding post regex rule fails, then just
496 # return content.
497 return ExtractedToolCallInformation(