main
py 1,529 lines 47.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import Generator
6
7 import pytest
8 from openai.types.responses import FunctionTool
9
10 from vllm.entrypoints.generate.base.protocol import (
11 DeltaMessage,
12 FunctionCall,
13 ToolCall,
14 )
15 from vllm.entrypoints.openai.chat_completion.protocol import (
16 ChatCompletionRequest,
17 ChatCompletionToolsParam,
18 )
19 from vllm.tokenizers import TokenizerLike, get_tokenizer
20 from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
21 from vllm.tool_parsers.step3p5_tool_parser import Step3p5ToolParser
22
23 MODEL = "stepfun-ai/Step-3.5-Flash"
24
25
26 @pytest.fixture(scope="module")
27 def step3p5_tokenizer():
28 return get_tokenizer(tokenizer_name=MODEL)
29
30
31 @pytest.fixture
32 def step3p5_tool_parser(step3p5_tokenizer, sample_tools):
33 return Step3p5ToolParser(step3p5_tokenizer, tools=sample_tools)
34
35
36 @pytest.fixture
37 def sample_tools():
38 return [
39 ChatCompletionToolsParam(
40 type="function",
41 function={
42 "name": "get_current_weather",
43 "description": "Get the current weather",
44 "parameters": {
45 "type": "object",
46 "properties": {
47 "city": {"type": "string", "description": "The city name"},
48 "state": {"type": "string", "description": "The state code"},
49 "unit": {"type": "string", "enum": ["fahrenheit", "celsius"]},
50 },
51 "required": ["city", "state"],
52 },
53 },
54 ),
55 ChatCompletionToolsParam(
56 type="function",
57 function={
58 "name": "calculate_area",
59 "description": "Calculate area of a shape",
60 "parameters": {
61 "type": "object",
62 "properties": {
63 "shape": {"type": "string"},
64 "dimensions": {"type": "object"},
65 "precision": {"type": "integer"},
66 },
67 },
68 },
69 ),
70 ]
71
72
73 def assert_tool_calls(
74 actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
75 ):
76 assert len(actual_tool_calls) == len(expected_tool_calls)
77
78 for actual_tool_call, expected_tool_call in zip(
79 actual_tool_calls, expected_tool_calls
80 ):
81 assert actual_tool_call.type == "function"
82 assert actual_tool_call.function.name == expected_tool_call.function.name
83 assert json.loads(actual_tool_call.function.arguments) == json.loads(
84 expected_tool_call.function.arguments
85 )
86
87
88 def stream_delta_message_generator(
89 step3p5_tool_parser,
90 step3p5_tokenizer: TokenizerLike,
91 model_output: str,
92 request: ChatCompletionRequest | None = None,
93 ) -> Generator[DeltaMessage, None, None]:
94 all_token_ids = step3p5_tokenizer.encode(model_output, add_special_tokens=False)
95
96 previous_text = ""
97 previous_tokens = None
98 prefix_offset = 0
99 read_offset = 0
100 for i, delta_token in enumerate(all_token_ids):
101 delta_token_ids = [delta_token]
102 previous_token_ids = all_token_ids[:i]
103 current_token_ids = all_token_ids[: i + 1]
104
105 (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
106 detokenize_incrementally(
107 tokenizer=step3p5_tokenizer,
108 all_input_ids=current_token_ids,
109 prev_tokens=previous_tokens,
110 prefix_offset=prefix_offset,
111 read_offset=read_offset,
112 skip_special_tokens=False,
113 spaces_between_special_tokens=True,
114 )
115 )
116
117 current_text = previous_text + delta_text
118
119 delta_message = step3p5_tool_parser.extract_tool_calls_streaming(
120 previous_text,
121 current_text,
122 delta_text,
123 previous_token_ids,
124 current_token_ids,
125 delta_token_ids,
126 request=request,
127 )
128 if delta_message:
129 yield delta_message
130
131 previous_text = current_text
132 previous_tokens = (
133 previous_tokens + new_tokens if previous_tokens else new_tokens
134 )
135 prefix_offset = new_prefix_offset
136 read_offset = new_read_offset
137
138
139 def stream_delta_message_generator_from_chunks(
140 step3p5_tool_parser,
141 step3p5_tokenizer: TokenizerLike,
142 delta_text_chunks: list[str],
143 request: ChatCompletionRequest | None = None,
144 ) -> Generator[DeltaMessage, None, None]:
145 previous_text = ""
146 previous_token_ids: list[int] = []
147
148 for delta_text in delta_text_chunks:
149 delta_token_ids = step3p5_tokenizer.encode(delta_text, add_special_tokens=False)
150 current_text = previous_text + delta_text
151 current_token_ids = previous_token_ids + delta_token_ids
152
153 delta_message = step3p5_tool_parser.extract_tool_calls_streaming(
154 previous_text,
155 current_text,
156 delta_text,
157 previous_token_ids,
158 current_token_ids,
159 delta_token_ids,
160 request=request,
161 )
162 if delta_message:
163 yield delta_message
164
165 previous_text = current_text
166 previous_token_ids = current_token_ids
167
168
169 def test_extract_tool_calls_no_tools(step3p5_tool_parser):
170 model_output = "This is a test response without any tool calls"
171 extracted_tool_calls = step3p5_tool_parser.extract_tool_calls(
172 model_output, request=None
173 )
174 assert not extracted_tool_calls.tools_called
175 assert extracted_tool_calls.tool_calls == []
176 assert extracted_tool_calls.content == model_output
177
178
179 @pytest.mark.parametrize(
180 ids=[
181 "single_tool",
182 "single_tool_with_content",
183 "single_tool_multiline_param",
184 "parallel_tools",
185 "tool_with_typed_params",
186 ],
187 argnames=["model_output", "expected_tool_calls", "expected_content"],
188 argvalues=[
189 (
190 """<tool_call>
191 <function=get_current_weather>
192 <parameter=city>
193 Dallas
194 </parameter>
195 <parameter=state>
196 TX
197 </parameter>
198 <parameter=unit>
199 fahrenheit
200 </parameter>
201 </function>
202 </tool_call>""",
203 [
204 ToolCall(
205 function=FunctionCall(
206 name="get_current_weather",
207 arguments=json.dumps(
208 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
209 ),
210 )
211 )
212 ],
213 None,
214 ),
215 (
216 """Sure! Let me check the weather for you.<tool_call>
217 <function=get_current_weather>
218 <parameter=city>
219 Dallas
220 </parameter>
221 <parameter=state>
222 TX
223 </parameter>
224 <parameter=unit>
225 fahrenheit
226 </parameter>
227 </function>
228 </tool_call>""",
229 [
230 ToolCall(
231 function=FunctionCall(
232 name="get_current_weather",
233 arguments=json.dumps(
234 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
235 ),
236 )
237 )
238 ],
239 "Sure! Let me check the weather for you.",
240 ),
241 (
242 """<tool_call>
243 <function=calculate_area>
244 <parameter=shape>
245 rectangle
246 </parameter>
247 <parameter=dimensions>
248 {"width": 10,
249 "height": 20}
250 </parameter>
251 <parameter=precision>
252 2
253 </parameter>
254 </function>
255 </tool_call>""",
256 [
257 ToolCall(
258 function=FunctionCall(
259 name="calculate_area",
260 arguments=json.dumps(
261 {
262 "shape": "rectangle",
263 "dimensions": {"width": 10, "height": 20},
264 "precision": 2,
265 }
266 ),
267 )
268 )
269 ],
270 None,
271 ),
272 (
273 """<tool_call>
274 <function=get_current_weather>
275 <parameter=city>
276 Dallas
277 </parameter>
278 <parameter=state>
279 TX
280 </parameter>
281 <parameter=unit>
282 fahrenheit
283 </parameter>
284 </function>
285 </tool_call>
286 <tool_call>
287 <function=get_current_weather>
288 <parameter=city>
289 Orlando
290 </parameter>
291 <parameter=state>
292 FL
293 </parameter>
294 <parameter=unit>
295 fahrenheit
296 </parameter>
297 </function>
298 </tool_call>""",
299 [
300 ToolCall(
301 function=FunctionCall(
302 name="get_current_weather",
303 arguments=json.dumps(
304 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
305 ),
306 )
307 ),
308 ToolCall(
309 function=FunctionCall(
310 name="get_current_weather",
311 arguments=json.dumps(
312 {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}
313 ),
314 )
315 ),
316 ],
317 None,
318 ),
319 (
320 """Let me calculate that area for you.<tool_call>
321 <function=calculate_area>
322 <parameter=shape>
323 circle
324 </parameter>
325 <parameter=dimensions>
326 {"radius": 15.5}
327 </parameter>
328 <parameter=precision>
329 3
330 </parameter>
331 </function>
332 </tool_call>""",
333 [
334 ToolCall(
335 function=FunctionCall(
336 name="calculate_area",
337 arguments=json.dumps(
338 {
339 "shape": "circle",
340 "dimensions": {"radius": 15.5},
341 "precision": 3,
342 }
343 ),
344 )
345 )
346 ],
347 "Let me calculate that area for you.",
348 ),
349 ],
350 )
351 def test_extract_tool_calls(
352 step3p5_tool_parser,
353 sample_tools,
354 model_output,
355 expected_tool_calls,
356 expected_content,
357 ):
358 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
359 extracted_tool_calls = step3p5_tool_parser.extract_tool_calls(
360 model_output, request=request
361 )
362 assert extracted_tool_calls.tools_called
363
364 assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
365
366 assert extracted_tool_calls.content == expected_content
367
368
369 def test_extract_tool_calls_fallback_no_tags(step3p5_tool_parser, sample_tools):
370 """Test fallback parsing when XML tags are missing."""
371 model_output = """<function=get_current_weather>
372 <parameter=city>
373 Dallas
374 </parameter>
375 <parameter=state>
376 TX
377 </parameter>
378 </function>"""
379
380 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
381 extracted_tool_calls = step3p5_tool_parser.extract_tool_calls(
382 model_output, request=request
383 )
384
385 assert extracted_tool_calls.tools_called
386 assert len(extracted_tool_calls.tool_calls) == 1
387 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
388
389
390 @pytest.mark.parametrize("api", ["chat", "responses"])
391 def test_extract_tool_calls_type_conversion(step3p5_tokenizer, api):
392 """Test parameter type conversion based on Chat and Responses tool schemas."""
393 parameters = {
394 "type": "object",
395 "properties": {
396 "int_param": {"type": "integer"},
397 "float_param": {"type": "float"},
398 "bool_param": {"type": "boolean"},
399 "str_param": {"type": "string"},
400 "obj_param": {"type": "object"},
401 },
402 }
403 tools: list[ChatCompletionToolsParam | FunctionTool] = (
404 [
405 ChatCompletionToolsParam(
406 type="function",
407 function={"name": "test_types", "parameters": parameters},
408 )
409 ]
410 if api == "chat"
411 else [FunctionTool(type="function", name="test_types", parameters=parameters)]
412 )
413
414 model_output = """<tool_call>
415 <function=test_types>
416 <parameter=int_param>
417 42
418 </parameter>
419 <parameter=float_param>
420 3.14
421 </parameter>
422 <parameter=bool_param>
423 true
424 </parameter>
425 <parameter=str_param>
426 hello world
427 </parameter>
428 <parameter=obj_param>
429 {"key": "value"}
430 </parameter>
431 </function>
432 </tool_call>"""
433
434 parser = Step3p5ToolParser(step3p5_tokenizer, tools=tools)
435 request = ChatCompletionRequest(model=MODEL, messages=[])
436 extracted_tool_calls = parser.extract_tool_calls(model_output, request=request)
437
438 args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
439 assert args["int_param"] == 42
440 assert args["float_param"] == 3.14
441 assert args["bool_param"] is True
442 assert args["str_param"] == "hello world"
443 assert args["obj_param"] == {"key": "value"}
444
445
446 @pytest.mark.parametrize(
447 ids=[
448 "no_tools",
449 "single_tool",
450 "single_tool_with_content",
451 "single_tool_multiline_param",
452 "parallel_tools",
453 "tool_with_typed_params", # Added this test case
454 ],
455 argnames=["model_output", "expected_tool_calls", "expected_content"],
456 argvalues=[
457 ("This is a test without tools", [], "This is a test without tools"),
458 (
459 """<tool_call>
460 <function=get_current_weather>
461 <parameter=city>
462 Dallas
463 </parameter>
464 <parameter=state>
465 TX
466 </parameter>
467 <parameter=unit>
468 fahrenheit
469 </parameter>
470 </function>
471 </tool_call>""",
472 [
473 ToolCall(
474 function=FunctionCall(
475 name="get_current_weather",
476 arguments=json.dumps(
477 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
478 ),
479 )
480 )
481 ],
482 None,
483 ),
484 (
485 """Sure! Let me check the weather for you.<tool_call>
486 <function=get_current_weather>
487 <parameter=city>
488 Dallas
489 </parameter>
490 <parameter=state>
491 TX
492 </parameter>
493 <parameter=unit>
494 fahrenheit
495 </parameter>
496 </function>
497 </tool_call>""",
498 [
499 ToolCall(
500 function=FunctionCall(
501 name="get_current_weather",
502 arguments=json.dumps(
503 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
504 ),
505 )
506 )
507 ],
508 "Sure! Let me check the weather for you.",
509 ),
510 (
511 """<tool_call>
512 <function=calculate_area>
513 <parameter=shape>
514 rectangle
515 </parameter>
516 <parameter=dimensions>
517 {"width": 10,
518 "height": 20}
519 </parameter>
520 <parameter=precision>
521 2
522 </parameter>
523 </function>
524 </tool_call>""",
525 [
526 ToolCall(
527 function=FunctionCall(
528 name="calculate_area",
529 arguments=json.dumps(
530 {
531 "shape": "rectangle",
532 "dimensions": {"width": 10, "height": 20},
533 "precision": 2,
534 }
535 ),
536 )
537 )
538 ],
539 None,
540 ),
541 (
542 """<tool_call>
543 <function=get_current_weather>
544 <parameter=city>
545 Dallas
546 </parameter>
547 <parameter=state>
548 TX
549 </parameter>
550 <parameter=unit>
551 fahrenheit
552 </parameter>
553 </function>
554 </tool_call>
555 <tool_call>
556 <function=get_current_weather>
557 <parameter=city>
558 Orlando
559 </parameter>
560 <parameter=state>
561 FL
562 </parameter>
563 <parameter=unit>
564 celsius
565 </parameter>
566 </function>
567 </tool_call>""",
568 [
569 ToolCall(
570 function=FunctionCall(
571 name="get_current_weather",
572 arguments=json.dumps(
573 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
574 ),
575 )
576 ),
577 ToolCall(
578 function=FunctionCall(
579 name="get_current_weather",
580 arguments=json.dumps(
581 {"city": "Orlando", "state": "FL", "unit": "celsius"}
582 ),
583 )
584 ),
585 ],
586 None,
587 ),
588 # Added tool_with_typed_params test case
589 (
590 """Let me calculate that area for you.<tool_call>
591 <function=calculate_area>
592 <parameter=shape>
593 circle
594 </parameter>
595 <parameter=dimensions>
596 {"radius": 15.5}
597 </parameter>
598 <parameter=precision>
599 3
600 </parameter>
601 </function>
602 </tool_call>""",
603 [
604 ToolCall(
605 function=FunctionCall(
606 name="calculate_area",
607 arguments=json.dumps(
608 {
609 "shape": "circle",
610 "dimensions": {"radius": 15.5},
611 "precision": 3,
612 }
613 ),
614 )
615 )
616 ],
617 "Let me calculate that area for you.",
618 ),
619 ],
620 )
621 def test_extract_tool_calls_streaming(
622 step3p5_tool_parser,
623 step3p5_tokenizer,
624 sample_tools,
625 model_output,
626 expected_tool_calls,
627 expected_content,
628 ):
629 """Test incremental streaming behavior including typed parameters."""
630 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
631
632 other_content = ""
633 tool_states = {} # Track state per tool index
634
635 for delta_message in stream_delta_message_generator(
636 step3p5_tool_parser, step3p5_tokenizer, model_output, request
637 ):
638 # role should never be streamed from tool parser
639 assert not delta_message.role
640
641 if delta_message.content:
642 other_content += delta_message.content
643
644 if delta_message.tool_calls:
645 for tool_call in delta_message.tool_calls:
646 idx = tool_call.index
647
648 # Initialize state for new tool
649 if idx not in tool_states:
650 tool_states[idx] = {
651 "id": None,
652 "name": None,
653 "arguments": "",
654 "type": None,
655 }
656
657 # First chunk should have id, name, and type
658 if tool_call.id:
659 tool_states[idx]["id"] = tool_call.id
660
661 if tool_call.type:
662 assert tool_call.type == "function"
663 tool_states[idx]["type"] = tool_call.type
664
665 if tool_call.function:
666 if tool_call.function.name:
667 # Should only be set once
668 assert tool_states[idx]["name"] is None
669 tool_states[idx]["name"] = tool_call.function.name
670
671 if tool_call.function.arguments is not None:
672 # Accumulate arguments incrementally
673 tool_states[idx]["arguments"] += tool_call.function.arguments
674
675 # Verify final content
676 assert other_content == (expected_content or "") # Handle None case
677
678 # Verify we got all expected tool calls
679 assert len(tool_states) == len(expected_tool_calls)
680
681 # Verify each tool call
682 for idx, expected_tool in enumerate(expected_tool_calls):
683 state = tool_states[idx]
684 assert state["id"] is not None
685 assert state["type"] == "function"
686 assert state["name"] == expected_tool.function.name
687
688 # Parse accumulated arguments
689 arguments_str = state["arguments"]
690 assert arguments_str is not None
691 actual_args = json.loads(arguments_str)
692 expected_args = json.loads(expected_tool.function.arguments)
693 assert actual_args == expected_args
694
695
696 def test_extract_tool_calls_missing_closing_parameter_tag(
697 step3p5_tool_parser, sample_tools
698 ):
699 """Test handling of missing closing </parameter> tag."""
700 # Using get_current_weather from sample_tools but with malformed XML
701 model_output = """Let me check the weather for you:
702 <tool_call>
703 <function=get_current_weather>
704 <parameter=city>
705 Dallas
706 <parameter=state>
707 TX
708 </parameter>
709 <parameter=unit>
710 fahrenheit
711 </parameter>
712 </function>
713 </tool_call>"""
714
715 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
716 extracted_tool_calls = step3p5_tool_parser.extract_tool_calls(
717 model_output, request=request
718 )
719
720 # The parser should handle the malformed XML gracefully
721 assert extracted_tool_calls.tools_called
722 assert len(extracted_tool_calls.tool_calls) == 1
723
724 # Verify the function name is correct
725 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
726
727 # Verify the arguments are parsed despite the missing closing tag
728 args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
729 assert "city" in args
730 assert args["city"] == "Dallas"
731 assert args["state"] == "TX"
732 assert args["unit"] == "fahrenheit"
733
734 # Check that content before the tool call is preserved
735 assert "Let me check the weather for you:" in extracted_tool_calls.content
736
737
738 def test_extract_tool_calls_streaming_missing_closing_tag(
739 step3p5_tool_parser, step3p5_tokenizer, sample_tools
740 ):
741 """Test streaming with missing closing </parameter> tag."""
742 # Using get_current_weather from sample_tools but with malformed XML
743 model_output = """Let me check the weather for you:
744 <tool_call>
745 <function=get_current_weather>
746 <parameter=city>
747 Dallas
748 <parameter=state>
749 TX
750 </parameter>
751 <parameter=unit>
752 fahrenheit
753 </parameter>
754 </function>
755 </tool_call>"""
756
757 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
758
759 other_content = ""
760 tool_states = {}
761
762 for delta_message in stream_delta_message_generator(
763 step3p5_tool_parser, step3p5_tokenizer, model_output, request
764 ):
765 if delta_message.content:
766 other_content += delta_message.content
767
768 if delta_message.tool_calls:
769 for tool_call in delta_message.tool_calls:
770 idx = tool_call.index
771
772 if idx not in tool_states:
773 tool_states[idx] = {
774 "id": None,
775 "name": None,
776 "arguments": "",
777 "type": None,
778 }
779
780 if tool_call.id:
781 tool_states[idx]["id"] = tool_call.id
782
783 if tool_call.type:
784 assert tool_call.type == "function"
785 tool_states[idx]["type"] = tool_call.type
786
787 if tool_call.function:
788 if tool_call.function.name:
789 tool_states[idx]["name"] = tool_call.function.name
790
791 if tool_call.function.arguments is not None:
792 tool_states[idx]["arguments"] += tool_call.function.arguments
793
794 # Verify content was streamed
795 assert "Let me check the weather for you:" in other_content
796
797 # Verify we got the tool call
798 assert len(tool_states) == 1
799 state = tool_states[0]
800 assert state["id"] is not None
801 assert state["type"] == "function"
802 assert state["name"] == "get_current_weather"
803
804 # Verify arguments were parsed correctly despite missing closing tag
805 assert state["arguments"] is not None
806 args = json.loads(state["arguments"])
807 assert args["city"] == "Dallas"
808 assert args["state"] == "TX"
809 assert args["unit"] == "fahrenheit"
810
811
812 def test_extract_tool_calls_streaming_incremental(
813 step3p5_tool_parser, step3p5_tokenizer, sample_tools
814 ):
815 """Test that streaming is truly incremental."""
816 model_output = """I'll check the weather.<tool_call>
817 <function=get_current_weather>
818 <parameter=city>
819 Dallas
820 </parameter>
821 <parameter=state>
822 TX
823 </parameter>
824 </function>
825 </tool_call>"""
826
827 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
828
829 chunks = []
830 for delta_message in stream_delta_message_generator(
831 step3p5_tool_parser, step3p5_tokenizer, model_output, request
832 ):
833 chunks.append(delta_message)
834
835 # Should have multiple chunks
836 assert len(chunks) > 3
837
838 # First chunk(s) should be content
839 assert chunks[0].content is not None
840 assert chunks[0].tool_calls is None or chunks[0].tool_calls == []
841
842 # Should have a chunk with tool header (id, name, type)
843 header_found = False
844 for chunk in chunks:
845 if chunk.tool_calls and chunk.tool_calls[0].id:
846 header_found = True
847 assert chunk.tool_calls[0].function.name == "get_current_weather"
848 assert chunk.tool_calls[0].type == "function"
849 # Empty initially
850 assert not chunk.tool_calls[0].function.arguments
851 break
852 assert header_found
853
854 # Should have chunks with incremental arguments
855 arg_chunks = []
856 for chunk in chunks:
857 if chunk.tool_calls and chunk.tool_calls[0].function.arguments:
858 arg_chunks.append(chunk.tool_calls[0].function.arguments)
859
860 # Arguments should be streamed incrementally
861 assert len(arg_chunks) > 1
862
863 # Concatenated arguments should form valid JSON
864 full_args = "".join(arg_chunks)
865 parsed_args = json.loads(full_args)
866 assert parsed_args["city"] == "Dallas"
867 assert parsed_args["state"] == "TX"
868
869
870 def test_extract_tool_calls_streaming_mixed_content_and_multiple_tool_calls(
871 step3p5_tool_parser, step3p5_tokenizer, sample_tools
872 ):
873 """Test mixed content with multiple complete tool calls.
874
875 Scenario: Model outputs "hello" + complete tool call + "hi" + complete tool call.
876 Expected: "hello" as content, first tool call parsed (index=0), "hi" as content,
877 second tool call parsed (index=1).
878 """
879 # Model output: hello + complete tool call + hi + complete tool call
880 model_output = """hello<tool_call>
881 <function=get_current_weather>
882 <parameter=city>
883 Dallas
884 </parameter>
885 <parameter=state>
886 TX
887 </parameter>
888 </function>
889 </tool_call>hi<tool_call>
890 <function=calculate_area>
891 <parameter=shape>
892 rectangle
893 </parameter>
894 <parameter=dimensions>
895 {"width": 10, "height": 5}
896 </parameter>
897 </function>
898 </tool_call>"""
899
900 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
901
902 other_content = ""
903 tool_states = {}
904
905 for delta_message in stream_delta_message_generator(
906 step3p5_tool_parser, step3p5_tokenizer, model_output, request
907 ):
908 if delta_message.content:
909 other_content += delta_message.content
910
911 if delta_message.tool_calls:
912 for tool_call in delta_message.tool_calls:
913 idx = tool_call.index
914
915 if idx not in tool_states:
916 tool_states[idx] = {
917 "id": None,
918 "name": None,
919 "arguments": "",
920 "type": None,
921 }
922
923 if tool_call.id:
924 tool_states[idx]["id"] = tool_call.id
925
926 if tool_call.type:
927 assert tool_call.type == "function"
928 tool_states[idx]["type"] = tool_call.type
929
930 if tool_call.function:
931 if tool_call.function.name:
932 tool_states[idx]["name"] = tool_call.function.name
933
934 if tool_call.function.arguments is not None:
935 tool_states[idx]["arguments"] += tool_call.function.arguments
936
937 # Should have exactly two complete tool calls
938 assert len(tool_states) == 2, "Should have exactly two complete tool calls"
939
940 # Verify the first tool call (index=0)
941 assert tool_states[0]["name"] == "get_current_weather"
942 assert tool_states[0]["arguments"]
943 args_dict_0 = json.loads(tool_states[0]["arguments"])
944 assert args_dict_0["city"] == "Dallas"
945 assert args_dict_0["state"] == "TX"
946
947 # Verify the second tool call (index=1)
948 assert tool_states[1]["name"] == "calculate_area"
949 assert tool_states[1]["arguments"]
950 args_dict_1 = json.loads(tool_states[1]["arguments"])
951 assert args_dict_1["shape"] == "rectangle"
952 assert isinstance(args_dict_1["dimensions"], dict), "dimensions should be a dict"
953 assert args_dict_1["dimensions"]["width"] == 10
954 assert args_dict_1["dimensions"]["height"] == 5
955 # Verify content: should contain "hello", "hi"
956 assert "hello" in other_content, "Should contain 'hello' as content"
957 assert "hi" in other_content, "Should contain 'hi' as content"
958
959 # Verify the order: hello should come first, then hi
960 hello_index = other_content.find("hello")
961 hi_index = other_content.find("hi")
962
963 assert hello_index >= 0, "'hello' should be in content"
964 assert hi_index > hello_index, "'hi' should come after 'hello'"
965
966 # Verify that tool call tags are NOT in the content
967 # We should not see complete tool call structures in content
968 assert "<function=get_current_weather>" not in other_content, (
969 "First tool call should not be in content"
970 )
971 assert "<function=calculate_area>" not in other_content, (
972 "Second tool call should not be in content"
973 )
974
975
976 def test_extract_tool_calls_non_streaming_mixed_content_and_multiple_tool_calls(
977 step3p5_tool_parser, sample_tools
978 ):
979 """Test non-streaming extraction with mixed content and multiple tool calls.
980
981 Scenario: Model outputs "hello" + complete tool call + "hi" + complete tool call.
982 Expected: "hello" as content, first tool call parsed (index=0), second tool call
983 parsed (index=1). Non-streaming content stops at the first tool call.
984 """
985 # Model output: hello + complete tool call + hi + complete tool call
986 model_output = """hello<tool_call>
987 <function=get_current_weather>
988 <parameter=city>
989 Dallas
990 </parameter>
991 <parameter=state>
992 TX
993 </parameter>
994 </function>
995 </tool_call>hi<tool_call>
996 <function=calculate_area>
997 <parameter=shape>
998 rectangle
999 </parameter>
1000 <parameter=dimensions>
1001 {"width": 10, "height": 5}
1002 </parameter>
1003 </function>
1004 </tool_call>"""
1005
1006 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1007
1008 extracted_tool_calls = step3p5_tool_parser.extract_tool_calls(
1009 model_output, request=request
1010 )
1011
1012 # Should have exactly two complete tool calls
1013 assert extracted_tool_calls.tools_called
1014 assert len(extracted_tool_calls.tool_calls) == 2, (
1015 "Should have exactly two complete tool calls"
1016 )
1017
1018 # Verify the first tool call (index=0)
1019 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
1020 args_dict_0 = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
1021 assert args_dict_0["city"] == "Dallas"
1022 assert args_dict_0["state"] == "TX"
1023
1024 # Verify the second tool call (index=1)
1025 assert extracted_tool_calls.tool_calls[1].function.name == "calculate_area"
1026 args_dict_1 = json.loads(extracted_tool_calls.tool_calls[1].function.arguments)
1027 assert args_dict_1["shape"] == "rectangle"
1028 assert isinstance(args_dict_1["dimensions"], dict), "dimensions should be a dict"
1029 assert args_dict_1["dimensions"]["width"] == 10
1030 assert args_dict_1["dimensions"]["height"] == 5
1031
1032 assert extracted_tool_calls.content == "hello"
1033
1034 # Verify that tool call tags are NOT in the content
1035 assert "<function=get_current_weather>" not in extracted_tool_calls.content, (
1036 "First tool call should not be in content"
1037 )
1038 assert "<function=calculate_area>" not in extracted_tool_calls.content, (
1039 "Second tool call should not be in content"
1040 )
1041
1042
1043 def test_extract_tool_calls_streaming_full_input_mixed_content_and_multiple_tool_calls(
1044 step3p5_tool_parser, step3p5_tokenizer, sample_tools
1045 ):
1046 """Test streaming with entire input as single delta_text.
1047
1048 Scenario: Model outputs "hello" + complete tool call + "hi" + complete tool call.
1049 This test simulates the case where the entire input is sent as a single delta_text.
1050 Expected: "hello" as content, first tool call parsed (index=0), "hi" as content,
1051 second tool call parsed (index=1).
1052 """
1053 # Model output: hello + complete tool call + hi + complete tool call
1054 model_output = """hello<tool_call>
1055 <function=get_current_weather>
1056 <parameter=city>
1057 Dallas
1058 </parameter>
1059 <parameter=state>
1060 TX
1061 </parameter>
1062 </function>
1063 </tool_call>hi<tool_call>
1064 <function=calculate_area>
1065 <parameter=shape>
1066 rectangle
1067 </parameter>
1068 <parameter=dimensions>
1069 {"width": 10, "height": 5}
1070 </parameter>
1071 </function>
1072 </tool_call>"""
1073
1074 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1075
1076 other_content = ""
1077 tool_states = {}
1078
1079 # Encode all content tokens at once
1080 all_token_ids = step3p5_tokenizer.encode(model_output, add_special_tokens=False)
1081 eos_token_id = step3p5_tokenizer.eos_token_id
1082
1083 # Include EOS token in delta_token_ids if available
1084 if eos_token_id is not None:
1085 delta_token_ids = all_token_ids + [eos_token_id]
1086 else:
1087 delta_token_ids = all_token_ids
1088
1089 # current_token_ids includes all content tokens (EOS is not part of the text)
1090 current_token_ids = all_token_ids
1091 previous_token_ids: list[int] = []
1092
1093 # Decode all tokens to get the full text
1094 current_text = step3p5_tokenizer.decode(
1095 current_token_ids, skip_special_tokens=False
1096 )
1097 previous_text = ""
1098 delta_text = current_text
1099
1100 # Call parser once with all tokens including EOS
1101 delta_result = step3p5_tool_parser.extract_tool_calls_streaming(
1102 previous_text,
1103 current_text,
1104 delta_text,
1105 previous_token_ids,
1106 current_token_ids,
1107 delta_token_ids,
1108 request=request,
1109 )
1110
1111 # Serving flushes the parser at the end of the stream.
1112 for delta in (delta_result, step3p5_tool_parser.finish_streaming()):
1113 if not delta:
1114 continue
1115 if delta.content:
1116 other_content += delta.content
1117 if delta.tool_calls:
1118 for tool_call in delta.tool_calls:
1119 idx = tool_call.index
1120 if idx not in tool_states:
1121 tool_states[idx] = {
1122 "id": None,
1123 "name": None,
1124 "arguments": "",
1125 "type": None,
1126 }
1127 if tool_call.id:
1128 tool_states[idx]["id"] = tool_call.id
1129 if tool_call.type:
1130 tool_states[idx]["type"] = tool_call.type
1131 if tool_call.function:
1132 if tool_call.function.name:
1133 tool_states[idx]["name"] = tool_call.function.name
1134 if tool_call.function.arguments is not None:
1135 tool_states[idx]["arguments"] += tool_call.function.arguments
1136
1137 # Should have exactly two complete tool calls
1138 assert len(tool_states) == 2, "Should have exactly two complete tool calls"
1139
1140 # Verify the first tool call (index=0)
1141 assert tool_states[0]["name"] == "get_current_weather"
1142 assert tool_states[0]["arguments"]
1143 args_dict_0 = json.loads(tool_states[0]["arguments"])
1144 assert args_dict_0["city"] == "Dallas"
1145 assert args_dict_0["state"] == "TX"
1146
1147 # Verify the second tool call (index=1)
1148 assert tool_states[1]["name"] == "calculate_area"
1149 assert tool_states[1]["arguments"]
1150 args_dict_1 = json.loads(tool_states[1]["arguments"])
1151 assert args_dict_1["shape"] == "rectangle"
1152 assert isinstance(args_dict_1["dimensions"], dict), "dimensions should be a dict"
1153 assert args_dict_1["dimensions"]["width"] == 10
1154 assert args_dict_1["dimensions"]["height"] == 5
1155
1156 # Verify content: should contain "hello", "hi"
1157 assert "hello" in other_content, "Should contain 'hello' as content"
1158 assert "hi" in other_content, "Should contain 'hi' as content"
1159
1160 # Verify the order: hello should come first, then hi
1161 hello_index = other_content.find("hello")
1162 hi_index = other_content.find("hi")
1163
1164 assert hello_index >= 0, "'hello' should be in content"
1165 assert hi_index > hello_index, "'hi' should come after 'hello'"
1166
1167 # Verify that tool call tags are NOT in the content
1168 assert "<function=get_current_weather>" not in other_content, (
1169 "First tool call should not be in content"
1170 )
1171 assert "<function=calculate_area>" not in other_content, (
1172 "Second tool call should not be in content"
1173 )
1174
1175
1176 def test_extract_tool_calls_streaming_multiple_tool_calls_no_content_between(
1177 step3p5_tool_parser, step3p5_tokenizer, sample_tools
1178 ):
1179 """Test multiple tool calls with no content between them.
1180
1181 Scenario: Model outputs "hello" + tool call + tool call
1182 Expected: "hello" as content, first tool call parsed (index=0),
1183 second tool call parsed (index=1).
1184 No content should appear between the two tool calls.
1185 """
1186 # Model output: hello + tool call + tool call (no content between tool calls)
1187 model_output = """hello<tool_call>
1188 <function=get_current_weather>
1189 <parameter=city>
1190 Dallas
1191 </parameter>
1192 <parameter=state>
1193 TX
1194 </parameter>
1195 </function>
1196 </tool_call><tool_call>
1197 <function=calculate_area>
1198 <parameter=shape>
1199 rectangle
1200 </parameter>
1201 <parameter=dimensions>
1202 {"width": 10, "height": 5}
1203 </parameter>
1204 </function>
1205 </tool_call>"""
1206
1207 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1208
1209 other_content = ""
1210 tool_states = {}
1211
1212 for delta_message in stream_delta_message_generator(
1213 step3p5_tool_parser, step3p5_tokenizer, model_output, request
1214 ):
1215 if delta_message.content:
1216 other_content += delta_message.content
1217
1218 if delta_message.tool_calls:
1219 for tool_call in delta_message.tool_calls:
1220 idx = tool_call.index
1221
1222 if idx not in tool_states:
1223 tool_states[idx] = {
1224 "id": None,
1225 "name": None,
1226 "arguments": "",
1227 "type": None,
1228 }
1229
1230 if tool_call.id:
1231 tool_states[idx]["id"] = tool_call.id
1232
1233 if tool_call.type:
1234 assert tool_call.type == "function"
1235 tool_states[idx]["type"] = tool_call.type
1236
1237 if tool_call.function:
1238 if tool_call.function.name:
1239 tool_states[idx]["name"] = tool_call.function.name
1240
1241 if tool_call.function.arguments is not None:
1242 tool_states[idx]["arguments"] += tool_call.function.arguments
1243
1244 # Should have exactly two complete tool calls
1245 assert len(tool_states) == 2, "Should have exactly two complete tool calls"
1246
1247 # Verify the first tool call (index=0)
1248 assert tool_states[0]["name"] == "get_current_weather"
1249 assert tool_states[0]["arguments"]
1250 args_dict_0 = json.loads(tool_states[0]["arguments"])
1251 assert args_dict_0["city"] == "Dallas"
1252 assert args_dict_0["state"] == "TX"
1253
1254 # Verify the second tool call (index=1)
1255 assert tool_states[1]["name"] == "calculate_area"
1256 assert tool_states[1]["arguments"]
1257 args_dict_1 = json.loads(tool_states[1]["arguments"])
1258 assert args_dict_1["shape"] == "rectangle"
1259 assert isinstance(args_dict_1["dimensions"], dict), "dimensions should be a dict"
1260 assert args_dict_1["dimensions"]["width"] == 10
1261 assert args_dict_1["dimensions"]["height"] == 5
1262
1263 assert "hello" in other_content, "Should contain 'hello' as content"
1264
1265 # Verify that tool call tags are NOT in the content
1266 assert "<function=get_current_weather>" not in other_content, (
1267 "First tool call should not be in content"
1268 )
1269 assert "<function=calculate_area>" not in other_content, (
1270 "Second tool call should not be in content"
1271 )
1272
1273
1274 def test_extract_tool_calls_streaming_multi_token_chunk_boundary(
1275 step3p5_tool_parser, step3p5_tokenizer, sample_tools
1276 ):
1277 """Ensure fallback doesn't close a new tool_call when boundary is in one chunk."""
1278 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1279 delta_text_chunks = [
1280 """<tool_call>
1281 <function=get_current_weather>
1282 <parameter=city>
1283 Sys""",
1284 """
1285 </parameter>
1286 </function>
1287 """,
1288 """</tool_call><tool_call>
1289 <""",
1290 """function=calculate_area>
1291 <parameter=shape>
1292 rectangle""",
1293 """</parameter>
1294 </function>
1295 </tool_call>""",
1296 ]
1297 boundary_chunk = delta_text_chunks[1]
1298 assert len(step3p5_tokenizer.encode(boundary_chunk, add_special_tokens=False)) > 1
1299
1300 tool_states = {}
1301 for delta_message in stream_delta_message_generator_from_chunks(
1302 step3p5_tool_parser, step3p5_tokenizer, delta_text_chunks, request
1303 ):
1304 print(delta_message)
1305 if delta_message.tool_calls:
1306 for tool_call in delta_message.tool_calls:
1307 idx = tool_call.index
1308 if idx not in tool_states:
1309 tool_states[idx] = {
1310 "name": None,
1311 "arguments": "",
1312 }
1313 if tool_call.function:
1314 if tool_call.function.name:
1315 tool_states[idx]["name"] = tool_call.function.name
1316 if tool_call.function.arguments is not None:
1317 tool_states[idx]["arguments"] += tool_call.function.arguments
1318
1319 assert len(tool_states) == 2
1320 assert all(state["name"] for state in tool_states.values())
1321 assert tool_states[0]["name"] == "get_current_weather"
1322 assert tool_states[1]["name"] == "calculate_area"
1323
1324
1325 def test_extract_tool_calls_non_streaming_multiple_tool_calls_no_content_between(
1326 step3p5_tool_parser, sample_tools
1327 ):
1328 """Test non-streaming extraction with tool calls and no content between them.
1329
1330 Scenario: Model outputs "hello" + tool call + tool call.
1331 Expected: "hello" as content, first tool call parsed (index=0),
1332 second tool call parsed (index=1).
1333 No content should appear between the two tool calls.
1334 """
1335 # Model output: hello + tool call + tool call (no content between tool calls)
1336 model_output = """hello<tool_call>
1337 <function=get_current_weather>
1338 <parameter=city>
1339 Dallas
1340 </parameter>
1341 <parameter=state>
1342 TX
1343 </parameter>
1344 </function>
1345 </tool_call><tool_call>
1346 <function=calculate_area>
1347 <parameter=shape>
1348 rectangle
1349 </parameter>
1350 <parameter=dimensions>
1351 {"width": 10, "height": 5}
1352 </parameter>
1353 </function>
1354 </tool_call>"""
1355
1356 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1357
1358 extracted_tool_calls = step3p5_tool_parser.extract_tool_calls(
1359 model_output, request=request
1360 )
1361
1362 # Should have exactly two complete tool calls
1363 assert extracted_tool_calls.tools_called
1364 assert len(extracted_tool_calls.tool_calls) == 2, (
1365 "Should have exactly two complete tool calls"
1366 )
1367
1368 # Verify the first tool call (index=0)
1369 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
1370 args_dict_0 = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
1371 assert args_dict_0["city"] == "Dallas"
1372 assert args_dict_0["state"] == "TX"
1373
1374 # Verify the second tool call (index=1)
1375 assert extracted_tool_calls.tool_calls[1].function.name == "calculate_area"
1376 args_dict_1 = json.loads(extracted_tool_calls.tool_calls[1].function.arguments)
1377 assert args_dict_1["shape"] == "rectangle"
1378 assert isinstance(args_dict_1["dimensions"], dict), "dimensions should be a dict"
1379 assert args_dict_1["dimensions"]["width"] == 10
1380 assert args_dict_1["dimensions"]["height"] == 5
1381
1382 # Verify content: should contain "hello"
1383 assert extracted_tool_calls.content is not None
1384 assert "hello" in extracted_tool_calls.content, "Should contain 'hello' as content"
1385
1386 # Verify that tool call tags are NOT in the content
1387 assert "<function=get_current_weather>" not in extracted_tool_calls.content, (
1388 "First tool call should not be in content"
1389 )
1390 assert "<function=calculate_area>" not in extracted_tool_calls.content, (
1391 "Second tool call should not be in content"
1392 )
1393
1394
1395 def _accumulate_tool_states(delta_messages):
1396 """Accumulate tool call state from a stream of DeltaMessage objects."""
1397 content = ""
1398 tool_states = {}
1399 for delta_message in delta_messages:
1400 if delta_message.content:
1401 content += delta_message.content
1402 if delta_message.tool_calls:
1403 for tool_call in delta_message.tool_calls:
1404 idx = tool_call.index
1405 if idx not in tool_states:
1406 tool_states[idx] = {
1407 "id": None,
1408 "name": None,
1409 "arguments": "",
1410 "type": None,
1411 }
1412 if tool_call.id:
1413 tool_states[idx]["id"] = tool_call.id
1414 if tool_call.type:
1415 tool_states[idx]["type"] = tool_call.type
1416 if tool_call.function:
1417 if tool_call.function.name:
1418 tool_states[idx]["name"] = tool_call.function.name
1419 if tool_call.function.arguments is not None:
1420 tool_states[idx]["arguments"] += tool_call.function.arguments
1421 return content, tool_states
1422
1423
1424 def test_streaming_mtp_variable_chunks(
1425 step3p5_tool_parser, step3p5_tokenizer, sample_tools
1426 ):
1427 """Regression: MTP variable-size chunks spanning param boundaries (PR #33690)."""
1428 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1429
1430 delta_text_chunks = [
1431 "<tool_call>\n<function=get_current_weather>\n<parameter=city>\n",
1432 "Dallas\n</parameter>\n<parameter=state>\nTX",
1433 "\n</parameter>\n<parameter=unit>\nfahrenheit\n</parameter>",
1434 "\n</function>\n</tool_call>",
1435 ]
1436
1437 _, tool_states = _accumulate_tool_states(
1438 stream_delta_message_generator_from_chunks(
1439 step3p5_tool_parser, step3p5_tokenizer, delta_text_chunks, request
1440 )
1441 )
1442
1443 assert len(tool_states) == 1
1444
1445 state = tool_states[0]
1446 assert state["id"] is not None
1447 assert state["type"] == "function"
1448 assert state["name"] == "get_current_weather"
1449
1450 args = json.loads(state["arguments"])
1451 assert args["city"] == "Dallas"
1452 assert args["state"] == "TX"
1453 assert args["unit"] == "fahrenheit"
1454
1455
1456 def test_streaming_multi_token_per_step(
1457 step3p5_tool_parser, step3p5_tokenizer, sample_tools
1458 ):
1459 """Regression: MTP large chunks spanning multiple tool calls (PR #33690)."""
1460 model_output = """<tool_call>
1461 <function=get_current_weather>
1462 <parameter=city>
1463 Dallas
1464 </parameter>
1465 <parameter=state>
1466 TX
1467 </parameter>
1468 <parameter=unit>
1469 fahrenheit
1470 </parameter>
1471 </function>
1472 </tool_call>
1473 <tool_call>
1474 <function=get_current_weather>
1475 <parameter=city>
1476 Orlando
1477 </parameter>
1478 <parameter=state>
1479 FL
1480 </parameter>
1481 <parameter=unit>
1482 celsius
1483 </parameter>
1484 </function>
1485 </tool_call>"""
1486
1487 request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
1488
1489 # MTP-style large chunks
1490 mtp_chunks = [
1491 (
1492 "<tool_call>\n<function=get_current_weather>\n"
1493 "<parameter=city>\nDallas\n</parameter>\n"
1494 "<parameter=state>\nTX"
1495 ),
1496 (
1497 "\n</parameter>\n<parameter=unit>\nfahrenheit\n</parameter>\n"
1498 "</function>\n</tool_call>\n"
1499 "<tool_call>\n<function=get_current_weather>\n"
1500 "<parameter=city>\nOrlando\n</parameter>\n"
1501 "<parameter=state>\nFL\n</parameter>\n"
1502 "<parameter=unit>\ncelsius\n</parameter>\n"
1503 "</function>\n</tool_call>"
1504 ),
1505 ]
1506
1507 _, mtp_tool_states = _accumulate_tool_states(
1508 stream_delta_message_generator_from_chunks(
1509 step3p5_tool_parser, step3p5_tokenizer, mtp_chunks, request
1510 )
1511 )
1512
1513 # Token-by-token streaming (reference)
1514 step3p5_tool_parser_ref = Step3p5ToolParser(step3p5_tokenizer)
1515 _, ref_tool_states = _accumulate_tool_states(
1516 stream_delta_message_generator(
1517 step3p5_tool_parser_ref, step3p5_tokenizer, model_output, request
1518 )
1519 )
1520
1521 assert len(mtp_tool_states) == 2
1522 assert len(ref_tool_states) == 2
1523
1524 # MTP results must match reference
1525 for idx in range(2):
1526 assert mtp_tool_states[idx]["name"] == ref_tool_states[idx]["name"]
1527 mtp_args = json.loads(mtp_tool_states[idx]["arguments"])
1528 ref_args = json.loads(ref_tool_states[idx]["arguments"])
1529 assert mtp_args == ref_args