main
py 234 lines 9 KB
Raw
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)