From 1851249ed39a82e039945c16a76daec82c0964ef Mon Sep 17 00:00:00 2001 From: Yigtwxx Date: Sat, 1 Aug 2026 13:04:32 +0300 Subject: [PATCH 1/4] [#17156][fix] Flush buffered text in DeepSeekR1Parser.finish() parse_delta withholds a trailing fragment that could still grow into a / tag. DeepSeekR1Parser never overrode finish(), so when a stream ended while such a fragment was buffered the characters were silently dropped from content or reasoning_content. Override finish() to emit the withheld text, attributing it to the block it was withheld in. A buffer holding exactly a complete tag is a delimiter rather than model output and is still discarded. NemotronV3ReasoningParser and Gemma4ReasoningParser already implement this flush; this brings the shared base parser in line with them. Signed-off-by: Yigtwxx --- tensorrt_llm/llmapi/reasoning_parser.py | 25 ++++++ .../unittest/llmapi/test_reasoning_parser.py | 90 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/tensorrt_llm/llmapi/reasoning_parser.py b/tensorrt_llm/llmapi/reasoning_parser.py index ccc81e2e05e2..c04456bdf6d3 100644 --- a/tensorrt_llm/llmapi/reasoning_parser.py +++ b/tensorrt_llm/llmapi/reasoning_parser.py @@ -210,6 +210,31 @@ def parse_delta(self, delta_text: str) -> ReasoningParserResult: raise RuntimeError( "Unreachable code reached in `DeepSeekR1Parser.parse_delta`") + def finish(self) -> ReasoningParserResult: + """Flush text withheld by `parse_delta` when the stream ends. + + `parse_delta` holds back a trailing fragment that could still grow + into a `` / `` tag. If the stream ends while such a + fragment is buffered - because the response was truncated mid-tag, or + simply ends with a literal `<` - the fragment is ordinary model output + and must still be emitted, otherwise it is silently dropped. The + buffered text is attributed to the block it was withheld in: inside a + reasoning block it is reasoning content, otherwise it is visible + content. + + A buffer holding exactly a complete tag is a delimiter rather than + model output, so it is discarded as it would have been had more text + followed. + """ + remaining = self._buffer + self._buffer = "" + if not remaining or remaining in (self.reasoning_start, + self.reasoning_end): + return ReasoningParserResult() + if self.in_reasoning: + return ReasoningParserResult(reasoning_content=remaining) + return ReasoningParserResult(content=remaining) + @register_reasoning_parser("deepseek_v4") class DeepSeekV4ReasoningParser(BaseReasoningParser): diff --git a/tests/unittest/llmapi/test_reasoning_parser.py b/tests/unittest/llmapi/test_reasoning_parser.py index 11c70bf2b334..944080e8f747 100644 --- a/tests/unittest/llmapi/test_reasoning_parser.py +++ b/tests/unittest/llmapi/test_reasoning_parser.py @@ -62,6 +62,96 @@ def test_deepseek_r1_reasoning_parser_stream(delta_texts: list, content: list, assert result.reasoning_content == reasoning_context[i] +# Parser keys backed by DeepSeekR1Parser whose stream starts inside the +# reasoning block (`reasoning_at_start=True`). +R1_AT_START_KEYS = [ + "deepseek-r1", "qwen3_5", "minimax_m2", "minimax_m2_append_think" +] + + +@pytest.mark.parametrize("parser_key", R1_AT_START_KEYS) +@pytest.mark.parametrize(("delta_texts", "flushed"), [ + (["a <"], "<"), + (["a", " Date: Sat, 1 Aug 2026 14:22:36 +0300 Subject: [PATCH 2/4] [#17156][chore] Annotate and privatize new reasoning parser test helpers Follow-up on review feedback: the tests added for DeepSeekR1Parser.finish() lacked return annotations and used a bare list type, and the parser-key constant is module-internal. CODING_GUIDELINES requires every function to be annotated and non-public names to be prefixed with an underscore. Signed-off-by: Yigtwxx --- tests/unittest/llmapi/test_reasoning_parser.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unittest/llmapi/test_reasoning_parser.py b/tests/unittest/llmapi/test_reasoning_parser.py index 944080e8f747..657c244d6483 100644 --- a/tests/unittest/llmapi/test_reasoning_parser.py +++ b/tests/unittest/llmapi/test_reasoning_parser.py @@ -64,12 +64,12 @@ def test_deepseek_r1_reasoning_parser_stream(delta_texts: list, content: list, # Parser keys backed by DeepSeekR1Parser whose stream starts inside the # reasoning block (`reasoning_at_start=True`). -R1_AT_START_KEYS = [ +_R1_AT_START_KEYS = [ "deepseek-r1", "qwen3_5", "minimax_m2", "minimax_m2_append_think" ] -@pytest.mark.parametrize("parser_key", R1_AT_START_KEYS) +@pytest.mark.parametrize("parser_key", _R1_AT_START_KEYS) @pytest.mark.parametrize(("delta_texts", "flushed"), [ (["a <"], "<"), (["a", " None: reasoning_parser = ReasoningParserFactory.create_reasoning_parser( parser_key) for delta_text in delta_texts: @@ -95,7 +95,7 @@ def test_deepseek_r1_reasoning_parser_finish_flushes_reasoning( (["a"], ""), ]) def test_deepseek_r1_reasoning_parser_finish_flushes_content( - parser_key: str, delta_texts: list, flushed: str): + parser_key: str, delta_texts: list[str], flushed: str) -> None: reasoning_parser = ReasoningParserFactory.create_reasoning_parser( parser_key) for delta_text in delta_texts: @@ -108,7 +108,7 @@ def test_deepseek_r1_reasoning_parser_finish_flushes_content( @pytest.mark.parametrize("parser_key", ["deepseek-r1", "qwen3"]) @pytest.mark.parametrize("tag", [R1_START, R1_END]) def test_deepseek_r1_reasoning_parser_finish_drops_complete_tag( - parser_key: str, tag: str): + parser_key: str, tag: str) -> None: """A buffer holding only a delimiter must not leak into the output.""" reasoning_parser = ReasoningParserFactory.create_reasoning_parser( parser_key) @@ -118,7 +118,7 @@ def test_deepseek_r1_reasoning_parser_finish_drops_complete_tag( assert result.reasoning_content == "" -def test_deepseek_r1_reasoning_parser_finish_is_idempotent(): +def test_deepseek_r1_reasoning_parser_finish_is_idempotent() -> None: reasoning_parser = ReasoningParserFactory.create_reasoning_parser( "deepseek-r1") reasoning_parser.parse_delta("a <") @@ -134,7 +134,8 @@ def test_deepseek_r1_reasoning_parser_finish_is_idempotent(): "a None: """Streaming char-by-char then finishing must match a non-streaming parse.""" expected = ReasoningParserFactory.create_reasoning_parser( "deepseek-r1").parse(text) From 87ce88be285a4df38672ebc0e105d534a3e52b68 Mon Sep 17 00:00:00 2001 From: Yigtwxx Date: Wed, 5 Aug 2026 10:28:23 +0300 Subject: [PATCH 3/4] [#17156][chore] Trim new reasoning parser finish() tests to a minimal set The new coverage cost 37 parametrized cases for a 25-line fix. CPU pre-merge runtime is a shared cost paid by every PR in the repo, so keep net new cases down: one (parser_key, text) pair per branch of finish() in the stream / non-stream property test, which subsumes the example-based tests of the individual branches, plus one multi-character delta case because streaming a character at a time never reaches the rfind branch of parse_delta that fills _buffer from a delta carrying both text and a partial tag. The extra deepseek_v4 case covers the delegating subclass named in the PR scope, whose finish() forwards to DeepSeekR1Parser or IdentityReasoningParser depending on the thinking flag. Signed-off-by: Yigtwxx --- .../unittest/llmapi/test_reasoning_parser.py | 129 +++++++----------- 1 file changed, 53 insertions(+), 76 deletions(-) diff --git a/tests/unittest/llmapi/test_reasoning_parser.py b/tests/unittest/llmapi/test_reasoning_parser.py index 657c244d6483..65eb368b4ba7 100644 --- a/tests/unittest/llmapi/test_reasoning_parser.py +++ b/tests/unittest/llmapi/test_reasoning_parser.py @@ -62,85 +62,30 @@ def test_deepseek_r1_reasoning_parser_stream(delta_texts: list, content: list, assert result.reasoning_content == reasoning_context[i] -# Parser keys backed by DeepSeekR1Parser whose stream starts inside the -# reasoning block (`reasoning_at_start=True`). -_R1_AT_START_KEYS = [ - "deepseek-r1", "qwen3_5", "minimax_m2", "minimax_m2_append_think" -] - - -@pytest.mark.parametrize("parser_key", _R1_AT_START_KEYS) -@pytest.mark.parametrize(("delta_texts", "flushed"), [ - (["a <"], "<"), - (["a", " None: - reasoning_parser = ReasoningParserFactory.create_reasoning_parser( - parser_key) - for delta_text in delta_texts: - reasoning_parser.parse_delta(delta_text) - result = reasoning_parser.finish() - assert result.reasoning_content == flushed - assert result.content == "" - - -@pytest.mark.parametrize("parser_key", ["qwen3", "laguna"]) -@pytest.mark.parametrize(("delta_texts", "flushed"), [ - (["a", "<"], "<"), - (["a", " None: - reasoning_parser = ReasoningParserFactory.create_reasoning_parser( - parser_key) - for delta_text in delta_texts: - reasoning_parser.parse_delta(delta_text) - result = reasoning_parser.finish() - assert result.content == flushed - assert result.reasoning_content == "" - +@pytest.mark.parametrize( + ("parser_key", "text"), + [ + # `finish()` flushes as reasoning: the stream starts inside the + # reasoning block, so the withheld `<` is reasoning output. + ("deepseek-r1", "a <"), + # `finish()` flushes as content: no reasoning block was entered. + ("qwen3", "a<"), + # `finish()` discards: the buffer holds exactly a delimiter. Passes on + # `main` too - it guards against a fix that leaks the tag instead. + ("deepseek-r1", f"a{R1_END}"), + ]) +def test_deepseek_r1_reasoning_parser_stream_matches_non_stream( + parser_key: str, text: str) -> None: + """Streaming char-by-char then finishing must match a non-streaming parse. -@pytest.mark.parametrize("parser_key", ["deepseek-r1", "qwen3"]) -@pytest.mark.parametrize("tag", [R1_START, R1_END]) -def test_deepseek_r1_reasoning_parser_finish_drops_complete_tag( - parser_key: str, tag: str) -> None: - """A buffer holding only a delimiter must not leak into the output.""" + One `(parser_key, text)` pair per branch of `finish()`. This is the + contract the missing flush violated, so it subsumes example-based tests + of the individual branches. + """ + expected = ReasoningParserFactory.create_reasoning_parser(parser_key).parse( + text) reasoning_parser = ReasoningParserFactory.create_reasoning_parser( parser_key) - reasoning_parser.parse_delta(tag) - result = reasoning_parser.finish() - assert result.content == "" - assert result.reasoning_content == "" - - -def test_deepseek_r1_reasoning_parser_finish_is_idempotent() -> None: - reasoning_parser = ReasoningParserFactory.create_reasoning_parser( - "deepseek-r1") - reasoning_parser.parse_delta("a <") - assert reasoning_parser.finish().reasoning_content == "<" - assert reasoning_parser.finish().reasoning_content == "" - - -@pytest.mark.parametrize("text", [ - f"a{R1_END}b", - f"a{R1_END}", - f"{R1_END}b", - "a <", - "a None: - """Streaming char-by-char then finishing must match a non-streaming parse.""" - expected = ReasoningParserFactory.create_reasoning_parser( - "deepseek-r1").parse(text) - reasoning_parser = ReasoningParserFactory.create_reasoning_parser( - "deepseek-r1") content, reasoning_context = "", "" for char in text: result = reasoning_parser.parse_delta(char) @@ -153,6 +98,38 @@ def test_deepseek_r1_reasoning_parser_stream_matches_non_stream( assert reasoning_context == expected.reasoning_content +def test_deepseek_r1_reasoning_parser_finish_flushes_partial_tag() -> None: + """A delta carrying both text and a partial tag fills `_buffer` through + the `rfind` branch of `parse_delta`, which one-character-at-a-time + streaming never reaches. That is the shape a real stream delivers, and + the fragment must still be emitted when the stream ends.""" + reasoning_parser = ReasoningParserFactory.create_reasoning_parser( + "deepseek-r1") + assert reasoning_parser.parse_delta("a None: + """`DeepSeekV4ReasoningParser.finish()` forwards to whichever parser the + thinking flag selected: `DeepSeekR1Parser`, which now flushes, or + `IdentityReasoningParser`, which withholds nothing to flush.""" + reasoning_parser = ReasoningParserFactory.create_reasoning_parser( + "deepseek_v4", chat_template_kwargs) + reasoning_parser.parse_delta("a <") + result = reasoning_parser.finish() + assert result.reasoning_content == flushed + assert result.content == "" + + @pytest.mark.parametrize("chat_template_kwargs", [{ "thinking": True }, { From f51dff8d3c3aa1753d47aaaa80fb15e49b4d9478 Mon Sep 17 00:00:00 2001 From: Yigtwxx Date: Wed, 5 Aug 2026 10:35:41 +0300 Subject: [PATCH 4/4] [#17156][chore] Fix lint and typing nits in the new reasoning parser tests Narrow `chat_template_kwargs` to `dict[str, bool]`, which is what the parametrized values are, and reshape the two new docstrings so the summary line stands alone and the closing quotes sit on their own line - ruff-legacy flagged D205 and D209 on both. Signed-off-by: Yigtwxx --- .../unittest/llmapi/test_reasoning_parser.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/unittest/llmapi/test_reasoning_parser.py b/tests/unittest/llmapi/test_reasoning_parser.py index 65eb368b4ba7..b2a8f78457a5 100644 --- a/tests/unittest/llmapi/test_reasoning_parser.py +++ b/tests/unittest/llmapi/test_reasoning_parser.py @@ -99,10 +99,12 @@ def test_deepseek_r1_reasoning_parser_stream_matches_non_stream( def test_deepseek_r1_reasoning_parser_finish_flushes_partial_tag() -> None: - """A delta carrying both text and a partial tag fills `_buffer` through - the `rfind` branch of `parse_delta`, which one-character-at-a-time - streaming never reaches. That is the shape a real stream delivers, and - the fragment must still be emitted when the stream ends.""" + """A partial tag arriving alongside text must still be flushed. + + Such a delta fills `_buffer` through the `rfind` branch of `parse_delta`, + which one-character-at-a-time streaming never reaches - and it is the + shape a real stream delivers. + """ reasoning_parser = ReasoningParserFactory.create_reasoning_parser( "deepseek-r1") assert reasoning_parser.parse_delta("a None: }, ""), ]) def test_deepseek_v4_reasoning_parser_finish_delegates( - chat_template_kwargs: dict, flushed: str) -> None: - """`DeepSeekV4ReasoningParser.finish()` forwards to whichever parser the - thinking flag selected: `DeepSeekR1Parser`, which now flushes, or - `IdentityReasoningParser`, which withholds nothing to flush.""" + chat_template_kwargs: dict[str, bool], flushed: str) -> None: + """`finish()` must reach whichever parser the thinking flag selected. + + `DeepSeekV4ReasoningParser` delegates to two different targets: + `DeepSeekR1Parser`, which now flushes, and `IdentityReasoningParser`, + which withholds nothing to flush. + """ reasoning_parser = ReasoningParserFactory.create_reasoning_parser( "deepseek_v4", chat_template_kwargs) reasoning_parser.parse_delta("a <")