[Bugfix][Frontend] Accept Anthropic tool_addition and tool_removal content blocks (#57693)
Signed-off-by: Anurag Singh <dr.anuragkr@gmail.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: Antigravity <antigravity@google.com> Co-authored-by: sfeng33 <4florafeng@gmail.com>
Anurag Singh committed
Oct 2, 2026 at 05:33 UTC
28c57456db9220fbdde6041a20b97bebd141259b
3 files changed
+197
-2
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+85
@@ -27,6 +27,7 @@ from pydantic import BaseModel, Field, ValidationError
27
28
from vllm.entrypoints.anthropic.api_router import attach_router
29
from vllm.entrypoints.anthropic.protocol import (
30
+ AnthropicCountTokensRequest,
31
AnthropicMessagesRequest,
32
)
33
from vllm.entrypoints.anthropic.serving import (
@@ -2032,3 +2033,87 @@ class TestProbeDisabledThinkingEffort:
2033
@pytest.mark.asyncio
2034
async def test_renderer_rejects_none(self):
2035
assert await self._probe(self._reject_none) == "low"
2036
+
2037
+
2038
+class TestMidConversationToolChanges:
2039
+ """``tool_addition``/``tool_removal`` blocks decide which tools the chat
2040
+ template is given, since it has no other way to see them."""
2041
+
2042
+ @staticmethod
2043
+ def _convert_tools(*changes: dict) -> dict[str, bool | None]:
2044
+ request = _make_request(
2045
+ [
2046
+ {"role": "user", "content": "Hello"},
2047
+ {
2048
+ "role": "system",
2049
+ "content": [{"type": "text", "text": "Tools changed."}, *changes],
2050
+ },
2051
+ ],
2052
+ tools=[
2053
+ {"name": "bash", "input_schema": {}},
2054
+ {"name": "search", "input_schema": {}, "defer_loading": True},
2055
+ {"name": "fetch", "input_schema": {}, "defer_loading": True},
2056
+ ],
2057
+ )
2058
+ result = _convert(request)
2059
+ assert result.messages[-1] == {"role": "system", "content": "Tools changed."}
2060
+ assert result.tools is not None
2061
+ return {t.function.name: t.function.defer_loading for t in result.tools}
2062
+
2063
+ @staticmethod
2064
+ def _change(block_type: str, name: str) -> dict:
2065
+ return {"type": block_type, "tool": {"type": "tool_reference", "name": name}}
2066
+
2067
+ def test_addition_loads_deferred_tool(self):
2068
+ tools = self._convert_tools(self._change("tool_addition", "search"))
2069
+ assert tools == {"bash": None, "search": None, "fetch": True}
2070
+
2071
+ def test_removal_withdraws_tool_until_added_again(self):
2072
+ removal = self._change("tool_removal", "bash")
2073
+ assert "bash" not in self._convert_tools(removal)
2074
+ readded = self._convert_tools(removal, self._change("tool_addition", "bash"))
2075
+ assert list(readded) == ["bash", "search", "fetch"]
2076
+
2077
+ def test_addition_defines_tool_by_value(self):
2078
+ definition = {"name": "db_query", "input_schema": {}}
2079
+ tools = self._convert_tools(
2080
+ {
2081
+ "type": "tool_addition",
2082
+ "tool": {"type": "tool_definition", "definition": definition},
2083
+ }
2084
+ )
2085
+ assert list(tools) == ["bash", "search", "fetch", "db_query"]
2086
+
2087
+ def test_unknown_tool_reference_is_rejected(self):
2088
+ with pytest.raises(ValueError, match="not declared in tools"):
2089
+ self._convert_tools(self._change("tool_addition", "missing"))
2090
+
2091
+ def test_removal_by_value_is_rejected(self):
2092
+ definition = {"name": "bash", "input_schema": {}}
2093
+ with pytest.raises(ValidationError, match="only accepts a tool_reference"):
2094
+ self._convert_tools(
2095
+ {
2096
+ "type": "tool_removal",
2097
+ "tool": {"type": "tool_definition", "definition": definition},
2098
+ }
2099
+ )
2100
+
2101
+ @pytest.mark.parametrize(
2102
+ "request_cls", [AnthropicMessagesRequest, AnthropicCountTokensRequest]
2103
+ )
2104
+ @pytest.mark.parametrize("in_top_level_system", [False, True])
2105
+ def test_tool_change_outside_system_message_is_rejected(
2106
+ self, request_cls, in_top_level_system
2107
+ ):
2108
+ change = self._change("tool_addition", "search")
2109
+ if in_top_level_system:
2110
+ fields = {
2111
+ "messages": [{"role": "user", "content": "Hi"}],
2112
+ "system": [change],
2113
+ }
2114
+ else:
2115
+ fields = {"messages": [{"role": "user", "content": [change]}]}
2116
+ with pytest.raises(
2117
+ ValidationError, match='only allowed in messages with role "system"'
2118
+ ):
2119
+ request_cls(model="test-model", max_tokens=128, **fields)
vllm/entrypoints/anthropic/protocol.py
+62
@@ -44,6 +44,8 @@ class AnthropicContentBlock(BaseModel):
44
"tool_reference",
45
"thinking",
46
"redacted_thinking",
47
+ "tool_addition",
48
+ "tool_removal",
49
]
50
text: str | None = None
51
# For image content
@@ -62,6 +64,28 @@ class AnthropicContentBlock(BaseModel):
64
signature: str | None = None
65
# For redacted thinking content (safety-filtered by the API)
66
data: str | None = None
67
+ # For tool_addition/tool_removal content
68
+ tool: "AnthropicToolChange | None" = None
69
+
70
+ @model_validator(mode="after")
71
+ def validate_tool_change(self) -> "AnthropicContentBlock":
72
+ if self.type not in ("tool_addition", "tool_removal"):
73
+ return self
74
+ if self.tool is None:
75
+ raise ValueError(f"tool is required for {self.type} blocks")
76
+ if self.type == "tool_removal" and self.tool.type != "tool_reference":
77
+ raise ValueError("tool_removal only accepts a tool_reference")
78
+ return self
79
+
80
+
81
+def _reject_tool_changes(content: str | list[AnthropicContentBlock] | None) -> None:
82
+ if content is None or isinstance(content, str):
83
+ return
84
+ for block in content:
85
+ if block.type in ("tool_addition", "tool_removal"):
86
+ raise ValueError(
87
+ f'{block.type} blocks are only allowed in messages with role "system"'
88
+ )
89
90
91
class AnthropicMessage(BaseModel):
@@ -70,6 +94,12 @@ class AnthropicMessage(BaseModel):
94
role: Literal["user", "assistant", "system"]
95
content: str | list[AnthropicContentBlock]
96
97
+ @model_validator(mode="after")
98
+ def validate_tool_change_role(self) -> "AnthropicMessage":
99
+ if self.role != "system":
100
+ _reject_tool_changes(self.content)
101
+ return self
102
+
103
104
class AnthropicTool(BaseModel):
105
"""Tool definition."""
@@ -90,6 +120,26 @@ class AnthropicTool(BaseModel):
120
return v
121
122
123
+class AnthropicToolChangeReference(BaseModel):
124
+ """A tool named by a tool_addition or tool_removal block."""
125
+
126
+ type: Literal["tool_reference"]
127
+ name: str
128
+
129
+
130
+class AnthropicToolChangeDefinition(BaseModel):
131
+ """A tool defined by value in a tool_addition block."""
132
+
133
+ type: Literal["tool_definition"]
134
+ definition: AnthropicTool
135
+
136
+
137
+AnthropicToolChange = Annotated[
138
+ AnthropicToolChangeReference | AnthropicToolChangeDefinition,
139
+ Field(discriminator="type"),
140
+]
141
+
142
+
143
class AnthropicToolChoice(BaseModel):
144
"""Tool Choice definition."""
145
@@ -231,6 +281,12 @@ class AnthropicMessagesRequest(BaseModel):
281
raise ValueError("max_tokens must be positive")
282
return v
283
284
+ @field_validator("system")
285
+ @classmethod
286
+ def validate_system(cls, v):
287
+ _reject_tool_changes(v)
288
+ return v
289
+
290
@model_validator(mode="after")
291
def validate_thinking_budget(self) -> "AnthropicMessagesRequest":
292
# P/D prefill legs are sent with max_tokens=1 and never decode; the
@@ -343,6 +399,12 @@ class AnthropicCountTokensRequest(BaseModel):
399
raise ValueError("Model is required")
400
return v
401
402
+ @field_validator("system")
403
+ @classmethod
404
+ def validate_system(cls, v):
405
+ _reject_tool_changes(v)
406
+ return v
407
+
408
409
class AnthropicCountTokensResponse(BaseModel):
410
"""Anthropic messages.count_tokens response."""
vllm/entrypoints/anthropic/serving.py
+50
-2
@@ -31,6 +31,8 @@ from vllm.entrypoints.anthropic.protocol import (
31
AnthropicOutputConfig,
32
AnthropicStreamEvent,
33
AnthropicThinkingConfig,
34
+ AnthropicTool,
35
+ AnthropicToolChangeDefinition,
36
AnthropicUsage,
37
)
38
from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
@@ -710,6 +712,51 @@ class AnthropicServingMessages(OpenAIServingChat):
712
}
713
)
714
715
+ @classmethod
716
+ def _resolve_tools(
717
+ cls,
718
+ anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
719
+ ) -> list[AnthropicTool] | None:
720
+ """Apply tool_addition/tool_removal blocks in ``messages`` to ``tools``.
721
+
722
+ Chat templates see a single tool list, so this resolves the tools in
723
+ effect at the end of the conversation: a tool added by reference is no
724
+ longer deferred, a tool added by value is declared, and a removed tool
725
+ is dropped.
726
+ """
727
+ changes = [
728
+ block
729
+ for msg in anthropic_request.messages
730
+ if not isinstance(msg.content, str)
731
+ for block in msg.content
732
+ if block.type in ("tool_addition", "tool_removal")
733
+ ]
734
+ if not changes:
735
+ return anthropic_request.tools
736
+
737
+ tools = {tool.name: tool for tool in anthropic_request.tools or []}
738
+ removed: set[str] = set()
739
+ for block in changes:
740
+ change = block.tool
741
+ assert change is not None
742
+ if isinstance(change, AnthropicToolChangeDefinition):
743
+ name = change.definition.name
744
+ tools[name] = change.definition
745
+ else:
746
+ name = change.name
747
+ if name not in tools:
748
+ raise ValueError(
749
+ f"{block.type} references tool {name!r}, "
750
+ "which is not declared in tools"
751
+ )
752
+ if block.type == "tool_addition":
753
+ tools[name] = tools[name].model_copy(update={"defer_loading": None})
754
+ if block.type == "tool_removal":
755
+ removed.add(name)
756
+ else:
757
+ removed.discard(name)
758
+ return [tool for name, tool in tools.items() if name not in removed]
759
+
760
@classmethod
761
def _convert_tools(
762
cls,
@@ -717,11 +764,12 @@ class AnthropicServingMessages(OpenAIServingChat):
764
req: ChatCompletionRequest,
765
) -> None:
766
"""Convert Anthropic tools to OpenAI format."""
720
- if anthropic_request.tools is None:
767
+ anthropic_tools = cls._resolve_tools(anthropic_request)
768
+ if anthropic_tools is None:
769
return
770
771
tools = []
724
- for tool in anthropic_request.tools:
772
+ for tool in anthropic_tools:
773
tools.append(
774
ChatCompletionToolsParam.model_validate(
775
{