@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 683033d4ba
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 2, 18:15 UTC

[Frontend] Add MiniCPM5 XML tool call parser (#43175)

Signed-off-by: zhangtao <zhangtao2@modelbest.cn> Signed-off-by: zhangtao2 <zhangtao2@modelbest.cn> Co-authored-by: zhangtao <zhangtao2@modelbest.cn> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

zhangtao2-1 committed May 27, 2026 at 15:39 UTC 683033d4ba60a2861ddcc5df9bb9d2e6ca7c935c
3 files changed +1553
tests/tool_parsers/test_minicpm5xml_tool_parser.py new
+735
@@ -0,0 +1,735 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# ruff: noqa: E501
4 +
5 +import json
6 +import random
7 +from unittest.mock import MagicMock
8 +
9 +import pytest
10 +
11 +from tests.tool_parsers.utils import run_tool_extraction_streaming
12 +from vllm.entrypoints.openai.chat_completion.protocol import (
13 + ChatCompletionRequest,
14 + ChatCompletionToolsParam,
15 +)
16 +from vllm.entrypoints.openai.engine.protocol import FunctionCall, ToolCall
17 +from vllm.tool_parsers import ToolParser, ToolParserManager
18 +from vllm.tool_parsers.minicpm5xml_tool_parser import MiniCPM5XMLToolParser
19 +
20 +
21 +def _tool(name: str, parameters: dict) -> ChatCompletionToolsParam:
22 + return ChatCompletionToolsParam(
23 + type="function",
24 + function={
25 + "name": name,
26 + "parameters": parameters,
27 + },
28 + )
29 +
30 +
31 +def make_tools_weather() -> list[ChatCompletionToolsParam]:
32 + return [
33 + _tool(
34 + "get_weather",
35 + {
36 + "type": "object",
37 + "properties": {
38 + "city": {"type": "string"},
39 + "date": {"type": "string"},
40 + },
41 + "required": ["city"],
42 + },
43 + )
44 + ]
45 +
46 +
47 +def make_tools_sum() -> list[ChatCompletionToolsParam]:
48 + return [
49 + _tool(
50 + "sum_values",
51 + {
52 + "type": "object",
53 + "properties": {
54 + "nums": {"type": "array"},
55 + "exact": {"type": "boolean"},
56 + },
57 + "required": ["nums"],
58 + },
59 + )
60 + ]
61 +
62 +
63 +def make_tools_no_required() -> list[ChatCompletionToolsParam]:
64 + return [
65 + _tool(
66 + "noop",
67 + {
68 + "type": "object",
69 + "properties": {"note": {"type": "string"}},
70 + "required": [],
71 + },
72 + )
73 + ]
74 +
75 +
76 +def make_request(
77 + tools: list[ChatCompletionToolsParam],
78 + tool_choice: str = "auto",
79 +) -> ChatCompletionRequest:
80 + return ChatCompletionRequest(
81 + model="test-model",
82 + messages=[{"role": "user", "content": "hi"}],
83 + tools=tools,
84 + tool_choice=tool_choice,
85 + )
86 +
87 +
88 +def make_tool_call(name: str, arguments: dict) -> ToolCall:
89 + return ToolCall(
90 + type="function",
91 + function=FunctionCall(
92 + name=name,
93 + arguments=json.dumps(arguments, ensure_ascii=False),
94 + ),
95 + )
96 +
97 +
98 +def assert_tool_calls(
99 + actual: list[ToolCall],
100 + expected: list[ToolCall],
101 +) -> None:
102 + assert len(actual) == len(expected)
103 + for act, exp in zip(actual, expected):
104 + assert act.type == "function"
105 + assert act.function.name == exp.function.name
106 + assert json.loads(act.function.arguments) == json.loads(exp.function.arguments)
107 +
108 +
109 +@pytest.fixture
110 +def parser() -> ToolParser:
111 + mock_tokenizer = MagicMock()
112 + return MiniCPM5XMLToolParser(mock_tokenizer)
113 +
114 +
115 +def test_registered_in_tool_parser_manager() -> None:
116 + cls = ToolParserManager.get_tool_parser("minicpm5")
117 + assert cls is MiniCPM5XMLToolParser
118 +
119 +
120 +def test_adjust_request_skip_special_tokens(parser: ToolParser) -> None:
121 + request = make_request(make_tools_weather())
122 + assert request.skip_special_tokens is True
123 + adjusted = parser.adjust_request(request)
124 + assert adjusted.skip_special_tokens is False
125 +
126 +
127 +def test_adjust_request_tool_choice_none(parser: ToolParser) -> None:
128 + request = make_request(make_tools_weather(), tool_choice="none")
129 + adjusted = parser.adjust_request(request)
130 + assert adjusted.skip_special_tokens is True
131 +
132 +
133 +def test_no_tool_call(parser: ToolParser) -> None:
134 + request = make_request(make_tools_weather())
135 + out = parser.extract_tool_calls("How can I help you?", request)
136 + assert not out.tools_called
137 + assert out.tool_calls == []
138 + assert out.content == "How can I help you?"
139 +
140 +
141 +def test_single_call_with_surrounding_text(parser: ToolParser) -> None:
142 + request = make_request(make_tools_weather())
143 + text = (
144 + "Intro before.\n"
145 + '<function name="get_weather">'
146 + '<param name="city">上海</param>'
147 + '<param name="date">2024-06-27</param>'
148 + "</function>\n"
149 + "Outro after.\n"
150 + )
151 + out = parser.extract_tool_calls(text, request)
152 + assert out.tools_called
153 + assert_tool_calls(
154 + out.tool_calls,
155 + [
156 + make_tool_call(
157 + "get_weather",
158 + {
159 + "city": "上海",
160 + "date": "2024-06-27",
161 + },
162 + )
163 + ],
164 + )
165 + assert out.content is None
166 +
167 +
168 +def test_cdata_multiline(parser: ToolParser) -> None:
169 + request = make_request(make_tools_weather())
170 + text = (
171 + '<function name="get_weather">'
172 + '<param name="city"><![CDATA[北\n京]]></param>'
173 + '<param name="date">2024-06-27</param>'
174 + "</function>\n"
175 + )
176 + out = parser.extract_tool_calls(text, request)
177 + assert len(out.tool_calls) == 1
178 + args = json.loads(out.tool_calls[0].function.arguments)
179 + assert args["city"] == "北\n京"
180 + assert args["date"] == "2024-06-27"
181 +
182 +
183 +def test_tokenizer_space_marker(parser: ToolParser) -> None:
184 + request = make_request(make_tools_weather())
185 + text = (
186 + '<function\u0120name="get_weather">'
187 + '<param\u0120name="city">上海</param>'
188 + '<param\u0120name="date">2024-06-27</param>'
189 + "</function>\n"
190 + )
191 + out = parser.extract_tool_calls(text, request)
192 + assert out.tools_called
193 + assert_tool_calls(
194 + out.tool_calls,
195 + [
196 + make_tool_call(
197 + "get_weather",
198 + {
199 + "city": "上海",
200 + "date": "2024-06-27",
201 + },
202 + )
203 + ],
204 + )
205 +
206 +
207 +def test_collapsed_function_and_param_tags(parser: ToolParser) -> None:
208 + request = make_request(make_tools_weather())
209 + text = (
210 + '<functionname="get_weather">'
211 + '<paramname="city">上海</param>'
212 + '<paramname="date">2024-06-27</param>'
213 + "</function>\n"
214 + )
215 + out = parser.extract_tool_calls(text, request)
216 + assert out.tools_called
217 + assert_tool_calls(
218 + out.tool_calls,
219 + [
220 + make_tool_call(
221 + "get_weather",
222 + {
223 + "city": "上海",
224 + "date": "2024-06-27",
225 + },
226 + )
227 + ],
228 + )
229 +
230 +
231 +def test_collapsed_param_tags_with_tokenizer_space_function(
232 + parser: ToolParser,
233 +) -> None:
234 + request = make_request(make_tools_weather())
235 + text = (
236 + '<function\u0120name="get_weather">'
237 + '<paramname="city">上海</param>'
238 + '<paramname="date">2024-06-27</param>'
239 + "</function>\n"
240 + )
241 + out = parser.extract_tool_calls(text, request)
242 + assert out.tools_called
243 + assert_tool_calls(
244 + out.tool_calls,
245 + [
246 + make_tool_call(
247 + "get_weather",
248 + {
249 + "city": "上海",
250 + "date": "2024-06-27",
251 + },
252 + )
253 + ],
254 + )
255 +
256 +
257 +def test_extract_tool_calls_streaming_partial_chunks(parser: ToolParser) -> None:
258 + request = make_request(make_tools_weather())
259 + chunks = [
260 + '<function name="get_weather">',
261 + '<param name="city">',
262 + '上海</param><param name="date">2024-06-27</param></function>\n',
263 + ]
264 + reconstructor = run_tool_extraction_streaming(
265 + parser,
266 + chunks,
267 + request,
268 + assert_one_tool_per_delta=False,
269 + )
270 + assert len(reconstructor.tool_calls) == 1
271 + assert reconstructor.tool_calls[0].function.name == "get_weather"
272 + assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
273 + "city": "上海",
274 + "date": "2024-06-27",
275 + }
276 +
277 +
278 +def test_extract_tool_calls_streaming_tokenizer_space_marker(
279 + parser: ToolParser,
280 +) -> None:
281 + request = make_request(make_tools_weather())
282 + chunks = [
283 + '<function\u0120name="get_weather">',
284 + '<param\u0120name="city">',
285 + '上海</param><param\u0120name="date">2024-06-27</param></function>\n',
286 + ]
287 + reconstructor = run_tool_extraction_streaming(
288 + parser,
289 + chunks,
290 + request,
291 + assert_one_tool_per_delta=False,
292 + )
293 + assert len(reconstructor.tool_calls) == 1
294 + assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
295 + "city": "上海",
296 + "date": "2024-06-27",
297 + }
298 +
299 +
300 +def test_extract_tool_calls_streaming_collapsed_tags_weather(
301 + parser: ToolParser,
302 +) -> None:
303 + request = make_request(make_tools_weather())
304 + text = (
305 + '<functionname="get_weather">'
306 + '<paramname="city">上海</param>'
307 + '<paramname="date">2024-06-27</param>'
308 + "</function>\n"
309 + )
310 + random.seed(2)
311 + reconstructor = run_tool_extraction_streaming(
312 + parser,
313 + _random_chunks(text, 1, 4),
314 + request,
315 + )
316 + assert len(reconstructor.tool_calls) == 1
317 + assert reconstructor.tool_calls[0].function.name == "get_weather"
318 + assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
319 + "city": "上海",
320 + "date": "2024-06-27",
321 + }
322 +
323 +
324 +def test_collapsed_tags_current_weather(parser: ToolParser) -> None:
325 + request = make_request(
326 + [
327 + _tool(
328 + "get_current_weather",
329 + {
330 + "type": "object",
331 + "properties": {
332 + "city": {"type": "string"},
333 + "state": {"type": "string"},
334 + "unit": {"type": "string"},
335 + },
336 + "required": ["city", "state", "unit"],
337 + },
338 + )
339 + ]
340 + )
341 + text = (
342 + '<functionname="get_current_weather">'
343 + '<paramname="city">Dallas</param>'
344 + '<paramname="state">TX</param>'
345 + '<paramname="unit">fahrenheit</param>'
346 + "</function>"
347 + )
348 + out = parser.extract_tool_calls(text, request)
349 + assert out.tools_called
350 + assert json.loads(out.tool_calls[0].function.arguments) == {
351 + "city": "Dallas",
352 + "state": "TX",
353 + "unit": "fahrenheit",
354 + }
355 +
356 +
357 +def test_extract_tool_calls_streaming_incremental_arguments(
358 + parser: ToolParser,
359 +) -> None:
360 + request = make_request(
361 + [
362 + _tool(
363 + "get_current_weather",
364 + {
365 + "type": "object",
366 + "properties": {
367 + "city": {"type": "string"},
368 + "state": {"type": "string"},
369 + "unit": {"type": "string"},
370 + },
371 + "required": ["city", "state", "unit"],
372 + },
373 + )
374 + ]
375 + )
376 + text = (
377 + '<function name="get_current_weather">'
378 + '<param name="city">Dallas</param>'
379 + '<param name="state">TX</param>'
380 + '<param name="unit">fahrenheit</param>'
381 + "</function>"
382 + )
383 + prev = ""
384 + arguments = ""
385 + for chunk in [text[:37], text[37:70], text[70:]]:
386 + current = prev + chunk
387 + delta = parser.extract_tool_calls_streaming(
388 + prev,
389 + current,
390 + chunk,
391 + [],
392 + [],
393 + [],
394 + request,
395 + )
396 + if delta and delta.tool_calls:
397 + arg_delta = delta.tool_calls[0].function.arguments
398 + if arg_delta:
399 + arguments += arg_delta
400 + prev = current
401 +
402 + assert json.loads(arguments) == {
403 + "city": "Dallas",
404 + "state": "TX",
405 + "unit": "fahrenheit",
406 + }
407 +
408 +
409 +def test_unknown_tool_block_preserved(parser: ToolParser) -> None:
410 + request = make_request(make_tools_weather())
411 + text = '<function name="unknown"><param name="x">1</param></function>\n'
412 + out = parser.extract_tool_calls(text, request)
413 + assert not out.tools_called
414 + assert "unknown" in (out.content or "")
415 +
416 +
417 +def test_non_string_types(parser: ToolParser) -> None:
418 + request = make_request(make_tools_sum())
419 + text = (
420 + '<function name="sum_values">'
421 + '<param name="nums">[1, 2, 3]</param>'
422 + '<param name="exact">true</param>'
423 + "</function>\n"
424 + )
425 + out = parser.extract_tool_calls(text, request)
426 + assert len(out.tool_calls) == 1
427 + args = json.loads(out.tool_calls[0].function.arguments)
428 + assert args["nums"] == [1, 2, 3]
429 + assert args["exact"] is True
430 +
431 +
432 +def test_multiple_calls_interleaved_text(parser: ToolParser) -> None:
433 + tools = make_tools_weather() + make_tools_sum()
434 + request = make_request(tools)
435 + text = (
436 + "Head\n"
437 + '<function name="get_weather"><param name="city">北京</param></function>\n'
438 + "TXT\n"
439 + '<function name="sum_values"><param name="nums">[7,8,9]</param>'
440 + '<param name="exact">false</param></function>\n'
441 + "Tail\n"
442 + )
443 + out = parser.extract_tool_calls(text, request)
444 + assert len(out.tool_calls) == 2
445 + args0 = json.loads(out.tool_calls[0].function.arguments)
446 + assert args0["city"] == "北京"
447 + args1 = json.loads(out.tool_calls[1].function.arguments)
448 + assert args1["nums"] == [7, 8, 9]
449 + assert args1["exact"] is False
450 + assert out.content is None
451 +
452 +
453 +def test_incomplete_missing_function_end(parser: ToolParser) -> None:
454 + request = make_request(make_tools_weather())
455 + text = '<function name="get_weather"><param name="city">北京</param>'
456 + out = parser.extract_tool_calls(text, request)
457 + assert not out.tools_called
458 + assert "get_weather" in (out.content or "")
459 +
460 +
461 +def test_param_missing_name_invalid(parser: ToolParser) -> None:
462 + request = make_request(make_tools_weather())
463 + text = (
464 + '<function name="get_weather">'
465 + "<param>北京</param>"
466 + '<param name="date">2024-06-27</param>'
467 + "</function>\n"
468 + )
469 + out = parser.extract_tool_calls(text, request)
470 + assert not out.tools_called
471 + assert "<param>北京</param>" in (out.content or "")
472 +
473 +
474 +def test_duplicate_param_names_invalid(parser: ToolParser) -> None:
475 + request = make_request(make_tools_weather())
476 + text = (
477 + '<function name="get_weather">'
478 + '<param name="city">北京</param>'
479 + '<param name="city">上海</param>'
480 + "</function>\n"
481 + )
482 + out = parser.extract_tool_calls(text, request)
483 + assert not out.tools_called
484 +
485 +
486 +def test_case_sensitive_param_name_invalid(parser: ToolParser) -> None:
487 + request = make_request(make_tools_weather())
488 + text = '<function name="get_weather"><param name="City">北京</param></function>\n'
489 + out = parser.extract_tool_calls(text, request)
490 + assert not out.tools_called
491 +
492 +
493 +def test_no_required_and_zero_param_valid(parser: ToolParser) -> None:
494 + request = make_request(make_tools_no_required())
495 + text = '<function name="noop"></function>\n'
496 + out = parser.extract_tool_calls(text, request)
497 + assert len(out.tool_calls) == 1
498 + args = json.loads(out.tool_calls[0].function.arguments)
499 + assert args == {}
500 +
501 +
502 +def test_thinking_only_sentencepiece_normalized_in_content(
503 + parser: ToolParser,
504 +) -> None:
505 + request = make_request(make_tools_weather())
506 + text = "\u010aFirst,\u0120I\u0120need\u0120to\u0120check\u0120the\u0120weather."
507 + out = parser.extract_tool_calls(text, request)
508 + assert not out.tools_called
509 + assert out.content is not None
510 + assert "\u0120" not in out.content
511 + assert "\u010a" not in out.content
512 + assert "First, I need to check the weather." in out.content
513 +
514 +
515 +def test_properties_wrapped_arguments(parser: ToolParser) -> None:
516 + tools = [
517 + _tool(
518 + "get_customer_by_phone",
519 + {
520 + "type": "object",
521 + "properties": {"phone_number": {"type": "string"}},
522 + "required": ["phone_number"],
523 + },
524 + )
525 + ]
526 + request = make_request(tools)
527 + text = (
528 + '<function name="get_customer_by_phone">'
529 + "<param name=\"properties\">{'phone_number': '555-123-2002'}</param>"
530 + "</function>"
531 + )
532 + out = parser.extract_tool_calls(text, request)
533 + assert len(out.tool_calls) == 1
534 + assert out.tool_calls[0].function.name == "get_customer_by_phone"
535 + args = json.loads(out.tool_calls[0].function.arguments)
536 + assert args == {"phone_number": "555-123-2002"}
537 +
538 +
539 +def test_arguments_wrapped_arguments(parser: ToolParser) -> None:
540 + request = make_request(make_tools_weather())
541 + text = (
542 + '<function name="get_weather">'
543 + '<param name="arguments">{"city": "上海", "date": "2024-06-27"}</param>'
544 + "</function>"
545 + )
546 + out = parser.extract_tool_calls(text, request)
547 + assert len(out.tool_calls) == 1
548 + args = json.loads(out.tool_calls[0].function.arguments)
549 + assert args == {"city": "上海", "date": "2024-06-27"}
550 +
551 +
552 +def test_wrapped_arguments_still_validate_schema(parser: ToolParser) -> None:
553 + request = make_request(make_tools_weather())
554 + text = (
555 + '<function name="get_weather">'
556 + '<param name="properties">{"unknown": "x"}</param>'
557 + "</function>"
558 + )
559 + out = parser.extract_tool_calls(text, request)
560 + assert not out.tools_called
561 +
562 +
563 +def test_extra_arguments_ignored_when_required_present(parser: ToolParser) -> None:
564 + request = make_request(make_tools_weather())
565 + text = (
566 + '<function name="get_weather">'
567 + '<param name="city">上海</param>'
568 + '<param name="unknown">ignored</param>'
569 + "</function>"
570 + )
571 + out = parser.extract_tool_calls(text, request)
572 + assert len(out.tool_calls) == 1
573 + args = json.loads(out.tool_calls[0].function.arguments)
574 + assert args == {"city": "上海"}
575 +
576 +
577 +def test_extra_arguments_do_not_satisfy_required(parser: ToolParser) -> None:
578 + request = make_request(make_tools_weather())
579 + text = (
580 + '<function name="get_weather"><param name="unknown">ignored</param></function>'
581 + )
582 + out = parser.extract_tool_calls(text, request)
583 + assert not out.tools_called
584 +
585 +
586 +def test_zero_arg_tool_ignores_extra_arguments(parser: ToolParser) -> None:
587 + request = make_request(make_tools_no_required())
588 + text = (
589 + '<function name="noop">'
590 + '<param name="note">ignored</param>'
591 + '<param name="extra">ignored</param>'
592 + "</function>"
593 + )
594 + out = parser.extract_tool_calls(text, request)
595 + assert len(out.tool_calls) == 1
596 + args = json.loads(out.tool_calls[0].function.arguments)
597 + assert args == {"note": "ignored"}
598 +
599 +
600 +def test_alias_get_details_by_phone(parser: ToolParser) -> None:
601 + tools = [
602 + _tool(
603 + "get_customer_by_phone",
604 + {
605 + "type": "object",
606 + "properties": {"phone_number": {"type": "string"}},
607 + "required": ["phone_number"],
608 + },
609 + )
610 + ]
611 + request = make_request(tools)
612 + text = (
613 + '<function name="get_details_by_phone">'
614 + '<param name="phone_number">555-123-2002</param>'
615 + "</function>"
616 + )
617 + out = parser.extract_tool_calls(text, request)
618 + assert len(out.tool_calls) == 1
619 + assert out.tool_calls[0].function.name == "get_customer_by_phone"
620 + args = json.loads(out.tool_calls[0].function.arguments)
621 + assert args == {"phone_number": "555-123-2002"}
622 +
623 +
624 +def test_alias_get_line_details(parser: ToolParser) -> None:
625 + tools = [
626 + _tool(
627 + "get_details_by_id",
628 + {
629 + "type": "object",
630 + "properties": {"id": {"type": "string"}},
631 + "required": ["id"],
632 + },
633 + )
634 + ]
635 + request = make_request(tools)
636 + text = (
637 + '<function name="get_line_details">'
638 + '<param name="customer_id">C1001</param>'
639 + '<param name="line_id">L1001</param>'
640 + "</function>"
641 + )
642 + out = parser.extract_tool_calls(text, request)
643 + assert len(out.tool_calls) == 1
644 + assert out.tool_calls[0].function.name == "get_details_by_id"
645 + args = json.loads(out.tool_calls[0].function.arguments)
646 + assert args == {"id": "L1001"}
647 +
648 +
649 +def test_alias_enable_roaming(parser: ToolParser) -> None:
650 + tools = [
651 + _tool(
652 + "toggle_roaming",
653 + {
654 + "type": "object",
655 + "properties": {
656 + "line_id": {"type": "string"},
657 + "enabled": {"type": "boolean"},
658 + },
659 + "required": ["line_id", "enabled"],
660 + },
661 + )
662 + ]
663 + request = make_request(tools)
664 + text = (
665 + '<function name="enable_roaming"><param name="line_id">L1001</param></function>'
666 + )
667 + out = parser.extract_tool_calls(text, request)
668 + assert len(out.tool_calls) == 1
669 + assert out.tool_calls[0].function.name == "toggle_roaming"
670 + args = json.loads(out.tool_calls[0].function.arguments)
671 + assert args == {"line_id": "L1001", "enabled": True}
672 +
673 +
674 +def _random_chunks(text: str, min_len: int, max_len: int) -> list[str]:
675 + chunks: list[str] = []
676 + index = 0
677 + while index < len(text):
678 + size = random.randint(min_len, max_len)
679 + chunks.append(text[index : index + size])
680 + index += size
681 + return chunks
682 +
683 +
684 +def test_extract_tool_calls_streaming_single(parser: ToolParser) -> None:
685 + request = make_request(make_tools_weather())
686 + text = (
687 + "Intro before.\n"
688 + '<function name="get_weather">'
689 + '<param name="city">上海</param>'
690 + '<param name="date">2024-06-27</param>'
691 + "</function>\n"
692 + "Outro after.\n"
693 + )
694 + random.seed(0)
695 + reconstructor = run_tool_extraction_streaming(
696 + parser,
697 + _random_chunks(text, 1, 4),
698 + request,
699 + )
700 + assert "Intro before." in reconstructor.other_content
701 + assert "Outro after." in reconstructor.other_content
702 + assert len(reconstructor.tool_calls) == 1
703 + assert reconstructor.tool_calls[0].function.name == "get_weather"
704 + assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
705 + "city": "上海",
706 + "date": "2024-06-27",
707 + }
708 +
709 +
710 +def test_extract_tool_calls_streaming_multiple(parser: ToolParser) -> None:
711 + tools = make_tools_weather() + make_tools_sum()
712 + request = make_request(tools)
713 + text = (
714 + "Head\n"
715 + '<function name="get_weather"><param name="city">北京</param></function>\n'
716 + "TXT\n"
717 + '<function name="sum_values"><param name="nums">[7,8,9]</param>'
718 + '<param name="exact">false</param></function>\n'
719 + "Tail\n"
720 + )
721 + random.seed(1)
722 + reconstructor = run_tool_extraction_streaming(
723 + parser,
724 + _random_chunks(text, 1, 5),
725 + request,
726 + )
727 + assert "Head" in reconstructor.other_content
728 + assert "TXT" in reconstructor.other_content
729 + assert "Tail" in reconstructor.other_content
730 + assert len(reconstructor.tool_calls) == 2
731 + assert json.loads(reconstructor.tool_calls[0].function.arguments)["city"] == "北京"
732 + assert json.loads(reconstructor.tool_calls[1].function.arguments) == {
733 + "nums": [7, 8, 9],
734 + "exact": False,
735 + }
vllm/tool_parsers/__init__.py
+4
@@ -130,6 +130,10 @@ _TOOL_PARSERS_TO_REGISTER = {
130 "minimax_tool_parser",
131 "MinimaxToolParser",
132 ),
133 + "minicpm5": (
134 + "minicpm5xml_tool_parser",
135 + "MiniCPM5XMLToolParser",
136 + ),
137 "mistral": (
138 "mistral_tool_parser",
139 "MistralToolParser",
vllm/tool_parsers/minicpm5xml_tool_parser.py new
+814
@@ -0,0 +1,814 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import ast
5 +import json
6 +from collections.abc import Sequence
7 +from typing import Any
8 +
9 +import regex as re
10 +
11 +from vllm.entrypoints.chat_utils import make_tool_call_id
12 +from vllm.entrypoints.openai.chat_completion.protocol import (
13 + ChatCompletionRequest,
14 + ChatCompletionToolsParam,
15 +)
16 +from vllm.entrypoints.openai.engine.protocol import (
17 + DeltaFunctionCall,
18 + DeltaMessage,
19 + DeltaToolCall,
20 + ExtractedToolCallInformation,
21 + FunctionCall,
22 + ToolCall,
23 +)
24 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
25 +from vllm.logger import init_logger
26 +from vllm.tokenizers import TokenizerLike
27 +from vllm.tool_parsers.abstract_tool_parser import (
28 + Tool,
29 + ToolParser,
30 +)
31 +from vllm.tool_parsers.utils import partial_tag_overlap
32 +from vllm.utils import random_uuid
33 +
34 +logger = init_logger(__name__)
35 +
36 +try:
37 + from lxml import etree as ET # type: ignore
38 +
39 + _HAS_LXML = True
40 +except Exception: # pragma: no cover
41 + import xml.etree.ElementTree as ET # type: ignore
42 +
43 + _HAS_LXML = False
44 +
45 +_FUNC_NAME_V1_REGEX = re.compile(r"<function\s+name=['\"]([^'\"]+)['\"][^>]*>")
46 +_PARAM_WITH_NAME_REGEX = re.compile(
47 + r"<param\s+name=['\"]([^'\"]+)['\"]>([\s\S]*?)</param>", re.DOTALL
48 +)
49 +_PARAM_MISSING_NAME_REGEX = re.compile(r"<param(?![^>]*\bname=)[^>]*>", re.DOTALL)
50 +_FUNC_BLOCK_REGEX = re.compile(r"<function.*?</function>", re.DOTALL)
51 +
52 +# SentencePiece/GPT-style decoders may emit U+0120 (Ġ) / U+010A (Ċ).
53 +_TOKENIZER_SPACE = "\u0120"
54 +_TOKENIZER_NEWLINE = "\u010a"
55 +
56 +
57 +def _normalize_model_output(text: str) -> str:
58 + if (
59 + _TOKENIZER_SPACE not in text
60 + and _TOKENIZER_NEWLINE not in text
61 + and "<functionname=" not in text
62 + and "<paramname=" not in text
63 + ):
64 + return text
65 +
66 + normalized = text.replace(_TOKENIZER_SPACE, " ")
67 + normalized = normalized.replace(_TOKENIZER_NEWLINE, "\n")
68 + # Some model outputs collapse tag names and attributes, e.g.
69 + # <functionname="foo"> or <paramname="bar">.
70 + normalized = normalized.replace("<functionname=", "<function name=")
71 + normalized = normalized.replace("<paramname=", "<param name=")
72 + return normalized
73 +
74 +
75 +def _strip_thinking_content(text: str) -> str:
76 + """Return only user-visible content after MiniCPM5 thinking, when present."""
77 + if "</think>" not in text:
78 + return text
79 + visible = text.rsplit("</think>", 1)[-1].lstrip()
80 + return visible if visible else text
81 +
82 +
83 +def _streaming_args_snapshot(args_json: str, *, is_complete: bool) -> str:
84 + """Return the streamed arguments prefix, omitting the closing brace until done."""
85 + if is_complete or not args_json.endswith("}"):
86 + return args_json
87 + return args_json[:-1]
88 +
89 +
90 +def _streaming_args_diff(
91 + prev_args: str, args_json: str, *, is_complete: bool
92 +) -> str | None:
93 + """Compute the next arguments fragment for OpenAI-style streaming accumulation."""
94 + if prev_args == "{}":
95 + prev_args = ""
96 +
97 + target = _streaming_args_snapshot(args_json, is_complete=is_complete)
98 + if not prev_args:
99 + return target or None
100 + if target == prev_args:
101 + return None
102 + if target.startswith(prev_args):
103 + return target[len(prev_args) :] or None
104 +
105 + # Recover from a previously closed partial JSON snapshot.
106 + if prev_args.endswith("}"):
107 + prev_open = prev_args[:-1]
108 + if target.startswith(prev_open):
109 + return target[len(prev_open) :] or None
110 +
111 + return None
112 +
113 +
114 +def _parse_arguments(json_value: str) -> tuple[Any, bool]:
115 + try:
116 + try:
117 + parsed_value = json.loads(json_value)
118 + except json.JSONDecodeError:
119 + parsed_value = ast.literal_eval(json_value)
120 + return parsed_value, True
121 + except Exception:
122 + return json_value, False
123 +
124 +
125 +def _get_argument_type(
126 + func_name: str,
127 + arg_key: str,
128 + name_to_tool: dict[str, ChatCompletionToolsParam],
129 +) -> str | None:
130 + tool = name_to_tool.get(func_name)
131 + if tool is None or tool.function.parameters is None:
132 + return None
133 + props = tool.function.parameters.get("properties", {})
134 + if arg_key not in props:
135 + return None
136 + return props[arg_key].get("type")
137 +
138 +
139 +def _coerce_argument_value(
140 + func_name: str,
141 + arg_key: str,
142 + value: Any,
143 + name_to_tool: dict[str, ChatCompletionToolsParam],
144 +) -> Any:
145 + arg_type = _get_argument_type(func_name, arg_key, name_to_tool)
146 + if arg_type == "string":
147 + return (
148 + value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
149 + )
150 + if isinstance(value, str):
151 + parsed_val, _ = _parse_arguments(value)
152 + return parsed_val
153 + return value
154 +
155 +
156 +def _add_argument(
157 + func_name: str,
158 + key: str,
159 + val_text: str,
160 + arguments: dict[str, Any],
161 + seen_keys: set[str],
162 + allowed_props: set[str],
163 + name_to_tool: dict[str, ChatCompletionToolsParam],
164 +) -> bool:
165 + """Add one argument; return False if it violates the tool schema."""
166 + # MiniCPM5 can emit OpenAI-style wrappers, e.g.
167 + # <param name="properties">{'id': '...'}</param>. Keep known fields and
168 + # ignore extra fields so valid tool calls do not fall back to long text.
169 + if (
170 + key in {"properties", "arguments"}
171 + and allowed_props
172 + and key not in allowed_props
173 + ):
174 + parsed_val, parsed_ok = _parse_arguments(val_text)
175 + if not parsed_ok or not isinstance(parsed_val, dict):
176 + return False
177 + added = False
178 + for wrapped_key, wrapped_value in parsed_val.items():
179 + if wrapped_key not in allowed_props:
180 + continue
181 + if wrapped_key in seen_keys:
182 + return False
183 + seen_keys.add(wrapped_key)
184 + arguments[wrapped_key] = _coerce_argument_value(
185 + func_name, wrapped_key, wrapped_value, name_to_tool
186 + )
187 + added = True
188 + return added
189 +
190 + if allowed_props and key not in allowed_props:
191 + return True
192 + if key in seen_keys:
193 + return False
194 + seen_keys.add(key)
195 + arguments[key] = _coerce_argument_value(func_name, key, val_text, name_to_tool)
196 + return True
197 +
198 +
199 +def _normalize_alias_tool_call(
200 + func_name: str,
201 + arguments: dict[str, Any],
202 + tool_names: set[str],
203 +) -> tuple[str, dict[str, Any]]:
204 + """Map common MiniCPM5 alias tool names to the exposed tool schema."""
205 + if func_name in tool_names:
206 + return func_name, arguments
207 +
208 + if func_name == "get_details_by_phone" and "get_customer_by_phone" in tool_names:
209 + phone_number = arguments.get("phone_number") or arguments.get("phone")
210 + if phone_number is not None:
211 + return "get_customer_by_phone", {"phone_number": phone_number}
212 +
213 + if func_name == "get_details_by_name" and "get_customer_by_name" in tool_names:
214 + full_name = arguments.get("full_name") or arguments.get("name")
215 + dob = arguments.get("date_of_birth") or arguments.get("dob")
216 + mapped = {}
217 + if full_name is not None:
218 + mapped["full_name"] = full_name
219 + if dob is not None:
220 + mapped["date_of_birth"] = dob
221 + mapped["dob"] = dob
222 + if mapped:
223 + return "get_customer_by_name", mapped
224 +
225 + if (
226 + func_name
227 + in {
228 + "get_line_details",
229 + "get_line_status",
230 + "get_roaming_status",
231 + }
232 + and "get_details_by_id" in tool_names
233 + ):
234 + detail_id = arguments.get("line_id") or arguments.get("id")
235 + if detail_id is not None:
236 + return "get_details_by_id", {"id": detail_id}
237 +
238 + if func_name == "get_plan_details" and "get_details_by_id" in tool_names:
239 + plan_id = arguments.get("plan_id") or arguments.get("id")
240 + if plan_id is not None:
241 + return "get_details_by_id", {"id": plan_id}
242 +
243 + if func_name == "enable_roaming" and "toggle_roaming" in tool_names:
244 + line_id = arguments.get("line_id") or arguments.get("id")
245 + if line_id is not None:
246 + return "toggle_roaming", {"line_id": line_id, "enabled": True}
247 +
248 + if func_name == "disable_roaming" and "toggle_roaming" in tool_names:
249 + line_id = arguments.get("line_id") or arguments.get("id")
250 + if line_id is not None:
251 + return "toggle_roaming", {"line_id": line_id, "enabled": False}
252 +
253 + if (
254 + func_name in {"add_refueled_data", "add_data_to_line"}
255 + and "refuel_data" in tool_names
256 + ):
257 + line_id = arguments.get("line_id") or arguments.get("id")
258 + amount = (
259 + arguments.get("amount_gb")
260 + or arguments.get("gb")
261 + or arguments.get("gb_amount")
262 + or arguments.get("amount")
263 + )
264 + mapped = {}
265 + if "customer_id" in arguments:
266 + mapped["customer_id"] = arguments["customer_id"]
267 + if line_id is not None:
268 + mapped["line_id"] = line_id
269 + if amount is not None:
270 + mapped["amount_gb"] = amount
271 + if mapped:
272 + return "refuel_data", mapped
273 +
274 + return func_name, arguments
275 +
276 +
277 +def _build_tool_maps(
278 + tools: list[ChatCompletionToolsParam] | None,
279 +) -> tuple[
280 + set[str],
281 + dict[str, set[str]],
282 + dict[str, set[str]],
283 + dict[str, ChatCompletionToolsParam],
284 +]:
285 + name_to_tool: dict[str, ChatCompletionToolsParam] = {}
286 + name_to_allowed_props: dict[str, set[str]] = {}
287 + name_to_required: dict[str, set[str]] = {}
288 +
289 + for tool in tools or []:
290 + name = tool.function.name
291 + if not name:
292 + continue
293 + name_to_tool[name] = tool
294 + params = tool.function.parameters or {}
295 + props = params.get("properties", {}) if isinstance(params, dict) else {}
296 + name_to_allowed_props[name] = set(props.keys())
297 + required = params.get("required", []) if isinstance(params, dict) else []
298 + try:
299 + name_to_required[name] = set(required)
300 + except TypeError:
301 + logger.warning(
302 + "Failed to parse 'required' field for tool %s. "
303 + "It should be a list of strings. Got: %s",
304 + name,
305 + required,
306 + )
307 + name_to_required[name] = set()
308 +
309 + return (
310 + set(name_to_tool.keys()),
311 + name_to_allowed_props,
312 + name_to_required,
313 + name_to_tool,
314 + )
315 +
316 +
317 +def _parse_function_block(
318 + block: str,
319 + tool_names: set[str],
320 + name_to_allowed_props: dict[str, set[str]],
321 + name_to_required: dict[str, set[str]],
322 + name_to_tool: dict[str, ChatCompletionToolsParam],
323 +) -> dict[str, Any] | None:
324 + """Return {name, parameters} if block is valid, else None."""
325 + func_name: str | None = None
326 + arguments: dict[str, Any] = {}
327 + parsed_ok = False
328 + param_invalid = False
329 +
330 + try:
331 + if _HAS_LXML:
332 + try:
333 + parser = ET.XMLParser(**{"strip_cdata": False}) # type: ignore[call-arg]
334 + except TypeError:
335 + parser = ET.XMLParser()
336 + root = ET.fromstring(block, parser=parser)
337 + else:
338 + root = ET.fromstring(block)
339 +
340 + if root.tag == "function":
341 + func_node = root
342 + else:
343 + func_node = root.find("function") if hasattr(root, "find") else None
344 +
345 + if func_node is not None:
346 + func_name = (func_node.attrib.get("name") or "").strip()
347 +
348 + args_node = func_node.find("arguments") if func_node is not None else None
349 + param_nodes: list[Any] = []
350 + if func_node is not None:
351 + param_nodes = list(func_node.findall("param"))
352 + if args_node is not None and not param_nodes:
353 + param_nodes = list(args_node.findall("param"))
354 +
355 + if func_node is not None:
356 + seen_keys: set[str] = set()
357 + allowed_props = name_to_allowed_props.get(func_name or "", set())
358 + has_invalid_param = False
359 + for param in param_nodes:
360 + key = param.attrib.get("name")
361 + if not key:
362 + has_invalid_param = True
363 + break
364 + val_text = (param.text or "").strip()
365 + if not _add_argument(
366 + func_name or "",
367 + key,
368 + val_text,
369 + arguments,
370 + seen_keys,
371 + allowed_props,
372 + name_to_tool,
373 + ):
374 + has_invalid_param = True
375 + break
376 + if has_invalid_param:
377 + arguments.clear()
378 + param_invalid = True
379 + parsed_ok = bool(func_name)
380 + except Exception:
381 + parsed_ok = False
382 +
383 + if not parsed_ok:
384 + try:
385 + m_fn = _FUNC_NAME_V1_REGEX.search(block)
386 + if m_fn:
387 + func_name = (m_fn.group(1) or "").strip()
388 + has_invalid_param = bool(_PARAM_MISSING_NAME_REGEX.search(block))
389 + seen_keys = set()
390 + allowed_props = name_to_allowed_props.get(func_name or "", set())
391 + for pm in _PARAM_WITH_NAME_REGEX.finditer(block):
392 + key = pm.group(1).strip()
393 + val_text = pm.group(2) or ""
394 + if val_text.startswith("<![CDATA[") and val_text.endswith("]]>"):
395 + val_text = val_text[len("<![CDATA[") : -len("]]>")]
396 + val_text = val_text.strip()
397 + if not _add_argument(
398 + func_name or "",
399 + key,
400 + val_text,
401 + arguments,
402 + seen_keys,
403 + allowed_props,
404 + name_to_tool,
405 + ):
406 + has_invalid_param = True
407 + break
408 + if has_invalid_param:
409 + arguments.clear()
410 + param_invalid = True
411 + parsed_ok = bool(func_name)
412 + except Exception:
413 + parsed_ok = False
414 +
415 + if not func_name or param_invalid:
416 + return None
417 +
418 + func_name, arguments = _normalize_alias_tool_call(func_name, arguments, tool_names)
419 +
420 + if func_name not in tool_names:
421 + return None
422 +
423 + req_props = name_to_required.get(func_name, set())
424 + if req_props and not req_props.issubset(arguments.keys()):
425 + return None
426 +
427 + if not parsed_ok:
428 + return None
429 +
430 + return {"name": func_name, "parameters": arguments}
431 +
432 +
433 +def _parse_partial_params(
434 + block: str,
435 + func_name: str,
436 + name_to_allowed_props: dict[str, set[str]],
437 + name_to_tool: dict[str, ChatCompletionToolsParam],
438 +) -> dict[str, Any]:
439 + arguments: dict[str, Any] = {}
440 + seen_keys: set[str] = set()
441 + allowed_props = name_to_allowed_props.get(func_name, set())
442 + for pm in _PARAM_WITH_NAME_REGEX.finditer(block):
443 + key = pm.group(1).strip()
444 + if not key or key in seen_keys:
445 + continue
446 + val_text = pm.group(2) or ""
447 + if val_text.startswith("<![CDATA[") and val_text.endswith("]]>"):
448 + val_text = val_text[len("<![CDATA[") : -len("]]>")]
449 + val_text = val_text.strip()
450 + _add_argument(
451 + func_name,
452 + key,
453 + val_text,
454 + arguments,
455 + seen_keys,
456 + allowed_props,
457 + name_to_tool,
458 + )
459 + return arguments
460 +
461 +
462 +class MiniCPM5XMLToolParser(ToolParser):
463 + """MiniCPM5 XML tool parser."""
464 +
465 + def __init__(
466 + self,
467 + tokenizer: TokenizerLike,
468 + tools: list[Tool] | None = None,
469 + ):
470 + super().__init__(tokenizer, tools)
471 + self.tool_call_start_token = "<function"
472 + self.tool_call_end_token = "</function>"
473 + self._processed_len = 0
474 +
475 + def _reset_stream_state(self) -> None:
476 + self._processed_len = 0
477 + self.current_tool_id = -1
478 + self.current_tool_name_sent = False
479 + self.prev_tool_call_arr = []
480 + self.streamed_args_for_tool = []
481 +
482 + def adjust_request(
483 + self, request: ChatCompletionRequest | ResponsesRequest
484 + ) -> ChatCompletionRequest | ResponsesRequest:
485 + request = super().adjust_request(request)
486 + if request.tools and request.tool_choice != "none":
487 + # Tool XML tags are special tokens in MiniCPM5; must not strip them
488 + # before tool parsing (see internlm2/mistral vLLM tool parsers).
489 + request.skip_special_tokens = False
490 + return request
491 +
492 + def extract_tool_calls(
493 + self,
494 + model_output: str,
495 + request: ChatCompletionRequest,
496 + ) -> ExtractedToolCallInformation:
497 + model_output = _normalize_model_output(model_output)
498 + if self.tool_call_start_token not in model_output:
499 + logger.debug("[MiniCPM5XMLToolParser] no <function token in output")
500 + return ExtractedToolCallInformation(
501 + tools_called=False,
502 + tool_calls=[],
503 + content=_strip_thinking_content(model_output),
504 + )
505 +
506 + tool_names, name_to_allowed_props, name_to_required, name_to_tool = (
507 + _build_tool_maps(request.tools)
508 + )
509 +
510 + tool_calls: list[ToolCall] = []
511 + normal_parts: list[str] = []
512 + last_end = 0
513 +
514 + try:
515 + for match in _FUNC_BLOCK_REGEX.finditer(model_output):
516 + if match.start() > last_end:
517 + normal_parts.append(model_output[last_end : match.start()])
518 +
519 + block = match.group(0)
520 + parsed = _parse_function_block(
521 + block,
522 + tool_names,
523 + name_to_allowed_props,
524 + name_to_required,
525 + name_to_tool,
526 + )
527 + if parsed is not None:
528 + tool_calls.append(
529 + ToolCall(
530 + id=f"call_{random_uuid()}",
531 + type="function",
532 + function=FunctionCall(
533 + name=parsed["name"],
534 + arguments=json.dumps(
535 + parsed["parameters"],
536 + ensure_ascii=False,
537 + ),
538 + ),
539 + )
540 + )
541 + else:
542 + normal_parts.append(block)
543 +
544 + last_end = match.end()
545 +
546 + if last_end < len(model_output):
547 + normal_parts.append(model_output[last_end:])
548 +
549 + content = _strip_thinking_content("".join(normal_parts).strip())
550 +
551 + logger.debug(
552 + "[MiniCPM5XMLToolParser] extracted %d tool calls",
553 + len(tool_calls),
554 + )
555 +
556 + tools_called = len(tool_calls) > 0
557 + return ExtractedToolCallInformation(
558 + tools_called=tools_called,
559 + tool_calls=tool_calls,
560 + content=None if tools_called else content,
561 + )
562 + except Exception as e:
563 + logger.error("Error in MiniCPM5XMLToolParser.extract_tool_calls: %s", e)
564 + return ExtractedToolCallInformation(
565 + tools_called=False,
566 + tool_calls=[],
567 + content=model_output,
568 + )
569 +
570 + def _emit_tool_args_delta(
571 + self,
572 + tool_index: int,
573 + args_json: str,
574 + *,
575 + is_complete: bool = False,
576 + ) -> DeltaMessage | None:
577 + prev_args = (
578 + self.streamed_args_for_tool[tool_index]
579 + if tool_index < len(self.streamed_args_for_tool)
580 + else ""
581 + )
582 + arg_diff = _streaming_args_diff(
583 + prev_args,
584 + args_json,
585 + is_complete=is_complete,
586 + )
587 + if not arg_diff:
588 + return None
589 + while len(self.streamed_args_for_tool) <= tool_index:
590 + self.streamed_args_for_tool.append("")
591 + self.streamed_args_for_tool[tool_index] = prev_args + arg_diff
592 + return DeltaMessage(
593 + tool_calls=[
594 + DeltaToolCall(
595 + index=tool_index,
596 + function=DeltaFunctionCall(
597 + arguments=arg_diff,
598 + ).model_dump(exclude_none=True),
599 + )
600 + ],
601 + )
602 +
603 + def _start_tool_call(self, func_name: str) -> DeltaMessage:
604 + self.current_tool_id += 1
605 + self.current_tool_name_sent = True
606 + while len(self.streamed_args_for_tool) <= self.current_tool_id:
607 + self.streamed_args_for_tool.append("")
608 + while len(self.prev_tool_call_arr) <= self.current_tool_id:
609 + self.prev_tool_call_arr.append({})
610 + self.prev_tool_call_arr[self.current_tool_id] = {"name": func_name}
611 + return DeltaMessage(
612 + tool_calls=[
613 + DeltaToolCall(
614 + index=self.current_tool_id,
615 + id=make_tool_call_id(),
616 + type="function",
617 + function=DeltaFunctionCall(
618 + name=func_name,
619 + ).model_dump(exclude_none=True),
620 + )
621 + ],
622 + )
623 +
624 + def _process_complete_block_streaming(
625 + self,
626 + block: str,
627 + request: ChatCompletionRequest,
628 + ) -> DeltaMessage | None:
629 + tool_names, name_to_allowed_props, name_to_required, name_to_tool = (
630 + _build_tool_maps(request.tools)
631 + )
632 + parsed = _parse_function_block(
633 + block,
634 + tool_names,
635 + name_to_allowed_props,
636 + name_to_required,
637 + name_to_tool,
638 + )
639 + if parsed is None:
640 + return DeltaMessage(content=block)
641 +
642 + args_json = json.dumps(parsed["parameters"], ensure_ascii=False)
643 + func_name = parsed["name"]
644 +
645 + if not self.current_tool_name_sent:
646 + self.current_tool_id += 1
647 + tool_index = self.current_tool_id
648 + while len(self.streamed_args_for_tool) <= tool_index:
649 + self.streamed_args_for_tool.append("")
650 + while len(self.prev_tool_call_arr) <= tool_index:
651 + self.prev_tool_call_arr.append({})
652 + self.streamed_args_for_tool[tool_index] = args_json
653 + self.prev_tool_call_arr[tool_index] = {
654 + "name": func_name,
655 + "arguments": parsed["parameters"],
656 + }
657 + if not self.prev_tool_call_arr:
658 + self.prev_tool_call_arr = [{"arguments": {}}]
659 + return DeltaMessage(
660 + tool_calls=[
661 + DeltaToolCall(
662 + index=tool_index,
663 + id=make_tool_call_id(),
664 + type="function",
665 + function=DeltaFunctionCall(
666 + name=func_name,
667 + arguments=args_json,
668 + ).model_dump(exclude_none=True),
669 + )
670 + ],
671 + )
672 +
673 + tool_index = self.current_tool_id
674 + self.prev_tool_call_arr[tool_index]["arguments"] = parsed["parameters"]
675 + delta = self._emit_tool_args_delta(
676 + tool_index,
677 + args_json,
678 + is_complete=True,
679 + )
680 + self.current_tool_name_sent = False
681 + if delta:
682 + if not self.prev_tool_call_arr:
683 + self.prev_tool_call_arr = [{"arguments": {}}]
684 + return delta
685 + return DeltaMessage(content="")
686 +
687 + def _process_partial_block_streaming(
688 + self,
689 + block: str,
690 + request: ChatCompletionRequest,
691 + ) -> DeltaMessage | None:
692 + tool_names, name_to_allowed_props, _, name_to_tool = _build_tool_maps(
693 + request.tools
694 + )
695 + if _PARAM_MISSING_NAME_REGEX.search(block):
696 + return None
697 +
698 + match = _FUNC_NAME_V1_REGEX.search(block)
699 + if not match:
700 + return None
701 + func_name = (match.group(1) or "").strip()
702 + if func_name not in tool_names:
703 + return None
704 +
705 + if not self.current_tool_name_sent:
706 + return self._start_tool_call(func_name)
707 +
708 + arguments = _parse_partial_params(
709 + block,
710 + func_name,
711 + name_to_allowed_props,
712 + name_to_tool,
713 + )
714 + if not arguments:
715 + return None
716 + args_json = json.dumps(arguments, ensure_ascii=False)
717 + self.prev_tool_call_arr[self.current_tool_id]["arguments"] = arguments
718 + delta = self._emit_tool_args_delta(
719 + self.current_tool_id,
720 + args_json,
721 + is_complete=False,
722 + )
723 + if delta and not self.prev_tool_call_arr:
724 + self.prev_tool_call_arr = [{"arguments": {}}]
725 + return delta
726 +
727 + def extract_tool_calls_streaming(
728 + self,
729 + previous_text: str,
730 + current_text: str,
731 + delta_text: str,
732 + previous_token_ids: Sequence[int],
733 + current_token_ids: Sequence[int],
734 + delta_token_ids: Sequence[int],
735 + request: ChatCompletionRequest,
736 + ) -> DeltaMessage | None:
737 + del previous_token_ids, current_token_ids, delta_token_ids
738 + try:
739 + current_text = _normalize_model_output(current_text)
740 + if not previous_text:
741 + self._reset_stream_state()
742 +
743 + if self.tool_call_start_token not in current_text:
744 + if self._processed_len < len(current_text):
745 + content = current_text[self._processed_len :]
746 + self._processed_len = len(current_text)
747 + return DeltaMessage(content=content) if content else None
748 + return None
749 +
750 + for match in _FUNC_BLOCK_REGEX.finditer(current_text):
751 + if match.end() <= self._processed_len:
752 + continue
753 +
754 + if match.start() > self._processed_len:
755 + gap = current_text[self._processed_len : match.start()]
756 + self._processed_len = match.start()
757 + return DeltaMessage(content=gap) if gap else None
758 +
759 + block = match.group(0)
760 + delta = self._process_complete_block_streaming(block, request)
761 + self._processed_len = match.end()
762 + if delta is not None:
763 + return delta
764 +
765 + remainder = current_text[self._processed_len :]
766 + if not remainder:
767 + if (
768 + not delta_text
769 + and self.current_tool_id >= 0
770 + and not self.current_tool_name_sent
771 + ):
772 + return DeltaMessage(content="")
773 + return None
774 +
775 + func_idx = remainder.find(self.tool_call_start_token)
776 + if func_idx > 0:
777 + gap = remainder[:func_idx]
778 + self._processed_len += func_idx
779 + return DeltaMessage(content=gap)
780 +
781 + if func_idx == -1:
782 + overlap = partial_tag_overlap(remainder, self.tool_call_start_token)
783 + if overlap:
784 + return None
785 + self._processed_len = len(current_text)
786 + return DeltaMessage(content=remainder) if remainder else None
787 +
788 + partial_block = remainder[func_idx:]
789 + if self.tool_call_end_token in partial_block:
790 + end_idx = partial_block.rfind(self.tool_call_end_token)
791 + complete_block = partial_block[
792 + : end_idx + len(self.tool_call_end_token)
793 + ]
794 + delta = self._process_complete_block_streaming(complete_block, request)
795 + self._processed_len += func_idx + len(complete_block)
796 + if delta is not None:
797 + return delta
798 + partial_block = partial_block[end_idx + len(self.tool_call_end_token) :]
799 + if not partial_block.strip():
800 + return None
801 + func_idx = partial_block.find(self.tool_call_start_token)
802 + if func_idx == -1:
803 + self._processed_len = len(current_text)
804 + if partial_block:
805 + return DeltaMessage(content=partial_block)
806 + return None
807 + partial_block = partial_block[func_idx:]
808 +
809 + return self._process_partial_block_streaming(partial_block, request)
810 + except Exception:
811 + logger.exception(
812 + "Error in MiniCPM5XMLToolParser.extract_tool_calls_streaming"
813 + )
814 + return None