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