[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 {