[ROCm][CI][Bugfix] Fix flaky parallel tool-call streaming (test assertion + Mistral/Granite parsers) (#47550)

Signed-off-by: Aakif Nawaz <aakif.nawaz@amd.com> Signed-off-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>

akii96 committed Jul 7, 2026 at 02:06 UTC 5769a7382cb111288a9c2927342ca908943b9793
6 files changed +142 -47
tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py
+23 -11
@@ -183,27 +183,39 @@ async def test_thinking_token_budget_mixed_requests(client: openai.AsyncOpenAI):
183 async def test_thinking_token_budget_limits_reasoning(client: openai.AsyncOpenAI):
184 """Test that thinking_token_budget limits the number of reasoning tokens.
185
186 - Counts non-empty streaming ``delta.reasoning`` chunks (coarse proxy; each
187 - chunk may represent multiple decode tokens — see
188 - ``_count_reasoning_decode_token_ids_between_markers`` and the Qwen3.5 MTP
189 - test for id-based checks).
186 + Counts reasoning decode tokens by id, which is robust to how tokens are
187 + grouped into streamed chunks (a single chunk can carry several tokens under
188 + async scheduling / stream_interval > 1). Counting chunks under-counts.
189 """
190
192 - reasoning_token_count = 0
191 + tokenizer = get_tokenizer(tokenizer_name=MODEL_NAME)
192 + start_ids = list(tokenizer.encode(REASONING_START_STR, add_special_tokens=False))
193 + end_ids = list(tokenizer.encode(REASONING_END_STR, add_special_tokens=False))
194 +
195 + prompt_token_ids: list[int] = []
196 + decode_token_ids: list[int] = []
197 stream = await client.chat.completions.create(
198 model=MODEL_NAME,
199 messages=MESSAGES,
200 max_tokens=100,
201 stream=True,
198 - extra_body={"thinking_token_budget": THINK_BUDGET},
202 + extra_body={"thinking_token_budget": THINK_BUDGET, "return_token_ids": True},
203 )
204 async for chunk in stream:
201 - delta = chunk.choices[0].delta
202 - if getattr(delta, "reasoning", None):
203 - reasoning_token_count += 1
204 -
205 + if not chunk.choices:
206 + continue
207 + if getattr(chunk, "prompt_token_ids", None):
208 + prompt_token_ids = list(chunk.prompt_token_ids)
209 + delta_ids = getattr(chunk.choices[0], "token_ids", None)
210 + if delta_ids:
211 + decode_token_ids.extend(delta_ids)
212 +
213 + reasoning_token_count = _count_reasoning_decode_token_ids_between_markers(
214 + prompt_token_ids + decode_token_ids, start_ids, end_ids
215 + )
216 + assert reasoning_token_count is not None, "missing reasoning start marker in ids"
217 assert reasoning_token_count == THINK_BUDGET, (
206 - f"reasoning tokens ({reasoning_token_count}) exceeded "
218 + f"reasoning tokens ({reasoning_token_count}) != "
219 f"thinking_token_budget ({THINK_BUDGET})"
220 )
221
tests/tool_parsers/test_granite_tool_parser.py
+44 -1
@@ -2,13 +2,21 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
5 +import json
6 +
7 import pytest
8
9 from tests.tool_parsers.common_tests import (
10 ToolParserTestConfig,
11 ToolParserTests,
12 )
11 -from tests.tool_parsers.utils import run_tool_extraction
13 +from tests.tool_parsers.utils import (
14 + run_tool_extraction,
15 + run_tool_extraction_streaming,
16 + split_string_into_token_deltas,
17 +)
18 +from vllm.tokenizers import get_tokenizer
19 +from vllm.tool_parsers.granite_tool_parser import GraniteToolParser
20
21
22 class TestGraniteToolParser(ToolParserTests):
@@ -116,3 +124,38 @@ I'll get that information.""",
124 f"Expected 1 tool call from string format, got {len(tool_calls)}"
125 )
126 assert tool_calls[0].function.name == "get_weather"
127 +
128 +
129 +# granite emits arguments before name and its own tokenizer (not gpt2) is used
130 +# here so the token boundaries match production; get_tokenizer only fetches the
131 +# small tokenizer files, not the model weights.
132 +@pytest.fixture(scope="module")
133 +def granite_tokenizer():
134 + return get_tokenizer(tokenizer_name="ibm-granite/granite-3.1-8b-instruct")
135 +
136 +
137 +@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5])
138 +def test_streaming_parallel_calls_batched_deltas(granite_tokenizer, chunk_size):
139 + """A batched delta (multiple tokens) spanning the boundary between two
140 + parallel calls must not drop the first call's name. granite streams
141 + arguments before name, so the name only completes as the next call appears.
142 + """
143 + parser = GraniteToolParser(granite_tokenizer)
144 + model_output = (
145 + '<|tool_call|> [{"arguments": {"city": "Tokyo"}, "name": "get_weather"}, '
146 + '{"arguments": {"timezone": "Asia/Tokyo"}, "name": "get_time"}]'
147 + )
148 + token_deltas = split_string_into_token_deltas(granite_tokenizer, model_output)
149 + batched = [
150 + "".join(token_deltas[i : i + chunk_size])
151 + for i in range(0, len(token_deltas), chunk_size)
152 + ]
153 + reconstructor = run_tool_extraction_streaming(
154 + parser, batched, assert_one_tool_per_delta=False
155 + )
156 + names = [tc.function.name for tc in reconstructor.tool_calls]
157 + assert names == ["get_weather", "get_time"]
158 + # trailing args of the final call are flushed by the serving layer
159 + assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
160 + "city": "Tokyo"
161 + }
tests/tool_parsers/test_mistral_tool_parser.py
+60 -13
@@ -144,6 +144,7 @@ def stream_delta_message_generator(
144 mistral_tokenizer: TokenizerLike,
145 model_output: str | None,
146 tools: list[tuple[str, str]] | None,
147 + chunk_size: int = 1,
148 ) -> Generator[DeltaMessage, None, None]:
149 if (
150 isinstance(mistral_tokenizer, MistralTokenizer)
@@ -182,15 +183,13 @@ def stream_delta_message_generator(
183 previous_tokens = None
184 prefix_offset = 0
185 read_offset = 0
186 + pending_text = ""
187 + pending_token_ids: list[int] = []
188 for i, delta_token in enumerate(all_token_ids):
186 - delta_token_ids = [delta_token]
187 - previous_token_ids = all_token_ids[:i]
188 - current_token_ids = all_token_ids[: i + 1]
189 -
189 (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
190 detokenize_incrementally(
191 tokenizer=mistral_tokenizer,
193 - all_input_ids=current_token_ids,
192 + all_input_ids=all_token_ids[: i + 1],
193 prev_tokens=previous_tokens,
194 prefix_offset=prefix_offset,
195 read_offset=read_offset,
@@ -198,27 +197,39 @@ def stream_delta_message_generator(
197 spaces_between_special_tokens=True,
198 )
199 )
200 + previous_tokens = (
201 + previous_tokens + new_tokens if previous_tokens else new_tokens
202 + )
203 + prefix_offset = new_prefix_offset
204 + read_offset = new_read_offset
205
202 - current_text = previous_text + delta_text
206 + # Buffer tokens so each streamed delta can carry ``chunk_size`` tokens,
207 + # reproducing the multi-token deltas produced by async scheduling /
208 + # stream_interval > 1.
209 + pending_text += delta_text
210 + pending_token_ids.append(delta_token)
211 + if len(pending_token_ids) < chunk_size and i != len(all_token_ids) - 1:
212 + continue
213 +
214 + previous_token_ids = all_token_ids[: i + 1 - len(pending_token_ids)]
215 + current_token_ids = all_token_ids[: i + 1]
216 + current_text = previous_text + pending_text
217
218 delta_message = mistral_tool_parser.extract_tool_calls_streaming(
219 previous_text,
220 current_text,
207 - delta_text,
221 + pending_text,
222 previous_token_ids,
223 current_token_ids,
210 - delta_token_ids,
224 + pending_token_ids,
225 request=_DUMMY_REQUEST,
226 )
227 if delta_message:
228 yield delta_message
229
230 previous_text = current_text
217 - previous_tokens = (
218 - previous_tokens + new_tokens if previous_tokens else new_tokens
219 - )
220 - prefix_offset = new_prefix_offset
221 - read_offset = new_read_offset
231 + pending_text = ""
232 + pending_token_ids = []
233
234
235 @pytest.mark.parametrize(
@@ -1572,3 +1583,39 @@ def test_grammar_from_tool_parser_set_by_adjust_request(
1583 request = _make_request()
1584 result = mistral_tool_parser.adjust_request(request)
1585 assert result._grammar_from_tool_parser is True
1586 +
1587 +
1588 +@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5])
1589 +def test_streaming_pre_v11_parallel_calls_batched_deltas(
1590 + mistral_pre_v11_tool_parser, mistral_pre_v11_tokenizer, chunk_size
1591 +):
1592 + """A batched delta spanning the boundary between two parallel calls must
1593 + keep them on distinct indices (the bug collapsed both onto index 0)."""
1594 + model_output = (
1595 + '[TOOL_CALLS] [{"name": "add", "arguments": {"a": 3.5, "b": 4}}, '
1596 + '{"name": "get_current_weather", "arguments": '
1597 + '{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]'
1598 + )
1599 + names: list[str] = []
1600 + args: list[str] = []
1601 + idx = -1
1602 + for delta_message in stream_delta_message_generator(
1603 + mistral_pre_v11_tool_parser,
1604 + mistral_pre_v11_tokenizer,
1605 + model_output,
1606 + tools=None,
1607 + chunk_size=chunk_size,
1608 + ):
1609 + for tool_call in delta_message.tool_calls or []:
1610 + if tool_call.index != idx:
1611 + idx = tool_call.index
1612 + args.append("")
1613 + if tool_call.function and tool_call.function.name:
1614 + names.append(tool_call.function.name)
1615 + if tool_call.function and tool_call.function.arguments:
1616 + args[tool_call.index] += tool_call.function.arguments
1617 +
1618 + assert names == ["add", "get_current_weather"]
1619 + assert len(args) == 2
1620 + # trailing args of the final call are flushed by the serving layer
1621 + assert json.loads(args[0]) == {"a": 3.5, "b": 4}
tests/tool_use/test_parallel_tool_calls.py
+4 -6
@@ -115,14 +115,12 @@ async def test_parallel_tool_calls(
115 assert not role_name or role_name == "assistant"
116 role_name = "assistant"
117
118 - # if a tool call is streamed make sure there's exactly one
119 - # (based on the request parameters
118 + # a chunk may carry >1 tool-call delta at a parallel-call boundary
119 streamed_tool_calls = chunk.choices[0].delta.tool_calls
120
122 - if streamed_tool_calls and len(streamed_tool_calls) > 0:
123 - # make sure only one diff is present - correct even for parallel
124 - assert len(streamed_tool_calls) == 1
125 - tool_call = streamed_tool_calls[0]
121 + for tool_call in streamed_tool_calls or []:
122 + # deltas arrive in non-decreasing index order
123 + assert tool_call.index >= tool_call_idx
124
125 # if a new tool is being called, set up empty arguments
126 if tool_call.index != tool_call_idx:
vllm/tool_parsers/granite_tool_parser.py
+6 -4
@@ -154,9 +154,11 @@ class GraniteToolParser(ToolParser):
154 current_tool_call: dict = tool_call_arr[self.current_tool_id]
155
156 delta = None
157 - # case: we are starting a new tool in the array
158 - # -> array has > 0 length AND length has moved past cursor
159 - if len(tool_call_arr) > self.current_tool_id + 1:
157 + # Only advance once the current tool name is streamed; granite
158 + # emits arguments before name, so advancing early would drop it.
159 + if len(tool_call_arr) > self.current_tool_id + 1 and (
160 + self.current_tool_id < 0 or self.current_tool_name_sent
161 + ):
162 # if we're moving on to a new call, first make sure we
163 # haven't missed anything in the previous one that was
164 # auto-generated due to JSON completions, but wasn't
@@ -184,7 +186,7 @@ class GraniteToolParser(ToolParser):
186 )
187
188 # re-set stuff pertaining to progress in the current tool
187 - self.current_tool_id = len(tool_call_arr) - 1
189 + self.current_tool_id += 1
190 self.current_tool_name_sent = False
191 self.streamed_args_for_tool.append("")
192 logger.debug("starting on new tool %d", self.current_tool_id)
vllm/tool_parsers/mistral_tool_parser.py
+5 -12
@@ -533,6 +533,7 @@ class MistralToolParser(ToolParser):
533
534 if prefix == "item" and event == "start_map":
535 self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY
536 + self.starting_new_tool = True
537 if prefix == "item" and event == "map_key" and value == "name":
538 self.streaming_state = StreamingState.PARSING_NAME
539 if prefix == "item.name" and event == "string":
@@ -640,18 +641,10 @@ class MistralToolParser(ToolParser):
641
642 # Given the parsed text and the possible streaming state change,
643 # let's add to the tool delta
643 - if (
644 - (streaming_state_before_parse != self.streaming_state)
645 - and streaming_state_before_parse
646 - in [StreamingState.WAITING_FOR_TOOL_START, StreamingState.TOOL_COMPLETE]
647 - and self.streaming_state
648 - not in [
649 - StreamingState.ALL_TOOLS_COMPLETE,
650 - StreamingState.TOOL_COMPLETE,
651 - StreamingState.WAITING_FOR_TOOL_START,
652 - ]
653 - ):
654 - # starting a new tool call
644 + # start_map is the authoritative new-tool signal and survives
645 + # batched deltas, unlike comparing pre/post streaming states
646 + if self.starting_new_tool:
647 + self.starting_new_tool = False
648 if current_tool_call_modified:
649 if self.current_tool_mistral_id is not None:
650 current_tool_call.id = self.current_tool_mistral_id