main
py 318 lines 12.1 KB
Raw
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 functools
7 import json
8 from dataclasses import replace
9 from itertools import groupby
10 from typing import TYPE_CHECKING
11
12 from vllm.entrypoints.generate.base.protocol import (
13 DeltaFunctionCall,
14 DeltaMessage,
15 DeltaToolCall,
16 )
17 from vllm.parser.engine.events import EventType, SemanticEvent
18 from vllm.parser.engine.parser_engine import ParserEngine
19 from vllm.parser.engine.parser_engine_config import (
20 ParserEngineConfig,
21 ParserState,
22 Transition,
23 )
24
25 if TYPE_CHECKING:
26 from vllm.entrypoints.openai.chat_completion.protocol import (
27 ChatCompletionRequest,
28 )
29 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
30 from vllm.tokenizers import TokenizerLike
31 from vllm.tool_parsers.abstract_tool_parser import Tool
32
33 BEGIN_TOOL_REQUESTS = "<|plamo:begin_tool_requests:plamo|>"
34 END_TOOL_REQUESTS = "<|plamo:end_tool_requests:plamo|>"
35 BEGIN_TOOL_REQUEST = "<|plamo:begin_tool_request:plamo|>"
36 END_TOOL_REQUEST = "<|plamo:end_tool_request:plamo|>"
37 BEGIN_TOOL_NAME = "<|plamo:begin_tool_name:plamo|>"
38 END_TOOL_NAME = "<|plamo:end_tool_name:plamo|>"
39 BEGIN_TOOL_ARGUMENTS = (
40 "<|plamo:begin_tool_arguments:plamo|><|plamo:constrain|>json<|plamo:msg|>"
41 )
42 END_TOOL_ARGUMENTS = "<|plamo:end_tool_arguments:plamo|>"
43 BEGIN_THINK = "<|plamo:begin_think:plamo|>"
44 END_THINK = "<|plamo:end_think:plamo|>"
45 EOT = "<|plamo:tag|>"
46
47 # PLaMo3 control markers are split into multiple tokenizer tokens. These
48 # atomic pieces must not be treated as unrelated special tokens by the
49 # parser-engine drop-token handling.
50 PLAMO_MARKER_TOKENS = frozenset(
51 {
52 "<|plamo:begin_",
53 "<|plamo:end_",
54 ":plamo|>",
55 }
56 )
57
58
59 @functools.cache
60 def plamo3_config(thinking: bool = True) -> ParserEngineConfig:
61 if thinking:
62 reasoning_terminals = {
63 "THINK_START": BEGIN_THINK,
64 "THINK_END": END_THINK,
65 }
66 reasoning_transitions = {
67 (ParserState.REASONING, "THINK_START"): Transition(
68 ParserState.REASONING,
69 ),
70 (ParserState.CONTENT, "THINK_START"): Transition(
71 ParserState.REASONING,
72 (EventType.REASONING_START,),
73 ),
74 (ParserState.REASONING, "THINK_END"): Transition(
75 ParserState.CONTENT,
76 (EventType.REASONING_END,),
77 ),
78 (ParserState.CONTENT, "THINK_END"): Transition(
79 ParserState.CONTENT,
80 (),
81 ),
82 (ParserState.REASONING, "TOOL_REQUESTS_START"): Transition(
83 ParserState.TOOL_PREAMBLE,
84 (EventType.REASONING_END,),
85 ),
86 }
87 initial_state = ParserState.REASONING
88 else:
89 reasoning_terminals = {}
90 reasoning_transitions = {}
91 initial_state = ParserState.CONTENT
92
93 return ParserEngineConfig(
94 name="plamo3",
95 initial_state=initial_state,
96 terminals={
97 **reasoning_terminals,
98 "TOOL_REQUESTS_START": BEGIN_TOOL_REQUESTS,
99 "TOOL_REQUESTS_END": END_TOOL_REQUESTS,
100 "TOOL_REQUEST_START": BEGIN_TOOL_REQUEST,
101 "TOOL_REQUEST_END": END_TOOL_REQUEST,
102 "TOOL_NAME_START": BEGIN_TOOL_NAME,
103 "TOOL_NAME_END": END_TOOL_NAME,
104 "TOOL_ARGS_START": BEGIN_TOOL_ARGUMENTS,
105 "TOOL_ARGS_END": END_TOOL_ARGUMENTS,
106 "EOT": EOT,
107 },
108 transitions={
109 **reasoning_transitions,
110 (ParserState.CONTENT, "TOOL_REQUESTS_START"): Transition(
111 ParserState.TOOL_PREAMBLE,
112 ),
113 (ParserState.TOOL_PREAMBLE, "TOOL_REQUEST_START"): Transition(
114 ParserState.TOOL_PREAMBLE,
115 ),
116 (ParserState.TOOL_PREAMBLE, "TOOL_NAME_START"): Transition(
117 ParserState.TOOL_NAME,
118 (EventType.TOOL_CALL_START,),
119 ),
120 (ParserState.TOOL_NAME, "TOOL_NAME_END"): Transition(
121 ParserState.TOOL_PREAMBLE,
122 ),
123 (ParserState.TOOL_PREAMBLE, "TOOL_ARGS_START"): Transition(
124 ParserState.TOOL_ARGS,
125 ),
126 (ParserState.TOOL_ARGS, "TOOL_ARGS_END"): Transition(
127 ParserState.TOOL_BETWEEN,
128 ),
129 (ParserState.TOOL_BETWEEN, "TOOL_REQUEST_END"): Transition(
130 ParserState.TOOL_PREAMBLE,
131 (EventType.TOOL_CALL_END,),
132 ),
133 (ParserState.TOOL_PREAMBLE, "TOOL_REQUESTS_END"): Transition(
134 ParserState.CONTENT,
135 ),
136 # EOT is normally consumed by generation stop handling. Keep it
137 # in the grammar as well so text-only parsing and explicitly
138 # supplied stop tokens cannot leak it into user-visible output.
139 (ParserState.REASONING, "EOT"): Transition(
140 ParserState.CONTENT,
141 (EventType.REASONING_END,),
142 ),
143 (ParserState.CONTENT, "EOT"): Transition(ParserState.CONTENT),
144 (ParserState.TOOL_PREAMBLE, "EOT"): Transition(ParserState.CONTENT),
145 (ParserState.TOOL_NAME, "EOT"): Transition(ParserState.CONTENT),
146 (ParserState.TOOL_ARGS, "EOT"): Transition(ParserState.CONTENT),
147 (ParserState.TOOL_BETWEEN, "EOT"): Transition(ParserState.CONTENT),
148 },
149 token_id_terminals={"EOT": EOT, **reasoning_terminals},
150 drop_whitespace_only_content_before_tools=True,
151 stream_arg_deltas=True,
152 strip_content_whitespace_with_tools=False,
153 strip_trailing_reasoning_whitespace=False,
154 preserve_tokens=PLAMO_MARKER_TOKENS,
155 tool_args_json=True,
156 )
157
158
159 class Plamo3Parser(ParserEngine):
160 """PLaMo3 reasoning and tool-call parser backed by ParserEngine."""
161
162 def __init__(
163 self,
164 tokenizer: TokenizerLike,
165 tools: list[Tool] | None = None,
166 **kwargs,
167 ) -> None:
168 # Configure PLaMo parsing and cache its multi-token reasoning markers.
169 chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
170 self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
171 kwargs.setdefault(
172 "parser_engine_config",
173 plamo3_config(thinking=self.thinking_enabled),
174 )
175 super().__init__(tokenizer, tools, **kwargs)
176
177 self._reasoning_start_token_id_sequence: list[int] = list(
178 tokenizer.encode(BEGIN_THINK, add_special_tokens=False)
179 )
180 self._reasoning_end_token_id_sequence: list[int] = list(
181 tokenizer.encode(END_THINK, add_special_tokens=False)
182 )
183 # Token-ID sequences of every terminal literal, for token-level
184 # unfinished-marker detection.
185 self._terminal_token_id_sequences: dict[str, tuple[int, ...]] = {
186 literal: tuple(tokenizer.encode(literal, add_special_tokens=False))
187 for literal in self.parser_engine_config.terminal_literals
188 }
189
190 def is_reasoning_end(self, input_ids: list[int]) -> bool:
191 # Detect PLaMo reasoning boundaries that span multiple token IDs.
192 if not self.thinking_enabled:
193 return True
194
195 start_ids = self._reasoning_start_token_id_sequence
196 end_ids = self._reasoning_end_token_id_sequence
197 for i in range(len(input_ids) - 1, -1, -1):
198 if input_ids[i : i + len(end_ids)] == end_ids:
199 return True
200 if input_ids[i : i + len(start_ids)] == start_ids:
201 return False
202 if input_ids[i] == self.vocab.get(EOT):
203 return False
204 return False
205
206 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
207 # Extract content after PLaMo's multi-token reasoning end marker.
208 if not self.thinking_enabled:
209 return input_ids
210 end_ids = self._reasoning_end_token_id_sequence
211 for i in range(len(input_ids) - len(end_ids), -1, -1):
212 if input_ids[i : i + len(end_ids)] == end_ids:
213 return input_ids[i + len(end_ids) :]
214 return input_ids
215
216 def _strip_unfinished_marker(self, value: str | None) -> str | None:
217 if not value:
218 return value
219 marker_start = value.rfind("<|plamo:")
220 if marker_start < 0:
221 return value or None
222 suffix = value[marker_start:]
223 # Strip a trailing partial marker at the token-ID level.
224 suffix_ids = tuple(
225 self.model_tokenizer.encode(suffix, add_special_tokens=False)
226 )
227 if any(
228 len(seq) > len(suffix_ids) and seq[: len(suffix_ids)] == suffix_ids
229 for seq in self._terminal_token_id_sequences.values()
230 ):
231 return value[:marker_start] or None
232 return value or None
233
234 def _coalesce_finished_tool_events(
235 self, events: list[SemanticEvent]
236 ) -> list[SemanticEvent]:
237 combined = []
238 for (kind, _), group in groupby(
239 events, key=lambda event: (event.type, event.tool_index)
240 ):
241 chunks = list(group)
242 if kind not in (EventType.TOOL_NAME, EventType.ARG_VALUE_CHUNK):
243 combined.extend(chunks)
244 continue
245 if kind == EventType.TOOL_NAME and chunks[0].tool_index < 0:
246 continue
247 value = "".join(chunk.value for chunk in chunks)
248 if kind == EventType.TOOL_NAME:
249 value = self._strip_unfinished_marker(value) or ""
250 if value:
251 combined.append(replace(chunks[0], value=value))
252 return combined
253
254 def _events_to_delta(
255 self,
256 events: list[SemanticEvent],
257 finished: bool = False,
258 ) -> DeltaMessage | None:
259 # Coalesce flushed tool fragments before stripping truncated markers.
260 if not finished:
261 return super()._events_to_delta(events)
262
263 events = self._coalesce_finished_tool_events(events)
264 delta = super()._events_to_delta(events, finished=True)
265 if delta is None:
266 return None
267
268 delta.reasoning = self._strip_unfinished_marker(delta.reasoning)
269 if not self.skip_tool_parsing:
270 delta.content = self._strip_unfinished_marker(delta.content)
271 # The flush emits the remaining (never-streamed) tool-call
272 # arguments; an empty remainder must match non-streaming
273 # extraction (_extract_args_json), which normalizes it to '{}'.
274 for tc in delta.tool_calls or []:
275 if tc.function is not None and not (tc.function.arguments or "").strip():
276 tc.function.arguments = "{}"
277 if delta.reasoning is None and delta.content is None and not delta.tool_calls:
278 return None
279 return delta
280
281 def _handle_arg_chunk(
282 self,
283 event: SemanticEvent,
284 deltas: list[DeltaToolCall],
285 ) -> None:
286 # Strip truncated marker suffixes and preserve the first argument delta.
287 idx = event.tool_index
288 slot = self._tool_slots[idx]
289 if (marker_pos := event.value.rfind("<|plamo:")) >= 0:
290 stripped = event.value[:marker_pos]
291 try:
292 json.loads(slot.args + stripped)
293 except ValueError:
294 pass
295 else:
296 event = replace(event, value=stripped)
297 name_sent_before = slot.name_sent
298 super()._handle_arg_chunk(event, deltas)
299 if event.value and not name_sent_before and slot.name_sent:
300 deltas.append(
301 DeltaToolCall(
302 index=idx,
303 function=DeltaFunctionCall(arguments=event.value),
304 )
305 )
306
307 def _extract_args_json(self, raw_args: str, func_name: str) -> str:
308 # Return the raw arguments as JSON
309 # since PLaMo3 generates function names and arguments separately.
310 return raw_args.strip() or "{}"
311
312 def extract_reasoning(
313 self,
314 model_output: str,
315 request: ChatCompletionRequest | ResponsesRequest,
316 ) -> tuple[str | None, str | None]:
317 reasoning, content = super().extract_reasoning(model_output, request)
318 return self._strip_unfinished_marker(reasoning), content