[Bugfix][Tool Parser] Migrate Granite to the streaming Parser Engine (#49648)

Signed-off-by: Nikhil Kulkarni <nikhilkulkarni1755@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

Nikhil Kulkarni committed Sep 23, 2026 at 03:37 UTC 15ed1262e70873a65582d82f7973784a0f11e5a2
14 files changed +631 -369
tests/parser/engine/test_delegating_replay.py
+4
@@ -99,6 +99,10 @@ def _discover_pairings() -> list[_PairingInfo]:
99 # terminal matching; combined-parser replay coverage lives in
100 # test_inkling.py.
101 continue
102 + if cfg.name == "granite":
103 + # Granite has a JSON-array tool body with no TOOL_END terminal;
104 + # its replay coverage lives in test_granite.py.
105 + continue
106
107 parser_cls = type(
108 f"_Delegating{engine_cls.__name__}",
tests/parser/engine/test_granite.py new
+234
@@ -0,0 +1,234 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for the engine-based Granite parser.
4 +
5 +Granite emits tool calls as a JSON array following a marker
6 +(``<|tool_call|>`` for 3.0, ``<tool_call>`` for 3.1) with no closing
7 +terminal; the ``]`` ends the region and surrounding prose is content. The
8 +engine's ``tool_call_body_array`` mode splits the array into one call per
9 +element and ``_granite_arg_converter`` carves each element's ``arguments``.
10 +"""
11 +
12 +import json
13 +
14 +import pytest
15 +
16 +from tests.parser.engine.conftest import make_mock_tokenizer
17 +from tests.parser.engine.trace_builder import _GRANITE_SCENARIOS, _build_granite
18 +from vllm.parser.granite import GraniteParser, _granite_arg_converter
19 +
20 +TOOL_TOKEN = "<|tool_call|>"
21 +TOOL_STRING = "<tool_call>"
22 +
23 +_GRANITE_VOCAB = {TOOL_TOKEN: 49154}
24 +
25 +
26 +@pytest.fixture
27 +def mock_tokenizer():
28 + return make_mock_tokenizer(_GRANITE_VOCAB)
29 +
30 +
31 +@pytest.fixture
32 +def parser(mock_tokenizer):
33 + return GraniteParser(mock_tokenizer)
34 +
35 +
36 +def _tokenize(text: str) -> list[tuple[int, str]]:
37 + """Tokenize like the real stream: ``<|tool_call|>`` is one special token,
38 + plain text becomes one token per character."""
39 + tokens: list[tuple[int, str]] = []
40 + i = 0
41 + while i < len(text):
42 + if text.startswith(TOOL_TOKEN, i):
43 + tokens.append((_GRANITE_VOCAB[TOOL_TOKEN], TOOL_TOKEN))
44 + i += len(TOOL_TOKEN)
45 + else:
46 + tokens.append((ord(text[i]), text[i]))
47 + i += 1
48 + return tokens
49 +
50 +
51 +def _stream(parser, request, text: str, chunk_size: int):
52 + tokens = _tokenize(text)
53 + results = []
54 + previous_text = ""
55 + previous_token_ids: list[int] = []
56 + for start in range(0, len(tokens), chunk_size):
57 + batch = tokens[start : start + chunk_size]
58 + delta_text = "".join(t for _, t in batch)
59 + delta_token_ids = [tid for tid, _ in batch]
60 + current_text = previous_text + delta_text
61 + current_token_ids = previous_token_ids + delta_token_ids
62 + delta = parser.extract_tool_calls_streaming(
63 + previous_text=previous_text,
64 + current_text=current_text,
65 + delta_text=delta_text,
66 + previous_token_ids=tuple(previous_token_ids),
67 + current_token_ids=tuple(current_token_ids),
68 + delta_token_ids=tuple(delta_token_ids),
69 + request=request,
70 + )
71 + results.append(delta)
72 + previous_text = current_text
73 + previous_token_ids = current_token_ids
74 + finish = parser.finish_streaming()
75 + if finish is not None:
76 + results.append(finish)
77 + return results
78 +
79 +
80 +def _collect_content(results) -> str:
81 + return "".join(d.content for d in results if d and d.content)
82 +
83 +
84 +def _collect_names(results) -> list[str]:
85 + names: dict[int, str] = {}
86 + for d in results:
87 + if not (d and d.tool_calls):
88 + continue
89 + for tc in d.tool_calls:
90 + if tc.function and tc.function.name:
91 + names[tc.index] = names.get(tc.index, "") + tc.function.name
92 + return [names[i] for i in sorted(names)]
93 +
94 +
95 +def _collect_args(results) -> dict[int, str]:
96 + args: dict[int, str] = {}
97 + for d in results:
98 + if not (d and d.tool_calls):
99 + continue
100 + for tc in d.tool_calls:
101 + if tc.function and tc.function.arguments:
102 + args[tc.index] = args.get(tc.index, "") + tc.function.arguments
103 + return args
104 +
105 +
106 +class TestArgConverter:
107 + def test_complete_wrapper(self):
108 + raw = '{"name": "get_weather", "arguments": {"city": "SF"}}'
109 + assert _granite_arg_converter(raw, False) == '{"city": "SF"}'
110 +
111 + def test_arguments_before_name(self):
112 + raw = '{"arguments": {"city": "SF"}, "name": "get_weather"}'
113 + assert _granite_arg_converter(raw, False) == '{"city": "SF"}'
114 +
115 + def test_partial_before_arguments(self):
116 + assert _granite_arg_converter('{"name": "get_w', True) == ""
117 +
118 + def test_partial_inside_arguments(self):
119 + raw = '{"name": "x", "arguments": {"a": 1'
120 + assert _granite_arg_converter(raw, True) == '{"a": 1'
121 +
122 + def test_prefix_stability(self):
123 + # Each growing prefix must extend the previous converter output.
124 + full = '{"name": "x", "arguments": {"city": "Tokyo"}}'
125 + prev = ""
126 + for i in range(1, len(full) + 1):
127 + out = _granite_arg_converter(full[:i], True)
128 + assert out.startswith(prev) or prev.startswith(out) or out == ""
129 + if out:
130 + prev = out
131 +
132 + def test_missing_arguments_defaults(self):
133 + assert _granite_arg_converter('{"name": "x"}', False) == "{}"
134 + assert _granite_arg_converter('{"name": "x"}', True) == ""
135 +
136 + def test_non_object_arguments_rejected(self):
137 + with pytest.raises(ValueError, match="JSON object"):
138 + _granite_arg_converter('{"name": "x", "arguments": [1]}', False)
139 +
140 +
141 +class TestNonStreaming:
142 + def test_plain_text(self, parser, mock_request):
143 + reasoning, content, tools = parser.parse("Just a reply.", mock_request)
144 + assert reasoning is None
145 + assert content == "Just a reply."
146 + assert tools is None
147 +
148 + def test_single_tool_token_marker(self, parser, mock_request):
149 + text = (
150 + f'{TOOL_TOKEN} [{{"name": "get_weather", "arguments": {{"city": "SF"}}}}]'
151 + )
152 + _, content, tools = parser.parse(text, mock_request)
153 + assert content is None
154 + assert [t.name for t in tools] == ["get_weather"]
155 + assert json.loads(tools[0].arguments) == {"city": "SF"}
156 +
157 + def test_single_tool_string_marker(self, parser, mock_request):
158 + # Granite 3.1 uses the plain-text ``<tool_call>`` marker.
159 + text = f'{TOOL_STRING} [{{"name": "get_time", "arguments": {{}}}}]'
160 + _, content, tools = parser.parse(text, mock_request)
161 + assert [t.name for t in tools] == ["get_time"]
162 + assert json.loads(tools[0].arguments) == {}
163 +
164 + def test_parallel_calls_one_array(self, parser, mock_request):
165 + text = (
166 + f'{TOOL_TOKEN} [{{"name": "a", "arguments": {{"x": 1}}}}, '
167 + f'{{"name": "b", "arguments": {{"y": [1, 2]}}}}]'
168 + )
169 + _, _, tools = parser.parse(text, mock_request)
170 + assert [t.name for t in tools] == ["a", "b"]
171 + assert json.loads(tools[0].arguments) == {"x": 1}
172 + assert json.loads(tools[1].arguments) == {"y": [1, 2]}
173 +
174 + def test_surrounding_text_is_content(self, parser, mock_request):
175 + text = (
176 + "Let me check.\n"
177 + f'{TOOL_TOKEN} [{{"name": "get_weather", "arguments": {{"city": "SF"}}}}]'
178 + )
179 + _, content, tools = parser.parse(text, mock_request)
180 + assert content == "Let me check."
181 + assert [t.name for t in tools] == ["get_weather"]
182 +
183 + def test_arguments_before_name(self, parser, mock_request):
184 + text = (
185 + f'{TOOL_TOKEN} [{{"arguments": {{"city": "SF"}}, "name": "get_weather"}}]'
186 + )
187 + _, _, tools = parser.parse(text, mock_request)
188 + assert [t.name for t in tools] == ["get_weather"]
189 + assert json.loads(tools[0].arguments) == {"city": "SF"}
190 +
191 + def test_marker_without_array_is_not_a_tool_call(self, parser, mock_request):
192 + # A non-array body after the marker (malformed) yields no tool call.
193 + text = f'{TOOL_TOKEN} {{"name": "func", "arguments": {{}}}}'
194 + _, _, tools = parser.parse(text, mock_request)
195 + assert not tools
196 +
197 +
198 +class TestStreaming:
199 + @pytest.mark.parametrize("chunk_size", [1, 2, 3, 5, 11])
200 + def test_parallel_calls_chunk_invariance(self, parser, mock_request, chunk_size):
201 + text = (
202 + f'{TOOL_TOKEN} [{{"name": "get_weather", '
203 + f'"arguments": {{"city": "Tokyo"}}}}, '
204 + f'{{"name": "get_time", "arguments": {{"timezone": "Asia/Tokyo"}}}}]'
205 + )
206 + results = _stream(parser, mock_request, text, chunk_size)
207 + assert _collect_names(results) == ["get_weather", "get_time"]
208 + args = _collect_args(results)
209 + assert json.loads(args[0]) == {"city": "Tokyo"}
210 + assert json.loads(args[1]) == {"timezone": "Asia/Tokyo"}
211 +
212 + @pytest.mark.parametrize("chunk_size", [1, 3, 7])
213 + def test_surrounding_text_streaming(self, parser, mock_request, chunk_size):
214 + text = (
215 + "Let me check.\n"
216 + f'{TOOL_TOKEN} [{{"name": "get_weather", "arguments": {{"city": "SF"}}}}]'
217 + )
218 + results = _stream(parser, mock_request, text, chunk_size)
219 + assert _collect_names(results) == ["get_weather"]
220 + assert "Let me check." in _collect_content(results)
221 +
222 + def test_incomplete_array_at_eos(self, parser, mock_request):
223 + # Truncated mid-element must not raise and must not leak the marker.
224 + text = f'{TOOL_TOKEN} [{{"name": "func", "arguments": {{"a": 1'
225 + results = _stream(parser, mock_request, text, 3)
226 + assert TOOL_TOKEN not in _collect_content(results)
227 +
228 +
229 +class TestReplay:
230 + @pytest.mark.parametrize("scenario", _GRANITE_SCENARIOS, ids=lambda s: s.id)
231 + def test_replay_scenarios(self, scenario):
232 + # ``_build_granite`` replays the sample through GraniteParser at
233 + # chunk_size=1 and asserts reasoning/content/tool_calls (validate=True).
234 + _build_granite(scenario)
tests/parser/engine/test_replay.py
+5
@@ -78,6 +78,11 @@ def _discover_parsers() -> list[_ParserInfo]:
78 # Inkling opts out of token-id terminal matching and has typed
79 # structural blocks; its replay coverage lives in test_inkling.py.
80 continue
81 + if cfg.name == "granite":
82 + # Granite has a JSON-array tool body with no TOOL_END terminal, so
83 + # it does not fit this token-terminal harness; its replay coverage
84 + # lives in test_granite.py.
85 + continue
86 tool_end = cfg.token_id_terminals.get("TOOL_END")
87 if not tool_end:
88 raise RuntimeError(
tests/parser/engine/trace_builder.py
+85
@@ -34,6 +34,7 @@ from vllm.parser.engine.registered_adapters import (
34 DeepSeekV41Parser,
35 Gemma4Parser,
36 Glm47MoeParser,
37 + GraniteParser,
38 InklingParser,
39 KimiK2Parser,
40 MinimaxM2Parser,
@@ -1042,6 +1043,89 @@ def _build_inkling(scenario: Scenario, validate: bool = True) -> Sample:
1043 return sample
1044
1045
1046 +# ── Granite (JSON-array tool bodies, no reasoning) ───────────────────────
1047 +
1048 +_GRANITE_VOCAB: dict[str, int] = {
1049 + "<|tool_call|>": 49154,
1050 +}
1051 +
1052 +
1053 +def _granite_segments(scenario: Scenario) -> list[tuple[str, bool]]:
1054 + segs: list[tuple[str, bool]] = []
1055 + if scenario.content:
1056 + # Granite has no reasoning; prose is plain content preceding the marker.
1057 + segs.append((scenario.content, False))
1058 + if scenario.tool_calls:
1059 + segs.append(("<|tool_call|>", True))
1060 + payload = json.dumps(
1061 + [
1062 + {"name": tc.name, "arguments": tc.arguments}
1063 + for tc in scenario.tool_calls
1064 + ],
1065 + ensure_ascii=False,
1066 + separators=(", ", ": "),
1067 + )
1068 + segs.append((" " + payload, False))
1069 + return segs
1070 +
1071 +
1072 +def _granite_expected_content(scenario: Scenario) -> str | None:
1073 + if scenario.tool_calls:
1074 + if not scenario.content:
1075 + return None
1076 + return scenario.content.strip() or None
1077 + return scenario.content
1078 +
1079 +
1080 +def _build_granite(scenario: Scenario, validate: bool = True) -> Sample:
1081 + sample = _make_sample(
1082 + sample_id=f"granite-{scenario.id}",
1083 + description=scenario.description,
1084 + vocab=_GRANITE_VOCAB,
1085 + segments=_granite_segments(scenario),
1086 + expected_reasoning=None,
1087 + expected_content=_granite_expected_content(scenario),
1088 + expected_tool_calls=_expected_tc(scenario),
1089 + tools=_expected_tools(scenario),
1090 + )
1091 + if validate:
1092 + _validate_sample(sample, GraniteParser)
1093 + return sample
1094 +
1095 +
1096 +# Granite has no reasoning, so the shared reasoning-centric SCENARIOS do not
1097 +# apply; these exercise the JSON-array tool body (single, parallel, surrounding
1098 +# text) instead.
1099 +_GRANITE_SCENARIOS: list[Scenario] = [
1100 + Scenario(
1101 + id="single-tool",
1102 + description="Single tool call",
1103 + tool_calls=[_READ_TOOL],
1104 + ),
1105 + Scenario(
1106 + id="parallel-tools",
1107 + description="Parallel tool calls in one JSON array",
1108 + tool_calls=[_BASH_TOOL, _WEATHER_TOOL],
1109 + ),
1110 + Scenario(
1111 + id="complex-json-args",
1112 + description="Tool call with nested objects, arrays, numbers, booleans",
1113 + tool_calls=[_COMPLEX_TOOL],
1114 + ),
1115 + Scenario(
1116 + id="content-only",
1117 + description="Plain content response without tool calls",
1118 + content="Hello! How can I help you today?",
1119 + ),
1120 + Scenario(
1121 + id="surrounding-text",
1122 + description="Prose content preceding the tool call",
1123 + content="Let me check the weather.",
1124 + tool_calls=[_WEATHER_TOOL],
1125 + ),
1126 +]
1127 +
1128 +
1129 # ── Registry and public API ──────────────────────────────────────────
1130
1131 _BUILDERS: dict[str, Any] = {
@@ -1049,6 +1133,7 @@ _BUILDERS: dict[str, Any] = {
1133 "deepseek_v4": _build_deepseek_v4,
1134 "deepseek_v41": functools.partial(_build_deepseek_v4, v41=True),
1135 "gemma4": _build_gemma4,
1136 + "granite": _build_granite,
1137 "minimax_m2": _build_minimax_m2,
1138 "nemotron_v3": _build_nemotron_v3,
1139 "seed_oss": _build_seed_oss,
tests/tool_parsers/test_granite_tool_parser.py
+2 -19
@@ -16,7 +16,7 @@ from tests.tool_parsers.utils import (
16 split_string_into_token_deltas,
17 )
18 from vllm.tokenizers import get_tokenizer
19 -from vllm.tool_parsers.granite_tool_parser import GraniteToolParser
19 +from vllm.tool_parsers.granite_engine_tool_parser import GraniteEngineToolParser
20
21
22 class TestGraniteToolParser(ToolParserTests):
@@ -76,23 +76,6 @@ I'll get that information.""",
76 single_tool_call_expected_content=None,
77 parallel_tool_calls_count=2,
78 parallel_tool_calls_names=["get_weather", "get_time"],
79 - # xfail markers
80 - xfail_streaming={
81 - "test_malformed_input": (
82 - "Streaming mode incorrectly creates tool call from malformed JSON"
83 - ),
84 - "test_surrounding_text": (
85 - "Parser doesn't handle surrounding text correctly in streaming"
86 - ),
87 - "test_streaming_reconstruction": (
88 - "Streaming mode doesn't strip <|tool_call|> marker from content"
89 - ),
90 - },
91 - xfail_nonstreaming={
92 - "test_surrounding_text": (
93 - "Parser doesn't handle surrounding text correctly in non-streaming"
94 - ),
95 - },
79 )
80
81 # Granite-Specific Tests
@@ -140,7 +123,7 @@ def test_streaming_parallel_calls_batched_deltas(granite_tokenizer, chunk_size):
123 parallel calls must not drop the first call's name. granite streams
124 arguments before name, so the name only completes as the next call appears.
125 """
143 - parser = GraniteToolParser(granite_tokenizer)
126 + parser = GraniteEngineToolParser(granite_tokenizer)
127 model_output = (
128 '<|tool_call|> [{"arguments": {"city": "Tokyo"}, "name": "get_weather"}, '
129 '{"arguments": {"timezone": "Asia/Tokyo"}, "name": "get_time"}]'
vllm/parser/engine/parser_engine_config.py
+6
@@ -82,6 +82,12 @@ class ParserEngineConfig:
82
83 tool_args_json: bool = True
84
85 + # The tool region body is a single JSON array of ``{"name", "arguments"}``
86 + # objects (one marker → N calls, no closing terminal). The engine splits
87 + # the array into one tool call per element; the array's ``]`` ends the
88 + # region and any trailing text becomes content. Used by Granite.
89 + tool_call_body_array: bool = False
90 +
91 arg_structural_chars: frozenset[str] | None = None
92
93 # Special tokens exempt from auto-drop but not state-machine terminals.
vllm/parser/engine/registered_adapters.py
+6
@@ -13,6 +13,7 @@ from vllm.parser.deepseek_v41 import DeepSeekV41Parser
13 from vllm.parser.engine.adapters import make_adapters
14 from vllm.parser.gemma4 import Gemma4Parser
15 from vllm.parser.glm47_moe import Glm47MoeParser
16 +from vllm.parser.granite import GraniteParser
17 from vllm.parser.inkling import InklingParser
18 from vllm.parser.kimi_k2 import KimiK2Parser
19 from vllm.parser.minimax_m2 import MinimaxM2Parser
@@ -46,6 +47,11 @@ from vllm.parser.seed_oss import SeedOssParser
47 Gemma4ParserToolAdapter,
48 ) = make_adapters(Gemma4Parser)
49
50 +(
51 + GraniteParserReasoningAdapter,
52 + GraniteParserToolAdapter,
53 +) = make_adapters(GraniteParser)
54 +
55 (
56 NemotronV3ParserReasoningAdapter,
57 NemotronV3ParserToolAdapter,
vllm/parser/engine/streaming_parser_engine.py
+102 -1
@@ -190,6 +190,12 @@ class StreamingParserEngine:
190 self._args_in_string: bool = False
191 self._args_escape_next: bool = False
192
193 + def _reset_array_state(self) -> None:
194 + # State for ``tool_call_body_array``: whether the opening ``[`` has been
195 + # seen, and whether a ``{...}`` element is currently being streamed.
196 + self._array_started: bool = False
197 + self._array_in_element: bool = False
198 +
199 def reset(self, initial_state: ParserState | None = None) -> None:
200 """Reset mutable state for reuse across requests.
201
@@ -213,6 +219,7 @@ class StreamingParserEngine:
219 self._message_header_token_count = 0
220 self._in_skipped_tool_span = False
221 self._reset_args_state()
222 + self._reset_array_state()
223
224 def feed(
225 self,
@@ -306,7 +313,12 @@ class StreamingParserEngine:
313 ParserState.TOOL_NAME,
314 ParserState.TOOL_BETWEEN,
315 ):
309 - if self.tool_index >= 0:
316 + # In array mode a closed element has already emitted its
317 + # TOOL_CALL_END; only a truncated (still-open) element needs one.
318 + end_open_call = (
319 + self._array_in_element if self.config.tool_call_body_array else True
320 + )
321 + if end_open_call and self.tool_index >= 0:
322 events.append(
323 SemanticEvent(
324 EventType.TOOL_CALL_END,
@@ -474,6 +486,8 @@ class StreamingParserEngine:
486 self._message_header_token_count += token_count
487 return []
488 if self.state == ParserState.TOOL_ARGS:
489 + if self.config.tool_call_body_array:
490 + return self._feed_array_text(text)
491 if self.config.tool_args_json:
492 return self._feed_args_text(text)
493 return [
@@ -564,6 +578,8 @@ class StreamingParserEngine:
578 self._args_in_string = False
579 self._args_escape_next = False
580 self._args_safe_end = 0
581 + if self.config.tool_call_body_array:
582 + self._reset_array_state()
583
584 return events
585
@@ -634,3 +650,88 @@ class StreamingParserEngine:
650 tool_index=self.tool_index,
651 )
652 ]
653 +
654 + # ── JSON-array tool body (tool_call_body_array) ─────────────────────
655 +
656 + def _feed_array_text(self, text: str) -> list[SemanticEvent]:
657 + """Split a JSON-array tool body into one tool call per element.
658 +
659 + Granite emits ``[{"name":..,"arguments":{..}}, ...]`` after the tool
660 + marker. Element interiors are streamed through the string/brace-aware
661 + :meth:`_feed_args_char`, so each element behaves exactly like a
662 + single-object tool body; the array's ``]`` ends the region and any
663 + trailing text becomes content.
664 + """
665 + events: list[SemanticEvent] = []
666 + for i, ch in enumerate(text):
667 + if self.state != ParserState.TOOL_ARGS:
668 + # The array closed mid-chunk; the rest of the text is content.
669 + events.extend(self._emit_for_state(text[i:]))
670 + break
671 + events.extend(self._feed_array_char(ch))
672 + return events
673 +
674 + def _feed_array_char(self, ch: str) -> list[SemanticEvent]:
675 + if self._array_in_element:
676 + events = self._feed_args_char(ch)
677 + # Element object closed: top-level '}' with all braces balanced.
678 + if (
679 + ch == "}"
680 + and not self._args_in_string
681 + and not self._args_escape_next
682 + and self._args_brace_depth == 0
683 + ):
684 + events.extend(self._end_array_element())
685 + return events
686 +
687 + # Between elements only structural array characters are expected.
688 + if ch in " \t\r\n":
689 + return []
690 + if not self._array_started:
691 + if ch == "[":
692 + self._array_started = True
693 + return []
694 + return self._bail_array_to_content(ch)
695 + if ch == ",":
696 + return []
697 + if ch == "{":
698 + return self._start_array_element(ch)
699 + if ch == "]":
700 + self.state = ParserState.CONTENT
701 + self._reset_array_state()
702 + return []
703 + return self._bail_array_to_content(ch)
704 +
705 + def _start_array_element(self, ch: str) -> list[SemanticEvent]:
706 + self.tool_index += 1
707 + self._reset_args_state()
708 + self._array_in_element = True
709 + events = [SemanticEvent(EventType.TOOL_CALL_START, tool_index=self.tool_index)]
710 + events.extend(self._feed_args_char(ch))
711 + return events
712 +
713 + def _end_array_element(self) -> list[SemanticEvent]:
714 + self._array_in_element = False
715 + events: list[SemanticEvent] = []
716 + # Flush the held closing '}' so the slot body is the full element.
717 + if self._args_buffer:
718 + events.append(
719 + SemanticEvent(
720 + EventType.ARG_VALUE_CHUNK,
721 + value=self._args_buffer,
722 + tool_index=self.tool_index,
723 + )
724 + )
725 + self._args_buffer = ""
726 + self._args_safe_end = 0
727 + events.append(
728 + SemanticEvent(EventType.TOOL_CALL_END, tool_index=self.tool_index)
729 + )
730 + return events
731 +
732 + def _bail_array_to_content(self, ch: str) -> list[SemanticEvent]:
733 + # Marker not followed by a JSON array (malformed) — revert to content
734 + # so the remaining tokens are emitted as plain text, no tool call.
735 + self.state = ParserState.CONTENT
736 + self._reset_array_state()
737 + return self._emit_for_state(ch)
vllm/parser/granite.py new
+88
@@ -0,0 +1,88 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Granite parser for JSON-array tool calls.
4 +
5 +Granite 3.0/3.1 emit tool calls as a JSON array following a marker::
6 +
7 + <|tool_call|> [{"name": "get_weather", "arguments": {"city": "SF"}}] # 3.0
8 + <tool_call> [{"name": "get_weather", "arguments": {"city": "SF"}}] # 3.1
9 +
10 +``<|tool_call|>`` is a single special token; ``<tool_call>`` is plain text.
11 +The array may hold several calls, has no closing marker (the ``]`` ends it),
12 +and any surrounding prose is content. The engine's ``tool_call_body_array``
13 +mode splits the array into one call per element and extracts each name via the
14 +name-from-args path; ``_granite_arg_converter`` carves the ``arguments`` object
15 +out of each ``{"name":..,"arguments":{..}}`` element. Granite has no reasoning.
16 +"""
17 +
18 +from __future__ import annotations
19 +
20 +import functools
21 +from typing import TYPE_CHECKING
22 +
23 +from vllm.parser.engine.parser_engine import ParserEngine
24 +from vllm.parser.engine.parser_engine_config import (
25 + ParserEngineConfig,
26 + ParserState,
27 + Transition,
28 +)
29 +from vllm.parser.utils import extract_json_member_object
30 +
31 +if TYPE_CHECKING:
32 + from vllm.tokenizers import TokenizerLike
33 + from vllm.tool_parsers.abstract_tool_parser import Tool
34 +
35 +TOOL_TOKEN = "<|tool_call|>" # granite 3.0 (single special token)
36 +TOOL_STRING = "<tool_call>" # granite 3.1 (plain text)
37 +
38 +
39 +def _granite_arg_converter(raw_args: str, partial: bool) -> str:
40 + """Carve the ``arguments`` object out of a ``{"name":..,"arguments":{..}}``
41 + array element (see :func:`extract_json_member_object`)."""
42 + span = extract_json_member_object(raw_args, "arguments")
43 + if span is None:
44 + return "" if partial else "{}"
45 + return span
46 +
47 +
48 +@functools.cache
49 +def granite_config() -> ParserEngineConfig:
50 + return ParserEngineConfig(
51 + name="granite",
52 + initial_state=ParserState.CONTENT,
53 + terminals={
54 + "TOOL_TOKEN": TOOL_TOKEN,
55 + "TOOL_STRING": TOOL_STRING,
56 + },
57 + token_id_terminals={
58 + "TOOL_TOKEN": TOOL_TOKEN,
59 + },
60 + transitions={
61 + # The marker only switches into the tool region; the array splitter
62 + # emits TOOL_CALL_START per element (empty events here).
63 + (ParserState.CONTENT, "TOOL_TOKEN"): Transition(
64 + ParserState.TOOL_ARGS,
65 + (),
66 + ),
67 + (ParserState.CONTENT, "TOOL_STRING"): Transition(
68 + ParserState.TOOL_ARGS,
69 + (),
70 + ),
71 + },
72 + arg_converter=_granite_arg_converter,
73 + tool_call_body_array=True,
74 + stream_arg_deltas=True,
75 + )
76 +
77 +
78 +class GraniteParser(ParserEngine):
79 + CONFIG_NAME = "granite"
80 +
81 + def __init__(
82 + self,
83 + tokenizer: TokenizerLike,
84 + tools: list[Tool] | None = None,
85 + **kwargs,
86 + ) -> None:
87 + kwargs.setdefault("parser_engine_config", granite_config())
88 + super().__init__(tokenizer, tools, **kwargs)
vllm/parser/inkling.py
+4 -90
@@ -37,6 +37,7 @@ from vllm.parser.engine.parser_engine_config import (
37 ParserState,
38 Transition,
39 )
40 +from vllm.parser.utils import extract_json_member_object
41
42 if TYPE_CHECKING:
43 from vllm.tokenizers import TokenizerLike
@@ -62,84 +63,6 @@ INKLING_SPECIAL_TOKENS = (
63 END_MESSAGE,
64 )
65
65 -_WS = " \t\r\n"
66 -
67 -
68 -def _scan_json_value(raw: str, start: int) -> int | None:
69 - """Return the end index (exclusive) of the JSON object starting at
70 - ``raw[start]``, or ``None`` when the object is still unterminated."""
71 - depth = 0
72 - in_string = False
73 - escape = False
74 - for i in range(start, len(raw)):
75 - ch = raw[i]
76 - if escape:
77 - escape = False
78 - continue
79 - if in_string:
80 - if ch == "\\":
81 - escape = True
82 - elif ch == '"':
83 - in_string = False
84 - continue
85 - if ch == '"':
86 - in_string = True
87 - elif ch == "{":
88 - depth += 1
89 - elif ch == "}":
90 - depth -= 1
91 - if depth == 0:
92 - return i + 1
93 - return None
94 -
95 -
96 -def _args_value_span(raw: str) -> str | None:
97 - """Extract the raw text span of the top-level ``"args"`` value from a
98 - (possibly incomplete) ``{"name":...,"args":{...}}`` wrapper.
99 -
100 - Returns the verbatim substring (prefix-stable across growing input,
101 - which the engine's argument-delta diffing relies on), possibly an
102 - unterminated object prefix; ``None`` when the value has not started.
103 - Raises ``ValueError`` when the value is not a JSON object.
104 - """
105 - depth = 0
106 - in_string = False
107 - escape = False
108 - string_start = -1
109 - last_string: str | None = None
110 - for i, ch in enumerate(raw):
111 - if escape:
112 - escape = False
113 - continue
114 - if in_string:
115 - if ch == "\\":
116 - escape = True
117 - elif ch == '"':
118 - in_string = False
119 - if depth == 1:
120 - last_string = raw[string_start + 1 : i]
121 - continue
122 - if ch == '"':
123 - in_string = True
124 - string_start = i
125 - elif ch == ":" and depth == 1 and last_string == "args":
126 - value_start = i + 1
127 - while value_start < len(raw) and raw[value_start] in _WS:
128 - value_start += 1
129 - if value_start >= len(raw):
130 - return None
131 - if raw[value_start] != "{":
132 - raise ValueError("Inkling tool call args must be a JSON object")
133 - value_end = _scan_json_value(raw, value_start)
134 - if value_end is None:
135 - return raw[value_start:]
136 - return raw[value_start:value_end]
137 - elif ch in "{[":
138 - depth += 1
139 - elif ch in "}]":
140 - depth -= 1
141 - return None
142 -
66
67 def _inkling_arg_converter(raw_args: str, partial: bool) -> str:
68 """Carve the ``args`` object out of the tool-call JSON wrapper.
@@ -150,19 +73,10 @@ def _inkling_arg_converter(raw_args: str, partial: bool) -> str:
73 converter, ``_compute_arg_delta`` streams the wrapper verbatim into
74 the OpenAI ``arguments`` field (``converter is None -> raw delta``,
75 unconditionally; ``stream_arg_deltas=False`` does not stop it).
153 -
154 - Why a hand-rolled scanner instead of (partial) ``json.loads`` +
155 - ``json.dumps``: the engine diffs successive converter outputs and
156 - requires each to extend the previous one (``startswith``); a
157 - violation silently drops argument deltas. Re-serialization changes
158 - whitespace and closes unterminated structures differently across
159 - ticks, so the only prefix-stable output is a verbatim substring of
160 - the input. The scanner is also string/escape-aware so an ``"args"``
161 - literal inside the name or a string value cannot mislead it, and it
162 - still recovers a partial span when EOS truncates the wrapper (where
163 - ``json.loads`` would fail).
76 + :func:`extract_json_member_object` returns a prefix-stable verbatim
77 + substring, which the engine's argument-delta diffing requires.
78 """
165 - span = _args_value_span(raw_args)
79 + span = extract_json_member_object(raw_args, "args")
80 if span is None:
81 # No args value yet (streaming) or none at all (treat as empty).
82 return "" if partial else "{}"
vllm/parser/utils.py
+85
@@ -63,3 +63,88 @@ def count_history_tool_calls(
63 return 0
64
65 return count_response_history_tool_calls(request_input)
66 +
67 +
68 +_JSON_WS = " \t\r\n"
69 +
70 +
71 +def scan_json_object_end(raw: str, start: int) -> int | None:
72 + """Return the end index (exclusive) of the JSON object starting at
73 + ``raw[start]`` (which must be ``{``), or ``None`` when it is still
74 + unterminated. String- and escape-aware so braces inside strings do not
75 + mislead the depth count."""
76 + depth = 0
77 + in_string = False
78 + escape = False
79 + for i in range(start, len(raw)):
80 + ch = raw[i]
81 + if escape:
82 + escape = False
83 + continue
84 + if in_string:
85 + if ch == "\\":
86 + escape = True
87 + elif ch == '"':
88 + in_string = False
89 + continue
90 + if ch == '"':
91 + in_string = True
92 + elif ch == "{":
93 + depth += 1
94 + elif ch == "}":
95 + depth -= 1
96 + if depth == 0:
97 + return i + 1
98 + return None
99 +
100 +
101 +def extract_json_member_object(raw: str, key: str) -> str | None:
102 + """Extract the raw text span of the top-level object-valued member ``key``
103 + from a (possibly incomplete) ``{...}`` JSON wrapper.
104 +
105 + Returns the verbatim substring (prefix-stable as the input grows, which
106 + the engine's argument-delta diffing relies on), possibly an unterminated
107 + object prefix; ``None`` when the value has not started. Raises
108 + ``ValueError`` when the member is present but its value is not a JSON
109 + object. A verbatim substring (not re-serialized JSON) is required because
110 + the engine diffs successive converter outputs and expects each to extend
111 + the previous one; the scanner is string/escape-aware so a matching literal
112 + inside another value cannot mislead it, and it recovers a partial span when
113 + the wrapper is truncated (where ``json.loads`` would fail)."""
114 + depth = 0
115 + in_string = False
116 + escape = False
117 + string_start = -1
118 + last_string: str | None = None
119 + for i, ch in enumerate(raw):
120 + if escape:
121 + escape = False
122 + continue
123 + if in_string:
124 + if ch == "\\":
125 + escape = True
126 + elif ch == '"':
127 + in_string = False
128 + if depth == 1:
129 + last_string = raw[string_start + 1 : i]
130 + continue
131 + if ch == '"':
132 + in_string = True
133 + string_start = i
134 + elif ch == ":" and depth == 1 and last_string == key:
135 + value_start = i + 1
136 + while value_start < len(raw) and raw[value_start] in _JSON_WS:
137 + value_start += 1
138 + if value_start >= len(raw):
139 + return None
140 + if raw[value_start] != "{":
141 + raise ValueError(f"member {key!r} must be a JSON object")
142 + value_end = scan_json_object_end(raw, value_start)
143 + if value_end is None:
144 + return raw[value_start:]
145 + return raw[value_start:value_end]
146 + elif ch in "{[":
147 + depth += 1
148 + elif ch in "}]":
149 + depth -= 1
150 + return None
vllm/tool_parsers/__init__.py
+2 -2
@@ -75,8 +75,8 @@ _TOOL_PARSERS_TO_REGISTER = {
75 "Granite20bFCToolParser",
76 ),
77 "granite": (
78 - "granite_tool_parser",
79 - "GraniteToolParser",
78 + "granite_engine_tool_parser",
79 + "GraniteEngineToolParser",
80 ),
81 "granite4": (
82 "granite4_tool_parser",
vllm/tool_parsers/granite_engine_tool_parser.py new
+8
@@ -0,0 +1,8 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import GraniteParserToolAdapter
5 +
6 +
7 +class GraniteEngineToolParser(GraniteParserToolAdapter): # type: ignore[valid-type, misc]
8 + structural_tag_model = None
vllm/tool_parsers/granite_tool_parser.py deleted
-257
@@ -1,257 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -import json
5 -from collections.abc import Sequence
6 -
7 -import partial_json_parser
8 -from partial_json_parser.core.options import Allow
9 -
10 -from vllm.entrypoints.chat_utils import make_tool_call_id
11 -from vllm.entrypoints.generate.base.protocol import (
12 - DeltaFunctionCall,
13 - DeltaMessage,
14 - DeltaToolCall,
15 - ExtractedToolCallInformation,
16 - FunctionCall,
17 - ToolCall,
18 -)
19 -from vllm.entrypoints.openai.chat_completion.protocol import (
20 - ChatCompletionRequest,
21 -)
22 -from vllm.logger import init_logger
23 -from vllm.tokenizers import TokenizerLike
24 -from vllm.tool_parsers.abstract_tool_parser import (
25 - Tool,
26 - ToolParser,
27 -)
28 -from vllm.tool_parsers.utils import (
29 - consume_space,
30 - find_common_prefix,
31 - is_complete_json,
32 - partial_json_loads,
33 -)
34 -
35 -logger = init_logger(__name__)
36 -
37 -
38 -class GraniteToolParser(ToolParser):
39 - """Tool call parser for the granite 3.0 models. Intended
40 - for use with the examples/tool_chat_template_granite.jinja
41 - template.
42 -
43 - Used when --enable-auto-tool-choice --tool-call-parser granite
44 - are all set
45 - """
46 -
47 - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
48 - super().__init__(tokenizer, tools)
49 - # for granite 3.0, the token `<|tool_call|>`
50 - self.bot_token = "<|tool_call|>"
51 - # for granite 3.1, the string `<tool_call>`
52 - self.bot_string = "<tool_call>"
53 -
54 - def extract_tool_calls(
55 - self, model_output: str, request: ChatCompletionRequest
56 - ) -> ExtractedToolCallInformation:
57 - stripped = (
58 - model_output.strip()
59 - .removeprefix(self.bot_token)
60 - .removeprefix(self.bot_string)
61 - .lstrip()
62 - )
63 - if not stripped or stripped[0] != "[":
64 - return ExtractedToolCallInformation(
65 - tools_called=False, tool_calls=[], content=model_output
66 - )
67 - try:
68 - raw_function_calls = json.loads(stripped)
69 - if not isinstance(raw_function_calls, list):
70 - raise Exception(
71 - f"Expected dict or list, got {type(raw_function_calls)}"
72 - )
73 -
74 - logger.debug("Extracted %d tool calls", len(raw_function_calls))
75 - tool_calls = [
76 - ToolCall(
77 - type="function",
78 - function=FunctionCall(
79 - name=function_call["name"],
80 - # function call args are JSON but as a string
81 - arguments=json.dumps(
82 - function_call["arguments"], ensure_ascii=False
83 - ),
84 - ),
85 - )
86 - for function_call in raw_function_calls
87 - ]
88 -
89 - return ExtractedToolCallInformation(
90 - tools_called=True,
91 - tool_calls=tool_calls,
92 - content=None,
93 - )
94 -
95 - except Exception as e:
96 - logger.error("Error in extracting tool call from response %s", e)
97 - return ExtractedToolCallInformation(
98 - tools_called=False, tool_calls=[], content=model_output
99 - )
100 -
101 - def extract_tool_calls_streaming(
102 - self,
103 - previous_text: str,
104 - current_text: str,
105 - delta_text: str,
106 - previous_token_ids: Sequence[int],
107 - current_token_ids: Sequence[int],
108 - delta_token_ids: Sequence[int],
109 - request: ChatCompletionRequest,
110 - ) -> DeltaMessage | None:
111 - start_idx = consume_space(0, current_text)
112 - if current_text[start_idx:].startswith(self.bot_token):
113 - start_idx = consume_space(start_idx + len(self.bot_token), current_text)
114 - if current_text[start_idx:].startswith(self.bot_string):
115 - start_idx = consume_space(start_idx + len(self.bot_string), current_text)
116 - if (
117 - not current_text
118 - or start_idx >= len(current_text)
119 - or current_text[start_idx] != "["
120 - ):
121 - return DeltaMessage(content=delta_text)
122 -
123 - # bit mask flags for partial JSON parsing. If the name hasn't been
124 - # sent yet, don't allow sending
125 - # an incomplete string since OpenAI only ever (as far as I have
126 - # seen) allows sending the entire tool/ function name at once.
127 - flags = Allow.ALL if self.current_tool_name_sent else Allow.ALL & ~Allow.STR
128 - try:
129 - tool_call_arr = None
130 - is_complete = None
131 - try:
132 - tool_calls, end_idx = partial_json_loads(
133 - current_text[start_idx:], flags
134 - )
135 - if type(tool_calls) is list:
136 - tool_call_arr = tool_calls
137 - else:
138 - return DeltaMessage(content=delta_text)
139 -
140 - is_complete = [True] * len(tool_calls)
141 - if not is_complete_json(current_text[start_idx : start_idx + end_idx]):
142 - is_complete[-1] = False
143 - except partial_json_parser.core.exceptions.MalformedJSON:
144 - logger.debug("not enough tokens to parse into JSON yet")
145 - return None
146 -
147 - # case -- if no tokens have been streamed for the tool, e.g.
148 - # only the array brackets, stream nothing
149 - if not tool_call_arr:
150 - return None
151 -
152 - # select as the current tool call the one we're on the state at
153 - current_tool_call: dict = tool_call_arr[self.current_tool_id]
154 -
155 - delta = None
156 - # Only advance once the current tool name is streamed; granite
157 - # emits arguments before name, so advancing early would drop it.
158 - if len(tool_call_arr) > self.current_tool_id + 1 and (
159 - self.current_tool_id < 0 or self.current_tool_name_sent
160 - ):
161 - # if we're moving on to a new call, first make sure we
162 - # haven't missed anything in the previous one that was
163 - # auto-generated due to JSON completions, but wasn't
164 - # streamed to the client yet.
165 - if self.current_tool_id >= 0:
166 - cur_arguments = current_tool_call.get("arguments")
167 - if cur_arguments:
168 - cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
169 - sent = len(self.streamed_args_for_tool[self.current_tool_id])
170 - argument_diff = cur_args_json[sent:]
171 -
172 - logger.debug("got arguments diff: %s", argument_diff)
173 - delta = DeltaMessage(
174 - tool_calls=[
175 - DeltaToolCall(
176 - index=self.current_tool_id,
177 - function=DeltaFunctionCall(
178 - arguments=argument_diff
179 - ).model_dump(exclude_none=True),
180 - )
181 - ]
182 - )
183 - self.streamed_args_for_tool[self.current_tool_id] += (
184 - argument_diff
185 - )
186 -
187 - # re-set stuff pertaining to progress in the current tool
188 - self.current_tool_id += 1
189 - self.current_tool_name_sent = False
190 - self.streamed_args_for_tool.append("")
191 - logger.debug("starting on new tool %d", self.current_tool_id)
192 - return delta
193 -
194 - # if the current tool name hasn't been sent, send if available
195 - # - otherwise send nothing
196 - elif not self.current_tool_name_sent:
197 - function_name = current_tool_call.get("name")
198 - if function_name:
199 - delta = DeltaMessage(
200 - tool_calls=[
201 - DeltaToolCall(
202 - index=self.current_tool_id,
203 - type="function",
204 - id=make_tool_call_id(),
205 - function=DeltaFunctionCall(
206 - name=function_name
207 - ).model_dump(exclude_none=True),
208 - )
209 - ]
210 - )
211 - self.current_tool_name_sent = True
212 -
213 - # now we know we're on the same tool call and we're streaming
214 - # arguments
215 - else:
216 - cur_arguments = current_tool_call.get("arguments")
217 -
218 - if cur_arguments:
219 - sent = len(self.streamed_args_for_tool[self.current_tool_id])
220 - cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
221 - prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get(
222 - "arguments"
223 - )
224 -
225 - argument_diff = None
226 - if is_complete[self.current_tool_id]:
227 - argument_diff = cur_args_json[sent:]
228 - elif prev_arguments:
229 - prev_args_json = json.dumps(prev_arguments, ensure_ascii=False)
230 - if cur_args_json != prev_args_json:
231 - prefix = find_common_prefix(prev_args_json, cur_args_json)
232 - argument_diff = prefix[sent:]
233 -
234 - if argument_diff is not None:
235 - delta = DeltaMessage(
236 - tool_calls=[
237 - DeltaToolCall(
238 - index=self.current_tool_id,
239 - function=DeltaFunctionCall(
240 - arguments=argument_diff
241 - ).model_dump(exclude_none=True),
242 - )
243 - ]
244 - )
245 - self.streamed_args_for_tool[self.current_tool_id] += (
246 - argument_diff
247 - )
248 -
249 - self.prev_tool_call_arr = tool_call_arr
250 - return delta
251 -
252 - except Exception as e:
253 - logger.error("Error trying to handle streaming tool call: %s", e)
254 - logger.debug(
255 - "Skipping chunk as a result of tool streaming extraction error"
256 - )
257 - return None