From 674b651262d8890aec2819c61bdf3fad167672b2 Mon Sep 17 00:00:00 2001 From: tensorrt-cicd <90828364+tensorrt-cicd@users.noreply.github.com> Date: Sat, 30 May 2026 02:04:23 -0700 Subject: [PATCH 1/3] [nvbugs/6240584][fix] Qwen3ToolParser: fall back to bare-JSON parsing in detect_and_parse Some Qwen3 chat templates (notably Qwen3.6 FP8 served with --reasoning_parser qwen3_5 --tool_parser qwen3) emit tool calls as bare JSON objects without the ... wrapper once the reasoning parser strips the block. detect_and_parse() previously returned calls=[] unconditionally whenever the bot_token ("\n") was missing, so those tool calls leaked out as message.content with finish_reason="stop". Try json.loads + parse_base_json before falling through to the original normal_text return. Streaming (parse_streaming_increment) and has_tool_call() are intentionally unchanged. Signed-off-by: tensorrt-cicd <90828364+tensorrt-cicd@users.noreply.github.com> --- tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py b/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py index 298389f47e19..216e7816e5e1 100644 --- a/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py +++ b/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py @@ -52,6 +52,16 @@ def detect_and_parse(self, text: str, idx = text.find(self.bot_token) normal_text = text[:idx].strip() if idx != -1 else text if self.bot_token not in text: + # Some Qwen3 chat templates (e.g. Qwen3.6 FP8 with thinking enabled) + # emit tool calls as bare JSON without a wrapper. Try to + # recover those before dropping the text into normal_text. + try: + parsed = json.loads(text.strip()) + calls = self.parse_base_json(parsed, tools) + if calls: + return StreamingParseResult(normal_text="", calls=calls) + except (json.JSONDecodeError, AttributeError): + pass return StreamingParseResult(normal_text=normal_text, calls=[]) # Find all \n...\n blocks From a845973ff830b6720e61e8996f7b6926bf750345 Mon Sep 17 00:00:00 2001 From: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:29:26 +0000 Subject: [PATCH 2/3] [https://nvbugs/6240584][fix] Qwen3ToolParser: close 4 gaps in bare-JSON fallback This stacks on the initial fix in this PR and closes four gaps: 1) detect_and_parse: tighten exception handling. Replace `except (JSONDecodeError, AttributeError)` with an explicit `isinstance(parsed, (dict, list))` guard so bare JSON scalars (e.g. "42", null) fall through cleanly without relying on AttributeError. 2) parse_streaming_increment: extend the streaming path with the same bare-JSON fallback. When the stream never contains `` but the accumulated buffer parses as a JSON object matching `{"name": ..., "arguments"/"parameters": ...}`, emit the tool call using the base-class streaming pattern (name-first with empty params, then arguments). Unblocks streaming clients on Qwen3.6 FP8 which otherwise saw the tool call leak into `delta.content` with `finish_reason="stop"`. Wrapped happy path is preserved by routing to the existing base implementation once `` is detected. 3) has_tool_call() semantics unchanged. It still strictly gates on `` for callers using it as a wrapper detector; the bare-JSON fallback lives inside detect_and_parse / parse_streaming_increment. 4) Tests. Unit tests exercise the bare-JSON fallback on detect_and_parse (dict, list, `parameters` key, non-JSON text, JSON scalar, malformed JSON) and on parse_streaming_increment (single chunk, split chunks, no content leak, non-JSON flush, wrapped-form regression). An integration test wires the qwen3_5 reasoning parser plus qwen3 tool parser through the postprocess pipeline using the exact bug-report input `...\\n{...}` and asserts `has_tool_call[0]` is True (so downstream flips `finish_reason` to `tool_calls`). Non-goals honored: no changes to other tool parsers, no changes to openai_protocol.py or the API stability YAML. Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com> --- .../serve/tool_parser/qwen3_tool_parser.py | 139 +++++++++++- .../unittest/llmapi/apps/test_tool_parsers.py | 208 ++++++++++++++++++ 2 files changed, 337 insertions(+), 10 deletions(-) diff --git a/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py b/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py index 216e7816e5e1..4a76235923f1 100644 --- a/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py +++ b/tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py @@ -7,11 +7,12 @@ from ..openai_protocol import ChatCompletionToolsParam as Tool from .base_tool_parser import BaseToolParser -from .core_types import StreamingParseResult, StructureInfo, _GetInfoFunc +from .core_types import (StreamingParseResult, StructureInfo, ToolCallItem, + _GetInfoFunc) class Qwen3ToolParser(BaseToolParser): - """ + r""" Detector for Qwen 2.5 and Qwen 3 model function call format. Format Structure: @@ -23,9 +24,21 @@ class Qwen3ToolParser(BaseToolParser): - Tool Call Tags: `` and `` wrap each individual call - Function Call Object: JSON object with "name" and "arguments" fields + Some Qwen3 chat templates (notably Qwen3.6 FP8 served with + `--reasoning_parser qwen3_5 --tool_parser qwen3`) emit tool calls as bare + JSON objects without the `...` wrapper once the + reasoning parser strips the `` block. Both `detect_and_parse` and + `parse_streaming_increment` fall back to a bare-JSON parse in that case + (see NVBug 6240584). + Reference: https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct?chat_template=default """ + # Streaming decision state for the bare-JSON fallback path. + _STREAM_MODE_UNDECIDED = "undecided" + _STREAM_MODE_WRAPPED = "wrapped" + _STREAM_MODE_BARE_JSON = "bare_json" + def __init__(self): """ Initializes the detector with necessary state variables. @@ -35,9 +48,18 @@ def __init__(self): self.eot_token = "\n" # nosec B105 self.tool_call_separator = "\n" self._normal_text_buffer = "" # Buffer for handling partial end tokens + # Bare-JSON streaming fallback state (see NVBug 6240584). + self._bare_json_buffer = "" + self._stream_mode = self._STREAM_MODE_UNDECIDED def has_tool_call(self, text: str) -> bool: - """Check if the text contains a Qwen 3 format tool call.""" + """Check if the text contains a Qwen 3 format tool call. + + Note: intentionally strict — this only checks for the `` + wrapper. Existing callers rely on it as a wrapped-form gate. The + bare-JSON fallback lives inside `detect_and_parse` / + `parse_streaming_increment` and does not go through this method. + """ return self.bot_token in text def detect_and_parse(self, text: str, @@ -53,15 +75,18 @@ def detect_and_parse(self, text: str, normal_text = text[:idx].strip() if idx != -1 else text if self.bot_token not in text: # Some Qwen3 chat templates (e.g. Qwen3.6 FP8 with thinking enabled) - # emit tool calls as bare JSON without a wrapper. Try to - # recover those before dropping the text into normal_text. + # emit tool calls as bare JSON without a wrapper. Try + # to recover those before dropping the text into normal_text. Use + # an explicit type guard to avoid relying on AttributeError to + # catch scalar JSON like "42" or null. try: parsed = json.loads(text.strip()) + except json.JSONDecodeError: + return StreamingParseResult(normal_text=normal_text, calls=[]) + if isinstance(parsed, (dict, list)): calls = self.parse_base_json(parsed, tools) if calls: return StreamingParseResult(normal_text="", calls=calls) - except (json.JSONDecodeError, AttributeError): - pass return StreamingParseResult(normal_text=normal_text, calls=[]) # Find all \n...\n blocks @@ -81,13 +106,107 @@ def detect_and_parse(self, text: str, def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult: - """ + r""" Streaming incremental parsing for Qwen 3 tool calls. - Uses base class implementation with buffering to handle partial end tokens. + + Handles two shapes: + - Wrapped: `\n{...}\n` — delegates to the base + class implementation, which streams the name first and then the + arguments incrementally. Applies the same partial-end-token + scrubbing as before. + - Bare JSON (NVBug 6240584): when the stream never contains + `` but the accumulated buffer is a complete JSON object + matching `{"name": ..., "arguments"/"parameters": ...}`, parse it + via `parse_base_json` and emit the calls in the same + name-first-then-arguments pattern used by the base class. + + The parser picks a mode once and stays in it for the remainder of the + stream. Modes: + - undecided: buffer input; peek to decide. + - wrapped: route everything through the base implementation. + - bare_json: bare-JSON tool call already emitted; drop trailing text. """ + if self._stream_mode == self._STREAM_MODE_WRAPPED: + return self._wrapped_streaming(new_text, tools) + if self._stream_mode == self._STREAM_MODE_BARE_JSON: + # Tool call already emitted; ignore any trailing text. + return StreamingParseResult() + + # Undecided: buffer and decide. + self._bare_json_buffer += new_text + accumulated = self._bare_json_buffer + + if self.bot_token in accumulated: + # Wrapped form: replay the accumulated buffer through the base + # streaming path. + self._stream_mode = self._STREAM_MODE_WRAPPED + replay = accumulated + self._bare_json_buffer = "" + return self._wrapped_streaming(replay, tools) + + if self._ends_with_partial_token(accumulated, self.bot_token): + # Could still resolve into a wrapped form — keep buffering. + return StreamingParseResult() + + stripped = accumulated.lstrip() + if not stripped: + # Only whitespace so far — keep buffering. + return StreamingParseResult() + + if stripped[0] not in "{[": + # Not JSON-like at all — flush as normal text via wrapped path. + self._stream_mode = self._STREAM_MODE_WRAPPED + replay = accumulated + self._bare_json_buffer = "" + return self._wrapped_streaming(replay, tools) + + # Leading `{` or `[` — could be a bare JSON tool call, or partial. + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + # Not yet complete — buffer more. + return StreamingParseResult() + + if isinstance(parsed, (dict, list)): + calls = self.parse_base_json(parsed, tools) + if calls: + streaming_calls: List[ToolCallItem] = [] + for i, call_item in enumerate(calls): + # Emit name first with empty parameters, then the full + # arguments — mirrors the base class streaming pattern. + streaming_calls.append( + ToolCallItem( + tool_index=i, + name=call_item.name, + parameters="", + )) + if call_item.parameters: + streaming_calls.append( + ToolCallItem( + tool_index=i, + parameters=call_item.parameters, + )) + self._stream_mode = self._STREAM_MODE_BARE_JSON + self._bare_json_buffer = "" + # Best-effort bookkeeping for callers that inspect these. + self.current_tool_id = max(0, len(calls) - 1) + self.current_tool_name_sent = True + return StreamingParseResult(normal_text="", + calls=streaming_calls) + + # Parsed as JSON but not a tool call — flush as normal text via + # wrapped path. + self._stream_mode = self._STREAM_MODE_WRAPPED + replay = accumulated + self._bare_json_buffer = "" + return self._wrapped_streaming(replay, tools) + + def _wrapped_streaming(self, new_text: str, + tools: List[Tool]) -> StreamingParseResult: + """Wrapped-form streaming: base implementation + partial-end-token scrubbing.""" result = super().parse_streaming_increment(new_text, tools) - # Handle partial end tokens that are streamed character by character + # Handle partial end tokens that are streamed character by character. if result.normal_text: self._normal_text_buffer += result.normal_text diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index 3b7e19f3ce55..154757a7d62f 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -797,6 +797,158 @@ def test_qwen3_format_compliance(self, sample_tools, parser): assert result.calls[0].name == "get_weather" assert json.loads(result.calls[0].parameters) == {"location": "Tokyo"} + # ------------------------------------------------------------------ + # NVBug 6240584: bare-JSON fallback in detect_and_parse + # + # Some Qwen3 chat templates (notably Qwen3.6 FP8 with + # `--reasoning_parser qwen3_5 --tool_parser qwen3`) emit tool calls + # as bare JSON, without a `...` wrapper, once + # the reasoning parser strips the `` block. The parser must + # recover those before dropping the text into `normal_text`. + # ------------------------------------------------------------------ + + def test_detect_and_parse_bare_json_dict(self, sample_tools, parser): + """Bare JSON dict without wrapper is parsed as a tool call.""" + text = '{"name":"get_weather","arguments":{"location":"Paris"}}' + + result = parser.detect_and_parse(text, sample_tools) + + assert result.normal_text == "" + assert len(result.calls) == 1 + assert result.calls[0].name == "get_weather" + assert json.loads(result.calls[0].parameters) == {"location": "Paris"} + + def test_detect_and_parse_bare_json_list(self, sample_tools, parser): + """Bare JSON list of tool calls without wrapper is parsed.""" + text = '[{"name":"get_weather","arguments":{"location":"Paris"}}]' + + result = parser.detect_and_parse(text, sample_tools) + + assert result.normal_text == "" + assert len(result.calls) == 1 + assert result.calls[0].name == "get_weather" + assert json.loads(result.calls[0].parameters) == {"location": "Paris"} + + def test_detect_and_parse_bare_json_parameters_key(self, sample_tools, + parser): + """Bare JSON with `parameters` (instead of `arguments`) is still parsed.""" + text = '{"name":"get_weather","parameters":{"location":"Paris"}}' + + result = parser.detect_and_parse(text, sample_tools) + + assert len(result.calls) == 1 + assert result.calls[0].name == "get_weather" + assert json.loads(result.calls[0].parameters) == {"location": "Paris"} + + def test_detect_and_parse_non_json_text_falls_through( + self, sample_tools, parser): + """Plain non-JSON text passes through as normal_text with no calls.""" + text = "Hello world" + + result = parser.detect_and_parse(text, sample_tools) + + assert result.normal_text == "Hello world" + assert result.calls == [] + + def test_detect_and_parse_bare_json_scalar_falls_through( + self, sample_tools, parser): + """A JSON scalar (e.g. `"42"`) must fall through cleanly, not crash. + + This exercises the explicit `isinstance(parsed, (dict, list))` guard — + `parse_base_json` would raise `AttributeError` on a bare int, so the + guard prevents relying on exception catching for scalar JSON. + """ + text = "42" + + result = parser.detect_and_parse(text, sample_tools) + + assert result.calls == [] + # No crash is the important part. + + def test_detect_and_parse_malformed_bare_json_falls_through( + self, sample_tools, parser): + """Malformed JSON without wrapper falls through cleanly.""" + text = '{"name": "get_weather", "arguments": MALFORMED}' + + result = parser.detect_and_parse(text, sample_tools) + + assert result.calls == [] + assert result.normal_text == text + + # ------------------------------------------------------------------ + # NVBug 6240584: bare-JSON fallback in parse_streaming_increment + # + # The streaming path must also recover bare-JSON tool calls when the + # `` wrapper never appears. Without this, streaming clients + # receive the JSON as `delta.content` with `finish_reason="stop"`. + # ------------------------------------------------------------------ + + def test_streaming_bare_json_one_chunk(self, sample_tools, parser): + """A complete bare-JSON tool call arriving in a single chunk emits calls.""" + result = parser.parse_streaming_increment( + '{"name":"get_weather","arguments":{"city":"Paris"}}', sample_tools) + + names = [c.name for c in result.calls if c.name] + assert "get_weather" in names + params = "".join(c.parameters for c in result.calls if c.parameters) + assert "Paris" in params + assert result.normal_text == "" + + def test_streaming_bare_json_split_across_chunks(self, sample_tools, + parser): + """Bare-JSON tool call split across multiple chunks parses on completion.""" + r1 = parser.parse_streaming_increment('{"name":"get_', sample_tools) + r2 = parser.parse_streaming_increment('weather","arguments":', + sample_tools) + r3 = parser.parse_streaming_increment('{"city":"Paris"}}', sample_tools) + + all_calls = list(r1.calls) + list(r2.calls) + list(r3.calls) + names = [c.name for c in all_calls if c.name] + assert "get_weather" in names + params = "".join(c.parameters for c in all_calls if c.parameters) + assert "Paris" in params + + def test_streaming_bare_json_does_not_leak_content(self, sample_tools, + parser): + """After a bare-JSON tool call is emitted, trailing text is not leaked. + + This must be the case even for subsequent empty/whitespace chunks: + leaking any normal_text would flip `finish_reason` back to `stop`. + """ + r1 = parser.parse_streaming_increment( + '{"name":"get_weather","arguments":{"city":"Paris"}}', sample_tools) + # Any subsequent chunks must not emit normal_text either. + r2 = parser.parse_streaming_increment("", sample_tools) + + assert r1.normal_text == "" + assert r2.normal_text == "" + + def test_streaming_non_json_text_flushed_as_normal(self, sample_tools, + parser): + """Non-JSON text without a wrapper is flushed to normal_text.""" + result = parser.parse_streaming_increment("Hello world", sample_tools) + + assert result.normal_text == "Hello world" + assert result.calls == [] + + def test_streaming_wrapped_form_unregressed(self, sample_tools, parser): + """The pre-existing wrapped-form streaming path continues to work.""" + # Send bot token. + parser.parse_streaming_increment("\n", sample_tools) + + # Partial JSON with name -> emits name with empty params. + r_name = parser.parse_streaming_increment('{"name":"get_weather"', + sample_tools) + assert len(r_name.calls) == 1 + assert r_name.calls[0].name == "get_weather" + assert r_name.calls[0].parameters == "" + + # Complete the JSON and the wrapper. + r_args = parser.parse_streaming_increment( + ',"arguments":{"location":"SF"}}\n', sample_tools) + assert len(r_args.calls) == 1 + assert json.loads(r_args.calls[0].parameters) == {"location": "SF"} + class TestQwen3CoderToolParser(BaseToolParserTestClass): """Test suite for Qwen3CoderToolParser class.""" @@ -2691,6 +2843,62 @@ def test_auto_detect_laguna(self, tmp_path): class TestToolParserIntegration: """Integration tests for tool parsers.""" + def test_qwen3_5_reasoning_plus_qwen3_tool_parser_bare_json_pipeline( + self, sample_tools): + """NVBug 6240584: end-to-end reasoning + tool parser pipeline. + + Reproduces the exact scenario from the bug report: the model emits + `...` followed by a bare JSON tool call (no + `` wrapper). The `qwen3_5` reasoning parser strips the + thinking block, then the `qwen3` tool parser must recover the tool + call so `args.has_tool_call[0]` is True and downstream logic sets + `finish_reason="tool_calls"` (see chat_response_post_processor). + """ + from tensorrt_llm.serve.openai_protocol import ChatCompletionRequest + from tensorrt_llm.serve.postprocess_handlers import ( + ChatPostprocArgs, apply_reasoning_parser, apply_tool_parser) + + # Bug-report input: reasoning block + bare JSON tool call. + text = ('Reasoning here.\n' + '{"name":"get_weather","arguments":{"city":"Paris"}}') + + # Build a minimal request so we can construct ChatPostprocArgs. + req = ChatCompletionRequest( + model="Qwen/Qwen3.6-27B-FP8", + messages=[{ + "role": "user", + "content": "What is the weather in Paris?" + }], + tools=sample_tools, + ) + args = ChatPostprocArgs.from_request(req) + args.reasoning_parser = "qwen3_5" + args.tool_parser = "qwen3" + + # Non-streaming path. + content, reasoning_content = apply_reasoning_parser(args, + output_index=0, + text=text, + streaming=False) + assert reasoning_content == "Reasoning here." + # The reasoning parser strips `...` — the remaining + # content is the bare JSON, possibly with a leading newline. + assert '"name":"get_weather"' in content + + normal_text, calls = apply_tool_parser(args, + output_index=0, + text=content, + streaming=False) + + assert len(calls) == 1 + assert calls[0].name == "get_weather" + assert json.loads(calls[0].parameters) == {"city": "Paris"} + # Downstream (chat_response_post_processor) checks this flag to flip + # finish_reason from "stop" to "tool_calls". + assert args.has_tool_call.get(0) is True + # And no bare JSON leaks into the visible content. + assert normal_text == "" + def test_end_to_end_single_tool(self, sample_tools): """Test end-to-end parsing of a single tool call.""" parser = Qwen3ToolParser() From 1ef4ab3bec075ba392b8420bf7c2940cff913702 Mon Sep 17 00:00:00 2001 From: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:28:39 +0000 Subject: [PATCH 3/3] [https://nvbugs/6240584][test] align integration test with qwen3_5 protocol qwen3_5 is registered with `reasoning_at_start=True` because the Qwen3.6 chat template pre-injects `\n` into the assistant prompt prefix, so model output starts inside the reasoning block with no opening `` tag. `DeepSeekR1Parser.parse` in that mode only partitions on ``; it never strips a leading ``. The integration test was feeding `Reasoning here.\n{...}` which does not match that protocol: the leading `` was preserved in `reasoning_content`, so the `reasoning_content == "Reasoning here."` assertion failed on CI. Update the input to match the real production shape (Reasoning content + `` sentinel + bare JSON tool call) so the test exercises what NVBug 6240584 actually describes. Runtime fix in qwen3_tool_parser.py is unchanged and continues to cover the bug scenario. Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com> --- .../unittest/llmapi/apps/test_tool_parsers.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index 154757a7d62f..b2f519b95060 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -2845,21 +2845,27 @@ class TestToolParserIntegration: def test_qwen3_5_reasoning_plus_qwen3_tool_parser_bare_json_pipeline( self, sample_tools): - """NVBug 6240584: end-to-end reasoning + tool parser pipeline. - - Reproduces the exact scenario from the bug report: the model emits - `...` followed by a bare JSON tool call (no - `` wrapper). The `qwen3_5` reasoning parser strips the - thinking block, then the `qwen3` tool parser must recover the tool - call so `args.has_tool_call[0]` is True and downstream logic sets + r"""NVBug 6240584: end-to-end reasoning + tool parser pipeline. + + Reproduces the exact scenario from the bug report: the Qwen3.6 FP8 + chat template pre-injects `\n` into the assistant prompt + prefix, so the model output starts *inside* the reasoning block + with no opening `` tag. Content up to `` is the + reasoning, and what follows is a bare JSON tool call (no + `` wrapper). The `qwen3_5` reasoning parser (registered + with `reasoning_at_start=True`) strips the thinking block, then + the `qwen3` tool parser must recover the tool call so + `args.has_tool_call[0]` is True and downstream logic sets `finish_reason="tool_calls"` (see chat_response_post_processor). """ from tensorrt_llm.serve.openai_protocol import ChatCompletionRequest from tensorrt_llm.serve.postprocess_handlers import ( ChatPostprocArgs, apply_reasoning_parser, apply_tool_parser) - # Bug-report input: reasoning block + bare JSON tool call. - text = ('Reasoning here.\n' + # Bug-report input: reasoning content (no leading `` — the + # chat template already injected it into the prompt prefix) followed + # by a bare JSON tool call. + text = ('Reasoning here.\n' '{"name":"get_weather","arguments":{"city":"Paris"}}') # Build a minimal request so we can construct ChatPostprocArgs.