[Frontend] Fix Kimi K2 tool call IDs for required tool choice (#46344)

Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com>

Chauncey committed Jun 25, 2026 at 03:59 UTC 84c2f9f0fb3f12b79e68d20b62ff7ec154dc860e
11 files changed +406 -178
tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py
+1 -74
@@ -24,6 +24,7 @@ tools = [
24 "description": "Get the current weather in a given location",
25 "parameters": {
26 "type": "object",
27 + "strict": True,
28 "properties": {
29 "city": {
30 "type": "string",
@@ -215,80 +216,6 @@ async def test_function_tool_use(
216 assert len(reasoning) > 0
217
218
218 -@pytest.fixture(scope="module")
219 -def k2_server():
220 - args = [
221 - # use half precision for speed and memory savings in CI environment
222 - "--dtype",
223 - "half",
224 - "--enable-auto-tool-choice",
225 - "--structured-outputs-config.backend",
226 - "xgrammar",
227 - "--tool-call-parser",
228 - "hermes",
229 - "--reasoning-parser",
230 - "qwen3",
231 - "--gpu-memory-utilization",
232 - "0.4",
233 - ] + ROCM_EXTRA_ARGS
234 - # Test kimi_k2 tool use tool_id format by overriding model_type.
235 - # is_deepseek_mla safely returns False via getattr when kv_lora_rank
236 - # is absent from the underlying config.
237 - with RemoteOpenAIServer(
238 - MODEL_NAME,
239 - args,
240 - env_dict=ROCM_ENV_OVERRIDES,
241 - override_hf_configs={"model_type": "kimi_k2"},
242 - ) as remote_server:
243 - yield remote_server
244 -
245 -
246 -@pytest_asyncio.fixture
247 -async def k2_client(k2_server):
248 - async with k2_server.get_async_client() as async_client:
249 - yield async_client
250 -
251 -
252 -@pytest.mark.asyncio
253 -@pytest.mark.skip(reason="Skipping Kimi K2 tool ID test")
254 -@pytest.mark.parametrize("model_name", [MODEL_NAME])
255 -@pytest.mark.parametrize("stream", [True, False])
256 -@pytest.mark.parametrize("tool_choice", ["required"])
257 -async def test_tool_id_kimi_k2(
258 - k2_client: openai.AsyncOpenAI, model_name: str, stream: bool, tool_choice: str
259 -):
260 - if not stream:
261 - # Non-streaming test
262 - chat_completion = await k2_client.chat.completions.create(
263 - messages=messages, model=model_name, tools=tools, tool_choice=tool_choice
264 - )
265 - assert chat_completion.choices[0].message.tool_calls is not None
266 - assert len(chat_completion.choices[0].message.tool_calls) > 0
267 - assert chat_completion.choices[0].message.tool_calls[0].id in [
268 - "functions.get_current_weather:0",
269 - "functions.get_forecast:1",
270 - ]
271 - else:
272 - # Streaming test
273 - output_stream = await k2_client.chat.completions.create(
274 - messages=messages,
275 - model=model_name,
276 - tools=tools,
277 - tool_choice=tool_choice,
278 - stream=True,
279 - )
280 -
281 - output = []
282 - async for chunk in output_stream:
283 - if chunk.choices and chunk.choices[0].delta.tool_calls:
284 - output.extend(chunk.choices[0].delta.tool_calls)
285 - for o in output:
286 - assert o.id is None or o.id in [
287 - "functions.get_current_weather:0",
288 - "functions.get_forecast:1",
289 - ]
290 -
291 -
219 @pytest.mark.asyncio
220 @pytest.mark.parametrize("model_name", [MODEL_NAME])
221 @pytest.mark.parametrize("arguments", ["{}", ""])
tests/entrypoints/openai/responses/test_parsable_context_unit.py
+9 -1
@@ -183,11 +183,19 @@ def _make_request_output(
183
184
185 def _make_context(parser_cls, **overrides):
186 + # ParsableContext no longer lazily builds a parser from ``parser_cls``;
187 + # the caller (here, the serving layer in production) must supply one.
188 + request = overrides.get("request", _make_request())
189 + response_parser = overrides.pop("response_parser", None)
190 + if response_parser is None and parser_cls is not None:
191 + response_parser = parser_cls(MagicMock(), request.tools)
192 +
193 defaults = dict(
194 tokenizer=MagicMock(),
195 parser_cls=parser_cls,
196 + response_parser=response_parser,
197 response_messages=[],
190 - request=_make_request(),
198 + request=request,
199 available_tools=None,
200 chat_template=None,
201 chat_template_content_format="auto",
tests/parser/test_parse.py
+149 -2
@@ -3,6 +3,7 @@
3
4 import json
5 import os
6 +from types import SimpleNamespace
7
8 import pytest
9
@@ -13,7 +14,9 @@ os.environ[_STRICT_TOOL_CALLING_ENV] = "0"
14 from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E402
15 ChatCompletionRequest,
16 )
17 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest # noqa: E402
18 from vllm.parser.abstract_parser import DelegatingParser # noqa: E402
19 +from vllm.parser.utils import count_history_tool_calls # noqa: E402
20 from vllm.reasoning.basic_parsers import ( # noqa: E402
21 BaseThinkingReasoningParser,
22 )
@@ -82,12 +85,55 @@ TOOLS = [
85 ]
86
87
85 -def make_parser(tokenizer, reasoning=False, tool=False):
88 +KIMI_K2_MODEL_CONFIG = SimpleNamespace(
89 + hf_text_config=SimpleNamespace(model_type="kimi_k2"),
90 + hf_overrides=None,
91 +)
92 +
93 +HISTORY_MESSAGES = [
94 + {"role": "user", "content": "first"},
95 + {
96 + "role": "assistant",
97 + "content": None,
98 + "tool_calls": [
99 + {
100 + "id": "functions.get_current_weather:0",
101 + "type": "function",
102 + "function": {
103 + "name": "get_current_weather",
104 + "arguments": "{}",
105 + },
106 + },
107 + {
108 + "id": "functions.get_forecast:1",
109 + "type": "function",
110 + "function": {
111 + "name": "get_forecast",
112 + "arguments": "{}",
113 + },
114 + },
115 + ],
116 + },
117 + {
118 + "role": "tool",
119 + "tool_call_id": "functions.get_current_weather:0",
120 + "content": "{}",
121 + },
122 + {
123 + "role": "tool",
124 + "tool_call_id": "functions.get_forecast:1",
125 + "content": "{}",
126 + },
127 + {"role": "user", "content": "again"},
128 +]
129 +
130 +
131 +def make_parser(tokenizer, reasoning=False, tool=False, **kwargs):
132 class TestParser(DelegatingParser):
133 reasoning_parser_cls = ThinkReasoningParser if reasoning else None
134 tool_parser_cls = Hermes2ProToolParser if tool else None
135
90 - return TestParser(tokenizer)
136 + return TestParser(tokenizer, **kwargs)
137
138
139 @pytest.mark.parametrize(
@@ -232,6 +278,107 @@ def test_parse_required_tool_choice(tokenizer):
278 assert json.loads(tool_calls[1].arguments) == {"timezone": "UTC"}
279
280
281 +def test_parse_required_tool_choice_kimi_k2_ids(tokenizer):
282 + parser = make_parser(
283 + tokenizer, reasoning=False, tool=True, model_config=KIMI_K2_MODEL_CONFIG
284 + )
285 + functions_json = json.dumps(
286 + [
287 + {"name": "get_current_weather", "parameters": {"city": "Dallas"}},
288 + {"name": "get_forecast", "parameters": {"city": "Dallas", "days": 2}},
289 + ]
290 + )
291 + request = make_request(tools=TOOLS, tool_choice="required")
292 + _, content, tool_calls = parser.parse(
293 + functions_json, request, enable_auto_tools=True
294 + )
295 +
296 + assert content is None
297 + assert tool_calls is not None
298 + assert [tc.id for tc in tool_calls] == [
299 + "functions.get_current_weather:0",
300 + "functions.get_forecast:1",
301 + ]
302 +
303 +
304 +def test_parse_required_tool_choice_kimi_k2_ids_after_history(tokenizer):
305 + parser = make_parser(
306 + tokenizer, reasoning=False, tool=True, model_config=KIMI_K2_MODEL_CONFIG
307 + )
308 + functions_json = json.dumps(
309 + [{"name": "get_current_weather", "parameters": {"city": "Dallas"}}]
310 + )
311 + request = make_request(
312 + messages=HISTORY_MESSAGES,
313 + tools=TOOLS,
314 + tool_choice="required",
315 + )
316 + _, _, tool_calls = parser.parse(functions_json, request, enable_auto_tools=True)
317 +
318 + assert tool_calls is not None
319 + assert tool_calls[0].id == "functions.get_current_weather:2"
320 +
321 +
322 +def test_count_history_tool_calls_responses_request():
323 + request = ResponsesRequest.model_validate(
324 + {
325 + "model": "test-model",
326 + "input": [
327 + {
328 + "type": "function_call",
329 + "call_id": "call_0",
330 + "name": "get_current_weather",
331 + "arguments": "{}",
332 + },
333 + {
334 + "type": "function_call",
335 + "call_id": "call_1",
336 + "name": "get_forecast",
337 + "arguments": "{}",
338 + },
339 + ],
340 + }
341 + )
342 +
343 + assert count_history_tool_calls(request) == 2
344 +
345 +
346 +def test_parse_required_tool_choice_random_ids_deferred(tokenizer):
347 + parser = make_parser(tokenizer, reasoning=False, tool=True)
348 + functions_json = json.dumps(
349 + [{"name": "get_current_weather", "parameters": {"city": "Dallas"}}]
350 + )
351 + request = make_request(
352 + messages=HISTORY_MESSAGES,
353 + tools=TOOLS,
354 + tool_choice="required",
355 + )
356 + _, _, tool_calls = parser.parse(functions_json, request, enable_auto_tools=True)
357 +
358 + assert tool_calls is not None
359 + assert tool_calls[0].id is None
360 +
361 +
362 +def test_parse_named_tool_choice_kimi_k2_id(tokenizer):
363 + parser = make_parser(
364 + tokenizer, reasoning=False, tool=True, model_config=KIMI_K2_MODEL_CONFIG
365 + )
366 + request = make_request(
367 + tools=TOOLS,
368 + tool_choice={
369 + "type": "function",
370 + "function": {"name": "get_weather"},
371 + },
372 + )
373 + _, content, tool_calls = parser.parse(
374 + TOOL_ARGUMENTS, request, enable_auto_tools=True
375 + )
376 +
377 + assert content is None
378 + assert tool_calls is not None
379 + assert tool_calls[0].id == "functions.get_weather:0"
380 +
381 +
382 def test_parse_named_tool_choice_content_none(tokenizer):
383 parser = make_parser(tokenizer, reasoning=False, tool=True)
384 request = make_request(
tests/parser/test_streaming.py
+106 -2
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 +from types import SimpleNamespace
6
7 import pytest
8
@@ -47,6 +48,36 @@ TOOLS = [
48 ]
49
50
51 +KIMI_K2_MODEL_CONFIG = SimpleNamespace(
52 + hf_text_config=SimpleNamespace(model_type="kimi_k2"),
53 + hf_overrides=None,
54 +)
55 +
56 +HISTORY_MESSAGES = [
57 + {"role": "user", "content": "first"},
58 + {
59 + "role": "assistant",
60 + "content": None,
61 + "tool_calls": [
62 + {
63 + "id": "functions.get_current_weather:0",
64 + "type": "function",
65 + "function": {
66 + "name": "get_current_weather",
67 + "arguments": "{}",
68 + },
69 + }
70 + ],
71 + },
72 + {
73 + "role": "tool",
74 + "tool_call_id": "functions.get_current_weather:0",
75 + "content": "{}",
76 + },
77 + {"role": "user", "content": "again"},
78 +]
79 +
80 +
81 @pytest.fixture
82 def request_obj():
83 return ChatCompletionRequest(
@@ -57,12 +88,12 @@ def request_obj():
88 )
89
90
60 -def make_parser(tokenizer, reasoning=False, tool=False):
91 +def make_parser(tokenizer, reasoning=False, tool=False, **kwargs):
92 class TestParser(DelegatingParser):
93 reasoning_parser_cls = ThinkReasoningParser if reasoning else None
94 tool_parser_cls = Hermes2ProToolParser if tool else None
95
65 - return TestParser(tokenizer)
96 + return TestParser(tokenizer, **kwargs)
97
98
99 def stream_text(parser, tokenizer, text, request, prompt_token_ids=None):
@@ -365,3 +396,76 @@ def test_parse_delta_tool_choice_none_with_reasoning(tokenizer, request_obj):
396 assert len(tool_calls) == 0
397 assert "<tool_call>" in content
398 assert "get_weather" in content
399 +
400 +
401 +def test_parse_delta_required_tool_choice_kimi_k2_ids(tokenizer, request_obj):
402 + parser = make_parser(
403 + tokenizer, reasoning=False, tool=True, model_config=KIMI_K2_MODEL_CONFIG
404 + )
405 + request = request_obj.model_copy(update={"tool_choice": "required"})
406 + output = json.dumps(
407 + [
408 + {
409 + "name": "get_current_weather",
410 + "parameters": {"city": "Dallas"},
411 + }
412 + ]
413 + )
414 +
415 + results: list[DeltaMessage | None] = []
416 + prompt_token_ids: list[int] | None = []
417 + for i in range(0, len(output), 3):
418 + chunk = output[i : i + 3]
419 + results.append(
420 + parser.parse_delta(
421 + chunk,
422 + [],
423 + request,
424 + prompt_token_ids=prompt_token_ids,
425 + finished=False,
426 + )
427 + )
428 + prompt_token_ids = None
429 +
430 + _, content, tool_calls = collect_fields(results)
431 + assert content == ""
432 + assert any(tc.id == "functions.get_current_weather:0" for tc in tool_calls)
433 + assert all(tc.id in (None, "functions.get_current_weather:0") for tc in tool_calls)
434 +
435 +
436 +def test_parse_delta_required_tool_choice_kimi_k2_ids_after_history(
437 + tokenizer, request_obj
438 +):
439 + parser = make_parser(
440 + tokenizer, reasoning=False, tool=True, model_config=KIMI_K2_MODEL_CONFIG
441 + )
442 + request = request_obj.model_copy(
443 + update={"messages": HISTORY_MESSAGES, "tool_choice": "required"}
444 + )
445 + output = json.dumps(
446 + [
447 + {
448 + "name": "get_current_weather",
449 + "parameters": {"city": "Dallas"},
450 + }
451 + ]
452 + )
453 +
454 + results: list[DeltaMessage | None] = []
455 + prompt_token_ids: list[int] | None = []
456 + for i in range(0, len(output), 3):
457 + chunk = output[i : i + 3]
458 + results.append(
459 + parser.parse_delta(
460 + chunk,
461 + [],
462 + request,
463 + prompt_token_ids=prompt_token_ids,
464 + finished=False,
465 + )
466 + )
467 + prompt_token_ids = None
468 +
469 + _, _, tool_calls = collect_fields(results)
470 + assert any(tc.id == "functions.get_current_weather:1" for tc in tool_calls)
471 + assert all(tc.id in (None, "functions.get_current_weather:1") for tc in tool_calls)
vllm/entrypoints/openai/chat_completion/serving.py
+17 -68
@@ -17,8 +17,6 @@ from vllm.engine.protocol import EngineClient
17 from vllm.entrypoints.chat_utils import (
18 ChatTemplateContentFormatOption,
19 ConversationMessage,
20 - get_history_tool_calls_cnt,
21 - get_tool_call_id_type,
20 make_tool_call_id,
21 )
22 from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -170,8 +168,6 @@ class OpenAIServingChat(OpenAIServing):
168 if mc.generation_config not in ("auto", "vllm")
169 else getattr(mc, "override_generation_config", {}).get("max_new_tokens")
170 )
173 - self.tool_call_id_type = get_tool_call_id_type(self.model_config)
174 -
171 # NOTE(woosuk): While OpenAI's chat completion API supports browsing
172 # for some models, currently vLLM doesn't support it. Please use the
173 # Responses API instead.
@@ -261,6 +257,7 @@ class OpenAIServingChat(OpenAIServing):
257 tokenizer,
258 request.tools,
259 chat_template_kwargs=chat_template_kwargs,
260 + model_config=self.model_config,
261 )
262 result = await self.render_chat_request(request)
263 if isinstance(result, ErrorResponse):
@@ -433,11 +430,6 @@ class OpenAIServingChat(OpenAIServing):
430 else:
431 tool_choice_function_name = None
432
436 - if self.tool_call_id_type == "kimi_k2":
437 - history_tool_call_cnt = get_history_tool_calls_cnt(conversation)
438 - else:
439 - history_tool_call_cnt = 0
440 -
433 previous_texts = [""] * num_choices
434
435 try:
@@ -451,14 +443,10 @@ class OpenAIServingChat(OpenAIServing):
443 tokenizer,
444 request.tools,
445 chat_template_kwargs=chat_template_kwargs,
446 + model_config=self.model_config,
447 )
448 for _ in range(num_choices)
449 ]
457 - for p in parsers:
458 - if p is not None:
459 - # NOTE: HarmonyParser ignores _stream_state (uses its own FSM).
460 - p._stream_state.tool_call_id_type = self.tool_call_id_type
461 - p._stream_state.history_tool_call_cnt = history_tool_call_cnt
450 else:
451 parsers = [None] * num_choices
452 except Exception as e:
@@ -842,10 +830,6 @@ class OpenAIServingChat(OpenAIServing):
830 )
831
832 choices: list[ChatCompletionResponseChoice] = []
845 - if self.tool_call_id_type == "kimi_k2":
846 - history_tool_call_cnt = get_history_tool_calls_cnt(conversation)
847 - else:
848 - history_tool_call_cnt = 0
833
834 role = self.get_chat_request_role(request)
835 tool_parser_cls = (
@@ -885,54 +869,26 @@ class OpenAIServingChat(OpenAIServing):
869 tool_calls = []
870
871 auto_tools_called = False
872 + is_named_tool_choice = (
873 + request.tool_choice is not None
874 + and type(request.tool_choice) is ChatCompletionNamedToolChoiceParam
875 + )
876 + is_required_tool_choice = request.tool_choice == "required"
877
878 if (not self.enable_auto_tools or not tool_parser_cls) and (
890 - not isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
891 - and request.tool_choice != "required"
879 + not is_named_tool_choice and not is_required_tool_choice
880 ):
881 message = ChatMessage(role=role, reasoning=reasoning, content=content)
882
895 - elif (
896 - request.tool_choice
897 - and type(request.tool_choice) is ChatCompletionNamedToolChoiceParam
898 - ):
899 - tool_call_items = []
900 - tool_calls = tool_calls or []
901 - for tc in tool_calls:
902 - if not tc.id:
903 - tc.id = make_tool_call_id(
904 - id_type=self.tool_call_id_type,
905 - func_name=tc.name,
906 - idx=history_tool_call_cnt,
907 - )
908 - tool_call_items.append(ToolCall(id=tc.id, function=tc))
909 - history_tool_call_cnt += 1
883 + elif is_named_tool_choice or is_required_tool_choice:
884 message = ChatMessage(
885 role=role,
886 reasoning=reasoning,
887 content=content or "",
914 - tool_calls=tool_call_items,
915 - )
916 -
917 - elif request.tool_choice and request.tool_choice == "required":
918 - tool_call_items = []
919 - tool_calls = tool_calls or []
920 - for tool_call in tool_calls:
921 - if not tool_call.id:
922 - tool_call.id = make_tool_call_id(
923 - id_type=self.tool_call_id_type,
924 - func_name=tool_call.name,
925 - idx=history_tool_call_cnt,
926 - )
927 - tool_call_items.append(
928 - ToolCall(id=tool_call.id, function=tool_call)
929 - )
930 - history_tool_call_cnt += 1
931 - message = ChatMessage(
932 - role=role,
933 - content=content or "",
934 - tool_calls=tool_call_items,
935 - reasoning=reasoning,
888 + tool_calls=[
889 + ToolCall(id=tc.id or make_tool_call_id(), function=tc)
890 + for tc in (tool_calls or [])
891 + ],
892 )
893
894 # if the request doesn't use tool choice
@@ -949,21 +905,14 @@ class OpenAIServingChat(OpenAIServing):
905 ):
906 auto_tools_called = tool_calls is not None and len(tool_calls) > 0
907 if tool_calls:
952 - tool_call_items = []
953 - for tc in tool_calls:
954 - if not tc.id:
955 - tc.id = make_tool_call_id(
956 - id_type=self.tool_call_id_type,
957 - func_name=tc.name,
958 - idx=history_tool_call_cnt,
959 - )
960 - tool_call_items.append(ToolCall(id=tc.id, function=tc))
961 - history_tool_call_cnt += 1
908 message = ChatMessage(
909 role=role,
910 reasoning=reasoning,
911 content=content,
966 - tool_calls=tool_call_items,
912 + tool_calls=[
913 + ToolCall(id=tc.id or make_tool_call_id(), function=tc)
914 + for tc in tool_calls
915 + ],
916 )
917
918 else:
vllm/entrypoints/openai/responses/context.py
-14
@@ -301,7 +301,6 @@ class ParsableContext(ConversationContext):
301 chat_template_content_format: ChatTemplateContentFormatOption,
302 response_parser: Parser | None = None,
303 enable_auto_tools: bool = False,
304 - tool_call_id_type: str = "random",
304 ):
305 self.num_prompt_tokens = 0
306 self.num_output_tokens = 0
@@ -314,20 +313,8 @@ class ParsableContext(ConversationContext):
313 self.num_init_messages = len(response_messages)
314 self.finish_reason: str | None = None
315 self.enable_auto_tools = enable_auto_tools
317 - self.tool_call_id_type = tool_call_id_type
316
317 self.response_parser = response_parser
320 - if self.response_parser is None and parser_cls is not None:
321 - chat_template_kwargs = request.build_chat_params(
322 - default_template=chat_template,
323 - default_template_content_format=chat_template_content_format,
324 - ).chat_template_kwargs
325 - self.response_parser = parser_cls(
326 - tokenizer,
327 - tools=request.tools,
328 - chat_template_kwargs=chat_template_kwargs,
329 - )
330 -
318 self.parser_cls = parser_cls
319 self.request = request
320
@@ -365,7 +352,6 @@ class ParsableContext(ConversationContext):
352 reasoning=reasoning,
353 content=content,
354 tool_calls=tool_calls,
368 - tool_call_id_type=self.tool_call_id_type,
355 )
356 )
357 elif completion.text:
vllm/entrypoints/openai/responses/serving.py
+1 -6
@@ -30,7 +30,6 @@ from vllm.engine.protocol import EngineClient
30 from vllm.entrypoints.chat_utils import (
31 ChatCompletionMessageParam,
32 ChatTemplateContentFormatOption,
33 - get_tool_call_id_type,
33 )
34 from vllm.entrypoints.mcp.tool_server import ToolServer
35 from vllm.entrypoints.openai.engine.protocol import (
@@ -222,9 +221,6 @@ class OpenAIServingResponses(OpenAIServing):
221 "For gpt-oss, we ignore --enable-auto-tool-choice "
222 "and always enable tool use."
223 )
225 -
226 - self.tool_call_id_type = get_tool_call_id_type(self.model_config)
227 -
224 self.enable_auto_tools = enable_auto_tools
225 # HACK(woosuk): This is a hack. We should use a better store.
226 # FIXME: If enable_store=True, this may cause a memory leak since we
@@ -272,6 +268,7 @@ class OpenAIServingResponses(OpenAIServing):
268 tokenizer,
269 request.tools,
270 chat_template_kwargs=chat_template_kwargs,
271 + model_config=self.model_config,
272 )
273
274 def _validate_generator_input(
@@ -493,7 +490,6 @@ class OpenAIServingResponses(OpenAIServing):
490 chat_template=self.chat_template,
491 chat_template_content_format=self.chat_template_content_format,
492 enable_auto_tools=self.enable_auto_tools,
496 - tool_call_id_type=self.tool_call_id_type,
493 )
494 else:
495 context = SimpleContext(
@@ -1073,7 +1069,6 @@ class OpenAIServingResponses(OpenAIServing):
1069 content=content,
1070 tool_calls=tool_calls,
1071 logprobs=logprobs,
1076 - tool_call_id_type=self.tool_call_id_type,
1072 )
1073
1074 # Fallback when no parser is configured
vllm/entrypoints/openai/responses/utils.py
+1 -7
@@ -45,7 +45,6 @@ def build_response_output_items(
45 content: str | None,
46 tool_calls: list[FunctionCall] | None,
47 logprobs: list[Logprob] | None = None,
48 - tool_call_id_type: str = "random",
48 ) -> list[ResponseOutputItem]:
49 outputs: list[ResponseOutputItem] = []
50
@@ -86,12 +85,7 @@ def build_response_output_items(
85 ResponseFunctionToolCall(
86 id=f"fc_{random_uuid()}",
87 call_id=tool_call.id
89 - if tool_call.id
90 - else make_tool_call_id(
91 - id_type=tool_call_id_type,
92 - func_name=tool_call.name,
93 - idx=idx,
94 - ),
88 + or make_tool_call_id(func_name=tool_call.name, idx=idx),
89 type="function_call",
90 status="completed",
91 name=tool_call.name,
vllm/parser/abstract_parser.py
+46 -2
@@ -11,6 +11,10 @@ from functools import cached_property
11 from openai.types.responses import ToolChoiceFunction
12 from pydantic import TypeAdapter, ValidationError
13
14 +from vllm.entrypoints.chat_utils import (
15 + get_tool_call_id_type,
16 + make_tool_call_id,
17 +)
18 from vllm.entrypoints.openai.chat_completion.protocol import (
19 ChatCompletionNamedToolChoiceParam,
20 ChatCompletionRequest,
@@ -24,6 +28,7 @@ from vllm.entrypoints.openai.engine.protocol import (
28 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
29 from vllm.logger import init_logger
30 from vllm.parser.metrics import record_tool_parser_invocation
31 +from vllm.parser.utils import count_history_tool_calls
32 from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
33 from vllm.sampling_params import StructuredOutputsParams
34 from vllm.tokenizers import TokenizerLike
@@ -46,6 +51,7 @@ class StreamState:
51 previous_text: str = ""
52 previous_token_ids: list[int] = field(default_factory=list)
53 history_tool_call_cnt: int = 0
54 + history_tool_call_cnt_initialized: bool = False
55 tool_call_id_type: str = "random"
56 # only used for "required" and "named tool" choices,
57 # tracks whether function name has been fully returned in the stream yet
@@ -108,6 +114,7 @@ class Parser:
114 tokenizer: TokenizerLike,
115 tools: list[Tool] | None = None,
116 *args,
117 + model_config=None,
118 **kwargs,
119 ):
120 self.model_tokenizer = tokenizer
@@ -124,7 +131,14 @@ class Parser:
131 self._reasoning_parser is None
132 or self._reasoning_parser.engine_based_streaming
133 ) and (self._tool_parser is None or self._tool_parser.engine_based_streaming)
127 - self._stream_state = StreamState(engine_based=self._engine_based)
134 + self._stream_state = StreamState(
135 + tool_call_id_type=(
136 + get_tool_call_id_type(model_config)
137 + if model_config is not None
138 + else "random"
139 + ),
140 + engine_based=self._engine_based,
141 + )
142
143 @cached_property
144 def vocab(self) -> dict[str, int]:
@@ -149,6 +163,19 @@ class Parser:
163 def tool_parser(self, parser: ToolParser | None) -> None:
164 self._tool_parser = parser
165
166 + def _initialize_history_tool_call_cnt(
167 + self,
168 + request: ChatCompletionRequest | ResponsesRequest,
169 + ) -> None:
170 + state = self._stream_state
171 + if state.history_tool_call_cnt_initialized:
172 + return
173 + if state.tool_call_id_type != "kimi_k2":
174 + state.history_tool_call_cnt_initialized = True
175 + return
176 + state.history_tool_call_cnt = count_history_tool_calls(request)
177 + state.history_tool_call_cnt_initialized = True
178 +
179 # ========== Reasoning Parser Methods ==========
180
181 @abstractmethod
@@ -375,6 +402,18 @@ class DelegatingParser(Parser):
402 return request.tool_choice.function.name
403 raise ValueError("Invalid tool_choice for function name extraction.")
404
405 + def _make_tool_call_id(self, function_name: str) -> str | None:
406 + state = self._stream_state
407 + if state.tool_call_id_type != "kimi_k2":
408 + return None
409 + tool_call_id = make_tool_call_id(
410 + id_type=state.tool_call_id_type,
411 + func_name=function_name,
412 + idx=state.history_tool_call_cnt,
413 + )
414 + state.history_tool_call_cnt += 1
415 + return tool_call_id
416 +
417 def _extract_tool_calls(
418 self,
419 content: str | None,
@@ -404,9 +443,11 @@ class DelegatingParser(Parser):
443 if is_named_tool_choice and supports_required_and_named:
444 if content is None:
445 return [], None
446 + function_name = self._get_function_name(request)
447 tool_calls.append(
448 FunctionCall(
409 - name=self._get_function_name(request),
449 + id=self._make_tool_call_id(function_name),
450 + name=function_name,
451 arguments=content,
452 )
453 )
@@ -422,6 +463,7 @@ class DelegatingParser(Parser):
463 for tc in parsed_calls:
464 tool_calls.append(
465 FunctionCall(
466 + id=self._make_tool_call_id(tc.name),
467 name=tc.name,
468 arguments=json.dumps(tc.parameters, ensure_ascii=False),
469 )
@@ -733,6 +775,7 @@ class DelegatingParser(Parser):
775 enable_auto_tools: bool = False,
776 model_output_token_ids: Sequence[int] = (),
777 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
778 + self._initialize_history_tool_call_cnt(request)
779 reasoning, content = self.extract_reasoning(model_output, request)
780 tool_calls, content = self._extract_tool_calls(
781 content=content,
@@ -750,6 +793,7 @@ class DelegatingParser(Parser):
793 *,
794 finished: bool,
795 ) -> DeltaMessage | None:
796 + self._initialize_history_tool_call_cnt(request)
797 state = self._stream_state
798
799 if not state.prompt_reasoning_checked and prompt_token_ids is not None:
vllm/parser/engine/parser_engine.py
+11 -2
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
13
14 import regex as re
15
16 -from vllm.entrypoints.chat_utils import make_tool_call_id
16 +from vllm.entrypoints.chat_utils import get_tool_call_id_type, make_tool_call_id
17 from vllm.entrypoints.openai.engine.protocol import (
18 DeltaFunctionCall,
19 DeltaMessage,
@@ -89,11 +89,18 @@ class ParserEngine(Parser):
89 tools: list[Tool] | None = None,
90 *,
91 parser_engine_config: ParserEngineConfig,
92 + model_config=None,
93 **kwargs,
94 ) -> None:
95 self.model_tokenizer = tokenizer
96 self._tools = tools
96 - self._stream_state = StreamState()
97 + self._stream_state = StreamState(
98 + tool_call_id_type=(
99 + get_tool_call_id_type(model_config)
100 + if model_config is not None
101 + else "random"
102 + ),
103 + )
104 self._reasoning_parser = None
105 self._tool_parser = None
106 self.parser_engine_config = parser_engine_config
@@ -419,6 +426,7 @@ class ParserEngine(Parser):
426 *,
427 finished: bool,
428 ) -> DeltaMessage | None:
429 + self._initialize_history_tool_call_cnt(request)
430 if not self._prompt_streaming_prepared and prompt_token_ids is not None:
431 # NOTE: call the hook BEFORE setting the flag, because the hook
432 # may invoke ``_reset`` (e.g. via ``initialize_streaming``) which
@@ -658,6 +666,7 @@ class ParserEngine(Parser):
666 enable_auto_tools: bool = False,
667 model_output_token_ids: Sequence[int] = (),
668 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
669 + self._initialize_history_tool_call_cnt(request)
670 self._check_skip_tool_parsing(request)
671 reasoning, content, tool_call_info = self._single_pass_parse(
672 model_output,
vllm/parser/utils.py new
+65
@@ -0,0 +1,65 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from collections.abc import Iterable, Sequence
5 +
6 +from openai.types.responses import ResponseFunctionToolCall
7 +
8 +from vllm.entrypoints.chat_utils import ChatCompletionMessageParam
9 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
10 +from vllm.entrypoints.openai.responses.protocol import (
11 + ResponseInputOutputItem,
12 + ResponsesRequest,
13 +)
14 +
15 +
16 +def count_tool_calls(tool_calls: object) -> int:
17 + if tool_calls is None:
18 + return 0
19 + if isinstance(tool_calls, (str, bytes, dict)):
20 + return 1
21 + if isinstance(tool_calls, Iterable):
22 + return sum(1 for _ in tool_calls)
23 + return 1
24 +
25 +
26 +def count_chat_history_tool_calls(
27 + messages: Sequence[ChatCompletionMessageParam],
28 +) -> int:
29 + return sum(
30 + count_tool_calls(msg.get("tool_calls"))
31 + for msg in messages
32 + if isinstance(msg, dict) and msg.get("role") == "assistant"
33 + )
34 +
35 +
36 +def count_response_history_tool_calls(
37 + response_items: Sequence[ResponseInputOutputItem],
38 +) -> int:
39 + count = 0
40 + for item in response_items:
41 + if isinstance(item, ResponseFunctionToolCall):
42 + count += 1
43 + continue
44 +
45 + if isinstance(item, dict):
46 + item_type = item.get("type")
47 + if item_type == "function_call":
48 + count += 1
49 + elif item.get("role") == "assistant":
50 + count += count_tool_calls(item.get("tool_calls"))
51 +
52 + return count
53 +
54 +
55 +def count_history_tool_calls(
56 + request: ChatCompletionRequest | ResponsesRequest,
57 +) -> int:
58 + if isinstance(request, ChatCompletionRequest):
59 + return count_chat_history_tool_calls(request.messages)
60 +
61 + request_input = request.input
62 + if isinstance(request_input, str):
63 + return 0
64 +
65 + return count_response_history_tool_calls(request_input)