Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
5 changes: 3 additions & 2 deletions docs/source/features/disagg-serving.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,10 @@ The disaggregated server also serves `/v1/responses`, with both `context_first`
- A request with `previous_response_id` is rejected with a 400, because a stored response lives in one worker's memory and a later request may reach another worker. Send the earlier turns in `input` instead.
- `store` is ignored: nothing is persisted, and the server logs a warning once when a client asks for it.

One limitation applies to `/v1/responses` on every server, disaggregated or not:
Two limitations apply to `/v1/responses` on every server, disaggregated or not:

- `parallel_tool_calls: false`, and a `tool_choice` other than `auto` or `none` on non-Harmony models, are accepted but not enforced. The server logs a warning once.
- A `tool_choice` other than `auto` or `none` on non-Harmony models is accepted but not enforced. The server logs a warning once.
- `parallel_tool_calls: false` ends generation after the first tool call only when the tool parser declares the text that ends one call, as the DeepSeek DSML parsers (`deepseek_v32`, `deepseek_v4`, `deepseek_v41`) do. With other tool parsers, and on Harmony models, it is accepted but not enforced, and the server logs a warning once.

#### Launching disaggregated servers on SLURM clusters

Expand Down
7 changes: 5 additions & 2 deletions tensorrt_llm/serve/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,6 @@ def _convert_tool_choice(
if choice is None:
# check_tool_choice validator defaults to "auto" when tools are set.
return "auto" if tools else None
if choice.disable_parallel_tool_use and choice.type != "none":
raise AnthropicRequestError("tool_choice.disable_parallel_tool_use=true is not supported")
if choice.type == "none":
return "none"
if tools is None:
Expand Down Expand Up @@ -475,6 +473,11 @@ def convert_anthropic_request(request: AnthropicMessagesRequest) -> ChatCompleti
chat_request["tools"] = [t.model_dump() for t in tools]
if tool_choice is not None:
chat_request["tool_choice"] = tool_choice
choice = request.tool_choice
if choice is not None and choice.disable_parallel_tool_use and choice.type != "none":
# At most one tool_use block: the chat pipeline ends generation after
# the first tool call where the tool-call format allows it.
chat_request["parallel_tool_calls"] = False
if request.temperature is not None:
chat_request["temperature"] = request.temperature
if request.top_p is not None:
Comment thread
JunyiXu-nv marked this conversation as resolved.
Resolved
Expand Down
8 changes: 5 additions & 3 deletions tensorrt_llm/serve/openai_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,9 +1106,11 @@ class ChatCompletionRequest(OpenAIBaseModel):
tools: Optional[List[ChatCompletionToolsParam]] = None
tool_choice: Optional[Union[Literal["none", "auto", "required"],
ChatCompletionNamedToolChoiceParam]] = "none"
# Standard OpenAI field, accepted for compatibility. `false` is not
# enforced: the engine does not restrict how many tool calls the model
# emits per turn, so parallel emission remains model behavior either way.
# Standard OpenAI field. `false` ends generation after the first tool
# call when the tool-call format has a text that ends one call
# (BaseToolParser.single_call_stops) and the request has no stops of its
# own that are left out of the output; otherwise it is accepted but not
# enforced.
parallel_tool_calls: Optional[bool] = None
user: Optional[str] = None
reasoning_effort: Optional[ReasoningEffort | Literal[
Expand Down
101 changes: 93 additions & 8 deletions tensorrt_llm/serve/openai_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,47 @@ def _configure_parser_special_token_decoding(
sampling_params.spaces_between_special_tokens = False


def _single_tool_call_stops(tool_parser_name: Optional[str]) -> tuple[str, ...]:
"""The texts that end one tool call in this format; empty if it has none.

See ``BaseToolParser.single_call_stops``.
"""
if not tool_parser_name:
return ()
tool_parser_cls = ToolParserFactory.parsers.get(tool_parser_name.lower())
if tool_parser_cls is None:
return ()
return tool_parser_cls().single_call_stops


def _stop_after_first_tool_call(sampling_params: SamplingParams,
stops: tuple[str, ...]) -> bool:
"""End generation on the first of ``stops`` and keep it in the output.

This honours a request for at most one tool call per turn without
rewriting what the model wrote: ``stops`` end one call
(``BaseToolParser.single_call_stops``), and the tool parser needs the
matched text to read that call as complete. ``include_stop_str_in_output``
covers every stop of a request, so nothing changes when the request's
own stop strings or ids are to be left out of the output.

Returns:
Whether the stops were added.
"""
client_stops = sampling_params.stop
if isinstance(client_stops, str):
client_stops = [client_stops]
client_stops = list(client_stops or [])
if ((client_stops or sampling_params.stop_token_ids)
and not sampling_params.include_stop_str_in_output):
return False
sampling_params.stop = client_stops + [
stop for stop in stops if stop not in client_stops
]
sampling_params.include_stop_str_in_output = True
return True


def _record_generator_termination(generator) -> None:
"""Classify a fatal generator error without exposing exception details."""
component = "llm"
Expand Down Expand Up @@ -2298,7 +2339,33 @@ async def chat_stream_generator(
"server to be started with --tool_parser."),
err_type="BadRequestError",
status_code=HTTPStatus.BAD_REQUEST)
# At most one tool call per turn (also Anthropic's
# disable_parallel_tool_use): generation ends after the first call
# where the tool-call format allows it; elsewhere this is accepted
# and said once.
single_call_stops: tuple[str, ...] = ()
if (request.parallel_tool_calls is False and all_tools
and request.tool_choice != "none"):
stops = _single_tool_call_stops(self.tool_parser)
client_stops = sampling_params.stop
if isinstance(client_stops, str):
client_stops = [client_stops]
client_stops = set(client_stops or [])
if stops and _stop_after_first_tool_call(
sampling_params, stops):
# A stop the request lists itself keeps its stop reason.
single_call_stops = tuple(stop for stop in stops
if stop not in client_stops)
else:
logger.warning_once(
"'parallel_tool_calls=false' is accepted but not "
"enforced when the tool-call format has no text that "
"ends a single call, or when the request's own stop "
"strings are left out of the output; the model may "
"still emit several tool calls in one turn.",
key="chat_parallel_tool_calls_unenforced")
postproc_args = ChatPostprocArgs.from_request(request)
postproc_args.single_call_stops = single_call_stops
self._apply_spec_decode_stats_opt_in(postproc_args)
# Some models' prompt-token accounting excludes trailing rendered
# tokens (kimi_k3: the generation channel opener); the model still
Expand Down Expand Up @@ -3336,9 +3403,12 @@ async def openai_responses(self, request: ResponsesRequest,
raw_request: Request) -> Response:
"""Serve one /v1/responses request, streaming or not.

``parallel_tool_calls=false`` and, on the non-Harmony path,
``tool_choice`` values other than "auto" and "none" are accepted but
not enforced (logged once). ``tool_choice="none"`` skips tool parsing.
``parallel_tool_calls=false`` ends generation after the first tool
call where the tool-call format has a text that ends one call
(``BaseToolParser.single_call_stops``); elsewhere, and on the Harmony
path, it is accepted but not enforced (logged once). So are, on the
non-Harmony path, ``tool_choice`` values other than "auto" and
"none". ``tool_choice="none"`` skips tool parsing.
"""

async def create_response(
Expand Down Expand Up @@ -3425,13 +3495,23 @@ async def create_streaming_generator(promise: RequestOutput,
# worker would pull KV cache from.
self._validate_internal_disagg_request(request, raw_request)

# parallel_tool_calls=false ends generation after the first tool
# call where the tool-call format allows it (applied below, once
# the sampling params exist); elsewhere it is accepted and said
# once.
single_call_stops: tuple[str, ...] = ()
if ("parallel_tool_calls" in request.model_fields_set
and request.parallel_tool_calls is False):
logger.warning_once(
"Responses API: 'parallel_tool_calls=false' is accepted "
"but not enforced; the model may still emit several tool "
"calls in one turn.",
key="responses_parallel_tool_calls_unenforced")
if not self.use_harmony:
single_call_stops = _single_tool_call_stops(
self.tool_parser)
if not single_call_stops:
logger.warning_once(
"Responses API: 'parallel_tool_calls=false' is "
"accepted but not enforced for this tool-call format; "
"the model may still emit several tool calls in one "
"turn.",
key="responses_parallel_tool_calls_unenforced")
if not self.use_harmony and request.tool_choice not in ("auto",
"none"):
logger.warning_once(
Expand Down Expand Up @@ -3486,6 +3566,11 @@ async def create_streaming_generator(promise: RequestOutput,
reasoning_parser_name=self.generator.args.reasoning_parser,
tool_parser_name=self.tool_parser,
has_tools=bool(request.tools))
if (single_call_stops and request.tools
and request.tool_choice != "none"):
# A Responses request sets no stops of its own, so these
# always apply.
_stop_after_first_tool_call(sampling_params, single_call_stops)

streaming_processor = None
if request.stream:
Expand Down
18 changes: 15 additions & 3 deletions tensorrt_llm/serve/postprocess_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ class ChatPostprocArgs(PostprocArgs):
reasoning_parser: Optional[str] = None
tool_parser: Optional[str] = None
tool_parser_grammar_constrained: bool = False
# Stops the server added to end generation after the first tool call
# (parallel_tool_calls=false). The client did not ask for them, so a
# generation they end reports no stop_reason.
single_call_stops: tuple[str, ...] = ()
reasoning_parser_dict: dict[int, BaseReasoningParser] = field(
default_factory=dict)
tool_parser_dict: dict[int, BaseToolParser] = field(default_factory=dict)
Expand Down Expand Up @@ -398,6 +402,14 @@ def forced_tool_arguments_end(text: str) -> Optional[int]:
return end


def _client_stop_reason(stop_reason: Optional[Union[int, str]],
args: ChatPostprocArgs) -> Optional[Union[int, str]]:
"""The stop reason to report: none for a stop the server added itself."""
if stop_reason in args.single_call_stops:
return None
return stop_reason


Comment thread
JunyiXu-nv marked this conversation as resolved.
Resolved
@nvtx_range_debug("chat_stream_post_processor")
def chat_stream_post_processor(rsp: GenerationResultBase,
args: ChatPostprocArgs) -> List[str]:
Expand Down Expand Up @@ -595,7 +607,7 @@ def yield_first_chat(num_tokens: int,
speculative_decoding=_build_spec_decode_stats(
getattr(output, '_spec_dec_counters', None), args,
output.finish_reason),
stop_reason=output.stop_reason,
stop_reason=_client_stop_reason(output.stop_reason, args),
)
if args.return_logprobs:
logprobs = output.logprobs_diff
Expand All @@ -608,7 +620,7 @@ def yield_first_chat(num_tokens: int,
choice.finish_reason = "tool_calls"
else:
choice.finish_reason = output.finish_reason
choice.stop_reason = output.stop_reason
choice.stop_reason = _client_stop_reason(output.stop_reason, args)
finish_reason_sent[i] = True
chunk = ChatCompletionStreamResponse(choices=[choice],
model=args.model,
Expand Down Expand Up @@ -755,7 +767,7 @@ def chat_response_post_processor(
choice = ChatCompletionResponseChoice(
index=output.index,
message=message,
stop_reason=output.stop_reason,
stop_reason=_client_stop_reason(output.stop_reason, args),
disaggregated_params=disaggregated_params,
avg_decoded_tokens_per_iter=getattr(rsp,
'avg_decoded_tokens_per_iter',
Expand Down
14 changes: 14 additions & 0 deletions tensorrt_llm/serve/tool_parser/base_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,20 @@ def has_tool_call(self, text: str) -> bool:
"""
raise NotImplementedError()

@property
def single_call_stops(self) -> tuple[str, ...]:
"""Texts that end one complete tool call, to stop generation on.

A request for at most one tool call per turn
(``parallel_tool_calls=false``) stops generating on the first of these
texts and keeps it in the output, so the parser reads that call as
complete and the output stays a prefix of what the model generated.
Stops match on token ids, so a text is listed in each form the model
tokenizes it in. Empty when the format has no such text; requests
then get every call the model writes.
"""
return ()

def supports_structural_tag(self) -> bool:
"""Return True if this detector supports structural tag format."""
return True
Expand Down
31 changes: 21 additions & 10 deletions tensorrt_llm/serve/tool_parser/deepseekv32_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a deepseek v32 format tool call."""
return self.bot_token in text

@property
def single_call_stops(self) -> tuple[str, ...]:
# Each call is its own invoke block, so its closing tag ends exactly one
# call. That leaves the calls block open; the whole-text and streaming
# parses both deliver the complete invokes of an open block.
# Stops match on token ids, and the encoders follow the tag with a
# newline that tokenizes together with ">", so the tag is also listed
# with that newline. That form comes first: the decoded text is cut
# after the first listed stop it contains, and the newline belongs to
# the stop's last token.
return (self.invoke_end_token + "\n", self.invoke_end_token)

def _parse_parameters_from_xml(self, invoke_content: str) -> dict:
"""Parse parameters from either XML-like format or JSON format to dict.

Expand Down Expand Up @@ -148,16 +160,15 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult

calls = []
try:
# Extract content between function_calls tags
function_calls_match = re.search(
re.escape(self.bot_token) + r"(.*?)" + re.escape(self.eot_token),
text,
re.DOTALL,
)
if not function_calls_match:
return StreamingParseResult(normal_text=normal_text, calls=[])

function_calls_content = function_calls_match.group(1)
# The calls block runs to its closing tag, or to the end of the text
# when generation stopped inside it (single_call_stops, a token
# limit). Only its complete invokes are calls; an unfinished last
# invoke is dropped, as the streaming parse holds it back.
block_start = idx + len(self.bot_token)
block_end = text.find(self.eot_token, block_start)
if block_end == -1:
block_end = len(text)
function_calls_content = text[block_start:block_end]

# Find all invoke blocks
invoke_pattern = self.invoke_begin_regex + r"(.*?)" + re.escape(self.invoke_end_token)
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 @@ -268,6 +268,7 @@ l0_cpu:
- unittest/llmapi/apps/test_responses_usage.py
- unittest/llmapi/apps/test_serve_lazy_import.py
- unittest/llmapi/apps/test_serving_extensions.py
- unittest/llmapi/apps/test_single_tool_call_stop.py
- unittest/llmapi/apps/test_tool_parsers.py
- unittest/llmapi/apps/test_web_search_shared.py
- unittest/llmapi/test_bench_async.py
Expand Down
25 changes: 18 additions & 7 deletions tests/unittest/llmapi/apps/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,14 +630,25 @@ def test_tool_choice_any_rejected_instead_of_downgraded():
)


def test_disable_parallel_tool_use_rejected():
with pytest.raises(AnthropicRequestError, match="disable_parallel_tool_use"):
convert_anthropic_request(
make_request(
tools=[{"name": "f", "input_schema": {"type": "object"}}],
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
)
@pytest.mark.parametrize(
("tool_choice", "parallel_tool_calls"),
[
({"type": "auto", "disable_parallel_tool_use": True}, False),
({"type": "auto", "disable_parallel_tool_use": False}, None),
({"type": "auto"}, None),
# "none" allows no call at all, so there is nothing to limit.
({"type": "none", "disable_parallel_tool_use": True}, None),
],
)
def test_disable_parallel_tool_use_asks_for_one_call(tool_choice, parallel_tool_calls):
"""The chat pipeline then stops generation after the first tool call."""
chat = convert_anthropic_request(
make_request(
tools=[{"name": "f", "input_schema": {"type": "object"}}],
tool_choice=tool_choice,
)
)
assert chat.parallel_tool_calls is parallel_tool_calls


@pytest.mark.parametrize("choice", [{"type": "any"}, {"type": "tool", "name": "f"}])
Expand Down
Loading
Loading