A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 9, 14:45 UTC
fix: preserve native tool call ID in multi-turn tool calling (#32768)
Signed-off-by: wanglinian <wanglinian@stu.pku.edu.cn> Signed-off-by: wangln19 <96399074+wangln19@users.noreply.github.com> Signed-off-by: Roger Wang <hey@rogerw.io> Co-authored-by: Roger Wang <hey@rogerw.io> Co-authored-by: Isotr0py <2037008807@qq.com>
wangln19 committed
Jan 27, 2026 at 10:22 UTC
2d7053438a112e2be55cf6d2bde9deb8a169d0a4
7 files changed
+183
-68
tests/entrypoints/openai/test_chat_error.py
+1
@@ -42,6 +42,7 @@ class MockModelConfig:
42
tokenizer_revision = None
43
multimodal_config = MultiModalConfig()
44
hf_config = MockHFConfig()
45
+ hf_text_config = MockHFConfig()
46
logits_processor_pattern = None
47
logits_processors: list[str] | None = None
48
diff_sampling_param: dict | None = None
tests/entrypoints/openai/test_serving_chat.py
+1
@@ -518,6 +518,7 @@ class MockModelConfig:
518
tokenizer_revision = None
519
multimodal_config = MultiModalConfig()
520
hf_config = MockHFConfig()
521
+ hf_text_config = MockHFConfig()
522
logits_processors: list[str] | None = None
523
logits_processor_pattern = None
524
diff_sampling_param: dict | None = None
vllm/entrypoints/openai/chat_completion/serving.py
+121
-39
@@ -44,6 +44,7 @@ from vllm.entrypoints.openai.engine.protocol import (
44
DeltaMessage,
45
DeltaToolCall,
46
ErrorResponse,
47
+ FunctionCall,
48
PromptTokenUsageInfo,
49
RequestResponseMetadata,
50
ToolCall,
@@ -143,11 +144,6 @@ class OpenAIServingChat(OpenAIServing):
144
self.enable_prompt_tokens_details = enable_prompt_tokens_details
145
self.enable_force_include_usage = enable_force_include_usage
146
self.default_sampling_params = self.model_config.get_diff_sampling_param()
146
- if self.model_config.hf_config.model_type == "kimi_k2":
147
- self.tool_call_id_type = "kimi_k2"
148
- else:
149
- self.tool_call_id_type = "random"
150
-
147
self.use_harmony = self.model_config.hf_config.model_type == "gpt_oss"
148
if self.use_harmony:
149
if "stop_token_ids" not in self.default_sampling_params:
@@ -156,6 +152,16 @@ class OpenAIServingChat(OpenAIServing):
152
get_stop_tokens_for_assistant_actions()
153
)
154
155
+ # Handle tool call ID type for Kimi K2 (supporting test mocking via overrides)
156
+ hf_overrides = getattr(self.model_config, "hf_overrides", None)
157
+ if self.model_config.hf_text_config.model_type == "kimi_k2" or (
158
+ isinstance(hf_overrides, dict)
159
+ and hf_overrides.get("model_type") == "kimi_k2"
160
+ ):
161
+ self.tool_call_id_type = "kimi_k2"
162
+ else:
163
+ self.tool_call_id_type = "random"
164
+
165
# NOTE(woosuk): While OpenAI's chat completion API supports browsing
166
# for some models, currently vLLM doesn't support it. Please use the
167
# Responses API instead.
@@ -247,8 +253,8 @@ class OpenAIServingChat(OpenAIServing):
253
# because of issues with pydantic we need to potentially
254
# re-serialize the tool_calls field of the request
255
# for more info: see comment in `maybe_serialize_tool_calls`
250
- maybe_serialize_tool_calls(request)
251
- truncate_tool_call_ids(request)
256
+ maybe_serialize_tool_calls(request) # type: ignore[arg-type]
257
+ truncate_tool_call_ids(request) # type: ignore[arg-type]
258
validate_request_params(request)
259
260
# Check if tool parsing is unavailable (common condition)
@@ -454,6 +460,7 @@ class OpenAIServingChat(OpenAIServing):
460
461
# Streaming response
462
tokenizer = self.renderer.tokenizer
463
+ assert tokenizer is not None
464
465
if request.stream:
466
return self.chat_completion_stream_generator(
@@ -632,9 +639,11 @@ class OpenAIServingChat(OpenAIServing):
639
request_id: str,
640
model_name: str,
641
conversation: list[ConversationMessage],
635
- tokenizer: TokenizerLike | None,
642
+ tokenizer: TokenizerLike,
643
request_metadata: RequestResponseMetadata,
644
) -> AsyncGenerator[str, None]:
645
+ from vllm.tokenizers.mistral import MistralTokenizer
646
+
647
created_time = int(time.time())
648
chunk_object_type: Final = "chat.completion.chunk"
649
first_iteration = True
@@ -698,7 +707,7 @@ class OpenAIServingChat(OpenAIServing):
707
)
708
reasoning_parser = self.reasoning_parser(
709
tokenizer,
701
- chat_template_kwargs=chat_template_kwargs, # type: ignore[call-arg]
710
+ chat_template_kwargs=chat_template_kwargs or {}, # type: ignore[call-arg]
711
)
712
except RuntimeError as e:
713
logger.exception("Error in reasoning parser creation.")
@@ -955,8 +964,17 @@ class OpenAIServingChat(OpenAIServing):
964
index=i,
965
)
966
else:
967
+ # Generate ID based on tokenizer type
968
+ if isinstance(tokenizer, MistralTokenizer):
969
+ tool_call_id = MistralToolCall.generate_random_id()
970
+ else:
971
+ tool_call_id = make_tool_call_id(
972
+ id_type=self.tool_call_id_type,
973
+ func_name=tool_choice_function_name,
974
+ idx=history_tool_call_cnt,
975
+ )
976
delta_tool_call = DeltaToolCall(
959
- id=make_tool_call_id(),
977
+ id=tool_call_id,
978
type="function",
979
function=DeltaFunctionCall(
980
name=tool_choice_function_name,
@@ -1387,9 +1405,11 @@ class OpenAIServingChat(OpenAIServing):
1405
request_id: str,
1406
model_name: str,
1407
conversation: list[ConversationMessage],
1390
- tokenizer: TokenizerLike | None,
1408
+ tokenizer: TokenizerLike,
1409
request_metadata: RequestResponseMetadata,
1410
) -> ErrorResponse | ChatCompletionResponse:
1411
+ from vllm.tokenizers.mistral import MistralTokenizer
1412
+
1413
created_time = int(time.time())
1414
final_res: RequestOutput | None = None
1415
@@ -1524,39 +1544,85 @@ class OpenAIServingChat(OpenAIServing):
1544
tool_call_class = (
1545
MistralToolCall if isinstance(tokenizer, MistralTokenizer) else ToolCall
1546
)
1527
- if (not self.enable_auto_tools or not self.tool_parser) and (
1547
+ if self.use_harmony:
1548
+ # Harmony models already have parsed content and tool_calls
1549
+ # through parse_chat_output. Respect its output directly.
1550
+ message = ChatMessage(
1551
+ role=role,
1552
+ reasoning=reasoning,
1553
+ content=content,
1554
+ tool_calls=tool_calls if tool_calls else [],
1555
+ )
1556
+
1557
+ elif (not self.enable_auto_tools or not self.tool_parser) and (
1558
not isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
1559
and request.tool_choice != "required"
1560
):
1561
message = ChatMessage(role=role, reasoning=reasoning, content=content)
1562
1533
- # if the request uses tools and specified a tool choice
1563
elif (
1564
request.tool_choice
1565
and type(request.tool_choice) is ChatCompletionNamedToolChoiceParam
1566
):
1567
assert tool_calls is not None and len(tool_calls) > 0
1568
+ tool_call_class_items = []
1569
+ for idx, tc in enumerate(tool_calls):
1570
+ # Use native ID if available (e.g., Kimi K2),
1571
+ # otherwise generate ID with correct id_type
1572
+ if tc.id:
1573
+ tool_call_class_items.append(
1574
+ tool_call_class(id=tc.id, function=tc)
1575
+ )
1576
+ else:
1577
+ # Generate ID using the correct format (kimi_k2 or random),
1578
+ # but leave it to the class if it's Mistral to preserve
1579
+ # 9-char IDs
1580
+ if isinstance(tokenizer, MistralTokenizer):
1581
+ tool_call_class_items.append(tool_call_class(function=tc))
1582
+ else:
1583
+ generated_id = make_tool_call_id(
1584
+ id_type=self.tool_call_id_type,
1585
+ func_name=tc.name,
1586
+ idx=history_tool_call_cnt + idx,
1587
+ )
1588
+ tool_call_class_items.append(
1589
+ tool_call_class(id=generated_id, function=tc)
1590
+ )
1591
+ history_tool_call_cnt += 1
1592
message = ChatMessage(
1593
role=role,
1594
reasoning=reasoning,
1595
content="",
1543
- tool_calls=[tool_call_class(function=tc) for tc in tool_calls],
1596
+ tool_calls=tool_call_class_items,
1597
)
1598
1599
elif request.tool_choice and request.tool_choice == "required":
1600
tool_call_class_items = []
1601
assert tool_calls is not None and len(tool_calls) > 0
1549
- for tool_call in tool_calls:
1550
- tool_call_class_items.append(
1551
- tool_call_class(
1552
- id=make_tool_call_id(
1602
+ for idx, tool_call in enumerate(tool_calls):
1603
+ # Use native ID if available,
1604
+ # otherwise generate ID with correct id_type
1605
+ if tool_call.id:
1606
+ tool_call_class_items.append(
1607
+ tool_call_class(id=tool_call.id, function=tool_call)
1608
+ )
1609
+ else:
1610
+ # Generate ID using the correct format (kimi_k2 or random),
1611
+ # but leave it to the class if it's Mistral to preserve
1612
+ # 9-char IDs
1613
+ if isinstance(tokenizer, MistralTokenizer):
1614
+ tool_call_class_items.append(
1615
+ tool_call_class(function=tool_call)
1616
+ )
1617
+ else:
1618
+ generated_id = make_tool_call_id(
1619
id_type=self.tool_call_id_type,
1620
func_name=tool_call.name,
1555
- idx=history_tool_call_cnt,
1556
- ),
1557
- function=tool_call,
1558
- )
1559
- )
1621
+ idx=history_tool_call_cnt + idx,
1622
+ )
1623
+ tool_call_class_items.append(
1624
+ tool_call_class(id=generated_id, function=tool_call)
1625
+ )
1626
history_tool_call_cnt += 1
1627
message = ChatMessage(
1628
role=role,
@@ -1582,17 +1648,35 @@ class OpenAIServingChat(OpenAIServing):
1648
# call. The same is not true for named function calls
1649
auto_tools_called = tool_calls is not None and len(tool_calls) > 0
1650
if tool_calls:
1651
+ tool_call_items = []
1652
+ for idx, tc in enumerate(tool_calls):
1653
+ # Use native ID if available (e.g., Kimi K2),
1654
+ # otherwise generate ID with correct id_type
1655
+ if tc.id:
1656
+ tool_call_items.append(
1657
+ tool_call_class(id=tc.id, function=tc)
1658
+ )
1659
+ else:
1660
+ # Generate ID using the correct format (kimi_k2 or random),
1661
+ # but leave it to the class if it's Mistral to preserve
1662
+ # 9-char IDs
1663
+ if isinstance(tokenizer, MistralTokenizer):
1664
+ tool_call_items.append(tool_call_class(function=tc))
1665
+ else:
1666
+ generated_id = make_tool_call_id(
1667
+ id_type=self.tool_call_id_type,
1668
+ func_name=tc.name,
1669
+ idx=history_tool_call_cnt + idx,
1670
+ )
1671
+ tool_call_items.append(
1672
+ tool_call_class(id=generated_id, function=tc)
1673
+ )
1674
+ history_tool_call_cnt += 1
1675
message = ChatMessage(
1676
role=role,
1677
reasoning=reasoning,
1678
content=content,
1589
- tool_calls=[
1590
- ToolCall(
1591
- function=tc,
1592
- type="function",
1593
- )
1594
- for tc in tool_calls
1595
- ],
1679
+ tool_calls=tool_call_items,
1680
)
1681
1682
else:
@@ -1701,13 +1785,11 @@ class OpenAIServingChat(OpenAIServing):
1785
elif choice.message.tool_calls:
1786
# For tool calls, log the function name and arguments
1787
tool_call_descriptions = []
1704
- for tc in choice.message.tool_calls:
1705
- if hasattr(tc.function, "name") and hasattr(
1706
- tc.function, "arguments"
1707
- ):
1708
- tool_call_descriptions.append(
1709
- f"{tc.function.name}({tc.function.arguments})"
1710
- )
1788
+ for tc in choice.message.tool_calls: # type: ignore
1789
+ function_call: FunctionCall = tc.function # type: ignore
1790
+ tool_call_descriptions.append(
1791
+ f"{function_call.name}({function_call.arguments})"
1792
+ )
1793
tool_calls_str = ", ".join(tool_call_descriptions)
1794
output_text = f"[tool_calls: {tool_calls_str}]"
1795
@@ -1895,7 +1977,7 @@ class OpenAIServingChat(OpenAIServing):
1977
# because of issues with pydantic we need to potentially
1978
# re-serialize the tool_calls field of the request
1979
# for more info: see comment in `maybe_serialize_tool_calls`
1898
- maybe_serialize_tool_calls(request)
1980
+ maybe_serialize_tool_calls(request) # type: ignore[arg-type]
1981
1982
# Add system message.
1983
# NOTE: In Chat Completion API, browsing is enabled by default
@@ -1913,7 +1995,7 @@ class OpenAIServingChat(OpenAIServing):
1995
# Add developer message.
1996
if request.tools:
1997
dev_msg = get_developer_message(
1916
- tools=request.tools if should_include_tools else None
1998
+ tools=request.tools if should_include_tools else None # type: ignore[arg-type]
1999
)
2000
messages.append(dev_msg)
2001
vllm/entrypoints/openai/engine/protocol.py
+4
@@ -218,6 +218,10 @@ def get_logits_processors(
218
219
220
class FunctionCall(OpenAIBaseModel):
221
+ # Internal field to preserve native tool call ID from tool parser.
222
+ # Excluded from serialization to maintain OpenAI API compatibility
223
+ # (function object should only contain 'name' and 'arguments').
224
+ id: str | None = Field(default=None, exclude=True)
225
name: str
226
arguments: str
227
vllm/entrypoints/openai/engine/serving.py
+1
@@ -1525,6 +1525,7 @@ class OpenAIServing:
1525
# extract_tool_calls() returns a list of tool calls.
1526
function_calls.extend(
1527
FunctionCall(
1528
+ id=tool_call.id,
1529
name=tool_call.function.name,
1530
arguments=tool_call.function.arguments,
1531
)
vllm/entrypoints/openai/responses/serving.py
+53
-27
@@ -63,6 +63,7 @@ from vllm.engine.protocol import EngineClient
63
from vllm.entrypoints.chat_utils import (
64
ChatCompletionMessageParam,
65
ChatTemplateContentFormatOption,
66
+ make_tool_call_id,
67
)
68
from vllm.entrypoints.logger import RequestLogger
69
from vllm.entrypoints.mcp.tool_server import ToolServer
@@ -250,6 +251,17 @@ class OpenAIServingResponses(OpenAIServing):
251
self.default_sampling_params["stop_token_ids"].extend(
252
get_stop_tokens_for_assistant_actions()
253
)
254
+
255
+ # Handle tool call ID type for Kimi K2 (supporting test mocking via overrides)
256
+ hf_overrides = getattr(self.model_config, "hf_overrides", None)
257
+ if self.model_config.hf_text_config.model_type == "kimi_k2" or (
258
+ isinstance(hf_overrides, dict)
259
+ and hf_overrides.get("model_type") == "kimi_k2"
260
+ ):
261
+ self.tool_call_id_type = "kimi_k2"
262
+ else:
263
+ self.tool_call_id_type = "random"
264
+
265
self.enable_auto_tools = enable_auto_tools
266
# set up tool use
267
self.tool_parser = self._get_tool_parser(
@@ -954,25 +966,28 @@ class OpenAIServingResponses(OpenAIServing):
966
enable_auto_tools=self.enable_auto_tools,
967
tool_parser_cls=self.tool_parser,
968
)
957
- if content:
958
- output_text = ResponseOutputText(
959
- text=content,
960
- annotations=[], # TODO
961
- type="output_text",
962
- logprobs=(
963
- self._create_response_logprobs(
964
- token_ids=final_output.token_ids,
965
- logprobs=final_output.logprobs,
966
- tokenizer=tokenizer,
967
- top_logprobs=request.top_logprobs,
968
- )
969
- if request.is_include_output_logprobs()
970
- else None
971
- ),
972
- )
969
+
970
+ if content or (self.use_harmony and tool_calls):
971
+ res_text_part = None
972
+ if content:
973
+ res_text_part = ResponseOutputText(
974
+ text=content,
975
+ annotations=[], # TODO
976
+ type="output_text",
977
+ logprobs=(
978
+ self._create_response_logprobs(
979
+ token_ids=final_output.token_ids,
980
+ logprobs=final_output.logprobs,
981
+ tokenizer=tokenizer,
982
+ top_logprobs=request.top_logprobs,
983
+ )
984
+ if request.is_include_output_logprobs()
985
+ else None
986
+ ),
987
+ )
988
message_item = ResponseOutputMessage(
989
id=f"msg_{random_uuid()}",
975
- content=[output_text],
990
+ content=[res_text_part] if res_text_part else [],
991
role="assistant",
992
status="completed",
993
type="message",
@@ -984,17 +999,28 @@ class OpenAIServingResponses(OpenAIServing):
999
if message_item:
1000
outputs.append(message_item)
1001
if tool_calls:
987
- tool_call_items = [
988
- ResponseFunctionToolCall(
989
- id=f"fc_{random_uuid()}",
990
- call_id=f"call_{random_uuid()}",
991
- type="function_call",
992
- status="completed",
993
- name=tool_call.name,
994
- arguments=tool_call.arguments,
1002
+ # We use a simple counter for history_tool_call_count because
1003
+ # we don't track the history of tool calls in the Responses API yet.
1004
+ # This means that the tool call index will start from 0 for each
1005
+ # request.
1006
+ tool_call_items = []
1007
+ for history_tool_call_cnt, tool_call in enumerate(tool_calls):
1008
+ tool_call_items.append(
1009
+ ResponseFunctionToolCall(
1010
+ id=f"fc_{random_uuid()}",
1011
+ call_id=tool_call.id
1012
+ if tool_call.id
1013
+ else make_tool_call_id(
1014
+ id_type=self.tool_call_id_type,
1015
+ func_name=tool_call.name,
1016
+ idx=history_tool_call_cnt,
1017
+ ),
1018
+ type="function_call",
1019
+ status="completed",
1020
+ name=tool_call.name,
1021
+ arguments=tool_call.arguments,
1022
+ )
1023
)
996
- for tool_call in tool_calls
997
- ]
1024
outputs.extend(tool_call_items)
1025
return outputs
1026
vllm/tool_parsers/kimi_k2_tool_parser.py
+2
-2
@@ -448,7 +448,7 @@ class KimiK2ToolParser(ToolParser):
448
if current_tool_call_matches:
449
tool_id, tool_args = current_tool_call_matches.groups()
450
tool_name = tool_id.split(":")[0].split(".")[-1]
451
- current_tool_call["id"] = tool_id
451
+ current_tool_call["id"] = tool_id.strip()
452
current_tool_call["name"] = tool_name
453
current_tool_call["arguments"] = tool_args
454
else:
@@ -458,7 +458,7 @@ class KimiK2ToolParser(ToolParser):
458
if current_tool_call_name_matches:
459
(tool_id_str,) = current_tool_call_name_matches.groups()
460
tool_name = tool_id_str.split(":")[0].split(".")[-1]
461
- current_tool_call["id"] = tool_id_str
461
+ current_tool_call["id"] = tool_id_str.strip()
462
current_tool_call["name"] = tool_name
463
current_tool_call["arguments"] = ""
464
else: