Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/source/developer-guide/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ unset or when the safety sanitizer rejects the runtime value.
| `ray_placement_config.defer_workers_init` | `<class 'bool'>` | `value` | | |
| `ray_placement_config.per_worker_gpu_share` | `Optional[float]` | `value` | | |
| `ray_placement_config.placement_bundle_indices` | `Optional[List[List[int]]]` | `value` | | |
| `reasoning_parser` | `Optional[str]` | `categorical` | allowlist | `auto`, `deepseek-r1`, `laguna`, `qwen3`, `qwen3_5`, `minimax_m2`, `minimax_m2_append_think`, `nano-v3`, `gemma4`, `kimi_k2`, `kimi_k25` |
| `reasoning_parser` | `Optional[str]` | `categorical` | allowlist | `auto`, `deepseek-r1`, `poolside_v1`, `laguna`, `qwen3`, `qwen3_5`, `minimax_m2`, `minimax_m2_append_think`, `nano-v3`, `gemma4`, `kimi_k2`, `kimi_k25` |
| `reorder_policy_config.policy_args.agent_inflight_seq_num` | `<class 'int'>` | `value` | | |
| `reorder_policy_config.policy_args.agent_percentage` | `<class 'float'>` | `value` | | |
| `reorder_policy_config.policy_name` | `Optional[Literal['AgentTree']]` | `categorical` | | `AgentTree` |
Expand Down
5 changes: 3 additions & 2 deletions tensorrt_llm/llmapi/llm_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -4595,8 +4595,9 @@ class BaseLlmArgs(StrictBaseModel):
default=None,
description="The parser to separate reasoning content from output.",
status="prototype",
telemetry=TelemetryField.categorical('auto', 'deepseek-r1', 'laguna',
'qwen3', 'qwen3_5', 'minimax_m2',
telemetry=TelemetryField.categorical('auto', 'deepseek-r1',
'poolside_v1', 'laguna', 'qwen3',
'qwen3_5', 'minimax_m2',
'minimax_m2_append_think',
'nano-v3', 'gemma4', 'kimi_k2',
'kimi_k25'))
Expand Down
106 changes: 102 additions & 4 deletions tensorrt_llm/llmapi/reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@

import json
from abc import ABC, abstractmethod
from collections.abc import KeysView
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Type
from typing import Any, ClassVar, Optional, Type

from tensorrt_llm import logger

Expand All @@ -28,6 +29,11 @@ class ReasoningParserResult:
reasoning_content: str = ""


# Enough of the rendered prompt's tail to hold a prefilled marker and any
# trailing whitespace, without copying a prompt that may be very long.
_PROMPT_TAIL_CHARS = 64


def register_reasoning_parser(*keys: str, **default_kwargs):
"""Decorator that registers a BaseReasoningParser under one or more keys.

Expand All @@ -42,6 +48,16 @@ class MyParser(BaseReasoningParser):
"""

def decorator(parser_cls: Type["BaseReasoningParser"]):
if parser_cls.resolves_thinking_from_prompt:
# Fail at import rather than per request: `resolve_prefilled_thinking`
# reads these off the class, and subclasses of parsers that only set
# them in `__init__` would otherwise raise inside the request path.
for attr in ("reasoning_start", "reasoning_end"):
if not isinstance(getattr(parser_cls, attr, None), str):
raise TypeError(
f"{parser_cls.__name__} sets "
f"resolves_thinking_from_prompt but does not define "
f"{attr} as a class attribute")
for key in keys:
ReasoningParserFactory._parsers[key] = (parser_cls, default_kwargs)
return parser_cls
Expand Down Expand Up @@ -69,7 +85,54 @@ def create_reasoning_parser(
**default_kwargs)

@classmethod
def keys(cls):
def resolves_thinking_from_prompt(cls, reasoning_parser: str) -> bool:
"""Whether this parser selects its mode from the rendered prompt."""
entry = cls._parsers.get(reasoning_parser.lower())
return bool(entry) and entry[0].resolves_thinking_from_prompt

@classmethod
def resolve_prefilled_thinking(cls, reasoning_parser: str,
Comment thread
DomBrown marked this conversation as resolved.
prompt: str) -> Optional[bool]:
"""Read the reasoning mode off the tail of a rendered prompt.

These templates append the marker last, after the assistant header:
`...<|assistant|><think>` with thinking on, `...<|assistant|></think>`
with it off. The two are mutually exclusive and both land at the very
end, so whichever marker ends the prompt *is* the mode.

Testing the suffix rather than the whole prompt is what makes this
correct, not merely cheap: prior assistant turns render with their own
`<think>...</think>` pairs, so a containment check would misfire on
every multi-turn request. `_PROMPT_TAIL_CHARS` is a copy bound, not a
semantic window; whitespace before the marker is unbounded, but more
trailing whitespace than that slice would push the marker out of view
and read as unresolved.

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.
"""
entry = cls._parsers.get(reasoning_parser.lower())
if entry is None:
return None
parser_cls = entry[0]
if not parser_cls.resolves_thinking_from_prompt:
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):
Comment thread
DomBrown marked this conversation as resolved.
return False
if tail.endswith(parser_cls.reasoning_start):
return True
return None

