Skip to content

[#17146][fix] Resolve reasoning mode from the rendered prompt - #17305

Open
joerowell wants to merge 1 commit into
NVIDIA:mainfrom
joerowell:joerowell/reasoning-mode-from-rendered-prompt
Open

[#17146][fix] Resolve reasoning mode from the rendered prompt#17305
joerowell wants to merge 1 commit into
NVIDIA:mainfrom
joerowell:joerowell/reasoning-mode-from-rendered-prompt

Conversation

@joerowell

@joerowell joerowell commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Laguna templates prefill <think> or </think>, so the model never emits the opening marker and the request kwargs cannot tell the parser which mode was rendered. Thus, we should read it from the prompt instead.

To make this change minimal, I've kept it behind an opt-in flag so only poolside_v1 is affected.

As an aside, I've renamed the laguna parser to poolside_v1, to keep it consistent with the rest of the world. Happy to defer this to a follow-up PR, but figured I'd do it in one go. We keep the alias, so it shouldn't break anything.

Dev Engineer Review

  • Added opt-in prompt-based reasoning detection for rendered prompts with prefilled <think> or </think> markers.
  • Added poolside_v1 and retained laguna as a compatibility alias.
  • Added qwen3_5, minimax_m2, and minimax_m2_append_think registrations.
  • Updated OpenAI server handling to synchronize thinking and enable_thinking.
  • Updated model detection, telemetry documentation, manifests, and CLI stability references.
  • Configuration values are consistent with the parser rename.
  • No test-list files changed.

QA Engineer Review

  • Modified tests/unittest/llmapi/test_reasoning_parser.py.
  • Updated Laguna registration and auto-detection coverage.
  • Added coverage for thinking modes, streaming, split delimiters, aliases, prompt-based resolution, stale options, and opt-in behavior.
  • No corresponding tests/integration/test_lists/ entries were modified.
  • Verdict: needs follow-up because CI or manual QA coverage data is unavailable.

Description

Test Coverage

PR Checklist

Please review the following before submitting your PR:

  • PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.

  • PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.

  • Test cases are provided for new code paths (see test instructions)

  • If PR introduces API changes, an appropriate PR label is added - either api-compatible or api-breaking. For api-breaking, include BREAKING in the PR title.

  • Any new dependencies have been scanned for license and vulnerabilities

  • CODEOWNERS updated if ownership changes

  • Documentation updated as needed

  • Update tava architecture diagram if there is a significant design change in PR.

  • The reviewers assigned automatically/manually are appropriate for the PR.

  • Please check this after reviewing the above items as appropriate for this PR.

GitHub Bot Help

To see a list of available CI bot commands, please comment /bot help.

@joerowell
joerowell requested review from a team as code owners August 5, 2026 09:22
@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch from 47118cf to 616da4e Compare August 5, 2026 09:25
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The change adds prompt-based thinking-state resolution, Poolside V1 parser support, Qwen3.5 and MiniMax M2 registrations, Laguna alias mapping, server integration, and expanded tests.

Changes

Reasoning parser flow

Layer / File(s) Summary
Parser resolution and registrations
tensorrt_llm/llmapi/reasoning_parser.py, tensorrt_llm/llmapi/llm_args.py, docs/source/developer-guide/telemetry.md, tensorrt_llm/usage/llm_args_golden_manifest.json
The parser factory resolves prefilled thinking from prompts. It adds Poolside V1, Qwen3.5, and MiniMax M2 registrations. It maps Laguna to Poolside V1. The telemetry allowlists include poolside_v1.
Chat prompt integration
tensorrt_llm/serve/openai_server.py
The server stores rendered prompts and updates thinking and enable_thinking when prompt resolution returns a state.
Parser behavior validation
tests/unittest/llmapi/test_reasoning_parser.py, tests/unittest/api_stability/references/trtllm_serve_cli.yaml
The tests cover Poolside V1 parsing, streaming, aliases, prompt resolution, option precedence, opt-in behavior, Laguna auto-detection, and CLI parser choices.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChatTemplate
  participant OpenAIServer
  participant ReasoningParserFactory
  participant PoolsideV1ReasoningParser
  ChatTemplate->>OpenAIServer: Render chat prompt
  OpenAIServer->>ReasoningParserFactory: Resolve prefilled thinking state
  ReasoningParserFactory->>PoolsideV1ReasoningParser: Check prompt markers
  PoolsideV1ReasoningParser-->>ReasoningParserFactory: Return thinking state
  ReasoningParserFactory-->>OpenAIServer: Return true, false, or None
  OpenAIServer->>OpenAIServer: Update thinking and enable_thinking
Loading

Suggested reviewers: arysef, qijune

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the issue and solution, but the required Description and Test Coverage sections are empty and the checklist is largely unreviewed. Move the issue and solution summary into the Description section, add relevant test coverage, and complete the applicable checklist items.
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change and follows the required issue and type format.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
tests/unittest/llmapi/test_reasoning_parser.py (1)

370-374: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicated opt-in test.

test_resolve_prefilled_thinking_opted_in repeats test_alias_resolves_identically with parser="poolside_v1". The prompt, tails, and expectations are identical, and test_resolve_prefilled_thinking also covers both tails. The negative opt-in case is covered separately by test_resolve_prefilled_thinking_requires_opt_in.

♻️ Proposed removal
-@pytest.mark.parametrize(("tail", "expected"), [(R1_START, True),
-                                                (R1_END, False)])
-def test_resolve_prefilled_thinking_opted_in(tail: str, expected):
-    assert ReasoningParserFactory.resolve_prefilled_thinking(
-        "poolside_v1", f"<assistant>{tail}") is expected
-
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unittest/llmapi/test_reasoning_parser.py` around lines 370 - 374,
Remove the redundant test_resolve_prefilled_thinking_opted_in test, retaining
test_alias_resolves_identically, test_resolve_prefilled_thinking, and
test_resolve_prefilled_thinking_requires_opt_in as the existing coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tensorrt_llm/llmapi/reasoning_parser.py`:
- Around line 96-97: Add a return annotation to the class method
ReasoningParserFactory.keys, using the appropriate mapping view type for the
object returned by cls._parsers.keys(). Preserve the existing implementation and
behavior.

In `@tensorrt_llm/serve/openai_server.py`:
- Around line 1571-1585: Update the request-processing flow around
ReasoningParserFactory.resolve_prefilled_thinking so the prompt-resolved
thinking and enable_thinking values are applied before
add_thinking_budget_logits_processor runs. Ensure the budget processor for
poolside_v1 and laguna receives the resolved mode rather than stale request
kwargs, while preserving consistent parser selection during postprocessing.

In `@tests/unittest/llmapi/test_reasoning_parser.py`:
- Around line 400-413: Update test_resolve_prefilled_thinking_requires_opt_in to
first assert each parser name is registered in ReasoningParserFactory._parsers,
then retain the existing None assertions for every tail value. This ensures the
test validates opt-in behavior rather than passing for an unregistered name.
- Around line 377-397: Update test_resolved_mode_overrides_stale_thinking_kwarg
to include a scenario where only enable_thinking is set to False while stale
thinking=True remains, and assert that the parser places the visible text in
reasoning_content rather than content. Retain the existing case verifying that
clearing both keys yields normal content, so the test covers the parser’s OR
behavior and proves both keys must be cleared.

---

Nitpick comments:
In `@tests/unittest/llmapi/test_reasoning_parser.py`:
- Around line 370-374: Remove the redundant
test_resolve_prefilled_thinking_opted_in test, retaining
test_alias_resolves_identically, test_resolve_prefilled_thinking, and
test_resolve_prefilled_thinking_requires_opt_in as the existing coverage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 80888a38-a585-4a62-af3e-642c43dc6998

📥 Commits

Reviewing files that changed from the base of the PR and between 9564b3b and 47118cf.

📒 Files selected for processing (7)
  • docs/source/developer-guide/telemetry.md
  • tensorrt_llm/llmapi/llm_args.py
  • tensorrt_llm/llmapi/reasoning_parser.py
  • tensorrt_llm/serve/openai_server.py
  • tensorrt_llm/usage/llm_args_golden_manifest.json
  • tests/unittest/api_stability/references/trtllm_serve_cli.yaml
  • tests/unittest/llmapi/test_reasoning_parser.py

Comment thread tensorrt_llm/llmapi/reasoning_parser.py Outdated
Comment thread tensorrt_llm/serve/openai_server.py
Comment thread tests/unittest/llmapi/test_reasoning_parser.py Outdated
Comment thread tests/unittest/llmapi/test_reasoning_parser.py
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch from 616da4e to b62ee09 Compare August 5, 2026 10:00
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/unittest/llmapi/test_reasoning_parser.py (2)

416-432: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the non-opted-in parser list from the registry.

The hardcoded list does not cover parsers registered later. If a new parser sets resolves_thinking_from_prompt by mistake, this test still passes. Compute the list from ReasoningParserFactory.keys() minus the opted-in names. The registration guard at Line 429 then becomes unnecessary.

♻️ Proposed refactor
-@pytest.mark.parametrize("parser", [
-    "deepseek-r1", "deepseek_v4", "qwen3", "qwen3_5", "minimax_m2",
-    "minimax_m3", "nemotron-v3", "nano-v3", "gemma4", "kimi_k2", "kimi_k25"
-])
+_OPTED_IN_PARSERS = {"poolside_v1", "laguna"}
+
+
+@pytest.mark.parametrize(
+    "parser",
+    sorted(set(ReasoningParserFactory.keys()) - _OPTED_IN_PARSERS))
 def test_resolve_prefilled_thinking_requires_opt_in(parser: str):
     """Parsers that have not opted in must never be resolved from the prompt.
 
     `deepseek_v4` shares the base class and `nemotron-v3` / `nano-v3` also read
     `enable_thinking`, so without the flag they would silently pick up a mode
     the server inferred.
     """
-    # Otherwise a typo or a dropped registration passes vacuously, since an
-    # unknown name also resolves to None.
-    assert parser in ReasoningParserFactory.keys()
     for tail in (R1_START, R1_END, ""):
         assert ReasoningParserFactory.resolve_prefilled_thinking(
             parser, f"<assistant>{tail}") is None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unittest/llmapi/test_reasoning_parser.py` around lines 416 - 432,
Update test_resolve_prefilled_thinking_requires_opt_in to derive its
parameterized parser list from ReasoningParserFactory.keys(), excluding the
registered parsers that explicitly opt in via resolves_thinking_from_prompt.
Remove the redundant registry-membership assertion, while preserving the
existing assertions that each non-opted-in parser resolves to None for all
tested prompt tails.

370-374: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant opt-in test.

test_resolve_prefilled_thinking_opted_in repeats assertions that already exist. Lines 353-354 cover poolside_v1 with both markers, and test_alias_resolves_identically covers the same two tails for poolside_v1 and laguna. This test adds no new coverage.

♻️ Proposed removal
-@pytest.mark.parametrize(("tail", "expected"), [(R1_START, True),
-                                                (R1_END, False)])
-def test_resolve_prefilled_thinking_opted_in(tail: str, expected):
-    assert ReasoningParserFactory.resolve_prefilled_thinking(
-        "poolside_v1", f"<assistant>{tail}") is expected
-
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unittest/llmapi/test_reasoning_parser.py` around lines 370 - 374,
Remove the redundant test_resolve_prefilled_thinking_opted_in test and its
parametrized cases; retain the existing coverage for poolside_v1 markers and
alias behavior in the surrounding tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unittest/llmapi/test_reasoning_parser.py`:
- Around line 283-457: Add finalization coverage to
test_poolside_v1_reasoning_parser_stream for an unterminated </think> prefix
such as “reason</th”. Feed the fragment through parse_delta, then call finish()
and assert the buffered suffix is emitted as reasoning content rather than
dropped, with no visible content.

---

Nitpick comments:
In `@tests/unittest/llmapi/test_reasoning_parser.py`:
- Around line 416-432: Update test_resolve_prefilled_thinking_requires_opt_in to
derive its parameterized parser list from ReasoningParserFactory.keys(),
excluding the registered parsers that explicitly opt in via
resolves_thinking_from_prompt. Remove the redundant registry-membership
assertion, while preserving the existing assertions that each non-opted-in
parser resolves to None for all tested prompt tails.
- Around line 370-374: Remove the redundant
test_resolve_prefilled_thinking_opted_in test and its parametrized cases; retain
the existing coverage for poolside_v1 markers and alias behavior in the
surrounding tests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 821cb581-67c1-439f-ade6-410365d6bc21

📥 Commits

Reviewing files that changed from the base of the PR and between 9564b3b and b62ee09.

📒 Files selected for processing (7)
  • docs/source/developer-guide/telemetry.md
  • tensorrt_llm/llmapi/llm_args.py
  • tensorrt_llm/llmapi/reasoning_parser.py
  • tensorrt_llm/serve/openai_server.py
  • tensorrt_llm/usage/llm_args_golden_manifest.json
  • tests/unittest/api_stability/references/trtllm_serve_cli.yaml
  • tests/unittest/llmapi/test_reasoning_parser.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • docs/source/developer-guide/telemetry.md
  • tests/unittest/api_stability/references/trtllm_serve_cli.yaml
  • tensorrt_llm/usage/llm_args_golden_manifest.json
  • tensorrt_llm/llmapi/llm_args.py
  • tensorrt_llm/serve/openai_server.py
  • tensorrt_llm/llmapi/reasoning_parser.py

Comment thread tests/unittest/llmapi/test_reasoning_parser.py
@DomBrown DomBrown added the api-compatible Accepted LLM API contract change that is backwards-compatible label Aug 5, 2026

@brnguyen2 brnguyen2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the comments below are optional touch-ups, not blockers.

Approach looks right — reading the mode off the rendered prompt is the only thing that works when the template prefills the marker, and gating it on resolves_thinking_from_prompt with a test that every other registered parser stays at None is the right way to keep it contained. The rename matches what tool_parser_factory.py:38 already does, so doing it here is fine.

One ask before merge: the parsers that opt into resolves_thinking_from_prompt are only correct on the code path that can actually see a rendered prompt (openai_chat with add_generation_prompt). On every other path — offline LLM API, and the disagg generation server, which receives prompt_token_idslaguna/poolside_v1 now silently falls back to IdentityReasoningParser and returns reasoning text as content. That is a behavior regression relative to today, and it fails silently: no error, no log line, just a response with the wrong field populated. Please add a guard so an unsupported path is visible rather than quietly wrong. Concretely:

  • A one-time logger.warning when a parser with resolves_thinking_from_prompt = True is constructed without a resolvable mode (no thinking/enable_thinking in chat_template_kwargs), naming the parser and saying the mode could not be resolved so output will not be split.
  • A startup-time check in the disagg generation server: if reasoning_parser opts into prompt resolution and the server role is generation, that combination can never work today. I'd lean toward a hard error there rather than a warning, since it's a static config mistake that produces wrong output on every request, but a loud startup warning is acceptable if you'd rather not break existing deployments in a bugfix PR.

I wouldn't hard-fail the offline LLM path — callers that pass chat_template_kwargs themselves are legitimate and shouldn't be blocked — so a warning is the right level there.

Two gaps also worth a line in the PR description: the laguna key is not behavior-preserving, and the fix doesn't reach disaggregated serving. Details inline.

Comment thread tensorrt_llm/llmapi/reasoning_parser.py
dtype=np.int32).tolist()

rendered_prompt = None
if request.prompt_token_ids is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In disaggregated serving the generation server receives prompt_token_ids (relayed b64 from the context server, decoded a few lines above) and skips async_apply_chat_template entirely, so rendered_prompt stays None — but the gen server is the one that runs apply_reasoning_parser and produces the user-visible response. The fix therefore doesn't apply under disagg, and combined with the base-class change it's a step backwards there: laguna used to at least split on an emitted </think>, now it will return everything as content.

Unlike the offline case, this one is a static configuration property, not a per-request one: if the server role is generation and reasoning_parser is a parser with resolves_thinking_from_prompt = True, every request will be parsed in the wrong mode, and nothing in the response indicates it. That's worth a gate at startup rather than a note in the release notes. My preference would be to raise during server init on that combination (fail fast on a config that cannot produce correct output), with a logger.warning as the fallback if you'd rather not turn an existing deployment into a hard failure inside a bugfix PR — but silently degrading is the one option I'd rule out.

The real fix — ctx server relays the resolved mode, or the rendered prompt is propagated — is reasonable to defer to a follow-up; the gate is what keeps the gap from being invisible in the meantime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with @zhaoyangwang-nvidia's suggestion below instead of gating. DisaggregatedParams is already relayed wholesale from ctx to gen, so it just needed one more field: ctx stamps resolved_thinking, gen uses it when nothing was rendered.

So it's fixed rather than gated. I haven't run it against a live disagg deployment though, only unit tests either side, so that hop could use another pair of eyes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relay approach is better than gating, agreed. I traced the hop instead of deploying it and it holds: _get_ctx_request pins stream: False (openai_disagg_service.py:214), so the ctx worker always lands in chat_response_post_processor — the one handler you stamp — and _get_gen_request copies ctx_response.choices[0].disaggregated_params wholesale (openai_disagg_service.py:231), overwriting only request_type/schedule_style/conversation_id/ctx_usage. So the field survives end to end.

Two things left. The relay isn't gated on the parser opting in — separate inline comment on that. And the correctness of the streaming handler not stamping depends entirely on that forced stream: False, which is unstated. Rather than a comment next to the streaming handler, I'd put your original gate on the consuming side instead: gen worker, opted-in parser, nothing rendered, and no relayed value → once-per-process logger.warning. That one condition catches every route into the silently-wrong mode (a future non-_get_ctx_request orchestration path, a rolling upgrade where the ctx worker predates the field, a hand-crafted generation_only request), not just the streaming one, and it's the same if as the opt-in gate. Written up on the openai_server.py inline comment.

Comment thread tensorrt_llm/serve/openai_server.py Outdated
# Templates that prefill <think>/</think> leave the marker in the
# prompt, so the request kwargs alone cannot tell the parser which
# mode was rendered. Take it from the prompt instead.
if (postproc_args.reasoning_parser and rendered_prompt

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wiring has no test — test_poolside_v1_mode_resolved_from_prompt hand-simulates the two calls, so nothing catches it if this block gets dropped, if add_generation_prompt gating changes, or if postproc_args.chat_template_kwargs stops being what apply_reasoning_parser reads (postprocess_handlers.py:165). A small test under tests/unittest/llmapi/apps/ with a stub template that prefills <think> / </think> would pin the end-to-end path cheaply.

Note also that request.add_generation_prompt being false lands in the same silent-fallback bucket as the offline and disagg paths: the condition just doesn't fire and the parser runs in whatever mode the request kwargs implied. Whatever warning you add for the unresolved case should cover this branch too, so all three unsupported paths report the same way.

Also note add_thinking_budget_logits_processor (line 1483) still builds its parser from the raw request.chat_template_kwargs, before rendering. It happens to work here because reasoning_start/reasoning_end are class attributes on DeepSeekV4ReasoningParser, so _get_reasoning_boundary finds them even on the Identity branch — worth a comment so the coupling isn't accidental.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both done. New test is tests/unittest/llmapi/apps/test_reasoning_prompt_resolution.py, in l0_cpu.yml, driving the real ChatPostprocArgs and apply_reasoning_parser against a stub prefilling template.

It's CPU-only, so it mirrors the server block rather than importing it. Catches the chat_template_kwargs contract breaking, won't catch the block being deleted. That needs a GPU test with a custom --chat_template, which I'd rather leave for a follow-up.

Comment added on the budget coupling.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new file covers the chat_template_kwargs contract, which was the main thing I wanted pinned, and deferring the custom---chat_template GPU test is fine. One follow-up in the same file though: the two disagg tests (test_context_worker_stamps_the_resolved_mode, test_generation_worker_uses_the_relayed_mode) re-implement the relay inline rather than calling it, so they pass by construction. The ctx half is reachable from CPU — call chat_response_post_processor with an output carrying disaggregated_params and assert resolved_thinking on the resulting choice. Details in the inline comment.

@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch from b62ee09 to 2a6bce9 Compare August 6, 2026 09:41
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unittest/llmapi/test_reasoning_parser.py`:
- Around line 288-294: Add complete type annotations to all 14 new
reasoning-parser test functions in
tests/unittest/llmapi/test_reasoning_parser.py at lines 288-294, 305-316,
326-333, 336-341, 347-349, 360-362, 365-367, 372-374, 384-399, 407-415, 418-434,
445-454, 461-473, and 481-496: add -> None return annotations, use dict[str,
bool] for kwargs, list[str] for stream fixtures, and bool or bool | None for
expected values as applicable.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 50931dfd-1840-4f75-8af0-fea07f415824

📥 Commits

Reviewing files that changed from the base of the PR and between e3e5cca and 2a6bce9.

📒 Files selected for processing (7)
  • docs/source/developer-guide/telemetry.md
  • tensorrt_llm/llmapi/llm_args.py
  • tensorrt_llm/llmapi/reasoning_parser.py
  • tensorrt_llm/serve/openai_server.py
  • tensorrt_llm/usage/llm_args_golden_manifest.json
  • tests/unittest/api_stability/references/trtllm_serve_cli.yaml
  • tests/unittest/llmapi/test_reasoning_parser.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • tensorrt_llm/usage/llm_args_golden_manifest.json
  • tests/unittest/api_stability/references/trtllm_serve_cli.yaml
  • tensorrt_llm/llmapi/llm_args.py
  • tensorrt_llm/serve/openai_server.py
  • docs/source/developer-guide/telemetry.md

Comment thread tests/unittest/llmapi/test_reasoning_parser.py Outdated

@zhaoyangwang-nvidia zhaoyangwang-nvidia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the change. The approach (resolving the rendered mode from the prompt, behind an opt-in flag) is sound and the unit tests are thorough. What blocks me is the laguna semantics change: it now resolves to a kwargs-driven parser, while the fix only covers the "OpenAI chat endpoint + server-rendered prompt + add_generation_prompt=True" path. Offline LLM API users and the disagg generation server regress from "splits on an emitted </think>" to "everything is content" — as @brnguyen2 already pointed out; I checked and I agree those are real. Either give those paths an actual fix, or keep the old behavior and warn loudly when the mode cannot be resolved, rather than silently degrading. The rest below are non-blocking.

base64.b64decode(request.prompt_token_ids_b64),
dtype=np.int32).tolist()

rendered_prompt = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on @brnguyen2's disagg point: one option beyond gating at startup is that the context server does render the prompt, so it could resolve the mode there and relay it to the generation server alongside prompt_token_ids_b64, instead of the gen server having to infer it. Either way, could the unresolved cases (thinking is None, add_generation_prompt=False, non-str prompt from a processor) log a warning rather than silently falling back to the request kwargs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, this was the better idea. DisaggregatedParams already gets copied onto the gen request, so ctx stamps resolved_thinking and gen reads it.

On the warning: nothing degrades any more, those paths now behave as they do on main, so it'd be informational rather than a signal. Say if you still want it.

Comment thread tensorrt_llm/llmapi/reasoning_parser.py Outdated
parser_cls = entry[0]
if not parser_cls.resolves_thinking_from_prompt:
return None
tail = prompt.rstrip()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prompt.rstrip() allocates a copy of the whole prompt on every request, but only the last few characters matter. prompt[-64:].rstrip() gives the same result at constant cost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, prompt[-64:].rstrip() behind a constant.

Comment thread tensorrt_llm/llmapi/reasoning_parser.py Outdated
return None
tail = prompt.rstrip()
end = getattr(parser_cls, "reasoning_end", None)
start = getattr(parser_cls, "reasoning_start", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A parser that opted into resolves_thinking_from_prompt necessarily defines reasoning_start/reasoning_end, so the getattr(..., None) fallback defends an unreachable state — and it turns a typo into a silent None (i.e. a silent fallback to the request kwargs). Suggest declaring both on the base that carries this contract and accessing them directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The silent None fell back to the request kwargs, which is the exact failure this PR is meant to remove. Both markers are ClassVar[str] on the base now and accessed directly, so opting in without defining them raises.

Comment thread tensorrt_llm/llmapi/reasoning_parser.py Outdated
# Opt in on parsers whose template prefills the reasoning marker into the
# prompt and that select their mode from `enable_thinking`. Only those can
# have the mode resolved from the rendered prompt.
resolves_thinking_from_prompt = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: annotate as ClassVar[bool] to make it explicit that this is a class-level switch, not an instance field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread tensorrt_llm/llmapi/reasoning_parser.py Outdated
return self._parser.finish()


@register_reasoning_parser("poolside_v1")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: register_reasoning_parser takes *keys, and both keys share the same defaults here, so @register_reasoning_parser("poolside_v1", "laguna") on one line reads better than two stacked decorators (the stacked form elsewhere exists because each layer passes different kwargs).

Also: if laguna is meant to be a compatibility alias only, consider a deprecation warning when it is used — right now it is indistinguishable from poolside_v1 in the CLI choices, the telemetry allowlist and the manifest, so users have no signal to migrate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collapsed.

On deprecation, I'd rather not. laguna shipped in an RC and I'm keeping it as a permanent alias, not a transitional one, so there's nothing to migrate to. poolside_v1 is what our checkpoints declare and what vLLM and SGLang use, so that's the name I'd point people at, but the old one shouldn't start warning.

@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch from 2a6bce9 to 7000db3 Compare August 6, 2026 10:08
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch from 7000db3 to 5fafc13 Compare August 6, 2026 10:21
@joerowell
joerowell requested review from a team as code owners August 6, 2026 10:21
@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch 3 times, most recently from e76115b to a81dd4d Compare August 6, 2026 15:26
Laguna templates prefill <think> or </think>, so the model never emits the
opening marker and the request kwargs cannot tell the parser which mode was
rendered. Read it off the prompt instead, behind an opt-in flag so only
poolside_v1 is affected. Adds poolside_v1, keeping laguna as an alias.

Signed-off-by: Joe Rowell <joerowell4@gmail.com>
@joerowell
joerowell force-pushed the joerowell/reasoning-mode-from-rendered-prompt branch from a81dd4d to 1348236 Compare August 7, 2026 09:05
@joerowell

Copy link
Copy Markdown
Contributor Author

Thanks both for the reviews.

@zhaoyangwang-nvidia I think yours was read against an earlier state. 2a6bce940e, the commit you reviewed, is the one that added the fallback, so offline and disagg already keep the old splitting behaviour rather than dropping to Identity. Byte-identical to current main when I diffed them.

Disagg is properly fixed now too, following your suggestion, so the mode is relayed from the context worker rather than inferred by the generation worker.

Minor thing: on main laguna only splits when <think> is actually present, so plain</think>tail comes back as content there as well.

Rebased onto current main as of this morning, which also picks up #17157. Could you take another look?

@DomBrown

DomBrown commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

/bot run

@DomBrown DomBrown left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving from API perspective

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64606 [ run ] triggered by Bot. Commit: 1348236 Link to invocation

@brnguyen2 brnguyen2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the comments below are optional touch-ups, not blockers.

Re-reviewed after the three fixes. The unresolved-mode fallback in PoolsideV1ReasoningParser.__init__ restores the pre-PR laguna behavior exactly, so the base-class swap no longer changes any path that isn't newly resolved — that was my main concern and it's settled.

I traced the disagg relay rather than deploying it, and the hop holds up: _get_ctx_request forces stream: False (openai_disagg_service.py:214), so the ctx worker always lands in chat_response_post_processor — the one handler you stamp — and _get_gen_request copies ctx_response.choices[0].disaggregated_params wholesale (openai_disagg_service.py:231) before overwriting only request_type/schedule_style/conversation_id/ctx_usage. So the field survives.

That relay does rest on an unstated dependency, though: the streaming post-processor deliberately does not stamp, which is correct only because of the forced stream: False. Rather than a comment there, I'd put a once-per-process logger.warning on the consuming side — gen worker, nothing rendered, opted-in parser, and no relayed value — since that single condition catches every route into the silently-wrong mode (a future orchestration path that doesn't go through _get_ctx_request, a rolling upgrade where the ctx worker predates the field, a hand-crafted generation_only request), not just the streaming one. Details in the [openai_server.py:1585](https://github.com/NVIDIA/TensorRT-LLM/pull/17305/files#diff-2eb783dbd719ea8b2067105279f5a50460168065912d0efd2a3b2cdc0b3a78e5R1585) comment; it's the same if as the opt-in gate.

Remaining comments are narrow — the relay isn't gated on the parser opting in, two of the new tests re-implement the server logic rather than calling it (so they pass by construction), and resolve_prefilled_thinking's docstring under-specifies both why a suffix test is sound and what its three return values mean.

Description hygiene: the PR body still describes only the prompt-resolution flag and the rename. The new DisaggregatedParams.resolved_thinking field and the ctx→gen relay are a protocol addition that reviewers of a disagg deployment will care about; please add a line. The rename mirrors tool_parser/poolside_v1_parser.py and its "laguna" -> "poolside_v1" alias map, so it's consistent with what's already there — no complaint. No user-facing docs mention laguna, and the telemetry table/manifest/CLI reference are all updated, so nothing else is owed on docs.


@classmethod
def keys(cls):
def resolve_prefilled_thinking(cls, reasoning_parser: str,

@brnguyen2 brnguyen2 Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two suggestions for this helper's contract, both doc/naming fixes.

Document why a suffix test is correct, not just cheap. The docstring says templates "prefill the marker" but not where. In these templates the marker is the last thing appended after the assistant header — ...<|assistant|><think> when thinking is on, ...<|assistant|></think> when off (the stub in tests/unittest/llmapi/apps/test_reasoning_prompt_resolution.py matches this). The two prefills are mutually exclusive and both land at the very end, so which marker ends the prompt is the mode: a trailing unclosed <think> means the template opened reasoning; a trailing </think> means it already closed it and all model output is content.

The suffix restriction is what makes this correct, not merely fast: prior assistant turns render with their own <think>...</think> pairs, so a contains check would misfire on every multi-turn request. The docstring should state that.

One more line worth adding: _PROMPT_TAIL_CHARS is a copy bound, not a semantic window — whitespace before the marker is unbounded, but more than ~56 chars of trailing whitespace after it would push the marker out of the slice and read as unresolved.

Keep the tri-state return, but fix the name/docstring. Optional[bool] reads smelly, but the three cases are genuinely distinct at the call site (openai_server.py:1574-1585):

  • True/False — the template resolved the mode, overriding the request.
  • None — this prompt can't answer; the caller falls through to the disagg relay, then to the parser's own unresolved fallback.

Folding None into False would turn "couldn't tell" into "explicitly thinking-off", suppressing the relay and silently picking a mode.

That's why is_using_prefilled_thinking is the wrong name: an is_ prefix promises a bool, and None becomes a trap for anyone writing if not is_using_prefilled_thinking(...). Either keep the current name (it reads as a resolution, which is what it does) or rename to something like resolve_thinking_from_rendered_prompt. Either way, spell out the contract:

    Returns:
        True  - the template prefilled `<think>`: reasoning is open.
        False - the template prefilled `</think>`: reasoning is already
                closed, so all model output is content.
        None  - the mode cannot be determined from this prompt (unknown
                parser, parser has not opted in, or neither marker is at
                the tail). Callers must treat this as "ask elsewhere"
                (e.g. the relayed disagg value), not as thinking-off.

if disaggregated_params is not None and args.chat_template_kwargs:
# Relay the mode we resolved from the rendered prompt; the
# generation worker never renders and so cannot resolve it.
resolved = args.chat_template_kwargs.get("enable_thinking")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stamps for any reasoning parser, not just the ones that opted into prompt resolution — args.chat_template_kwargs["enable_thinking"] here is just whatever the client sent. Combined with the gen-side block, which writes both thinking and enable_thinking from the relayed value, that changes behavior for unrelated parsers: a request with {"thinking": true, "enable_thinking": false} against deepseek_v4 gets True in aggregated serving (the parser ORs the two keys) but False under disagg, because the relay flattens both keys to the enable_thinking value.

Degenerate input, but the fix is one line on each side — only stamp/consume when the configured parser has resolves_thinking_from_prompt, which is exactly the set this feature is meant for.

if thinking is None and request.disaggregated_params is not None:
# Generation worker: it never rendered, so use the mode the
# context worker resolved and relayed.
thinking = request.disaggregated_params.resolved_thinking

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two asks on this branch, both the same if.

Gate it on the parser opting in. As written this consumes resolved_thinking for whatever parser is configured, and then overwrites both keys of the caller's chat_template_kwargs (see the ctx-side comment on [postprocess_handlers.py:422](https://github.com/NVIDIA/TensorRT-LLM/pull/17305/files#diff-f18f66c51e14fc204af740548177c93251297a4d1e0ab8c9f00fbb08a79b8ad8R422) for the concrete misparse). Suggest only entering the disagg branch when ReasoningParserFactory.resolves_thinking_from_prompt(postproc_args.reasoning_parser) — a small classmethod would keep the registry lookup in one place, alongside resolve_prefilled_thinking.

Warn when the mode is unresolvable here. Once that gate exists, this is also the only place in the system with enough information to notice the bad state: opted-in parser, rendered_prompt is None (so nothing to resolve locally), and request.disaggregated_params.resolved_thinking is None. Every request on such a deployment is then parsed in a possibly-wrong mode — in thinking mode the prompt prefilled <think>, so the model's output has no opening tag, the fallback DeepSeekR1Parser(reasoning_at_start=False) waits for a marker that never arrives, and reasoning text lands in content with nothing in the response saying so. Same class of wrong answer this PR set out to fix.

A once-per-process logger.warning on that condition covers every route into it, not just one: a future orchestration path that doesn't go through _get_ctx_request (which is what currently forces stream: False and thereby guarantees the ctx worker hits the stamping handler), a rolling upgrade where the ctx worker is an older build without the field, or a client hand-crafting a generation_only request. I'd keep it a warning rather than a hard error — a mixed-version disagg upgrade would otherwise take out the whole gen tier, and the fallback still returns a usable, if unsplit, response.

I'd prefer this over a sentinel stamped from chat_stream_post_processor: ChatCompletionResponseStreamChoice has no disaggregated_params field at all ([openai_protocol.py:806](https://github.com/NVIDIA/TensorRT-LLM/pull/17305/files#diff-4b6ece0a77c7a186737d731752536269c760e4aa3e9ebc896f8023a37280d10aR806)-812) and the streaming handler never calls to_disaggregated_params, so a sentinel there is dead code whose only live path requires someone to first add disagg-param plumbing to the streaming choice — at which point they're already editing the code that would carry the stamp. It would also mean widening resolved_thinking past Optional[bool] on a wire type with extra="forbid", for a state that can't currently occur.



@pytest.mark.parametrize("relayed", [True, False])
def test_generation_worker_uses_the_relayed_mode(relayed: bool) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test and test_context_worker_stamps_the_resolved_mode above re-implement the two server halves inline (thinking = params.resolved_thinking, then assert the parser behaves accordingly), so they pass by construction and can't fail if the ctx stamp in [postprocess_handlers.py:419](https://github.com/NVIDIA/TensorRT-LLM/pull/17305/files#diff-f18f66c51e14fc204af740548177c93251297a4d1e0ab8c9f00fbb08a79b8ad8R419) or the gen block in [openai_server.py:1583](https://github.com/NVIDIA/TensorRT-LLM/pull/17305/files#diff-2eb783dbd719ea8b2067105279f5a50460168065912d0efd2a3b2cdc0b3a78e5R1583) changes or is dropped. That's the one hop you flagged as unverified against a live deployment, so it's the place where a construction-only test is least useful.

The ctx side at least is reachable from CPU: build a fake GenerationResult-ish output with disaggregated_params set and call chat_response_post_processor directly, then assert response.choices[0].disaggregated_params.resolved_thinking is True. That pins the stamp itself rather than a copy of it.

return None
# Only the tail matters, so avoid copying the whole prompt.
tail = prompt[-_PROMPT_TAIL_CHARS:].rstrip()
if tail.endswith(parser_cls.reasoning_end):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parser_cls.reasoning_start / .reasoning_end are declared on BaseReasoningParser as bare ClassVar[str] annotations with no values, so they only exist on classes that assign them. DeepSeekR1Parser sets them in __init__ as instance attributes — a future parser that subclasses it and flips resolves_thinking_from_prompt = True would hit AttributeError here, in the request path, not at startup.

Cheap insurance: read them with getattr(parser_cls, "reasoning_end", None) and return None (or raise at registration time) when either is missing, so the failure is a startup error rather than a per-request 500.

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64606 [ run ] completed with state SUCCESS. Commit: 1348236
/LLM/main/L0_MergeRequest_PR pipeline #52467 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-compatible Accepted LLM API contract change that is backwards-compatible

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants