main
py 138 lines 4.77 KB
Raw
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)