@classmethod
def keys(cls) -> KeysView[str]:
return cls._parsers.keys()

@classmethod
Expand All @@ -92,6 +155,14 @@ class BaseReasoningParser(ABC):
# when the request carries tools).
needs_raw_special_tokens: bool = False

# 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, and they must define
# both markers below.
resolves_thinking_from_prompt: ClassVar[bool] = False
reasoning_start: ClassVar[str]
reasoning_end: ClassVar[str]

def __init__(self,
*,
chat_template_kwargs: Optional[dict[str, Any]] = None) -> None:
Expand Down Expand Up @@ -126,7 +197,6 @@ def parse_delta(self, delta_text: str) -> ReasoningParserResult:


@register_reasoning_parser("deepseek-r1", reasoning_at_start=True)
@register_reasoning_parser("laguna")
@register_reasoning_parser("qwen3")
# Qwen3.5 (and forced-thinking Qwen3 variants) use a chat template that
# pre-injects `<think>\n` into the assistant prompt prefix, so the model
Expand Down Expand Up @@ -289,6 +359,34 @@ def finish(self) -> ReasoningParserResult:
return self._parser.finish()


@register_reasoning_parser("poolside_v1", "laguna")
class PoolsideV1ReasoningParser(DeepSeekV4ReasoningParser):
"""Poolside Laguna models, which prefill the marker the same way.

Comment thread
brnguyen2 marked this conversation as resolved.
The family's templates disagree on the `enable_thinking` default, so the
mode is resolved from the rendered prompt rather than from a constant.
`laguna` stays as an alias of `poolside_v1` for existing deployments.
"""

resolves_thinking_from_prompt = True

def __init__(
self,
*,
chat_template_kwargs: Optional[dict[str, Any]] = None,
) -> None:
super().__init__(chat_template_kwargs=chat_template_kwargs)
kwargs = chat_template_kwargs or {}
if kwargs.get("thinking") is None and kwargs.get(
"enable_thinking") is None:
# Mode unresolved (offline LLM API, disagg generation server,
# add_generation_prompt=false). Keep splitting on a `<think>` the
# model emits itself, as these models do in multi-turn and tools.
self._parser = DeepSeekR1Parser(
reasoning_at_start=False,
chat_template_kwargs=chat_template_kwargs)


@register_reasoning_parser("minimax_m3")
class MiniMaxM3ReasoningParser(DeepSeekR1Parser):
"""Reasoning parser for MiniMax-M3.
Expand Down Expand Up @@ -344,7 +442,7 @@ def parse(self, text: str) -> ReasoningParserResult:
"qwen3_next": "qwen3",
"deepseek_v3": "deepseek-r1",
"deepseek_v32": "deepseek-r1",
"laguna": "laguna",
"laguna": "poolside_v1",
"deepseek_v4": "deepseek_v4",
"nemotron_h": "nemotron-v3",
"nemotron_h_puzzle": "nemotron-v3",
Expand Down
4 changes: 4 additions & 0 deletions tensorrt_llm/serve/openai_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ class DisaggregatedParams(OpenAIBaseModel):
# Orchestrator -> context-worker instruction: return prompt_token_ids as a
# base64 int32 buffer (prompt_token_ids_b64) instead of a JSON int array.
return_prompt_token_ids_b64: bool = False
# Context worker -> generation worker: the reasoning mode the context
# worker read off the prompt it rendered. The generation worker only sees
# prompt_token_ids, so it cannot resolve this for itself.
resolved_thinking: Optional[bool] = None


class ConversationParams(OpenAIBaseModel):
Expand Down
55 changes: 55 additions & 0 deletions tensorrt_llm/serve/openai_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,30 @@ async def route_handler(request: Request):

TIMEOUT_KEEP_ALIVE = 5 # seconds.

_warned_unresolvable_thinking = False


def _warn_unresolvable_thinking_once(reasoning_parser: str) -> None:
"""Warn when a generation worker cannot learn the reasoning mode.

