[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