main
py 1,349 lines 37 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from copy import deepcopy
6 from types import SimpleNamespace
7 from typing import Any
8
9 import pytest
10 from openai.types.responses import NamespaceTool
11
12 from vllm.entrypoints.openai.chat_completion.protocol import (
13 ChatCompletionNamedToolChoiceParam,
14 ChatCompletionToolsParam,
15 )
16 from vllm.exceptions import VLLMValidationError
17 from vllm.parser import response_template as response_template_module
18 from vllm.parser.gemma4 import Gemma4Parser
19 from vllm.parser.parser_manager import ParserManager
20 from vllm.parser.response_template import (
21 ResponseTemplateParser,
22 ResponseTemplateReasoningParser,
23 ResponseTemplateToolParser,
24 resolve_response_template,
25 )
26 from vllm.reasoning import ReasoningParserManager
27 from vllm.tool_parsers import ToolParserManager
28
29 # Public metadata shared by the Gemma 4 instruction checkpoints.
30 GEMMA4_RESPONSE_TEMPLATE: dict[str, Any] = {
31 "defaults": {"role": "assistant"},
32 "fields": {
33 "content": {
34 "close": ["<turn|>", "<|tool_response>", "<eos>"],
35 "content": "text",
36 },
37 "thinking": {
38 "close": "<channel|>",
39 "content": "text",
40 "open": "<|channel>thought\n",
41 },
42 "tool_calls": {
43 "close": "<tool_call|>",
44 "content": "json",
45 "content_args": {
46 "string_delims": [['<|"|>', '<|"|>']],
47 "unquoted_keys": True,
48 },
49 "open_pattern": r"<\|tool_call>call:(?P<name>\w+)",
50 "repeats": True,
51 "transform": {
52 "function": {
53 "arguments": "{content}",
54 "name": "{name}",
55 },
56 "type": "function",
57 },
58 },
59 },
60 "start_anchor": ["<|turn>model\n", "<tool_response|>"],
61 }
62
63 PREFIX = "<|turn>model\n"
64 THINKING = "<|channel>thought\nplan<channel|>"
65 TOOL_GENERATION = (
66 '<|tool_call>call:set_alarm{hour:7,label:<|"|>morning<|"|>}<tool_call|>'
67 )
68
69 TOOLS = [
70 ChatCompletionToolsParam(
71 type="function",
72 function={
73 "name": "set_alarm",
74 "parameters": {
75 "type": "object",
76 "properties": {
77 "hour": {
78 "oneOf": [
79 {"type": "integer"},
80 {"type": "null"},
81 ]
82 },
83 "label": {"type": "string"},
84 },
85 },
86 },
87 ),
88 ChatCompletionToolsParam(
89 type="function",
90 function={
91 "name": "get_weather",
92 "parameters": {
93 "type": "object",
94 "properties": {
95 "city": {"type": "string"},
96 "unit": {"type": "string"},
97 },
98 },
99 },
100 ),
101 ChatCompletionToolsParam(
102 type="function",
103 function={
104 "name": "set_status",
105 "parameters": {
106 "type": "object",
107 "properties": {
108 "is_active": {"type": "boolean"},
109 "count": {"type": "integer"},
110 "score": {"type": "number"},
111 },
112 },
113 },
114 ),
115 ChatCompletionToolsParam(
116 type="function",
117 function={
118 "name": "complex_function",
119 "parameters": {
120 "type": "object",
121 "properties": {
122 "nested": {
123 "type": "object",
124 "properties": {"inner": {"type": "string"}},
125 },
126 "list": {
127 "type": "array",
128 "items": {"type": "string"},
129 },
130 },
131 },
132 },
133 ),
134 ChatCompletionToolsParam(
135 type="function",
136 function={
137 "name": "configure",
138 "parameters": {
139 "type": "object",
140 "properties": {
141 "enabled": {"type": "boolean"},
142 "ratio": {"type": "number"},
143 "label": {"type": "string"},
144 "value": {"type": ["string", "null"]},
145 },
146 },
147 },
148 ),
149 ChatCompletionToolsParam(
150 type="function",
151 function={
152 "name": "get_status",
153 "parameters": {"type": "object", "properties": {}},
154 },
155 ),
156 ChatCompletionToolsParam(
157 type="function",
158 function={
159 "name": "get-weather",
160 "parameters": {
161 "type": "object",
162 "properties": {"city": {"type": "string"}},
163 },
164 },
165 ),
166 ChatCompletionToolsParam(
167 type="function",
168 function={
169 "name": "search",
170 "parameters": {
171 "type": "object",
172 "properties": {
173 "query": {"type": "string"},
174 "filters": {
175 "type": "object",
176 "properties": {
177 "language": {"type": "string"},
178 "min_stars": {"type": "integer"},
179 },
180 },
181 },
182 },
183 },
184 ),
185 ]
186
187 VOCAB = {
188 "<|tool_call>": 48,
189 "<tool_call|>": 49,
190 "<|channel>": 50,
191 "<channel|>": 51,
192 '<|"|>': 52,
193 "<|turn>": 53,
194 "<|tool_response>": 54,
195 "<turn|>": 55,
196 }
197 PROMPT_TOKEN_IDS = [1]
198 OPEN_REASONING_PROMPT_TOKEN_IDS = [53, 100, 50, 101]
199 PROMPT_TOKEN_TEXT = {
200 100: "model\n",
201 101: "thought\n",
202 102: "plan",
203 103: "answer",
204 }
205
206
207 class FakeTokenizer:
208 def __init__(self, response_template=None, *, prefix=PREFIX):
209 self.response_template = response_template
210 self.init_kwargs = {}
211 self.name_or_path = ""
212 self.prefix = prefix
213 self.all_special_tokens = list(VOCAB)
214 self.all_special_ids = list(VOCAB.values())
215
216 def decode(self, token_ids, skip_special_tokens=False, **_kwargs):
217 if token_ids == [2]:
218 return PREFIX + "answer"
219 if token_ids == OPEN_REASONING_PROMPT_TOKEN_IDS:
220 return PREFIX + "<|channel>thought\n"
221 if token_ids != PROMPT_TOKEN_IDS:
222 inverse_vocab = {token_id: token for token, token_id in VOCAB.items()}
223 return "".join(
224 PROMPT_TOKEN_TEXT.get(token_id, inverse_vocab.get(token_id, ""))
225 for token_id in token_ids
226 )
227 return self.prefix if token_ids else ""
228
229 def get_vocab(self):
230 return VOCAB
231
232 def encode(self, text, add_special_tokens=False):
233 del add_special_tokens
234 if text == "plan":
235 return [102]
236 return list(text.encode())
237
238
239 def request(tool_choice="auto", *, include_reasoning=True, **overrides):
240 values = dict(
241 tool_choice=tool_choice,
242 include_reasoning=include_reasoning,
243 tools=TOOLS,
244 skip_special_tokens=True,
245 spaces_between_special_tokens=True,
246 include_stop_str_in_output=False,
247 parallel_tool_calls=True,
248 )
249 values.update(overrides)
250 return SimpleNamespace(**values)
251
252
253 def normalize_calls(calls):
254 return [(call.name, json.loads(call.arguments)) for call in calls or []]
255
256
257 def feed_chunks(
258 parser,
259 chunks,
260 req,
261 *,
262 prompt_token_ids=PROMPT_TOKEN_IDS,
263 ):
264 deltas = []
265 for index, chunk in enumerate(chunks):
266 delta = parser.parse_delta(
267 chunk,
268 [],
269 req,
270 prompt_token_ids=prompt_token_ids if index == 0 else None,
271 finished=index == len(chunks) - 1,
272 )
273 if delta is not None:
274 deltas.append(delta)
275 return deltas
276
277
278 def collect_stream(
279 parser,
280 text,
281 req,
282 chunk_size,
283 *,
284 prompt_token_ids=PROMPT_TOKEN_IDS,
285 ):
286 reasoning = []
287 content = []
288 calls: dict[int, dict[str, str]] = {}
289 for start in range(0, len(text), chunk_size):
290 end = min(start + chunk_size, len(text))
291 delta = parser.parse_delta(
292 text[start:end],
293 [],
294 req,
295 prompt_token_ids=prompt_token_ids if start == 0 else None,
296 finished=end == len(text),
297 )
298 if delta is None:
299 continue
300 if delta.reasoning:
301 reasoning.append(delta.reasoning)
302 if delta.content:
303 content.append(delta.content)
304 for call in delta.tool_calls:
305 function = call.function
306 assert function is not None
307 slot = calls.setdefault(
308 call.index,
309 {"name": "", "arguments": ""},
310 )
311 slot["name"] += function.name or ""
312 slot["arguments"] += function.arguments or ""
313 normalized_calls = [
314 (call["name"], json.loads(call["arguments"]))
315 for _, call in sorted(calls.items())
316 ]
317 return "".join(reasoning), "".join(content), normalized_calls
318
319
320 def test_resolve_response_template_from_tokenizer_configuration():
321 tokenizer = FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE)
322 assert resolve_response_template(tokenizer) == GEMMA4_RESPONSE_TEMPLATE
323
324 tokenizer = FakeTokenizer()
325 del tokenizer.response_template
326 tokenizer.init_kwargs["response_template"] = GEMMA4_RESPONSE_TEMPLATE
327 assert resolve_response_template(tokenizer) == GEMMA4_RESPONSE_TEMPLATE
328
329 explicit = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
330 explicit["defaults"]["role"] = "custom"
331 assert resolve_response_template(tokenizer, explicit) is explicit
332
333
334 def test_response_template_parsers_are_registered():
335 assert ToolParserManager.get_tool_parser("hf") is ResponseTemplateToolParser
336 assert (
337 ReasoningParserManager.get_reasoning_parser("hf")
338 is ResponseTemplateReasoningParser
339 )
340
341
342 @pytest.mark.parametrize(
343 ("reasoning", "tools"),
344 [(True, True), (True, False), (False, True)],
345 ids=["both", "reasoning", "tools"],
346 )
347 def test_parser_manager_selects_response_template_by_name(reasoning, tools):
348 parser_cls = ParserManager.get_parser(
349 tool_parser_name="hf" if tools else None,
350 reasoning_parser_name="hf" if reasoning else None,
351 enable_auto_tools=tools,
352 tokenizer=FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
353 )
354
355 assert parser_cls is not None
356 assert issubclass(parser_cls, ResponseTemplateParser)
357 assert (parser_cls.reasoning_parser_cls is not None) is reasoning
358 assert (parser_cls.tool_parser_cls is not None) is tools
359
360
361 def test_parser_manager_validates_checkpoint_metadata_at_startup():
362 with pytest.raises(TypeError, match="requires `response_template` metadata"):
363 ParserManager.get_parser(
364 reasoning_parser_name="hf",
365 tokenizer=FakeTokenizer(),
366 )
367 with pytest.raises(TypeError, match="Invalid response_template"):
368 ParserManager.get_parser(
369 reasoning_parser_name="hf",
370 tokenizer=FakeTokenizer({"fields": {}}),
371 )
372
373 template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
374 del template["fields"]["tool_calls"]
375 with pytest.raises(TypeError, match="tool_calls field"):
376 ParserManager.get_parser(
377 tool_parser_name="hf",
378 enable_auto_tools=True,
379 tokenizer=FakeTokenizer(template),
380 )
381 assert (
382 ParserManager.get_parser(
383 reasoning_parser_name="hf",
384 tokenizer=FakeTokenizer(template),
385 )
386 is not None
387 )
388
389
390 def test_response_template_parser_rejects_other_parsers():
391 with pytest.raises(TypeError, match="cannot be combined"):
392 ParserManager.get_parser(
393 tool_parser_name="hermes",
394 reasoning_parser_name="hf",
395 enable_auto_tools=True,
396 tokenizer=FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
397 )
398
399
400 def test_request_chat_template_is_honored_with_warning(monkeypatch):
401 warnings: list[str] = []
402 monkeypatch.setattr(
403 response_template_module.logger, "warning_once", warnings.append
404 )
405 parser = ResponseTemplateParser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
406
407 parser.adjust_request(request(chat_template="{{ messages }}"))
408
409 assert len(warnings) == 1
410 assert parser.response_template.fields
411
412
413 def test_reasoning_gate_waits_for_content_region():
414 parser = ResponseTemplateParser(
415 FakeTokenizer(
416 GEMMA4_RESPONSE_TEMPLATE,
417 prefix=PREFIX + "<|channel>thought\n",
418 ),
419 TOOLS,
420 )
421
422 assert parser.is_reasoning_end([1]) is False
423 assert parser.is_reasoning_end([2]) is True
424
425
426 def test_reasoning_gate_ends_at_content_opener_held_by_prompt():
427 template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
428 template["fields"]["content"]["open_pattern"] = r"<\|channel>final\n"
429 parser = ResponseTemplateParser(
430 FakeTokenizer(template, prefix=PREFIX + "<|channel>final\n"),
431 TOOLS,
432 )
433
434 assert parser.is_reasoning_end([1]) is True
435
436
437 def streamed_reasoning_end(reasoner, output, prompt=PROMPT_TOKEN_IDS):
438 """Index of the output token where the per-step gate reports the end."""
439 for index, token in enumerate(output):
440 if reasoner.is_reasoning_end_streaming(prompt + output[: index + 1], [token]):
441 return index
442 return None
443
444
445 def test_streaming_reasoning_gate_matches_thinking_closer_without_decoding():
446 tokenizer = FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE)
447 reasoner = ResponseTemplateReasoningParser(tokenizer)
448 tokenizer.decode = lambda *_args, **_kwargs: pytest.fail("decoded") # type: ignore[method-assign]
449 output = [50, 101] + [102] * 100 + [51, 103]
450
451 assert streamed_reasoning_end(reasoner, output) == len(output) - 2
452 assert reasoner.is_reasoning_end_streaming(PROMPT_TOKEN_IDS + output, output[-3:])
453
454
455 def test_reasoning_gate_honors_disabled_and_repeated_thinking():
456 disabled = ResponseTemplateParser(
457 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
458 TOOLS,
459 chat_template_kwargs={"enable_thinking": False},
460 )
461 assert disabled.is_reasoning_end([1]) is True
462
463 repeated_template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
464 repeated_template["fields"]["thinking"]["repeats"] = True
465 repeated = ResponseTemplateParser(
466 FakeTokenizer(repeated_template),
467 TOOLS,
468 response_template=repeated_template,
469 )
470 assert repeated.is_reasoning_end(OPEN_REASONING_PROMPT_TOKEN_IDS) is False
471 assert repeated.is_reasoning_end([2]) is False
472
473
474 @pytest.mark.parametrize(
475 "text",
476 [
477 "Hello<turn|>",
478 THINKING + "The answer is 42.<turn|>",
479 THINKING + TOOL_GENERATION,
480 "Let me check." + TOOL_GENERATION,
481 (
482 '<|tool_call>call:get_weather{city:<|"|>Paris<|"|>,'
483 'unit:<|"|>celsius<|"|>}<tool_call|>' + TOOL_GENERATION
484 ),
485 (
486 "<|tool_call>call:complex_function{"
487 'nested:{inner:<|"|>value<|"|>},'
488 'list:[<|"|>a<|"|>,<|"|>b<|"|>]}'
489 "<tool_call|>"
490 ),
491 ],
492 ids=[
493 "content",
494 "reasoning",
495 "tool",
496 "content_then_tool",
497 "multiple_tools",
498 "nested_arguments",
499 ],
500 )
501 def test_gemma4_response_template_matches_registered_parser(text):
502 tokenizer = FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE)
503 req = request()
504 registered = Gemma4Parser(tokenizer, TOOLS)
505 metadata = ResponseTemplateParser(
506 tokenizer,
507 TOOLS,
508 response_template=GEMMA4_RESPONSE_TEMPLATE,
509 )
510 metadata.set_prompt_token_ids([1])
511
512 expected_reasoning, expected_content, expected_calls = registered.parse(
513 text,
514 req,
515 enable_auto_tools=True,
516 )
517 reasoning, content, calls = metadata.parse(
518 text,
519 req,
520 enable_auto_tools=True,
521 )
522
523 assert reasoning == expected_reasoning
524 assert content == expected_content
525 assert normalize_calls(calls) == normalize_calls(expected_calls)
526
527
528 def test_metadata_parser_keeps_already_decoded_nullable_values():
529 text = (
530 "<|tool_call>call:configure{"
531 "enabled:false,ratio:5.0,label:null,value:null}"
532 "<tool_call|>"
533 )
534 parser = ResponseTemplateParser(
535 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
536 TOOLS,
537 response_template=GEMMA4_RESPONSE_TEMPLATE,
538 )
539 parser.set_prompt_token_ids([1])
540
541 _, content, calls = parser.parse(
542 text,
543 request(),
544 enable_auto_tools=True,
545 )
546
547 assert content is None
548 assert normalize_calls(calls) == [
549 (
550 "configure",
551 {
552 "enabled": False,
553 "ratio": 5.0,
554 "label": None,
555 "value": None,
556 },
557 )
558 ]
559
560
561 @pytest.mark.parametrize(
562 "text",
563 [
564 THINKING + TOOL_GENERATION,
565 THINKING + "The answer is 42.<turn|>",
566 "Let me check. " + TOOL_GENERATION,
567 (
568 '<|tool_call>call:get_weather{city:<|"|>Paris<|"|>}<tool_call|>'
569 + TOOL_GENERATION
570 ),
571 ],
572 ids=[
573 "reasoning_then_tool",
574 "reasoning_then_content",
575 "content_then_tool",
576 "multiple_tools",
577 ],
578 )
579 @pytest.mark.parametrize("chunk_size", [1, 7, 10_000])
580 def test_gemma4_response_template_streaming_matches_registered_parser(
581 text,
582 chunk_size,
583 ):
584 req = request()
585 registered = Gemma4Parser(
586 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
587 TOOLS,
588 )
589 metadata = ResponseTemplateParser(
590 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
591 TOOLS,
592 enable_auto_tools=True,
593 )
594
595 assert collect_stream(metadata, text, req, chunk_size) == collect_stream(
596 registered,
597 text,
598 req,
599 chunk_size,
600 )
601
602
603 @pytest.mark.parametrize(
604 ("prompt_token_ids", "text"),
605 [
606 (
607 OPEN_REASONING_PROMPT_TOKEN_IDS,
608 "continued reasoning<channel|>Final answer<turn|>",
609 ),
610 (
611 PROMPT_TOKEN_IDS,
612 "Direct answer without reasoning.<turn|>",
613 ),
614 ],
615 ids=["prefilled_reasoning", "direct_answer"],
616 )
617 def test_gemma4_response_template_prompt_state_matches_registered_parser(
618 prompt_token_ids,
619 text,
620 ):
621 req = request()
622 registered = Gemma4Parser(
623 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
624 TOOLS,
625 chat_template_kwargs={"enable_thinking": True},
626 )
627 metadata = ResponseTemplateParser(
628 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
629 TOOLS,
630 enable_auto_tools=True,
631 )
632
633 assert collect_stream(
634 metadata,
635 text,
636 req,
637 1,
638 prompt_token_ids=prompt_token_ids,
639 ) == collect_stream(
640 registered,
641 text,
642 req,
643 1,
644 prompt_token_ids=prompt_token_ids,
645 )
646
647
648 def test_gemma4_response_template_reasoning_suppression_matches_registered_parser():
649 req = request(include_reasoning=False)
650 text = THINKING + "Final answer<turn|>"
651
652 assert collect_stream(
653 ResponseTemplateParser(
654 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
655 TOOLS,
656 enable_auto_tools=True,
657 ),
658 text,
659 req,
660 1,
661 ) == collect_stream(
662 Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS),
663 text,
664 req,
665 1,
666 )
667
668
669 def test_streamed_tool_call_is_emitted_when_its_region_parses():
670 chunks = [
671 "<|tool_call>call:set_alarm{",
672 "hour:7,",
673 'label:<|"|>morning<|"|>}',
674 "<tool_call|>",
675 ]
676 deltas = feed_chunks(
677 ResponseTemplateParser(
678 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
679 TOOLS,
680 enable_auto_tools=True,
681 ),
682 chunks,
683 request(),
684 )
685
686 assert all(not delta.tool_calls for delta in deltas[:-1])
687 calls = deltas[-1].tool_calls
688 assert len(calls) == 1
689 assert calls[0].id is not None
690 assert calls[0].index == 0
691 assert calls[0].function is not None
692 assert calls[0].function.name == "set_alarm"
693 assert json.loads(calls[0].function.arguments) == {
694 "hour": 7,
695 "label": "morning",
696 }
697
698
699 def test_response_template_emits_whole_call_from_one_chunk():
700 parser = ResponseTemplateParser(
701 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
702 TOOLS,
703 enable_auto_tools=True,
704 )
705
706 delta = parser.parse_delta(
707 TOOL_GENERATION,
708 [],
709 request(),
710 prompt_token_ids=PROMPT_TOKEN_IDS,
711 finished=True,
712 )
713
714 assert delta is not None
715 assert len(delta.tool_calls) == 1
716 call = delta.tool_calls[0]
717 assert call.id is not None
718 assert call.function is not None
719 assert call.function.name == "set_alarm"
720 assert json.loads(call.function.arguments) == {"hour": 7, "label": "morning"}
721
722
723 def test_response_template_takes_name_from_region_content():
724 template = {
725 "defaults": {"role": "assistant"},
726 "start_anchor": PREFIX,
727 "fields": {
728 "tool_calls": {
729 "open": "<tool>",
730 "close": "</tool>",
731 "content": "json",
732 "transform": {
733 "type": "function",
734 "function": {
735 "name": "{content.name}",
736 "arguments": "{content.arguments}",
737 },
738 },
739 }
740 },
741 }
742 chunks = [
743 "<tool>",
744 '{"name":"set_',
745 'alarm","arguments":{"hour":7}}',
746 "</tool>",
747 ]
748 parser = ResponseTemplateParser(
749 FakeTokenizer(template),
750 TOOLS,
751 response_template=template,
752 enable_auto_tools=True,
753 )
754
755 deltas = feed_chunks(parser, chunks, request())
756
757 assert all(not delta.tool_calls for delta in deltas[:-1])
758 assert len(deltas[-1].tool_calls) == 1
759 call = deltas[-1].tool_calls[0]
760 assert call.function is not None
761 assert call.function.name == "set_alarm"
762 assert json.loads(call.function.arguments) == {"hour": 7}
763
764
765 def test_response_template_applies_literal_transformed_name():
766 template = {
767 "defaults": {"role": "assistant"},
768 "start_anchor": PREFIX,
769 "fields": {
770 "tool_calls": {
771 "open": "<call>",
772 "close": "</call>",
773 "content": "json",
774 "transform": {
775 "type": "function",
776 "function": {
777 "name": "set_alarm",
778 "arguments": "{content}",
779 },
780 },
781 }
782 },
783 }
784 parser = ResponseTemplateParser(
785 FakeTokenizer(template),
786 TOOLS,
787 response_template=template,
788 enable_auto_tools=True,
789 )
790
791 deltas = feed_chunks(
792 parser,
793 ["<call>", '{"hour":7}', "</call>"],
794 request(),
795 )
796
797 assert len(deltas) == 1
798 assert deltas[0].tool_calls[0].function.name == "set_alarm"
799 assert json.loads(deltas[0].tool_calls[0].function.arguments) == {"hour": 7}
800
801
802 def test_gemma4_response_template_drops_incomplete_call():
803 text = '<|tool_call>call:get_weather{city:<|"|>London'
804 req = request()
805 registered = Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
806 metadata = ResponseTemplateParser(
807 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
808 TOOLS,
809 )
810 metadata.set_prompt_token_ids(PROMPT_TOKEN_IDS)
811
812 _, _, registered_calls = registered.parse(
813 text,
814 req,
815 enable_auto_tools=True,
816 )
817 _, metadata_content, metadata_calls = metadata.parse(
818 text,
819 req,
820 enable_auto_tools=True,
821 )
822
823 assert normalize_calls(registered_calls) == [("get_weather", {"city": "London"})]
824 assert metadata_content is None
825 assert metadata_calls is None
826
827
828 def test_gemma4_response_template_preserves_content_after_tool_call():
829 text = TOOL_GENERATION + "Done.<turn|>"
830 req = request()
831 registered = Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
832 metadata = ResponseTemplateParser(
833 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
834 TOOLS,
835 )
836 metadata.set_prompt_token_ids(PROMPT_TOKEN_IDS)
837
838 _, registered_content, registered_calls = registered.parse(
839 text,
840 req,
841 enable_auto_tools=True,
842 )
843 _, metadata_content, metadata_calls = metadata.parse(
844 text,
845 req,
846 enable_auto_tools=True,
847 )
848
849 assert registered_content is None
850 assert metadata_content == "Done."
851 assert normalize_calls(metadata_calls) == normalize_calls(registered_calls)
852
853
854 def test_gemma4_checkpoint_grammar_excludes_hyphenated_name():
855 text = '<|tool_call>call:get-weather{city:<|"|>London<|"|>}<tool_call|>'
856 req = request()
857 registered = Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
858 metadata = ResponseTemplateParser(
859 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
860 TOOLS,
861 )
862 metadata.set_prompt_token_ids(PROMPT_TOKEN_IDS)
863
864 _, _, registered_calls = registered.parse(
865 text,
866 req,
867 enable_auto_tools=True,
868 )
869 _, metadata_content, metadata_calls = metadata.parse(
870 text,
871 req,
872 enable_auto_tools=True,
873 )
874
875 assert normalize_calls(registered_calls) == [("get-weather", {"city": "London"})]
876 assert metadata_content is None
877 assert metadata_calls is None
878
879
880 @pytest.mark.parametrize(
881 (
882 "prefix_suffix",
883 "generated",
884 "enable_auto_tools",
885 "expected_reasoning",
886 "expected_content",
887 ),
888 [
889 (
890 "<|channel>thought\nprefill",
891 " generated<channel|>",
892 False,
893 " generated",
894 None,
895 ),
896 ("prefill ", "generated<turn|>", False, None, "generated"),
897 ("prefill", "", False, None, None),
898 (TOOL_GENERATION.split("{", 1)[0], "", True, None, None),
899 ("<|chan", "", False, None, None),
900 ("<|chan", "nel", False, None, "nel"),
901 (
902 "<|chan",
903 "nel>thought\nreason<channel|>",
904 False,
905 "reason",
906 None,
907 ),
908 ],
909 ids=[
910 "reasoning",
911 "content",
912 "empty_content",
913 "empty_tool_open",
914 "empty_partial_delimiter",
915 "unresolved_partial_delimiter",
916 "completed_partial_delimiter",
917 ],
918 )
919 def test_streaming_prefill_is_state_only(
920 prefix_suffix,
921 generated,
922 enable_auto_tools,
923 expected_reasoning,
924 expected_content,
925 ):
926 tokenizer = FakeTokenizer(
927 GEMMA4_RESPONSE_TEMPLATE,
928 prefix=PREFIX + prefix_suffix,
929 )
930 parser = ResponseTemplateParser(
931 tokenizer,
932 TOOLS,
933 enable_auto_tools=enable_auto_tools,
934 )
935
936 delta = parser.parse_delta(
937 generated,
938 [],
939 request(),
940 prompt_token_ids=[1],
941 finished=True,
942 )
943
944 if expected_reasoning is None and expected_content is None:
945 assert delta is None
946 else:
947 assert delta is not None
948 assert delta.reasoning == expected_reasoning
949 assert delta.content == expected_content
950
951
952 def test_malformed_call_opened_across_prompt_boundary_is_dropped():
953 parser = ResponseTemplateParser(
954 FakeTokenizer(
955 GEMMA4_RESPONSE_TEMPLATE,
956 prefix=PREFIX + "<|tool_call>call:set_",
957 ),
958 TOOLS,
959 enable_auto_tools=True,
960 )
961
962 opened = parser.parse_delta(
963 "alarm{",
964 [],
965 request(),
966 prompt_token_ids=[1],
967 finished=False,
968 )
969 delta = parser.parse_delta(
970 "hour:7}unexpected<tool_call|>",
971 [],
972 request(),
973 finished=True,
974 )
975
976 assert opened is None
977 assert delta is None
978
979
980 def test_responses_namespace_tools_use_vllm_flattened_names():
981 namespace = NamespaceTool(
982 type="namespace",
983 name="calendar",
984 description="Calendar tools",
985 tools=[
986 {
987 "type": "function",
988 "name": "set_alarm",
989 "description": "Set an alarm",
990 "parameters": TOOLS[0].function.parameters,
991 }
992 ],
993 )
994 parser = ResponseTemplateParser(
995 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
996 [namespace],
997 )
998 parser.set_prompt_token_ids([1])
999 output = TOOL_GENERATION.replace(
1000 "call:set_alarm",
1001 "call:calendar__set_alarm",
1002 )
1003
1004 _, content, calls = parser.parse(
1005 output,
1006 request(),
1007 enable_auto_tools=True,
1008 )
1009
1010 assert content is None
1011 assert normalize_calls(calls) == [
1012 ("calendar__set_alarm", {"hour": 7, "label": "morning"})
1013 ]
1014
1015
1016 def test_streaming_unknown_call_is_forwarded():
1017 text = TOOL_GENERATION.replace("call:set_alarm", "call:unknown")
1018 parser = ResponseTemplateParser(
1019 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1020 TOOLS,
1021 enable_auto_tools=True,
1022 )
1023 _, content, calls = collect_stream(parser, text, request(), 7)
1024
1025 assert content == ""
1026 assert calls == [("unknown", {"hour": 7, "label": "morning"})]
1027
1028
1029 @pytest.mark.parametrize(
1030 "text",
1031 [
1032 TOOL_GENERATION.replace(
1033 "<tool_call|>",
1034 "unexpected<tool_call|>",
1035 ),
1036 TOOL_GENERATION.removesuffix("<tool_call|>") + "<tool_",
1037 ],
1038 ids=["malformed", "truncated"],
1039 )
1040 def test_streaming_invalid_call_is_dropped(text):
1041 parser = ResponseTemplateParser(
1042 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1043 TOOLS,
1044 enable_auto_tools=True,
1045 )
1046 deltas = feed_chunks(
1047 parser,
1048 [text[start : start + 7] for start in range(0, len(text), 7)],
1049 request(),
1050 )
1051
1052 assert deltas == []
1053
1054
1055 def test_malformed_call_does_not_block_following_call():
1056 parser = ResponseTemplateParser(
1057 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1058 TOOLS,
1059 enable_auto_tools=True,
1060 )
1061 malformed = TOOL_GENERATION.replace("<tool_call|>", "unexpected<tool_call|>")
1062
1063 _, content, calls = collect_stream(
1064 parser, malformed + TOOL_GENERATION, request(), 7
1065 )
1066
1067 assert content == ""
1068 assert calls == [("set_alarm", {"hour": 7, "label": "morning"})]
1069
1070
1071 def test_non_streaming_malformed_call_is_dropped():
1072 text = TOOL_GENERATION.replace(
1073 "<tool_call|>",
1074 "unexpected<tool_call|>",
1075 )
1076 parser = ResponseTemplateParser(
1077 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1078 TOOLS,
1079 )
1080 parser.set_prompt_token_ids([1])
1081
1082 reasoning, content, calls = parser.parse(
1083 text,
1084 request(),
1085 enable_auto_tools=True,
1086 )
1087
1088 assert reasoning is None
1089 assert content is None
1090 assert calls is None
1091
1092
1093 def test_streaming_malformed_call_is_dropped_before_emission():
1094 parser = ResponseTemplateParser(
1095 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1096 TOOLS,
1097 enable_auto_tools=True,
1098 )
1099 first = parser.parse_delta(
1100 "hello<turn|>",
1101 [],
1102 request(),
1103 prompt_token_ids=[1],
1104 finished=False,
1105 )
1106 malformed = TOOL_GENERATION.replace(
1107 "<tool_call|>",
1108 "unexpected<tool_call|>",
1109 )
1110 failed = parser.parse_delta(
1111 malformed,
1112 [],
1113 request(),
1114 finished=False,
1115 )
1116
1117 assert first is not None
1118 assert first.content == "hello"
1119 assert failed is None
1120
1121
1122 def test_prefilled_malformed_call_is_dropped():
1123 opening, body = TOOL_GENERATION.split("{", 1)
1124 malformed_body = "{" + body.replace(
1125 "<tool_call|>",
1126 "unexpected<tool_call|>",
1127 )
1128 tokenizer = FakeTokenizer(
1129 GEMMA4_RESPONSE_TEMPLATE,
1130 prefix=PREFIX + opening,
1131 )
1132 parser = ResponseTemplateParser(
1133 tokenizer,
1134 TOOLS,
1135 enable_auto_tools=True,
1136 )
1137
1138 delta = parser.parse_delta(
1139 malformed_body,
1140 [],
1141 request(),
1142 prompt_token_ids=[1],
1143 finished=True,
1144 )
1145
1146 assert delta is None
1147
1148
1149 def test_tool_choice_none_preserves_raw_call_as_content():
1150 parser = ResponseTemplateParser(
1151 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1152 TOOLS,
1153 enable_auto_tools=True,
1154 )
1155 _, content, calls = collect_stream(
1156 parser,
1157 TOOL_GENERATION,
1158 request("none"),
1159 5,
1160 )
1161
1162 assert content == TOOL_GENERATION
1163 assert calls == []
1164
1165
1166 def test_adjust_request_preserves_parser_delimiters_without_forcing_stop_text():
1167 req = request()
1168 parser = ResponseTemplateParser(
1169 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1170 TOOLS,
1171 )
1172
1173 assert parser.adjust_request(req) is req
1174 assert req.skip_special_tokens is False
1175 assert req.spaces_between_special_tokens is False
1176 assert req.include_stop_str_in_output is False
1177
1178
1179 def test_call_cut_off_before_its_closer_is_parsed():
1180 """A stop-token closer, such as a tool closer that ends generation, is
1181 absent from the text; the open region is parsed at the end of the stream."""
1182 trimmed = TOOL_GENERATION.removesuffix("<tool_call|>")
1183 parser = ResponseTemplateParser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
1184 streaming = ResponseTemplateParser(
1185 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1186 TOOLS,
1187 enable_auto_tools=True,
1188 )
1189
1190 _, content, calls = parser.parse(trimmed, request(), enable_auto_tools=True)
1191 streamed = streaming.parse_delta(
1192 trimmed,
1193 [],
1194 request(),
1195 prompt_token_ids=[1],
1196 finished=True,
1197 )
1198
1199 assert content is None
1200 assert normalize_calls(calls) == [("set_alarm", {"hour": 7, "label": "morning"})]
1201 assert streamed is not None
1202 assert [call.function.name for call in streamed.tool_calls] == ["set_alarm"]
1203
1204
1205 @pytest.mark.parametrize(
1206 ("req", "match"),
1207 [
1208 (request("required"), "required tool choice"),
1209 (
1210 request(
1211 ChatCompletionNamedToolChoiceParam(
1212 type="function",
1213 function={"name": "set_alarm"},
1214 )
1215 ),
1216 "named tool choice",
1217 ),
1218 (request("auto", parallel_tool_calls=False), "parallel_tool_calls=False"),
1219 (
1220 request(
1221 "auto",
1222 tools=[
1223 {
1224 "type": "function",
1225 "name": "set_alarm",
1226 "parameters": {"type": "object"},
1227 "strict": True,
1228 }
1229 ],
1230 ),
1231 "strict tools",
1232 ),
1233 ],
1234 )
1235 def test_unconstrained_tool_guarantees_are_rejected(req, match):
1236 parser = ResponseTemplateParser(
1237 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1238 req.tools,
1239 enable_auto_tools=True,
1240 )
1241
1242 with pytest.raises(VLLMValidationError, match=match):
1243 parser.adjust_request(req)
1244
1245
1246 def test_tool_choice_none_does_not_require_structural_guarantees():
1247 req = request(
1248 "none",
1249 tools=[
1250 {
1251 "type": "function",
1252 "name": "set_alarm",
1253 "parameters": {"type": "object"},
1254 "strict": True,
1255 }
1256 ],
1257 parallel_tool_calls=False,
1258 )
1259 parser = ResponseTemplateParser(
1260 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1261 req.tools,
1262 enable_auto_tools=True,
1263 )
1264
1265 assert parser.adjust_request(req) is req
1266
1267
1268 def test_unsupported_semantic_field_is_rejected():
1269 template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
1270 template["fields"]["citations"] = {
1271 "open": "<citation>",
1272 "close": "</citation>",
1273 }
1274
1275 with pytest.raises(ValueError, match="citations"):
1276 ResponseTemplateParser(
1277 FakeTokenizer(template),
1278 TOOLS,
1279 response_template=template,
1280 )
1281
1282
1283 @pytest.mark.parametrize(
1284 ("field_name", "field_update"),
1285 [
1286 ("content", {"content": "json"}),
1287 ("thinking", {"transform": "{content}"}),
1288 ],
1289 )
1290 def test_unsupported_content_field_semantics_are_rejected(
1291 field_name,
1292 field_update,
1293 ):
1294 template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
1295 template["fields"][field_name].update(field_update)
1296
1297 with pytest.raises(ValueError, match="cannot be streamed"):
1298 ResponseTemplateParser(
1299 FakeTokenizer(template),
1300 TOOLS,
1301 response_template=template,
1302 )
1303
1304
1305 def test_delimited_template_keeps_plain_content_and_tolerates_required_fields():
1306 template = {
1307 "start_anchor": "<assistant>",
1308 "fields": {
1309 "thinking": {"open": "<think>", "close": "</think>"},
1310 "tool_calls": {
1311 "open": "<call>",
1312 "close": "</call>",
1313 "content": "json",
1314 "optional": False,
1315 },
1316 },
1317 }
1318 parser = ResponseTemplateParser(
1319 FakeTokenizer(template, prefix="<assistant>"),
1320 TOOLS,
1321 response_template=template,
1322 )
1323
1324 assert parser.parse("<think>plan</think>answer", request()) == (
1325 "plan",
1326 "answer",
1327 None,
1328 )
1329
1330
1331 def test_separate_parse_calls_use_distinct_tool_call_ids():
1332 parser = ResponseTemplateParser(
1333 FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1334 TOOLS,
1335 enable_auto_tools=True,
1336 )
1337 parsed_calls = []
1338 for _ in range(2):
1339 _, _, calls = parser.parse(
1340 TOOL_GENERATION,
1341 request(),
1342 enable_auto_tools=True,
1343 )
1344 assert calls is not None
1345 parsed_calls.append(calls[0])
1346
1347 assert parsed_calls[0].id is not None
1348 assert parsed_calls[1].id is not None
1349 assert parsed_calls[0].id != parsed_calls[1].id