| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Declarative configuration for model tool-call and reasoning formats. |
| 4 | |
| 5 | Each model format is described by a :class:`ParserEngineConfig` that specifies: |
| 6 | |
| 7 | * **terminals** – literal strings or regex patterns that delimit the format |
| 8 | (e.g. ``<tool_call>``, ``</think>``). |
| 9 | * **token_id_terminals** – terminals that should be matched by token ID |
| 10 | rather than (or in addition to) text. |
| 11 | * **transitions** – a state machine mapping |
| 12 | ``(state, terminal) → (new_state, events_to_emit)`` that drives semantic |
| 13 | event generation during streaming. |
| 14 | * **content_events** – what :class:`EventType` to emit for plain content |
| 15 | (non-terminal text) in each state. |
| 16 | """ |
| 17 | |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | from collections.abc import Callable, Mapping |
| 21 | from dataclasses import dataclass, field |
| 22 | from enum import Enum, auto |
| 23 | from functools import cached_property |
| 24 | |
| 25 | from vllm.parser.engine.events import EventType |
| 26 | |
| 27 | |
| 28 | class ParserState(Enum): |
| 29 | CONTENT = auto() |
| 30 | REASONING = auto() |
| 31 | MESSAGE_HEADER = auto() |
| 32 | TOOL_PREAMBLE = auto() |
| 33 | TOOL_NAME = auto() |
| 34 | TOOL_ARGS = auto() |
| 35 | TOOL_BETWEEN = auto() |
| 36 | |
| 37 | |
| 38 | @dataclass(frozen=True, slots=True) |
| 39 | class Transition: |
| 40 | next_state: ParserState |
| 41 | events: tuple[EventType, ...] = field(default_factory=tuple) |
| 42 | skip_in_token_id_mode: bool = False |
| 43 | |
| 44 | |
| 45 | @dataclass(frozen=True) |
| 46 | class ParserEngineConfig: |
| 47 | """Declarative description of a model's tool-call / reasoning format. |
| 48 | |
| 49 | The engine feeds terminals from the incremental lexer into the |
| 50 | transition table and emits the corresponding semantic events. |
| 51 | Content tokens (text between terminals) are classified by the |
| 52 | current state via ``content_events``. |
| 53 | """ |
| 54 | |
| 55 | name: str |
| 56 | |
| 57 | # A terminal may carry several spellings; the first spelling is the canonical one. |
| 58 | terminals: Mapping[str, str | tuple[str, ...]] = field(default_factory=dict) |
| 59 | |
| 60 | token_id_terminals: dict[str, str] = field(default_factory=dict) |
| 61 | |
| 62 | transitions: dict[tuple[ParserState, str], Transition] = field( |
| 63 | default_factory=dict, |
| 64 | ) |
| 65 | |
| 66 | content_events: dict[ParserState, EventType] = field( |
| 67 | default_factory=lambda: { |
| 68 | ParserState.CONTENT: EventType.TEXT_CHUNK, |
| 69 | ParserState.REASONING: EventType.REASONING_CHUNK, |
| 70 | ParserState.TOOL_NAME: EventType.TOOL_NAME, |
| 71 | ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, |
| 72 | }, |
| 73 | ) |
| 74 | |
| 75 | initial_state: ParserState = ParserState.CONTENT |
| 76 | |
| 77 | wait_for_reasoning: bool | None = None |
| 78 | |
| 79 | arg_converter: Callable[[str, bool], str] | None = None |
| 80 | |
| 81 | stream_arg_deltas: bool = True |
| 82 | |
| 83 | tool_args_json: bool = True |
| 84 | |
| 85 | # The tool region body is a single JSON array of ``{"name", "arguments"}`` |
| 86 | # objects (one marker → N calls, no closing terminal). The engine splits |
| 87 | # the array into one tool call per element; the array's ``]`` ends the |
| 88 | # region and any trailing text becomes content. Used by Granite. |
| 89 | tool_call_body_array: bool = False |
| 90 | |
| 91 | arg_structural_chars: frozenset[str] | None = None |
| 92 | |
| 93 | # Special tokens exempt from auto-drop but not state-machine terminals. |
| 94 | preserve_tokens: frozenset[str] = field(default_factory=frozenset) |
| 95 | |
| 96 | # Special tokens delimiting conversation turns in the prompt only |
| 97 | # considers reasoning markers after the last boundary token. |
| 98 | turn_boundary_tokens: frozenset[str] = field(default_factory=frozenset) |
| 99 | |
| 100 | # Prevents trailing-whitespace accumulation across multi-turn conversations. |
| 101 | strip_trailing_reasoning_whitespace: bool = True |
| 102 | |
| 103 | # Drop content that is entirely whitespace when tool calls follow. |
| 104 | drop_whitespace_only_content_before_tools: bool = True |
| 105 | |
| 106 | # .strip() content text when tool calls are present. |
| 107 | strip_content_whitespace_with_tools: bool = True |
| 108 | |
| 109 | # Reject tool calls whose names are absent from the request tools. |
| 110 | validate_tool_names: bool = False |
| 111 | |
| 112 | def terminal_literal(self, name: str) -> str | None: |
| 113 | """Canonical spelling of terminal *name*, or ``None`` if undeclared.""" |
| 114 | value = self.terminals.get(name) |
| 115 | if isinstance(value, tuple): |
| 116 | return value[0] if value else None |
| 117 | return value |
| 118 | |
| 119 | @property |
| 120 | def terminal_literals(self) -> frozenset[str]: |
| 121 | """Every declared spelling across all terminals.""" |
| 122 | return frozenset( |
| 123 | lit |
| 124 | for value in self.terminals.values() |
| 125 | for lit in ((value,) if isinstance(value, str) else value) |
| 126 | ) |
| 127 | |
| 128 | @cached_property |
| 129 | def terminal_defs(self): |
| 130 | from vllm.parser.engine.incremental_lexer import terminals_from_literals |
| 131 | |
| 132 | return terminals_from_literals(self.terminals) |
| 133 | |
| 134 | @cached_property |
| 135 | def lexer_shape(self): |
| 136 | from vllm.parser.engine.incremental_lexer import LexerShape |
| 137 | |
| 138 | return LexerShape(self.terminal_defs) |