main
py 737 lines 26.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Streaming parser engine that orchestrates token ID scanning,
4 incremental lexing, and state-machine-driven semantic event emission."""
5
6 from __future__ import annotations
7
8 from collections.abc import Sequence
9 from dataclasses import dataclass
10
11 from vllm.parser.engine.events import EventType, SemanticEvent
12 from vllm.parser.engine.incremental_lexer import (
13 CONTENT_TERMINAL,
14 IncrementalLexer,
15 LexerShape,
16 LexToken,
17 TerminalDef,
18 )
19 from vllm.parser.engine.parser_engine_config import (
20 ParserEngineConfig,
21 ParserState,
22 Transition,
23 )
24 from vllm.parser.engine.token_id_scanner import (
25 DROP_TERMINAL,
26 LexerInput,
27 PreLexedTerminal,
28 TextChunk,
29 TokenIDScanner,
30 )
31
32
33 @dataclass(slots=True)
34 class _DropInfo:
35 lexer_shape: LexerShape
36 extra_token_ids: dict[int, str]
37
38
39 def _build_drop_info(
40 config: ParserEngineConfig,
41 tokenizer,
42 ) -> _DropInfo | None:
43 try:
44 special_tokens: list[str] = list(tokenizer.all_special_tokens)
45 special_ids: list[int] = list(tokenizer.all_special_ids)
46 except (AttributeError, NotImplementedError):
47 return None
48
49 if not special_tokens:
50 return None
51
52 configured_texts = (
53 set(config.token_id_terminals.values())
54 | config.terminal_literals
55 | config.preserve_tokens
56 )
57
58 extra_token_ids: dict[int, str] = {}
59 drop_texts: set[str] = set()
60 for text, tid in zip(special_tokens, special_ids):
61 if text not in configured_texts:
62 extra_token_ids[tid] = DROP_TERMINAL
63 drop_texts.add(text)
64
65 if not drop_texts:
66 return None
67
68 import regex as re
69
70 drop_terminal_defs = [
71 TerminalDef(
72 name=DROP_TERMINAL,
73 pattern=re.compile(re.escape(text)),
74 is_literal=True,
75 literal=text,
76 )
77 for text in drop_texts
78 ]
79
80 all_terminal_defs = list(config.terminal_defs) + drop_terminal_defs
81 lexer_shape = LexerShape(all_terminal_defs)
82
83 return _DropInfo(
84 lexer_shape=lexer_shape,
85 extra_token_ids=extra_token_ids,
86 )
87
88
89 class StreamingParserEngine:
90 """Consumes ``(delta_text, delta_token_ids)`` pairs and produces a
91 stream of :class:`SemanticEvent` instances.
92
93 This is the main entry point for streaming parsing.
94 Create one per request (it is stateful).
95
96 The pipeline is::
97
98 delta_text + delta_token_ids
99 → TokenIDScanner (special token pre-lexing)
100 → IncrementalLexer (text → terminal tokens with prefix buffering)
101 → State Machine (terminal → semantic events)
102 → list[SemanticEvent]
103
104 Usage::
105
106 engine = StreamingParserEngine(config, tokenizer)
107 for each streaming delta:
108 events = engine.feed(delta_text, delta_token_ids)
109 # convert events to DeltaMessage
110 """
111
112 def __init__(
113 self,
114 config: ParserEngineConfig,
115 tokenizer,
116 initial_state: ParserState | None = None,
117 vocab: dict[str, int] | None = None,
118 ) -> None:
119 self.config = config
120
121 resolved_token_ids: dict[int, str] = {}
122 if tokenizer is not None:
123 if vocab is None:
124 vocab = tokenizer.get_vocab()
125 if config.token_id_terminals:
126 for terminal_name, token_text in config.token_id_terminals.items():
127 tid = vocab.get(token_text)
128 if tid is not None:
129 resolved_token_ids[tid] = terminal_name
130
131 drop_info: _DropInfo | None = None
132 if tokenizer is not None:
133 drop_info = _build_drop_info(config, tokenizer)
134
135 lexer_shape = config.lexer_shape
136 if drop_info is not None:
137 resolved_token_ids.update(drop_info.extra_token_ids)
138 lexer_shape = drop_info.lexer_shape
139
140 self._resolved_token_ids = resolved_token_ids
141 self._has_drops = drop_info is not None
142
143 self._scanner = TokenIDScanner(
144 resolved_token_ids,
145 tokenizer,
146 )
147
148 self._token_id_terminal_names: frozenset[str] = frozenset(
149 resolved_token_ids.values()
150 )
151
152 self._lexer = IncrementalLexer(lexer_shape, content_terminal=CONTENT_TERMINAL)
153
154 self._tool_terminals: frozenset[str] = frozenset(
155 terminal
156 for (state, terminal), tr in config.transitions.items()
157 if tr.next_state in self._TOOL_STATES or state in self._TOOL_STATES
158 )
159 # TOOL_CALL_END may close an inner call rather than its lexical wrapper,
160 # as in MiniMax, so identify exits from state transitions instead.
161 self._tool_exit_terminals: frozenset[str] = frozenset(
162 terminal
163 for (state, terminal), tr in config.transitions.items()
164 if state in self._TOOL_STATES and tr.next_state not in self._TOOL_STATES
165 )
166
167 self._reasoning_markup_terminals: frozenset[str] = (
168 self._compute_reasoning_markup_terminals()
169 )
170
171 self.skip_tool_parsing = False
172 self.skip_reasoning_parsing = False
173 self.reset(initial_state=initial_state)
174
175 @property
176 def reasoning_token_count(self) -> int:
177 return self._reasoning_token_count
178
179 def _record_reasoning_tokens(self, events: Sequence[SemanticEvent]) -> None:
180 self._reasoning_token_count += sum(
181 event.token_count
182 for event in events
183 if event.type == EventType.REASONING_CHUNK
184 )
185
186 def _reset_args_state(self) -> None:
187 self._args_buffer: str = ""
188 self._args_safe_end: int = 0
189 self._args_brace_depth: int = 0
190 self._args_in_string: bool = False
191 self._args_escape_next: bool = False
192
193 def _reset_array_state(self) -> None:
194 # State for ``tool_call_body_array``: whether the opening ``[`` has been
195 # seen, and whether a ``{...}`` element is currently being streamed.
196 self._array_started: bool = False
197 self._array_in_element: bool = False
198
199 def reset(self, initial_state: ParserState | None = None) -> None:
200 """Reset mutable state for reuse across requests.
201
202 Preserves cached immutable structures (compiled terminals,
203 resolved token IDs, lexer shape, token text cache) to avoid
204 redundant initialization work.
205 """
206 self.state = (
207 initial_state if initial_state is not None else self.config.initial_state
208 )
209 self.tool_index = -1
210 self._ever_had_token_ids = False
211 self._reasoning_token_count = 0
212 # DO NOT reset skip_tool_parsing here — callers set it before
213 # calling methods that trigger reset() (e.g. extract_reasoning),
214 # and clearing it silently breaks non-streaming tool-call-as-
215 # implicit-reasoning-end (content returns None).
216 self._scanner.reset()
217 self._lexer.reset()
218 self._message_header_buffer = ""
219 self._message_header_token_count = 0
220 self._in_skipped_tool_span = False
221 self._reset_args_state()
222 self._reset_array_state()
223
224 def feed(
225 self,
226 delta_text: str,
227 delta_token_ids: Sequence[int],
228 ) -> list[SemanticEvent]:
229 if delta_token_ids:
230 self._ever_had_token_ids = True
231
232 # Fast path: skip scanner and lexer when the delta is plain
233 # content with no special tokens and no terminal-starting chars.
234 if (
235 delta_text
236 and not self._lexer.buffer
237 and not self._scanner._deferred_terminals
238 and self._lexer._literal_first_chars.isdisjoint(delta_text)
239 ):
240 has_special = False
241 for tid in delta_token_ids:
242 if tid in self._resolved_token_ids:
243 has_special = True
244 break
245 if not has_special:
246 events = self._emit_for_state(
247 delta_text, token_count=len(delta_token_ids)
248 )
249 self._record_reasoning_tokens(events)
250 return events
251
252 scanner_items = self._scanner.scan(delta_text, delta_token_ids)
253
254 if len(scanner_items) == 1 and isinstance(scanner_items[0], TextChunk):
255 item = scanner_items[0]
256 lex_tokens = self._lexer.feed(item.text, item.token_texts, item.token_count)
257 if len(lex_tokens) == 1 and lex_tokens[0].terminal == CONTENT_TERMINAL:
258 events = self._emit_for_state(
259 lex_tokens[0].value,
260 token_count=lex_tokens[0].token_count,
261 )
262 else:
263 events = self._process_lex_tokens(lex_tokens)
264 self._record_reasoning_tokens(events)
265 return events
266
267 events = self._process_scanner_items(scanner_items)
268 self._record_reasoning_tokens(events)
269 return events
270
271 def _process_scanner_items(
272 self, items: Sequence[LexerInput]
273 ) -> list[SemanticEvent]:
274 events: list[SemanticEvent] = []
275 for item in items:
276 if isinstance(item, PreLexedTerminal):
277 events.extend(self._process_lex_tokens(self._lexer.flush()))
278 events.extend(self._on_terminal(item.terminal, item.text))
279 elif isinstance(item, TextChunk):
280 if not item.text and item.token_count:
281 events.extend(
282 self._emit_for_state("", token_count=item.token_count)
283 )
284 else:
285 events.extend(
286 self._process_lex_tokens(
287 self._lexer.feed(
288 item.text, item.token_texts, item.token_count
289 )
290 )
291 )
292 return events
293
294 def finish(self) -> list[SemanticEvent]:
295 events = self._process_scanner_items(self._scanner.flush_pending())
296
297 events.extend(self._process_lex_tokens(self._lexer.flush()))
298
299 if self._args_buffer:
300 events.append(
301 SemanticEvent(
302 EventType.ARG_VALUE_CHUNK,
303 value=self._args_buffer,
304 tool_index=self.tool_index,
305 )
306 )
307 self._args_buffer = ""
308 self._args_safe_end = 0
309
310 if self.state in (
311 ParserState.TOOL_PREAMBLE,
312 ParserState.TOOL_ARGS,
313 ParserState.TOOL_NAME,
314 ParserState.TOOL_BETWEEN,
315 ):
316 # In array mode a closed element has already emitted its
317 # TOOL_CALL_END; only a truncated (still-open) element needs one.
318 end_open_call = (
319 self._array_in_element if self.config.tool_call_body_array else True
320 )
321 if end_open_call and self.tool_index >= 0:
322 events.append(
323 SemanticEvent(
324 EventType.TOOL_CALL_END,
325 tool_index=self.tool_index,
326 )
327 )
328 self.state = ParserState.CONTENT
329 elif self.state == ParserState.REASONING:
330 events.append(
331 SemanticEvent(EventType.REASONING_END, tool_index=self.tool_index)
332 )
333 self.state = ParserState.CONTENT
334 elif self.state == ParserState.MESSAGE_HEADER:
335 if self._message_header_buffer:
336 events.append(
337 SemanticEvent(
338 EventType.TEXT_CHUNK,
339 value=self._message_header_buffer,
340 tool_index=self.tool_index,
341 token_count=self._message_header_token_count,
342 )
343 )
344 self._message_header_buffer = ""
345 self._message_header_token_count = 0
346 self.state = ParserState.CONTENT
347
348 self._record_reasoning_tokens(events)
349 return events
350
351 def parse_complete(self, text: str) -> list[SemanticEvent]:
352 token_ids: list[int] = []
353 events = self.feed(text, token_ids)
354 events.extend(self.finish())
355 return events
356
357 def _process_lex_tokens(self, tokens: list[LexToken]) -> list[SemanticEvent]:
358 events: list[SemanticEvent] = []
359 strict = self._token_id_terminal_names if self._ever_had_token_ids else None
360 for tok in tokens:
361 if tok.terminal == CONTENT_TERMINAL or (strict and tok.terminal in strict):
362 events.extend(self._on_content(tok.value, tok.token_count))
363 else:
364 events.extend(
365 self._on_terminal(tok.terminal, tok.value, tok.token_count)
366 )
367 return events
368
369 _TOOL_STATES = frozenset(
370 {
371 ParserState.TOOL_PREAMBLE,
372 ParserState.TOOL_NAME,
373 ParserState.TOOL_ARGS,
374 ParserState.TOOL_BETWEEN,
375 }
376 )
377
378 _PLAIN_STATES = frozenset({ParserState.CONTENT, ParserState.REASONING})
379
380 _REASONING_EVENTS = frozenset({EventType.REASONING_START, EventType.REASONING_END})
381
382 def _compute_reasoning_markup_terminals(self) -> frozenset[str]:
383 """Terminals the ``skip_reasoning_parsing`` bypass may neutralize.
384
385 Only reasoning-exclusive markers qualify: every transition they
386 participate in stays within CONTENT/REASONING and emits nothing
387 but reasoning events. Inkling's ``<|end_message|>`` is labelled
388 THINK_END yet also closes text, header, and tool blocks;
389 bypassing a shared marker would eat that structure, so one impure
390 marker disables the bypass for the whole config.
391 """
392 markers = frozenset(
393 terminal
394 for (state, terminal), tr in self.config.transitions.items()
395 if ParserState.REASONING in (state, tr.next_state)
396 and tr.next_state not in self._TOOL_STATES
397 )
398 for (state, terminal), tr in self.config.transitions.items():
399 if terminal not in markers:
400 continue
401 if (
402 state not in self._PLAIN_STATES
403 or tr.next_state not in self._PLAIN_STATES
404 or not self._REASONING_EVENTS.issuperset(tr.events)
405 ):
406 return frozenset()
407 return markers
408
409 def _on_terminal(
410 self, terminal: str, value: str, token_count: int = 0
411 ) -> list[SemanticEvent]:
412 key = (self.state, terminal)
413 transition = self.config.transitions.get(key)
414
415 if transition is None:
416 if self._has_drops and terminal == DROP_TERMINAL:
417 return []
418 # The projected skip state may not define the wrapper closer.
419 if self.skip_tool_parsing and terminal in self._tool_exit_terminals:
420 self._in_skipped_tool_span = False
421 return self._emit_for_state(value, token_count)
422
423 if self.skip_reasoning_parsing and terminal in self._reasoning_markup_terminals:
424 return self._emit_for_state(value, token_count)
425
426 if self.skip_tool_parsing and terminal in self._tool_terminals:
427 # Inkling reuses one terminal for tool, text, and reasoning exits.
428 # Outside a forwarded tool span, apply its normal transition.
429 is_opener = transition.next_state in self._TOOL_STATES
430 is_exit = terminal in self._tool_exit_terminals
431 used_as_plain_closer = (
432 is_exit and not is_opener and not self._in_skipped_tool_span
433 )
434 if not used_as_plain_closer:
435 if is_opener:
436 self._in_skipped_tool_span = True
437 elif is_exit:
438 self._in_skipped_tool_span = False
439 leaving_message_header = self.state == ParserState.MESSAGE_HEADER
440 if leaving_message_header:
441 self._message_header_buffer = ""
442 self._message_header_token_count = 0
443 # A tool terminal that implicitly ends reasoning must report
444 # that even from the header state, or the reasoning pass never
445 # hands the block to the tool pass.
446 if EventType.REASONING_END in transition.events:
447 self.state = ParserState.CONTENT
448 return [
449 SemanticEvent(
450 EventType.REASONING_END,
451 value=value,
452 tool_index=self.tool_index,
453 ),
454 SemanticEvent(
455 EventType.TEXT_CHUNK,
456 value=value,
457 tool_index=self.tool_index,
458 ),
459 ]
460 elif leaving_message_header:
461 self.state = ParserState.CONTENT
462 return [
463 SemanticEvent(
464 EventType.TEXT_CHUNK,
465 value=value,
466 tool_index=self.tool_index,
467 )
468 ]
469 content_type = self.config.content_events.get(self.state)
470 if content_type is not None:
471 return [
472 SemanticEvent(
473 content_type, value=value, tool_index=self.tool_index
474 )
475 ]
476 return []
477
478 if transition.skip_in_token_id_mode and self._ever_had_token_ids:
479 return self._emit_for_state(value, token_count)
480
481 return self._apply_transition(transition, value, token_count)
482
483 def _emit_for_state(self, text: str, token_count: int = 0) -> list[SemanticEvent]:
484 if self.state == ParserState.MESSAGE_HEADER:
485 self._message_header_buffer += text
486 self._message_header_token_count += token_count
487 return []
488 if self.state == ParserState.TOOL_ARGS:
489 if self.config.tool_call_body_array:
490 return self._feed_array_text(text)
491 if self.config.tool_args_json:
492 return self._feed_args_text(text)
493 return [
494 SemanticEvent(
495 EventType.ARG_VALUE_CHUNK,
496 value=text,
497 tool_index=self.tool_index,
498 token_count=token_count,
499 )
500 ]
501 content_type = self.config.content_events.get(self.state)
502 if content_type is not None:
503 return [
504 SemanticEvent(
505 content_type,
506 value=text,
507 tool_index=self.tool_index,
508 token_count=token_count,
509 )
510 ]
511 return []
512
513 def _on_content(self, text: str, token_count: int = 0) -> list[SemanticEvent]:
514 if not text:
515 return []
516 return self._emit_for_state(text, token_count)
517
518 def _apply_transition(
519 self,
520 transition: Transition,
521 value: str,
522 token_count: int = 0,
523 ) -> list[SemanticEvent]:
524 events: list[SemanticEvent] = []
525 previous_state = self.state
526 message_header = ""
527 message_header_token_count = 0
528
529 if (
530 self.state == ParserState.TOOL_ARGS
531 and transition.next_state != ParserState.TOOL_ARGS
532 and self._args_buffer
533 ):
534 events.append(
535 SemanticEvent(
536 EventType.ARG_VALUE_CHUNK,
537 value=self._args_buffer,
538 tool_index=self.tool_index,
539 )
540 )
541 self._args_buffer = ""
542
543 if previous_state == ParserState.MESSAGE_HEADER:
544 message_header = self._message_header_buffer
545 message_header_token_count = self._message_header_token_count
546 self._message_header_buffer = ""
547 self._message_header_token_count = 0
548
549 self.state = transition.next_state
550
551 for event_type in transition.events:
552 if event_type == EventType.TOOL_CALL_START:
553 self.tool_index += 1
554 event_value = (
555 message_header
556 if previous_state == ParserState.MESSAGE_HEADER
557 and event_type == EventType.TEXT_CHUNK
558 else value
559 )
560 if event_type == EventType.TEXT_CHUNK and not event_value:
561 continue
562 events.append(
563 SemanticEvent(
564 event_type,
565 value=event_value,
566 tool_index=self.tool_index,
567 token_count=(
568 message_header_token_count
569 if previous_state == ParserState.MESSAGE_HEADER
570 and event_type == EventType.TEXT_CHUNK
571 else token_count
572 ),
573 )
574 )
575
576 if self.state == ParserState.TOOL_ARGS:
577 self._args_brace_depth = 0
578 self._args_in_string = False
579 self._args_escape_next = False
580 self._args_safe_end = 0
581 if self.config.tool_call_body_array:
582 self._reset_array_state()
583
584 return events
585
586 def _feed_args_text(self, text: str) -> list[SemanticEvent]:
587 """Feed text into the JSON argument streaming buffer.
588
589 Streams argument characters incrementally while holding back
590 closing braces/brackets that might change as more input arrives.
591 """
592 events: list[SemanticEvent] = []
593 for ch in text:
594 result = self._feed_args_char(ch)
595 events.extend(result)
596 return events
597
598 def _feed_args_char(self, ch: str) -> list[SemanticEvent]:
599 self._args_buffer += ch
600
601 if self._args_escape_next:
602 self._args_escape_next = False
603 self._args_safe_end = len(self._args_buffer)
604 return self._flush_safe_args()
605
606 if self._args_in_string:
607 if ch == "\\":
608 self._args_escape_next = True
609 elif ch == '"':
610 self._args_in_string = False
611 self._args_safe_end = len(self._args_buffer)
612 return self._flush_safe_args()
613
614 if ch == '"':
615 self._args_in_string = True
616 self._args_safe_end = len(self._args_buffer)
617 return self._flush_safe_args()
618
619 if ch in ("{", "["):
620 self._args_brace_depth += 1
621 self._args_safe_end = len(self._args_buffer)
622 return self._flush_safe_args()
623
624 if ch in ("}", "]"):
625 if self._args_brace_depth > 0:
626 self._args_brace_depth -= 1
627 if self._args_brace_depth == 0:
628 return []
629 self._args_safe_end = len(self._args_buffer)
630 return self._flush_safe_args()
631
632 self._args_safe_end = len(self._args_buffer)
633 return self._flush_safe_args()
634
635 def _flush_safe_args(self) -> list[SemanticEvent]:
636 """Emit buffered argument characters up to the safe-end watermark.
637
638 Top-level closing braces are held back (safe_end not advanced)
639 until confirmed safe by a subsequent character or finish().
640 """
641 if self._args_safe_end == 0:
642 return []
643 to_emit = self._args_buffer[: self._args_safe_end]
644 self._args_buffer = self._args_buffer[self._args_safe_end :]
645 self._args_safe_end = 0
646 return [
647 SemanticEvent(
648 EventType.ARG_VALUE_CHUNK,
649 value=to_emit,
650 tool_index=self.tool_index,
651 )
652 ]
653
654 # ── JSON-array tool body (tool_call_body_array) ─────────────────────
655
656 def _feed_array_text(self, text: str) -> list[SemanticEvent]:
657 """Split a JSON-array tool body into one tool call per element.
658
659 Granite emits ``[{"name":..,"arguments":{..}}, ...]`` after the tool
660 marker. Element interiors are streamed through the string/brace-aware
661 :meth:`_feed_args_char`, so each element behaves exactly like a
662 single-object tool body; the array's ``]`` ends the region and any
663 trailing text becomes content.
664 """
665 events: list[SemanticEvent] = []
666 for i, ch in enumerate(text):
667 if self.state != ParserState.TOOL_ARGS:
668 # The array closed mid-chunk; the rest of the text is content.
669 events.extend(self._emit_for_state(text[i:]))
670 break
671 events.extend(self._feed_array_char(ch))
672 return events
673
674 def _feed_array_char(self, ch: str) -> list[SemanticEvent]:
675 if self._array_in_element:
676 events = self._feed_args_char(ch)
677 # Element object closed: top-level '}' with all braces balanced.
678 if (
679 ch == "}"
680 and not self._args_in_string
681 and not self._args_escape_next
682 and self._args_brace_depth == 0
683 ):
684 events.extend(self._end_array_element())
685 return events
686
687 # Between elements only structural array characters are expected.
688 if ch in " \t\r\n":
689 return []
690 if not self._array_started:
691 if ch == "[":
692 self._array_started = True
693 return []
694 return self._bail_array_to_content(ch)
695 if ch == ",":
696 return []
697 if ch == "{":
698 return self._start_array_element(ch)
699 if ch == "]":
700 self.state = ParserState.CONTENT
701 self._reset_array_state()
702 return []
703 return self._bail_array_to_content(ch)
704
705 def _start_array_element(self, ch: str) -> list[SemanticEvent]:
706 self.tool_index += 1
707 self._reset_args_state()
708 self._array_in_element = True
709 events = [SemanticEvent(EventType.TOOL_CALL_START, tool_index=self.tool_index)]
710 events.extend(self._feed_args_char(ch))
711 return events
712
713 def _end_array_element(self) -> list[SemanticEvent]:
714 self._array_in_element = False
715 events: list[SemanticEvent] = []
716 # Flush the held closing '}' so the slot body is the full element.
717 if self._args_buffer:
718 events.append(
719 SemanticEvent(
720 EventType.ARG_VALUE_CHUNK,
721 value=self._args_buffer,
722 tool_index=self.tool_index,
723 )
724 )
725 self._args_buffer = ""
726 self._args_safe_end = 0
727 events.append(
728 SemanticEvent(EventType.TOOL_CALL_END, tool_index=self.tool_index)
729 )
730 return events
731
732 def _bail_array_to_content(self, ch: str) -> list[SemanticEvent]:
733 # Marker not followed by a JSON array (malformed) — revert to content
734 # so the remaining tokens are emitted as plain text, no tool call.
735 self.state = ParserState.CONTENT
736 self._reset_array_state()
737 return self._emit_for_state(ch)