main
py 613 lines 18.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from typing import cast
6
7 import pytest
8
9 from vllm.entrypoints.generate.base.protocol import DeltaMessage
10 from vllm.entrypoints.openai.chat_completion.protocol import (
11 ChatCompletionRequest,
12 )
13 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
14 from vllm.entrypoints.openai.responses.utils import build_response_output_items
15 from vllm.exceptions import VLLMValidationError
16 from vllm.parser.kimi_k3 import KimiK3Parser
17 from vllm.parser.parser_manager import ParserManager
18 from vllm.reasoning.kimi_k3_reasoning_parser import KimiK3ReasoningParser
19 from vllm.tokenizers.protocol import TokenizerLike
20 from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
21
22 OPEN = "<|open|>"
23 CLOSE = "<|close|>"
24 SEP = "<|sep|>"
25 THINK_OPEN = f"{OPEN}think{SEP}"
26 THINK_CLOSE = f"{CLOSE}think{SEP}"
27 RESPONSE_CLOSE = f"{CLOSE}response{SEP}"
28
29
30 class DummyTokenizer:
31 def get_vocab(self) -> dict[str, int]:
32 return {}
33
34 def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
35 if text == THINK_OPEN:
36 return [1, 2, 3]
37 if text == THINK_CLOSE:
38 return [4, 2, 3]
39 return [ord(ch) for ch in text]
40
41
42 def _dummy_tokenizer() -> TokenizerLike:
43 return cast(TokenizerLike, DummyTokenizer())
44
45
46 class KimiK3DelegatingParser(KimiK3Parser):
47 reasoning_parser_cls = KimiK3ReasoningParser
48 tool_parser_cls = KimiK3ToolParser
49
50
51 def test_parser_manager_selects_kimi_k3_parser():
52 parser_cls = ParserManager.get_parser(
53 tool_parser_name="kimi_k3",
54 reasoning_parser_name="kimi_k3",
55 enable_auto_tools=True,
56 )
57
58 assert parser_cls is not None
59 assert issubclass(parser_cls, KimiK3Parser)
60 assert parser_cls.reasoning_parser_cls is KimiK3ReasoningParser
61 assert parser_cls.tool_parser_cls is KimiK3ToolParser
62
63
64 def _request() -> ChatCompletionRequest:
65 return ChatCompletionRequest(
66 model="test-model",
67 messages=[],
68 tools=[
69 {
70 "type": "function",
71 "function": {
72 "name": "calc",
73 "parameters": {"type": "object", "properties": {}},
74 },
75 }
76 ],
77 tool_choice="auto",
78 )
79
80
81 def _named_request() -> ChatCompletionRequest:
82 return ChatCompletionRequest(
83 model="test-model",
84 messages=[],
85 tools=[
86 {
87 "type": "function",
88 "function": {
89 "name": "calc",
90 "parameters": {"type": "object", "properties": {}},
91 },
92 }
93 ],
94 tool_choice={"type": "function", "function": {"name": "calc"}},
95 )
96
97
98 def _responses_request(*, tool_choice="auto") -> ResponsesRequest:
99 return ResponsesRequest.model_validate(
100 {
101 "model": "test-model",
102 "input": "Call the calc tool.",
103 "tools": [
104 {
105 "type": "function",
106 "name": "calc",
107 "parameters": {"type": "object", "properties": {}},
108 }
109 ],
110 "tool_choice": tool_choice,
111 }
112 )
113
114
115 def _arg(key: str, typ: str, value: str) -> str:
116 return f'{OPEN}argument key="{key}" type="{typ}"{SEP}{value}{CLOSE}argument{SEP}'
117
118
119 def _call(tool: str, index: int, *args: str) -> str:
120 body = "".join(args)
121 return f'{OPEN}call tool="{tool}" index="{index}"{SEP}{body}{CLOSE}call{SEP}'
122
123
124 def _response(content: str) -> str:
125 return f"{OPEN}response{SEP}{content}{RESPONSE_CLOSE}"
126
127
128 def _tools(*calls: str) -> str:
129 return f"{OPEN}tools{SEP}{''.join(calls)}{CLOSE}tools{SEP}"
130
131
132 def test_extract_tool_calls_with_response_and_typed_arguments():
133 parser = KimiK3ToolParser(_dummy_tokenizer())
134
135 output = _response("answer") + _tools(
136 _call(
137 "calc",
138 1,
139 _arg("x", "number", "1"),
140 _arg("flag", "boolean", "true"),
141 _arg("text", "string", "raw"),
142 )
143 )
144 extracted = parser.extract_tool_calls(output, _request())
145
146 assert extracted.tools_called is True
147 assert extracted.content == "answer"
148 assert len(extracted.tool_calls) == 1
149 tool_call = extracted.tool_calls[0]
150 assert tool_call.function.name == "calc"
151 assert json.loads(tool_call.function.arguments) == {
152 "x": 1,
153 "flag": True,
154 "text": "raw",
155 }
156
157
158 def test_delegating_parser_preserves_tool_calls_after_reasoning():
159 parser = KimiK3DelegatingParser(_dummy_tokenizer())
160 output = (
161 f"{THINK_OPEN}step{THINK_CLOSE}"
162 + _response("answer")
163 + _tools(_call("calc", 1, _arg("x", "number", "1")))
164 )
165
166 reasoning, content, tool_calls = parser.parse(
167 output,
168 _request(),
169 enable_auto_tools=True,
170 )
171
172 assert reasoning == "step"
173 assert content == "answer"
174 assert tool_calls is not None
175 assert len(tool_calls) == 1
176 assert tool_calls[0].name == "calc"
177 assert json.loads(tool_calls[0].arguments) == {"x": 1}
178
179
180 def test_delegating_parser_required_tool_choice_uses_xtml_parser():
181 parser = KimiK3DelegatingParser(_dummy_tokenizer())
182 request = _request().model_copy(update={"tool_choice": "required"})
183 output = (
184 f"{THINK_OPEN}step{THINK_CLOSE}"
185 + _response("")
186 + _tools(_call("calc", 1, _arg("x", "number", "1")))
187 )
188
189 reasoning, content, tool_calls = parser.parse(
190 output,
191 request,
192 enable_auto_tools=True,
193 )
194
195 assert reasoning == "step"
196 assert content is None
197 assert tool_calls is not None
198 assert len(tool_calls) == 1
199 assert tool_calls[0].name == "calc"
200 assert json.loads(tool_calls[0].arguments) == {"x": 1}
201
202
203 def test_delegating_parser_named_tool_choice_uses_xtml_parser():
204 parser = KimiK3DelegatingParser(_dummy_tokenizer())
205 output = (
206 f"{THINK_OPEN}step{THINK_CLOSE}"
207 + _response("")
208 + _tools(_call("calc", 1, _arg("x", "number", "1")))
209 )
210
211 reasoning, content, tool_calls = parser.parse(
212 output,
213 _named_request(),
214 enable_auto_tools=True,
215 )
216
217 assert reasoning == "step"
218 assert content is None
219 assert tool_calls is not None
220 assert len(tool_calls) == 1
221 assert tool_calls[0].name == "calc"
222 assert json.loads(tool_calls[0].arguments) == {"x": 1}
223
224
225 def test_delegating_parser_auto_no_call_strips_consumed_response_prefix():
226 parser = KimiK3DelegatingParser(
227 _dummy_tokenizer(), chat_template_kwargs={"thinking": False}
228 )
229 request = _request().model_copy(
230 update={"chat_template_kwargs": {"thinking": False}}
231 )
232
233 reasoning, content, tool_calls = parser.parse(
234 f"answer{RESPONSE_CLOSE}",
235 request,
236 enable_auto_tools=True,
237 )
238
239 assert reasoning is None
240 assert content == "answer"
241 assert tool_calls is None
242
243
244 def test_delegating_parser_required_call_strips_consumed_response_prefix():
245 parser = KimiK3DelegatingParser(
246 _dummy_tokenizer(), chat_template_kwargs={"thinking": False}
247 )
248 request = _request().model_copy(
249 update={
250 "tool_choice": "required",
251 "chat_template_kwargs": {"thinking": False},
252 }
253 )
254 output = RESPONSE_CLOSE + _tools(_call("calc", 1, _arg("x", "number", "1")))
255
256 reasoning, content, tool_calls = parser.parse(
257 output,
258 request,
259 enable_auto_tools=True,
260 )
261
262 assert reasoning is None
263 assert content is None
264 assert tool_calls is not None
265 assert len(tool_calls) == 1
266 assert tool_calls[0].name == "calc"
267 assert json.loads(tool_calls[0].arguments) == {"x": 1}
268
269
270 def test_delegating_parser_truncated_tools_do_not_leak_xtml():
271 parser = KimiK3DelegatingParser(
272 _dummy_tokenizer(), chat_template_kwargs={"thinking": False}
273 )
274 request = _request().model_copy(
275 update={
276 "tool_choice": "required",
277 "chat_template_kwargs": {"thinking": False},
278 }
279 )
280
281 reasoning, content, tool_calls = parser.parse(
282 (f'{RESPONSE_CLOSE}{OPEN}tools{SEP}{OPEN}call tool="calc" index="1"'),
283 request,
284 enable_auto_tools=True,
285 )
286
287 assert reasoning is None
288 assert content is None
289 assert tool_calls is None
290
291
292 def test_extract_tool_calls_unescapes_attributes():
293 parser = KimiK3ToolParser(_dummy_tokenizer())
294
295 output = _tools(_call("a&amp;b&quot;c", 1, _arg("k&amp;q", "string", "v")))
296 extracted = parser.extract_tool_calls(output, _request())
297
298 assert extracted.tools_called is True
299 assert extracted.tool_calls[0].function.name == 'a&b"c'
300 assert json.loads(extracted.tool_calls[0].function.arguments) == {"k&q": "v"}
301
302
303 def test_extract_tool_calls_allows_less_than_in_attributes():
304 parser = KimiK3ToolParser(_dummy_tokenizer())
305
306 output = _tools(_call("calc<beta", 1, _arg("foo<bar", "string", "raw")))
307 extracted = parser.extract_tool_calls(output, _request())
308
309 assert extracted.tools_called is True
310 assert extracted.tool_calls[0].function.name == "calc<beta"
311 assert json.loads(extracted.tool_calls[0].function.arguments) == {"foo<bar": "raw"}
312
313
314 def test_extract_content_from_whitespace_degraded_markers():
315 parser = KimiK3ToolParser(_dummy_tokenizer())
316
317 extracted = parser.extract_tool_calls(
318 f"{OPEN} response {SEP}answer{CLOSE} response {SEP}",
319 _request(),
320 )
321
322 assert extracted.tools_called is False
323 assert extracted.content == "answer"
324
325
326 def test_streaming_split_markers_do_not_leak():
327 parser = KimiK3ToolParser(_dummy_tokenizer())
328 request = _request()
329 previous_text = ""
330 previous_ids: list[int] = []
331 messages: list[DeltaMessage] = []
332 chunks = [
333 OPEN,
334 "response",
335 f"{SEP}Hi",
336 OPEN,
337 "tools",
338 SEP,
339 f'{OPEN}call tool="calc" index="1"{SEP}',
340 _arg("x", "number", "1"),
341 f"{CLOSE}call",
342 SEP,
343 ]
344
345 for i, chunk in enumerate(chunks, start=1):
346 current_text = previous_text + chunk
347 current_ids = previous_ids + [i]
348 delta = parser.extract_tool_calls_streaming(
349 previous_text=previous_text,
350 current_text=current_text,
351 delta_text=chunk,
352 previous_token_ids=previous_ids,
353 current_token_ids=current_ids,
354 delta_token_ids=[i],
355 request=request,
356 )
357 if delta is not None:
358 messages.append(delta)
359 previous_text = current_text
360 previous_ids = current_ids
361
362 content = "".join(message.content or "" for message in messages)
363 tool_deltas = [
364 tool_call for message in messages for tool_call in (message.tool_calls or [])
365 ]
366
367 assert content == "Hi"
368 assert OPEN not in content
369 assert SEP not in content
370 assert len(tool_deltas) == 1
371 assert tool_deltas[0].function is not None
372 assert tool_deltas[0].function.name == "calc"
373 assert json.loads(tool_deltas[0].function.arguments) == {"x": 1}
374
375
376 def test_tool_call_ids_are_unique_across_messages():
377 output = _tools(_call("calc", 1))
378
379 first = KimiK3ToolParser(_dummy_tokenizer()).extract_tool_calls(output, _request())
380 second = KimiK3ToolParser(_dummy_tokenizer()).extract_tool_calls(output, _request())
381
382 assert first.tool_calls[0].id != second.tool_calls[0].id
383
384
385 def test_streaming_consumed_response_prefix_no_call_keeps_content():
386 parser = KimiK3ToolParser(_dummy_tokenizer())
387 request = _request()
388 previous_text = ""
389 previous_ids: list[int] = []
390 messages: list[DeltaMessage] = []
391 chunks = ["O", "K", CLOSE, f"response{SEP}"]
392
393 for i, chunk in enumerate(chunks, start=1):
394 current_text = previous_text + chunk
395 current_ids = previous_ids + [i]
396 delta = parser.extract_tool_calls_streaming(
397 previous_text=previous_text,
398 current_text=current_text,
399 delta_text=chunk,
400 previous_token_ids=previous_ids,
401 current_token_ids=current_ids,
402 delta_token_ids=[i],
403 request=request,
404 )
405 if delta is not None:
406 messages.append(delta)
407 previous_text = current_text
408 previous_ids = current_ids
409
410 assert "".join(message.content or "" for message in messages) == "OK"
411 assert all(CLOSE not in (message.content or "") for message in messages)
412
413
414 def test_delegating_parser_tool_choice_none_strips_xtml_and_suppresses_calls():
415 parser = KimiK3DelegatingParser(
416 _dummy_tokenizer(), chat_template_kwargs={"thinking": False}
417 )
418 request = _request().model_copy(
419 update={
420 "tool_choice": "none",
421 "chat_template_kwargs": {"thinking": False},
422 }
423 )
424 messages: list[DeltaMessage] = []
425 chunks = [
426 OPEN,
427 "response",
428 f"{SEP}answer",
429 RESPONSE_CLOSE,
430 _tools(_call("calc", 1, _arg("x", "number", "1"))),
431 ]
432
433 for index, chunk in enumerate(chunks, start=1):
434 delta = parser.parse_delta(
435 delta_text=chunk,
436 delta_token_ids=[index],
437 request=request,
438 prompt_token_ids=[1],
439 finished=index == len(chunks),
440 )
441 if delta is not None:
442 messages.append(delta)
443
444 content = "".join(message.content or "" for message in messages)
445 assert content == "answer"
446 assert OPEN not in content
447 assert CLOSE not in content
448 assert SEP not in content
449 assert all(not message.tool_calls for message in messages)
450
451
452 def test_adjust_request_keeps_xtml_markers_contiguous():
453 parser = KimiK3ToolParser(_dummy_tokenizer())
454 request = _request()
455
456 adjusted = parser.adjust_request(request)
457
458 assert adjusted.skip_special_tokens is False
459 if hasattr(adjusted, "spaces_between_special_tokens"):
460 assert adjusted.spaces_between_special_tokens is False
461 assert KimiK3ToolParser.supports_required_and_named is False
462
463
464 def test_adjust_request_required_uses_xtml_parser_not_json_guidance():
465 parser = KimiK3ToolParser(_dummy_tokenizer())
466 request = _request().model_copy(update={"tool_choice": "required"})
467
468 adjusted = parser.adjust_request(request)
469
470 assert adjusted.structured_outputs is None
471 assert adjusted.skip_special_tokens is False
472 if hasattr(adjusted, "spaces_between_special_tokens"):
473 assert adjusted.spaces_between_special_tokens is False
474
475
476 @pytest.mark.parametrize(
477 "tool_request",
478 [
479 _named_request(),
480 _responses_request(
481 tool_choice={"type": "function", "name": "calc"},
482 ),
483 ],
484 )
485 def test_adjust_request_rejects_named_tool_choice(tool_request):
486 parser = KimiK3ToolParser(_dummy_tokenizer())
487
488 with pytest.raises(VLLMValidationError) as exc_info:
489 parser.adjust_request(tool_request)
490
491 assert exc_info.value.parameter == "tool_choice"
492 assert "requires strict tool calling" in str(exc_info.value)
493
494
495 def test_responses_chat_params_carries_tool_choice_metadata():
496 request = _responses_request(tool_choice="required")
497
498 chat_params = request.build_chat_params(
499 default_template=None,
500 default_template_content_format="auto",
501 )
502
503 assert chat_params.tool_choice == "required"
504
505
506 def test_responses_chat_params_keeps_template_tool_choice_when_api_auto():
507 request = _responses_request().model_copy(
508 update={"chat_template_kwargs": {"tool_choice": "required"}}
509 )
510
511 chat_params = request.build_chat_params(
512 default_template=None,
513 default_template_content_format="auto",
514 )
515
516 assert chat_params.chat_template_kwargs["tool_choice"] == "required"
517 assert chat_params.tool_choice == "auto"
518
519
520 def test_responses_required_tool_choice_uses_xtml_parser():
521 parser = KimiK3DelegatingParser(
522 _dummy_tokenizer(), chat_template_kwargs={"thinking": False}
523 )
524 request = _responses_request(tool_choice="required").model_copy(
525 update={"chat_template_kwargs": {"thinking": False}}
526 )
527 output = RESPONSE_CLOSE + _tools(_call("calc", 1, _arg("x", "number", "1")))
528
529 reasoning, content, tool_calls = parser.parse(
530 output, request, enable_auto_tools=True, model_output_token_ids=[]
531 )
532 response_outputs = build_response_output_items(
533 reasoning=reasoning,
534 content=content,
535 tool_calls=tool_calls,
536 tools=request.tools,
537 )
538
539 assert len(response_outputs) == 1
540 tool_call = response_outputs[0]
541 assert tool_call.type == "function_call"
542 assert tool_call.name == "calc"
543 assert json.loads(tool_call.arguments) == {"x": 1}
544
545
546 def test_responses_named_tool_choice_uses_xtml_parser():
547 parser = KimiK3DelegatingParser(
548 _dummy_tokenizer(), chat_template_kwargs={"thinking": False}
549 )
550 request = _responses_request(
551 tool_choice={"type": "function", "name": "calc"}
552 ).model_copy(update={"chat_template_kwargs": {"thinking": False}})
553 output = RESPONSE_CLOSE + _tools(_call("calc", 1, _arg("x", "number", "1")))
554
555 reasoning, content, tool_calls = parser.parse(
556 output, request, enable_auto_tools=True, model_output_token_ids=[]
557 )
558 response_outputs = build_response_output_items(
559 reasoning=reasoning,
560 content=content,
561 tool_calls=tool_calls,
562 tools=request.tools,
563 )
564
565 assert len(response_outputs) == 1
566 tool_call = response_outputs[0]
567 assert tool_call.type == "function_call"
568 assert tool_call.name == "calc"
569 assert json.loads(tool_call.arguments) == {"x": 1}
570
571
572 def test_chat_params_carries_tool_choice_metadata():
573 request = _request().model_copy(update={"tool_choice": "required"})
574
575 chat_params = request.build_chat_params(
576 default_template=None,
577 default_template_content_format="auto",
578 )
579
580 assert chat_params.tool_choice == "required"
581
582
583 def test_chat_params_carries_response_format_metadata():
584 request = ChatCompletionRequest(
585 model="test-model",
586 messages=[],
587 response_format={"type": "json_object"},
588 )
589
590 chat_params = request.build_chat_params(
591 default_template=None,
592 default_template_content_format="auto",
593 )
594
595 assert chat_params.response_format is request.response_format
596 assert chat_params.tool_choice is None
597
598
599 def test_chat_params_keeps_template_tool_choice_when_api_auto():
600 request = _request().model_copy(
601 update={
602 "tool_choice": "auto",
603 "chat_template_kwargs": {"tool_choice": "required"},
604 }
605 )
606
607 chat_params = request.build_chat_params(
608 default_template=None,
609 default_template_content_format="auto",
610 )
611
612 assert chat_params.chat_template_kwargs["tool_choice"] == "required"
613 assert chat_params.tool_choice == "auto"