Nothing was rendered here and the context worker relayed no value, so
every request on this deployment is parsed in a possibly wrong mode with
nothing in the response saying so. Reachable via a rolling upgrade where
the context worker predates `resolved_thinking`, a hand-crafted
`generation_only` request, or an orchestration path that does not go
through `_get_ctx_request`.
"""
global _warned_unresolvable_thinking
if _warned_unresolvable_thinking:
return
_warned_unresolvable_thinking = True
logger.warning(
f"Reasoning parser {reasoning_parser!r} resolves its mode from the "
"rendered prompt, but this request had neither a rendered prompt nor "
"a relayed mode from a context worker. Reasoning content will not be "
"separated correctly. Check that the context worker is running a "
"build that relays 'resolved_thinking'.")


def _configure_parser_special_token_decoding(
sampling_params: SamplingParams, reasoning_parser_name: Optional[str],
Expand Down Expand Up @@ -1533,6 +1557,9 @@ async def chat_stream_generator(
gather_generation_logits,
reasoning_parser=self.generator.args.reasoning_parser,
backend=self.generator.args.backend)
# Pre-render, so the mode may be unresolved. Safe because the
# boundary lookup reads the parser class attributes, not the
# branch `__init__` picked.
add_thinking_budget_logits_processor(
sampling_params,
reasoning_parser=self.generator.args.reasoning_parser,
Expand Down Expand Up @@ -1584,6 +1611,7 @@ async def chat_stream_generator(
base64.b64decode(request.prompt_token_ids_b64),
dtype=np.int32).tolist()

rendered_prompt = None
Comment thread
DomBrown marked this conversation as resolved.
if request.prompt_token_ids is not None:
Comment thread
DomBrown marked this conversation as resolved.
prompt = request.prompt_token_ids
else:
Expand All @@ -1601,6 +1629,8 @@ async def chat_stream_generator(
)
prompt, (mm_data, mm_embeddings) = await asyncio.gather(
prompt_task, mm_coroutines)
if isinstance(prompt, str):
rendered_prompt = prompt
prompt = prompt_inputs(prompt)

if request.prompt_token_ids is not None:
Expand All @@ -1620,6 +1650,31 @@ async def chat_stream_generator(
prompt["mm_processor_kwargs"] = request.mm_processor_kwargs

postproc_args.reasoning_parser = self.generator.args.reasoning_parser
# 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 (
ReasoningParserFactory.resolves_thinking_from_prompt(
postproc_args.reasoning_parser)):
thinking = None
if rendered_prompt and request.add_generation_prompt:
thinking = ReasoningParserFactory.resolve_prefilled_thinking(
postproc_args.reasoning_parser, rendered_prompt)
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
Comment thread
DomBrown marked this conversation as resolved.
if thinking is None:
_warn_unresolvable_thinking_once(
postproc_args.reasoning_parser)
if thinking is not None:
# Both keys, because the parser ORs them: leaving a stale
# `thinking` in place would override what we resolved.
postproc_args.chat_template_kwargs = {
**(request.chat_template_kwargs or {}),
"thinking": thinking,
"enable_thinking": thinking,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
postproc_args.tool_parser = self.tool_parser
postproc_args.tool_call_id_type = self.tool_call_id_type
if conversation and conversation[-1].get(
Expand Down
11 changes: 11 additions & 0 deletions tensorrt_llm/serve/postprocess_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,17 @@ def chat_response_post_processor(
tool_calls=tool_calls)
disaggregated_params = to_disaggregated_params(
output.disaggregated_params)
if (disaggregated_params is not None and args.chat_template_kwargs
and args.reasoning_parser
and ReasoningParserFactory.resolves_thinking_from_prompt(
args.reasoning_parser)):
# Relay the mode we resolved from the rendered prompt; the
# generation worker never renders and so cannot resolve it. Gated
# on the parser opting in, so we never overwrite a mode another
# parser derives from the caller's own kwargs.
resolved = args.chat_template_kwargs.get("enable_thinking")
Comment thread
DomBrown marked this conversation as resolved.
if resolved is not None:
disaggregated_params.resolved_thinking = resolved
choice = ChatCompletionResponseChoice(
index=output.index,
message=message,
Expand Down
1 change: 1 addition & 0 deletions tensorrt_llm/usage/llm_args_golden_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,7 @@
"allowed_values": [
"auto",
"deepseek-r1",
"poolside_v1",
"laguna",
"qwen3",
"qwen3_5",
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_lists/test-db/l0_cpu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ l0_cpu:
- unittest/inputs
- unittest/llmapi/apps/test_chat_utils.py
- unittest/llmapi/apps/test_harmony_channel_validation.py
- unittest/llmapi/apps/test_reasoning_prompt_resolution.py
- unittest/llmapi/apps/test_tool_parsers.py
- unittest/llmapi/test_bench_async.py
- unittest/llmapi/test_additional_model_outputs.py -m "gpu1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ commands:
flags:
- "--post_processor_hook"
reasoning_parser:
type: Choice(['auto', 'deepseek-r1', 'deepseek_v4', 'gemma4', 'kimi_k2', 'kimi_k25', 'kimi_k3', 'laguna', 'minimax_m2', 'minimax_m2_append_think', 'minimax_m3', 'nano-v3', 'nemotron-v3', 'qwen3', 'qwen3_5'])
type: Choice(['auto', 'deepseek-r1', 'deepseek_v4', 'gemma4', 'kimi_k2', 'kimi_k25', 'kimi_k3', 'laguna', 'minimax_m2', 'minimax_m2_append_think', 'minimax_m3', 'nano-v3', 'nemotron-v3', 'poolside_v1', 'qwen3', 'qwen3_5'])
default: null
status: prototype
required: false
Expand Down
Loading
Loading