[Bugfix][Frontend] Report named Anthropic tool calls as tool_use (#47598)

Signed-off-by: Ting Sun <suntcrick@gmail.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>

Ting SUN committed Sep 30, 2026 at 13:32 UTC 329e6eee3f3cb1d028666afaf23c65394536efb6
2 files changed +147 -34
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+74 -5
@@ -37,6 +37,8 @@ from vllm.entrypoints.generate.base.protocol import (
37 DeltaFunctionCall,
38 DeltaMessage,
39 DeltaToolCall,
40 + FunctionCall,
41 + ToolCall,
42 )
43 from vllm.entrypoints.openai.chat_completion.protocol import (
44 ChatCompletionResponse,
@@ -999,11 +1001,6 @@ class TestInlineSystemMessageInMessagesArray:
1001
1002 def _make_stream_converter():
1003 obj = MagicMock(spec=AnthropicServingMessages)
1002 - obj.stop_reason_map = {
1003 - "stop": "end_turn",
1004 - "length": "max_tokens",
1005 - "tool_calls": "tool_use",
1006 - }
1004 obj.message_stream_converter = (
1005 AnthropicServingMessages.message_stream_converter.__get__(obj)
1006 )
@@ -1671,6 +1668,78 @@ class TestStopSequenceReason:
1668 assert msg_deltas[0]["delta"]["stop_sequence"] is None
1669
1670
1671 +class TestToolUseStopReason:
1672 + """A response carrying a tool_use block reports ``stop_reason="tool_use"``
1673 + even when the chat layer says ``finish_reason="stop"``, as it does for a
1674 + forced (named) tool. A call cut short by max_tokens or a stop string does not.
1675 + """
1676 +
1677 + @pytest.mark.parametrize(
1678 + ("finish_reason", "stop_reason", "expected"),
1679 + [
1680 + ("stop", None, "tool_use"),
1681 + ("tool_calls", "</x>", "tool_use"),
1682 + ],
1683 + )
1684 + def test_non_streaming(self, finish_reason, stop_reason, expected):
1685 + tool_call = ToolCall(
1686 + function=FunctionCall(name="get_weather", arguments='{"city": "Paris"}')
1687 + )
1688 + response = ChatCompletionResponse(
1689 + id="chatcmpl-test",
1690 + model="test-model",
1691 + choices=[
1692 + ChatCompletionResponseChoice(
1693 + index=0,
1694 + message=ChatMessage(role="assistant", tool_calls=[tool_call]),
1695 + finish_reason=finish_reason,
1696 + stop_reason=stop_reason,
1697 + )
1698 + ],
1699 + usage=UsageInfo(prompt_tokens=5, total_tokens=8, completion_tokens=3),
1700 + )
1701 +
1702 + result = _make_full_converter().messages_full_converter(response)
1703 +
1704 + assert result.content[0].type == "tool_use"
1705 + assert result.stop_reason == expected
1706 +
1707 + @pytest.mark.asyncio
1708 + @pytest.mark.parametrize(
1709 + ("args", "finish_reason", "stop_reason", "expected"),
1710 + [
1711 + ('{"city": "Paris"}', "stop", None, "tool_use"),
1712 + ('{"city": "Pa', "length", None, "max_tokens"),
1713 + ('{"city": "Paris"}', "tool_calls", "</x>", "tool_use"),
1714 + ('{"city": "Pa', "tool_calls", "</x>", "stop_sequence"),
1715 + ],
1716 + )
1717 + async def test_streaming(self, args, finish_reason, stop_reason, expected):
1718 + async def sse_input():
1719 + yield _make_stream_chunk(delta=DeltaMessage(role="assistant"))
1720 + yield _make_stream_chunk(
1721 + delta=DeltaMessage(
1722 + tool_calls=[_tc(id="call_1", name="get_weather", args=args)]
1723 + )
1724 + )
1725 + yield _make_stream_chunk(
1726 + finish_reason=finish_reason, stop_reason=stop_reason
1727 + )
1728 + yield _make_stream_chunk(
1729 + choices=[],
1730 + usage=UsageInfo(prompt_tokens=5, total_tokens=8, completion_tokens=3),
1731 + )
1732 + yield "data: [DONE]"
1733 +
1734 + converter = _make_stream_converter()
1735 + events = _parse_sse_events(
1736 + [e async for e in converter.message_stream_converter(sse_input())]
1737 + )
1738 +
1739 + msg_deltas = [data for ev_type, data in events if ev_type == "message_delta"]
1740 + assert msg_deltas[0]["delta"]["stop_reason"] == expected
1741 +
1742 +
1743 # ======================================================================
1744 # Client-caused errors are 4xx, not 500 (Issue #52088)
1745 # ======================================================================
vllm/entrypoints/anthropic/serving.py
+73 -29
@@ -10,7 +10,7 @@ import logging
10 import time
11 import uuid
12 from collections.abc import AsyncGenerator
13 -from typing import Any, get_args
13 +from typing import Any, Literal, get_args
14
15 import jinja2
16 from fastapi import Request
@@ -97,6 +97,46 @@ def _build_anthropic_usage(
97 return AnthropicUsage(**kwargs)
98
99
100 +def _to_anthropic_stop_reason(
101 + finish_reason: str | None,
102 + stop_reason: int | str | None,
103 + tool_used: bool,
104 + tool_complete: bool,
105 +) -> tuple[
106 + Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None,
107 + str | None,
108 +]:
109 + """Map an OpenAI finish reason to Anthropic ``(stop_reason, stop_sequence)``.
110 +
111 + Anthropic reports ``tool_use`` whenever the model invoked a tool. The chat
112 + layer reports ``finish_reason="stop"`` for a named (forced) tool call, so
113 + ``tool_used`` records whether a ``tool_use`` block was produced.
114 + ``max_tokens`` always wins. A matched stop string wins unless the tool
115 + calls are complete (``tool_complete``): a call it cut short must not run.
116 + """
117 + if finish_reason == "length":
118 + return "max_tokens", None
119 + if finish_reason not in ("stop", "tool_calls"):
120 + return None, None
121 + if tool_complete:
122 + return "tool_use", None
123 + # vLLM reports the matched stop string in stop_reason (a str);
124 + # an int stop-token-id or None (natural EOS) is not a stop sequence.
125 + if isinstance(stop_reason, str):
126 + return "stop_sequence", stop_reason
127 + if finish_reason == "tool_calls" or tool_used:
128 + return "tool_use", None
129 + return "end_turn", None
130 +
131 +
132 +def _is_json(text: str) -> bool:
133 + try:
134 + json.loads(text)
135 + except json.JSONDecodeError:
136 + return False
137 + return True
138 +
139 +
140 def wrap_data_with_event(data: str, event: str):
141 return f"event: {event}\ndata: {data}\n\n"
142
@@ -141,11 +181,6 @@ class AnthropicServingMessages(OpenAIServingChat):
181 enable_force_include_usage=enable_force_include_usage,
182 default_chat_template_kwargs=default_chat_template_kwargs,
183 )
144 - self.stop_reason_map = {
145 - "stop": "end_turn",
146 - "length": "max_tokens",
147 - "tool_calls": "tool_use",
148 - }
184 self._merge_inline_system = self._should_merge_inline_system(online_renderer)
185 # Resolved lazily from the renderer when "auto".
186 self._disabled_thinking_effort: AnthropicDisabledThinkingEffort | None = (
@@ -751,18 +786,13 @@ class AnthropicServingMessages(OpenAIServingChat):
786 ec_transfer_params=generator.ec_transfer_params,
787 )
788 choice = generator.choices[0]
754 - if choice.finish_reason == "stop":
755 - # vLLM reports the matched stop string in stop_reason (a str);
756 - # an int stop-token-id or None (natural EOS) maps to end_turn.
757 - if isinstance(choice.stop_reason, str):
758 - result.stop_reason = "stop_sequence"
759 - result.stop_sequence = choice.stop_reason
760 - else:
761 - result.stop_reason = "end_turn"
762 - elif choice.finish_reason == "length":
763 - result.stop_reason = "max_tokens"
764 - elif choice.finish_reason == "tool_calls":
765 - result.stop_reason = "tool_use"
789 + result.stop_reason, result.stop_sequence = _to_anthropic_stop_reason(
790 + choice.finish_reason,
791 + choice.stop_reason,
792 + tool_used=bool(choice.message.tool_calls),
793 + # Their arguments are json-decoded below, so parsed calls are complete.
794 + tool_complete=bool(choice.message.tool_calls),
795 + )
796
797 content: list[AnthropicContentBlock] = []
798 if choice.message.reasoning:
@@ -845,6 +875,10 @@ class AnthropicServingMessages(OpenAIServingChat):
875 # Matched stop string, when generation stopped on one (a str);
876 # int stop-token-id / None are not stop sequences.
877 stop_sequence: int | str | None = None
878 + tool_used = False
879 + # Arguments of the latest tool_use block; only the last call can be
880 + # cut short by a stop string.
881 + last_tool_args: list[str] = []
882 state = _ActiveBlockState()
883 # Map from tool call index to tool_use_id
884 tool_index_to_id: dict[int, str] = {}
@@ -954,19 +988,21 @@ class AnthropicServingMessages(OpenAIServingChat):
988 if len(origin_chunk.choices) == 0:
989 for event in stop_and_flush():
990 yield event
957 - if isinstance(stop_sequence, str):
958 - stop_delta = AnthropicDelta(
959 - stop_reason="stop_sequence",
960 - stop_sequence=stop_sequence,
961 - )
962 - else:
963 - stop_delta = AnthropicDelta(
964 - stop_reason=self.stop_reason_map.get(
965 - finish_reason or "stop"
991 + anthropic_stop_reason, anthropic_stop_sequence = (
992 + _to_anthropic_stop_reason(
993 + finish_reason or "stop",
994 + stop_sequence,
995 + tool_used=tool_used,
996 + tool_complete=(
997 + tool_used and _is_json("".join(last_tool_args))
998 ),
967 - # Set explicitly so exclude_unset=True keeps it.
968 - stop_sequence=None,
999 )
1000 + )
1001 + stop_delta = AnthropicDelta(
1002 + stop_reason=anthropic_stop_reason,
1003 + # Set explicitly so exclude_unset=True keeps it.
1004 + stop_sequence=anthropic_stop_sequence,
1005 + )
1006 chunk = AnthropicStreamEvent(
1007 type="message_delta",
1008 delta=stop_delta,
@@ -1060,6 +1096,8 @@ class AnthropicServingMessages(OpenAIServingChat):
1096 ):
1097 for event in stop_and_flush():
1098 yield event
1099 + tool_used = True
1100 + last_tool_args = []
1101 start_event = start_block(
1102 AnthropicContentBlock(
1103 type="tool_use",
@@ -1075,6 +1113,9 @@ class AnthropicServingMessages(OpenAIServingChat):
1113 and tool_call.function.arguments
1114 and state.tool_use_id == tool_call.id
1115 ):
1116 + last_tool_args.append(
1117 + tool_call.function.arguments
1118 + )
1119 chunk = AnthropicStreamEvent(
1120 index=(
1121 state.block_index
@@ -1100,6 +1141,9 @@ class AnthropicServingMessages(OpenAIServingChat):
1141 and tool_call.function.arguments
1142 and state.tool_use_id == tool_use_id
1143 ):
1144 + last_tool_args.append(
1145 + tool_call.function.arguments
1146 + )
1147 chunk = AnthropicStreamEvent(
1148 index=(
1149 state.block_index