main
py 603 lines 19.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5
6 import pytest
7
8 from vllm.entrypoints.openai.chat_completion.protocol import (
9 ChatCompletionToolsParam,
10 FunctionDefinition,
11 )
12 from vllm.tool_parsers.minimax_m2_tool_parser import (
13 MinimaxM2ToolParser,
14 )
15
16 pytestmark = pytest.mark.cpu_test
17
18 # Token IDs matching FakeTokenizer.vocab
19 TC_START_ID = 1
20 TC_END_ID = 2
21
22
23 class FakeTokenizer:
24 """Minimal fake tokenizer for unit tests."""
25
26 def __init__(self):
27 self.model_tokenizer = True
28 self.vocab = {
29 "<minimax:tool_call>": TC_START_ID,
30 "</minimax:tool_call>": TC_END_ID,
31 }
32
33 def get_vocab(self):
34 return self.vocab
35
36 def decode(self, token_ids):
37 id_to_token = {v: k for k, v in self.vocab.items()}
38 return "".join(id_to_token.get(token_id, "") for token_id in token_ids)
39
40
41 @pytest.fixture
42 def parser():
43 return MinimaxM2ToolParser(FakeTokenizer())
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 def _feed(parser, chunks, request=None):
52 """Feed chunks through the streaming parser and collect results.
53
54 Each element in *chunks* is either:
55 - a ``str``: used as delta_text (current_text accumulates automatically)
56 - a ``(delta_text, delta_token_ids)`` tuple for special-token scenarios
57
58 Returns a list of non-None DeltaMessage objects.
59 """
60 previous = ""
61 results = []
62 for chunk in chunks:
63 if isinstance(chunk, tuple):
64 delta, delta_ids = chunk
65 else:
66 delta = chunk
67 delta_ids = []
68
69 current = previous + delta
70 result = parser.extract_tool_calls_streaming(
71 previous_text=previous,
72 current_text=current,
73 delta_text=delta,
74 previous_token_ids=[],
75 current_token_ids=[],
76 delta_token_ids=delta_ids,
77 request=request,
78 )
79 if result is not None:
80 results.append(result)
81 previous = current
82
83 return results
84
85
86 def _collect_content(results):
87 """Join all content strings from a list of DeltaMessages."""
88 return "".join(r.content for r in results if r.content)
89
90
91 def _collect_tool_calls(results):
92 """Aggregate tool calls by index from a list of DeltaMessages.
93
94 Returns a dict: index -> {"id": ..., "name": ..., "arguments": ...}
95 """
96 tool_calls = {}
97 for r in results:
98 for tc in r.tool_calls or []:
99 if tc.index not in tool_calls:
100 tool_calls[tc.index] = {
101 "id": None,
102 "name": "",
103 "arguments": "",
104 }
105 if tc.id:
106 tool_calls[tc.index]["id"] = tc.id
107 if tc.function:
108 if tc.function.name:
109 tool_calls[tc.index]["name"] += tc.function.name
110 if tc.function.arguments:
111 tool_calls[tc.index]["arguments"] += tc.function.arguments
112 return tool_calls
113
114
115 # ---------------------------------------------------------------------------
116 # Phase 1: content before tool calls
117 # ---------------------------------------------------------------------------
118
119
120 class TestContentStreaming:
121 """Tests for plain content (no tool calls)."""
122
123 def test_plain_content(self, parser):
124 """No tool call tokens — all text is streamed as content."""
125 results = _feed(parser, ["Hello ", "world"])
126 assert _collect_content(results) == "Hello world"
127
128 def test_content_before_tool_call(self, parser):
129 """Text before <minimax:tool_call> is streamed as content."""
130 results = _feed(
131 parser,
132 [
133 "Let me check. ",
134 '<minimax:tool_call><invoke name="get_weather">'
135 '<parameter name="city">Seattle</parameter>'
136 "</invoke></minimax:tool_call>",
137 ],
138 )
139 assert _collect_content(results) == "Let me check. "
140
141 def test_empty_delta_no_crash(self, parser):
142 """Empty delta_text with no token IDs returns None."""
143 results = _feed(parser, [("", [])])
144 assert results == []
145
146
147 # ---------------------------------------------------------------------------
148 # Phase 2: tool call parsing
149 # ---------------------------------------------------------------------------
150
151
152 class TestSingleInvoke:
153 """Tests for a single <invoke> block."""
154
155 def test_incremental_chunks(self, parser):
156 """Each XML element arrives in a separate chunk."""
157 results = _feed(
158 parser,
159 [
160 "<minimax:tool_call>",
161 '<invoke name="get_weather">',
162 '<parameter name="city">Seattle</parameter>',
163 "</invoke></minimax:tool_call>",
164 ],
165 )
166 tc = _collect_tool_calls(results)
167 assert len(tc) == 1
168 assert tc[0]["name"] == "get_weather"
169 assert json.loads(tc[0]["arguments"]) == {"city": "Seattle"}
170 assert tc[0]["id"] is not None
171
172 def test_single_chunk_complete(self, parser):
173 """Entire tool call arrives in one delta."""
174 results = _feed(
175 parser,
176 [
177 '<minimax:tool_call><invoke name="get_weather">'
178 '<parameter name="city">Seattle</parameter>'
179 "</invoke></minimax:tool_call>",
180 ],
181 )
182 tc = _collect_tool_calls(results)
183 assert len(tc) == 1
184 assert json.loads(tc[0]["arguments"]) == {"city": "Seattle"}
185
186 def test_multiple_params(self, parser):
187 """Multiple parameters in one invoke."""
188 results = _feed(
189 parser,
190 [
191 "<minimax:tool_call>",
192 '<invoke name="get_weather">',
193 '<parameter name="city">Seattle</parameter>',
194 '<parameter name="days">5</parameter>',
195 "</invoke></minimax:tool_call>",
196 ],
197 )
198 tc = _collect_tool_calls(results)
199 assert json.loads(tc[0]["arguments"]) == {
200 "city": "Seattle",
201 "days": "5",
202 }
203
204
205 class TestMultipleInvokes:
206 """Tests for multiple <invoke> blocks in one tool call."""
207
208 def test_two_invokes_incremental(self, parser):
209 """Two invokes arriving one chunk at a time."""
210 results = _feed(
211 parser,
212 [
213 "<minimax:tool_call>",
214 '<invoke name="search_web">'
215 '<parameter name="query">OpenAI</parameter>'
216 "</invoke>",
217 '<invoke name="search_web">'
218 '<parameter name="query">Gemini</parameter>'
219 "</invoke>",
220 "</minimax:tool_call>",
221 ],
222 )
223 tc = _collect_tool_calls(results)
224 assert len(tc) == 2
225 assert tc[0]["name"] == "search_web"
226 assert tc[1]["name"] == "search_web"
227 assert json.loads(tc[0]["arguments"]) == {"query": "OpenAI"}
228 assert json.loads(tc[1]["arguments"]) == {"query": "Gemini"}
229
230 def test_two_invokes_in_single_delta(self, parser):
231 """Both invokes close in the same delta — loop must emit both."""
232 results = _feed(
233 parser,
234 [
235 "<minimax:tool_call>",
236 '<invoke name="fn_a"><parameter name="x">1</parameter></invoke>'
237 '<invoke name="fn_b"><parameter name="y">2</parameter></invoke>',
238 "</minimax:tool_call>",
239 ],
240 )
241 tc = _collect_tool_calls(results)
242 assert len(tc) == 2
243 assert tc[0]["name"] == "fn_a"
244 assert tc[1]["name"] == "fn_b"
245
246 def test_different_functions(self, parser):
247 """Parallel calls to different functions."""
248 results = _feed(
249 parser,
250 [
251 "<minimax:tool_call>",
252 '<invoke name="get_weather">'
253 '<parameter name="city">NYC</parameter>'
254 "</invoke>",
255 '<invoke name="get_stock">'
256 '<parameter name="ticker">AAPL</parameter>'
257 "</invoke>",
258 "</minimax:tool_call>",
259 ],
260 )
261 tc = _collect_tool_calls(results)
262 assert tc[0]["name"] == "get_weather"
263 assert tc[1]["name"] == "get_stock"
264
265
266 # ---------------------------------------------------------------------------
267 # DeltaMessage structure
268 # ---------------------------------------------------------------------------
269
270
271 class TestDeltaMessageFormat:
272 """Verify the shape of emitted DeltaMessage / DeltaToolCall."""
273
274 def test_tool_call_fields(self, parser):
275 """Each emitted tool call has id, name, arguments, type, index."""
276 results = _feed(
277 parser,
278 [
279 '<minimax:tool_call><invoke name="fn">'
280 '<parameter name="k">v</parameter>'
281 "</invoke></minimax:tool_call>",
282 ],
283 )
284 tc_deltas = [tc for r in results for tc in (r.tool_calls or [])]
285 assert len(tc_deltas) == 1
286 tc = tc_deltas[0]
287 assert tc.index == 0
288 assert tc.type == "function"
289 assert tc.id is not None
290 assert tc.function.name == "fn"
291 assert json.loads(tc.function.arguments) == {"k": "v"}
292
293 def test_multi_invoke_indices(self, parser):
294 """Multiple invokes get sequential indices."""
295 results = _feed(
296 parser,
297 [
298 "<minimax:tool_call>",
299 '<invoke name="a"><parameter name="x">1</parameter></invoke>',
300 '<invoke name="b"><parameter name="x">2</parameter></invoke>',
301 "</minimax:tool_call>",
302 ],
303 )
304 tc_deltas = [tc for r in results for tc in (r.tool_calls or [])]
305 indices = [tc.index for tc in tc_deltas]
306 assert indices == [0, 1]
307
308
309 # ---------------------------------------------------------------------------
310 # Large chunks (stream_interval > 1)
311 # ---------------------------------------------------------------------------
312
313
314 class TestLargeChunks:
315 """Simulate stream_interval > 1 where many tokens arrive at once."""
316
317 def test_header_and_params_in_separate_chunks(self, parser):
318 """Header in chunk 1, all params + close in chunk 2."""
319 chunk1 = '<minimax:tool_call><invoke name="get_weather">'
320 chunk2 = (
321 '<parameter name="city">Seattle</parameter>'
322 '<parameter name="days">5</parameter>'
323 "</invoke></minimax:tool_call>"
324 )
325
326 results = _feed(
327 parser,
328 [
329 chunk1,
330 chunk2,
331 ],
332 )
333
334 tc = _collect_tool_calls(results)
335 assert len(tc) == 1
336 parsed = json.loads(tc[0]["arguments"])
337 assert parsed == {"city": "Seattle", "days": "5"}
338
339
340 class TestAnyOfNullableParam:
341 """Regression: anyOf nullable parameter parsing (PR #32342)."""
342
343 def test_anyof_nullable_param_non_null_value(self):
344 """A valid non-null string should be preserved, not collapsed to None."""
345 tools = [
346 ChatCompletionToolsParam(
347 function=FunctionDefinition(
348 name="update_profile",
349 parameters={
350 "type": "object",
351 "properties": {
352 "nickname": {
353 "anyOf": [{"type": "string"}, {"type": "null"}],
354 },
355 },
356 },
357 ),
358 )
359 ]
360 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
361
362 results = _feed(
363 parser,
364 [
365 '<minimax:tool_call><invoke name="update_profile">'
366 '<parameter name="nickname">Alice</parameter>'
367 "</invoke></minimax:tool_call>",
368 ],
369 )
370 tc = _collect_tool_calls(results)
371 assert len(tc) == 1
372 parsed = json.loads(tc[0]["arguments"])
373 assert parsed["nickname"] == "Alice"
374
375 def test_anyof_nullable_param_null_value(self):
376 """An actual null-like value should be returned as None/null."""
377 tools = [
378 ChatCompletionToolsParam(
379 function=FunctionDefinition(
380 name="update_profile",
381 parameters={
382 "type": "object",
383 "properties": {
384 "nickname": {
385 "anyOf": [{"type": "string"}, {"type": "null"}],
386 },
387 },
388 },
389 ),
390 )
391 ]
392 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
393
394 results = _feed(
395 parser,
396 [
397 '<minimax:tool_call><invoke name="update_profile">'
398 '<parameter name="nickname">null</parameter>'
399 "</invoke></minimax:tool_call>",
400 ],
401 )
402 tc = _collect_tool_calls(results)
403 assert len(tc) == 1
404 parsed = json.loads(tc[0]["arguments"])
405 assert parsed["nickname"] is None
406
407 def test_anyof_nullable_param_object_value(self):
408 """A valid object value in anyOf with null should parse as dict."""
409 tools = [
410 ChatCompletionToolsParam(
411 function=FunctionDefinition(
412 name="update_settings",
413 parameters={
414 "type": "object",
415 "properties": {
416 "config": {
417 "anyOf": [{"type": "object"}, {"type": "null"}],
418 },
419 },
420 },
421 ),
422 )
423 ]
424 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
425
426 results = _feed(
427 parser,
428 [
429 '<minimax:tool_call><invoke name="update_settings">'
430 '<parameter name="config">{"theme": "dark", "fontSize": 14}'
431 "</parameter>"
432 "</invoke></minimax:tool_call>",
433 ],
434 )
435 tc = _collect_tool_calls(results)
436 assert len(tc) == 1
437 parsed = json.loads(tc[0]["arguments"])
438 assert parsed["config"] == {"theme": "dark", "fontSize": 14}
439 assert isinstance(parsed["config"], dict)
440
441
442 class TestNoneStringPreservation:
443 """Regression tests for #39567: 'none' as a string must not become None."""
444
445 def test_none_string_preserved_in_enum(self):
446 """'none' in an enum must stay as the string 'none', not Python None."""
447 tools = [
448 ChatCompletionToolsParam(
449 function=FunctionDefinition(
450 name="set_theme",
451 parameters={
452 "type": "object",
453 "properties": {
454 "theme": {
455 "type": "string",
456 "enum": ["dark", "light", "none"],
457 },
458 },
459 },
460 ),
461 )
462 ]
463 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
464
465 results = _feed(
466 parser,
467 [
468 '<minimax:tool_call><invoke name="set_theme">'
469 '<parameter name="theme">none</parameter>'
470 "</invoke></minimax:tool_call>",
471 ],
472 )
473 tc = _collect_tool_calls(results)
474 assert len(tc) == 1
475 parsed = json.loads(tc[0]["arguments"])
476 assert parsed["theme"] == "none"
477 assert parsed["theme"] is not None
478
479 def test_none_string_preserved_plain_string(self):
480 """'none' as a plain string param must stay as 'none'."""
481 tools = [
482 ChatCompletionToolsParam(
483 function=FunctionDefinition(
484 name="echo",
485 parameters={
486 "type": "object",
487 "properties": {
488 "message": {"type": "string"},
489 },
490 },
491 ),
492 )
493 ]
494 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
495
496 results = _feed(
497 parser,
498 [
499 '<minimax:tool_call><invoke name="echo">'
500 '<parameter name="message">none</parameter>'
501 "</invoke></minimax:tool_call>",
502 ],
503 )
504 tc = _collect_tool_calls(results)
505 assert len(tc) == 1
506 parsed = json.loads(tc[0]["arguments"])
507 assert parsed["message"] == "none"
508
509 def test_null_still_converts_to_none(self):
510 """'null' in a nullable param must still become Python None."""
511 tools = [
512 ChatCompletionToolsParam(
513 function=FunctionDefinition(
514 name="update_profile",
515 parameters={
516 "type": "object",
517 "properties": {
518 "nickname": {
519 "anyOf": [{"type": "string"}, {"type": "null"}],
520 },
521 },
522 },
523 ),
524 )
525 ]
526 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
527
528 results = _feed(
529 parser,
530 [
531 '<minimax:tool_call><invoke name="update_profile">'
532 '<parameter name="nickname">null</parameter>'
533 "</invoke></minimax:tool_call>",
534 ],
535 )
536 tc = _collect_tool_calls(results)
537 assert len(tc) == 1
538 parsed = json.loads(tc[0]["arguments"])
539 assert parsed["nickname"] is None
540
541 def test_nil_string_preserved(self):
542 """'nil' must stay as the string 'nil', not become None."""
543 tools = [
544 ChatCompletionToolsParam(
545 function=FunctionDefinition(
546 name="echo",
547 parameters={
548 "type": "object",
549 "properties": {
550 "value": {"type": "string"},
551 },
552 },
553 ),
554 )
555 ]
556 parser = MinimaxM2ToolParser(FakeTokenizer(), tools=tools)
557
558 results = _feed(
559 parser,
560 [
561 '<minimax:tool_call><invoke name="echo">'
562 '<parameter name="value">nil</parameter>'
563 "</invoke></minimax:tool_call>",
564 ],
565 )
566 tc = _collect_tool_calls(results)
567 assert len(tc) == 1
568 parsed = json.loads(tc[0]["arguments"])
569 assert parsed["value"] == "nil"
570
571
572 class TestParameterWhitespace:
573 """Parameter values must preserve surrounding whitespace."""
574
575 def test_whitespace_preserved(self, parser):
576 results = _feed(
577 parser,
578 [
579 '<minimax:tool_call><invoke name="echo">'
580 '<parameter name="msg"> hi </parameter>'
581 "</invoke></minimax:tool_call>",
582 ],
583 )
584 tc = _collect_tool_calls(results)
585 assert len(tc) == 1
586 assert json.loads(tc[0]["arguments"]) == {"msg": " hi "}
587
588 def test_whitespace_preserved_across_chunks(self, parser):
589 """Value split before </parameter> arrives, i.e. the partial path."""
590 results = _feed(
591 parser,
592 [
593 '<minimax:tool_call><invoke name="write">'
594 '<parameter name="content"> def foo():\n',
595 " return 1\n",
596 "</parameter></invoke></minimax:tool_call>",
597 ],
598 )
599 tc = _collect_tool_calls(results)
600 assert len(tc) == 1
601 assert json.loads(tc[0]["arguments"]) == {
602 "content": " def foo():\n return 1\n"
603 }