[Bugfix][Frontend] Flush engine reasoning parser at engine-reasoning → tool streaming boundary (#47606)
Signed-off-by: Aakif Nawaz <aakif.nawaz@amd.com> Co-authored-by: Ben Browning <56071+bbrowning@users.noreply.github.com>
akii96 committed
Jul 13, 2026 at 21:06 UTC
550218b13670fd6708916a645d8f538d773409c3
2 files changed
+298
-11
tests/parser/test_streaming.py
+286
-2
@@ -9,6 +9,7 @@ import pytest
9
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
10
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
11
from vllm.parser.abstract_parser import DelegatingParser
12
+from vllm.parser.engine.registered_adapters import Qwen3ParserReasoningAdapter
13
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
14
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
15
@@ -205,10 +206,11 @@ def stream_chunks(parser, tokenizer, chunks, request_obj):
206
return results
207
208
208
-def _boundary_chunks(tokenizer, parser):
209
+def _boundary_chunks(tokenizer, parser, end_token_id=None):
210
"""Split MODEL_OUTPUT into 3 chunks that straddle the </think> boundary."""
211
token_ids = tokenizer.encode(MODEL_OUTPUT, add_special_tokens=False)
211
- end_token_id = parser._reasoning_parser.end_token_id
212
+ if end_token_id is None:
213
+ end_token_id = parser._reasoning_parser.end_token_id
214
end_idx = token_ids.index(end_token_id)
215
return [
216
token_ids[: end_idx - 1],
@@ -469,3 +471,285 @@ def test_parse_delta_required_tool_choice_kimi_k2_ids_after_history(
471
_, _, tool_calls = collect_fields(results)
472
assert any(tc.id == "functions.get_current_weather:1" for tc in tool_calls)
473
assert all(tc.id in (None, "functions.get_current_weather:1") for tc in tool_calls)
474
+
475
+
476
+# ── Engine-based reasoning + non-engine tool parser (Qwen3 + Hermes) ──
477
+
478
+
479
+class Qwen3ReasoningHermesToolParser(DelegatingParser):
480
+ reasoning_parser_cls = Qwen3ParserReasoningAdapter
481
+ tool_parser_cls = Hermes2ProToolParser
482
+
483
+
484
+def test_engine_reasoning_hermes_tool_token_by_token(tokenizer, request_obj):
485
+ """Qwen3 engine reasoning + Hermes tool parser, token-by-token.
486
+
487
+ Sanity check that the mixed engine/non-engine configuration works
488
+ when tokens arrive one at a time (no deferred content)."""
489
+ parser = Qwen3ReasoningHermesToolParser(tokenizer)
490
+
491
+ assert parser._reasoning_parser.engine_based_streaming is True
492
+ assert parser._tool_parser.engine_based_streaming is False
493
+ assert parser._engine_based is False
494
+
495
+ results = stream_text(
496
+ parser, tokenizer, MODEL_OUTPUT, request_obj, prompt_token_ids=[]
497
+ )
498
+ reasoning, content, tool_calls = collect_fields(results)
499
+
500
+ assert "let me think about this" in reasoning
501
+ assert content == ""
502
+ assert len(tool_calls) > 0
503
+ assert tool_calls[0].function.name == "get_weather"
504
+ tool_args = "".join(
505
+ tc.function.arguments for tc in tool_calls if tc.function.arguments
506
+ )
507
+ assert json.loads(tool_args) == {"city": "Dallas"}
508
+
509
+
510
+def test_engine_reasoning_hermes_tool_boundary(tokenizer, request_obj):
511
+ """Qwen3 engine reasoning + Hermes tool parser, boundary chunks.
512
+
513
+ When </think> and <tool_call> are in the same chunk with aligned
514
+ text and token IDs, the engine processes both terminals and returns
515
+ the <tool_call> text as content."""
516
+ parser = Qwen3ReasoningHermesToolParser(tokenizer)
517
+ end_token_id = parser._reasoning_parser._parser_engine._reasoning_end_token_id
518
+ chunks = _boundary_chunks(tokenizer, parser, end_token_id=end_token_id)
519
+ results = stream_chunks(parser, tokenizer, chunks, request_obj)
520
+ reasoning, content, tool_calls = collect_fields(results)
521
+
522
+ assert "think about this" in reasoning
523
+ assert content == ""
524
+ assert len(tool_calls) > 0
525
+ assert tool_calls[0].function.name == "get_weather"
526
+ tool_args = "".join(
527
+ tc.function.arguments for tc in tool_calls if tc.function.arguments
528
+ )
529
+ assert json.loads(tool_args) == {"city": "Dallas"}
530
+ assert "tool_call" not in content
531
+
532
+
533
+def test_engine_reasoning_hermes_tool_text_holdback(tokenizer, request_obj):
534
+ """Qwen3 engine reasoning + Hermes tool parser with engine holdback.
535
+
536
+ Simulates stream_interval > 1 where a batched delta contains
537
+ '</think><'. The '<' is a regular character token — not the
538
+ <tool_call> special token — so the engine's incremental lexer
539
+ buffers it (it could be the start of a text terminal like
540
+ <tool_call>). The buffered '<' is only recoverable via
541
+ finish_streaming().
542
+
543
+ Without the fix, finish_streaming() is never called at the
544
+ reasoning->tool transition when _engine_based is False, so the '<'
545
+ is lost and the Hermes parser sees 'tool_call>...' instead of
546
+ '<tool_call>...'."""
547
+ parser = Qwen3ReasoningHermesToolParser(tokenizer)
548
+ vocab = tokenizer.get_vocab()
549
+ think_end_id = vocab["</think>"]
550
+ lt_id = vocab["<"]
551
+
552
+ token_ids = tokenizer.encode(MODEL_OUTPUT, add_special_tokens=False)
553
+ end_idx = token_ids.index(think_end_id)
554
+
555
+ # Reasoning tokens (aligned text + IDs)
556
+ pre_ids = token_ids[:end_idx]
557
+ pre_text = tokenizer.decode(pre_ids)
558
+
559
+ # Batched delta: '</think><' — the engine recognises </think> as
560
+ # THINK_END but the trailing '<' is consumed by the engine's lexer
561
+ # and held back (it could be the start of <tool_call>). The '<'
562
+ # is NOT in delta_message.content; it is only in the engine's
563
+ # internal buffer, recoverable via finish_streaming().
564
+ holdback_ids = [think_end_id, lt_id]
565
+ holdback_text = "</think><"
566
+
567
+ # Remaining text: 'tool_call>\n{...}\n</tool_call>' — the model
568
+ # generated <tool_call> as character tokens (not the special token),
569
+ # and the '<' was consumed above. Encode separately to get the
570
+ # correct token IDs for this substring.
571
+ rest_text = (
572
+ 'tool_call>\n{"name": "get_weather", '
573
+ '"arguments": {"city": "Dallas"}}\n</tool_call>'
574
+ )
575
+ rest_ids = tokenizer.encode(rest_text, add_special_tokens=False)
576
+
577
+ results: list[DeltaMessage | None] = []
578
+ results.append(
579
+ parser.parse_delta(
580
+ pre_text,
581
+ pre_ids,
582
+ request_obj,
583
+ prompt_token_ids=[],
584
+ finished=False,
585
+ )
586
+ )
587
+ results.append(
588
+ parser.parse_delta(
589
+ holdback_text,
590
+ holdback_ids,
591
+ request_obj,
592
+ finished=False,
593
+ )
594
+ )
595
+ results.append(
596
+ parser.parse_delta(
597
+ rest_text,
598
+ rest_ids,
599
+ request_obj,
600
+ finished=False,
601
+ )
602
+ )
603
+
604
+ reasoning, content, tool_calls = collect_fields(results)
605
+
606
+ assert "let me think about this" in reasoning
607
+ assert len(tool_calls) > 0, (
608
+ "Tool calls lost at engine-reasoning -> tool transition. "
609
+ "finish_streaming() not called when _engine_based is False."
610
+ )
611
+ assert tool_calls[0].function.name == "get_weather"
612
+ tool_args = "".join(
613
+ tc.function.arguments for tc in tool_calls if tc.function.arguments
614
+ )
615
+ assert json.loads(tool_args) == {"city": "Dallas"}
616
+ assert "tool_call" not in content
617
+
618
+
619
+# ── Engine-based reasoning WITHOUT a tool parser (Qwen3 only) ──
620
+
621
+
622
+class Qwen3ReasoningNoToolParser(DelegatingParser):
623
+ reasoning_parser_cls = Qwen3ParserReasoningAdapter
624
+ tool_parser_cls = None
625
+
626
+
627
+def test_engine_reasoning_no_tool_batched_content_passthrough(tokenizer, request_obj):
628
+ """Qwen3 engine reasoning with NO tool parser, batched boundary.
629
+
630
+ The three mixed-parser tests above all pair the engine reasoning
631
+ parser with Hermes; none exercise the engine-reasoning-only path
632
+ through the hoisted finish_streaming() transition (where
633
+ ``_engine_based`` is True and there is no tool parser). A single
634
+ batched delta carries ``</think>`` plus the following content
635
+ (as happens with stream_interval > 1). The post-``</think>`` text
636
+ must be emitted as content -- not dropped, not reclassified as
637
+ reasoning -- and the ``</think>`` marker must not leak either way."""
638
+ parser = Qwen3ReasoningNoToolParser(tokenizer)
639
+ assert parser._reasoning_parser.engine_based_streaming is True
640
+ assert parser._tool_parser is None
641
+
642
+ model_output = "<think>let me think about this</think>The answer is 42."
643
+ end_token_id = parser._reasoning_parser._parser_engine._reasoning_end_token_id
644
+ token_ids = tokenizer.encode(model_output, add_special_tokens=False)
645
+ end_idx = token_ids.index(end_token_id)
646
+ chunks = [token_ids[:end_idx], token_ids[end_idx:]]
647
+
648
+ results = stream_chunks(parser, tokenizer, chunks, request_obj)
649
+ reasoning, content, tool_calls = collect_fields(results)
650
+
651
+ assert "let me think about this" in reasoning
652
+ assert content == "The answer is 42."
653
+ assert "</think>" not in content
654
+ assert "</think>" not in reasoning
655
+ assert len(tool_calls) == 0
656
+
657
+
658
+def _decode_stream_deltas(tokenizer, groups):
659
+ """Decode token-ID groups into ``(delta_text, group)`` pairs via the real
660
+ incremental ``DecodeStream``.
661
+
662
+ This mirrors how vLLM's detokenizer feeds ``parse_delta`` in
663
+ production: byte-level UTF-8 hold-back means a character whose bytes
664
+ span multiple tokens is only surfaced once complete (a naive
665
+ per-token ``decode`` would instead emit U+FFFD replacement chars)."""
666
+ from tokenizers.decoders import DecodeStream
667
+
668
+ stream = DecodeStream(skip_special_tokens=False)
669
+ inner = tokenizer._tokenizer
670
+ pairs = []
671
+ for group in groups:
672
+ text = ""
673
+ for token_id in group:
674
+ piece = stream.step(inner, token_id)
675
+ if piece:
676
+ text += piece
677
+ pairs.append((text, group))
678
+ return pairs
679
+
680
+
681
+def test_engine_reasoning_hermes_tool_multibyte_holdback(tokenizer, request_obj):
682
+ """Multi-token character across the reasoning->tool boundary.
683
+
684
+ Extends the ASCII '<' hold-back guard with bbrowning's multi-token
685
+ *character* concern. Two things must both hold:
686
+
687
+ 1. The batched ``</think><`` delta relies on the hoisted
688
+ finish_streaming() to recover the engine-buffered '<'. Without
689
+ the fix the Hermes parser never sees ``<tool_call>`` and emits no
690
+ tool call at all.
691
+ 2. The tool-call arguments carry ``東京🧑\u200d🚀``; the astronaut
692
+ ZWJ sequence's bytes span multiple Qwen3 tokens, so the rest of
693
+ the stream is fed one token at a time through the real
694
+ ``DecodeStream``. Its UTF-8 hold-back yields the correct
695
+ character round-trip (a naive per-token decode would corrupt it),
696
+ verifying the boundary stays byte-safe for multi-token
697
+ characters."""
698
+ parser = Qwen3ReasoningHermesToolParser(tokenizer)
699
+ vocab = tokenizer.get_vocab()
700
+ think_end_id = vocab["</think>"]
701
+ lt_id = vocab["<"]
702
+
703
+ city = "東京🧑\u200d🚀"
704
+ # Faithfulness precondition: the emoji really is a multi-token char.
705
+ assert len(tokenizer.encode("🧑\u200d🚀", add_special_tokens=False)) > 1
706
+
707
+ model_output = (
708
+ "<think>let me think about this</think>"
709
+ '<tool_call>\n{"name": "get_weather", '
710
+ f'"arguments": {{"city": "{city}"}}}}\n</tool_call>'
711
+ )
712
+ token_ids = tokenizer.encode(model_output, add_special_tokens=False)
713
+ end_idx = token_ids.index(think_end_id)
714
+ pre_ids = token_ids[:end_idx]
715
+
716
+ rest_text = (
717
+ 'tool_call>\n{"name": "get_weather", '
718
+ f'"arguments": {{"city": "{city}"}}}}\n</tool_call>'
719
+ )
720
+ rest_ids = tokenizer.encode(rest_text, add_special_tokens=False)
721
+
722
+ # Deltas: reasoning, then a batched '</think><' (the engine buffers
723
+ # the '<'), then the remaining tokens one at a time so the
724
+ # multi-token character is genuinely split across deltas by the
725
+ # detokenizer.
726
+ groups = [pre_ids, [think_end_id, lt_id]] + [[tid] for tid in rest_ids]
727
+ pairs = _decode_stream_deltas(tokenizer, groups)
728
+
729
+ results: list[DeltaMessage | None] = []
730
+ prompt_token_ids: list[int] | None = []
731
+ for delta_text, group in pairs:
732
+ results.append(
733
+ parser.parse_delta(
734
+ delta_text,
735
+ group,
736
+ request_obj,
737
+ prompt_token_ids=prompt_token_ids,
738
+ finished=False,
739
+ )
740
+ )
741
+ prompt_token_ids = None
742
+
743
+ reasoning, content, tool_calls = collect_fields(results)
744
+
745
+ assert "let me think about this" in reasoning
746
+ assert len(tool_calls) > 0, (
747
+ "Tool call lost at engine-reasoning -> tool transition; the "
748
+ "buffered '<' was not recovered by finish_streaming()."
749
+ )
750
+ assert tool_calls[0].function.name == "get_weather"
751
+ tool_args = "".join(
752
+ tc.function.arguments for tc in tool_calls if tc.function.arguments
753
+ )
754
+ assert json.loads(tool_args) == {"city": city}
755
+ assert content == ""
vllm/parser/abstract_parser.py
+12
-9
@@ -851,19 +851,22 @@ class DelegatingParser(Parser):
851
state.reasoning_ended = True
852
reasoning_transitioned = True
853
current_token_ids = self.extract_content_ids(delta_token_ids)
854
+ # Flush whenever the reasoning parser is engine-based (not only
855
+ # when _engine_based is True): it buffers the post-marker text
856
+ # (e.g. the "<" of "<tool_call>"), surfaced via finish_streaming().
857
+ flush_delta = (
858
+ reasoning_parser.finish_streaming() # type: ignore[union-attr, attr-defined]
859
+ if reasoning_parser is not None
860
+ and reasoning_parser.engine_based_streaming
861
+ else None
862
+ )
863
+ current_text = (
864
+ (delta_message.content if delta_message else None) or ""
865
+ ) + ((flush_delta.content if flush_delta else None) or "")
866
if self._engine_based:
855
- flush_delta = reasoning_parser.finish_streaming() # type: ignore[union-attr, attr-defined]
856
- current_text = (
857
- (delta_message.content if delta_message else None) or ""
858
- ) + ((flush_delta.content if flush_delta else None) or "")
867
if delta_message and self._tool_parser is not None:
868
delta_message.content = None
869
else:
862
- current_text = (
863
- delta_message.content
864
- if delta_message and delta_message.content
865
- else ""
866
- )
870
delta_text = current_text
871
872
# Tool call extraction