Merge pull request #42288 from BerriAI/litellm_safeguards_bedrock_vertex_messages

fix(anthropic): forward Claude Code safeguards and dangerous-tool-use beta to Bedrock Invoke and Vertex on /v1/messages
This commit is contained in:
Mateo Wang 2026-09-21 14:15:36 -07:00 • committed by GitHub
commit fc82f6e8fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 289 additions and 1 deletions

View file

@ -11,6 +11,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27", "context-management-2025-06-27": "context-management-2025-06-27",
"dangerous-tool-use-2026-09-03": "dangerous-tool-use-2026-09-03",
"effort-2025-11-24": "effort-2025-11-24", "effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": "fast-mode-2026-02-01", "fast-mode-2026-02-01": "fast-mode-2026-02-01",
"files-api-2025-04-14": "files-api-2025-04-14", "files-api-2025-04-14": "files-api-2025-04-14",
@ -44,6 +45,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27", "context-management-2025-06-27": "context-management-2025-06-27",
"dangerous-tool-use-2026-09-03": null,
"effort-2025-11-24": "effort-2025-11-24", "effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": null, "fast-mode-2026-02-01": null,
"files-api-2025-04-14": "files-api-2025-04-14", "files-api-2025-04-14": "files-api-2025-04-14",
@ -76,6 +78,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": null, "context-management-2025-06-27": null,
"dangerous-tool-use-2026-09-03": null,
"effort-2025-11-24": "effort-2025-11-24", "effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": null, "fast-mode-2026-02-01": null,
"files-api-2025-04-14": null, "files-api-2025-04-14": null,
@ -109,6 +112,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27", "context-management-2025-06-27": "context-management-2025-06-27",
"dangerous-tool-use-2026-09-03": "dangerous-tool-use-2026-09-03",
"effort-2025-11-24": "effort-2025-11-24", "effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": null, "fast-mode-2026-02-01": null,
"files-api-2025-04-14": null, "files-api-2025-04-14": null,
@ -143,6 +147,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27", "context-management-2025-06-27": "context-management-2025-06-27",
"dangerous-tool-use-2026-09-03": "dangerous-tool-use-2026-09-03",
"effort-2025-11-24": "effort-2025-11-24", "effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": null, "fast-mode-2026-02-01": null,
"files-api-2025-04-14": null, "files-api-2025-04-14": null,
@ -177,6 +182,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27", "context-management-2025-06-27": "context-management-2025-06-27",
"dangerous-tool-use-2026-09-03": "dangerous-tool-use-2026-09-03",
"effort-2025-11-24": null, "effort-2025-11-24": null,
"fast-mode-2026-02-01": null, "fast-mode-2026-02-01": null,
"files-api-2025-04-14": null, "files-api-2025-04-14": null,
@ -210,6 +216,7 @@
"computer-use-2025-11-24": "computer-use-2025-11-24", "computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07", "context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27", "context-management-2025-06-27": "context-management-2025-06-27",
"dangerous-tool-use-2026-09-03": null,
"effort-2025-11-24": "effort-2025-11-24", "effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": "fast-mode-2026-02-01", "fast-mode-2026-02-01": "fast-mode-2026-02-01",
"files-api-2025-04-14": "files-api-2025-04-14", "files-api-2025-04-14": "files-api-2025-04-14",

View file

@ -533,6 +533,9 @@ class AmazonAnthropicClaudeMessagesConfig(
if anthropic_model_info.is_eager_input_streaming_used(tools): if anthropic_model_info.is_eager_input_streaming_used(tools):
beta_set.add(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER) beta_set.add(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER)
if anthropic_messages_optional_request_params.get("safeguards") is not None:
beta_set.add(ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value)
self._filter_context_management_for_bedrock_invoke( self._filter_context_management_for_bedrock_invoke(
anthropic_messages_request=anthropic_messages_request, anthropic_messages_request=anthropic_messages_request,
beta_set=beta_set, beta_set=beta_set,

View file

@ -108,6 +108,9 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert
if anthropic_model_info.is_tool_search_used(tools): if anthropic_model_info.is_tool_search_used(tools):
beta_values.add(get_tool_search_beta_header("vertex_ai")) beta_values.add(get_tool_search_beta_header("vertex_ai"))
if optional_params.get("safeguards") is not None:
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value)
if beta_values: if beta_values:
headers["anthropic-beta"] = ",".join(beta_values) headers["anthropic-beta"] = ",".join(beta_values)

View file

@ -751,6 +751,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum):
FAST_MODE_2026_02_01 = "fast-mode-2026-02-01" FAST_MODE_2026_02_01 = "fast-mode-2026-02-01"
ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-01" ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-01"
PER_TURN_CONTROL_2026_07_01 = "per-turn-control-2026-07-01" PER_TURN_CONTROL_2026_07_01 = "per-turn-control-2026-07-01"
DANGEROUS_TOOL_USE_2026_09_03 = "dangerous-tool-use-2026-09-03"
# Tool search beta header constant (for Anthropic direct API and Microsoft Foundry) # Tool search beta header constant (for Anthropic direct API and Microsoft Foundry)

View file

@ -1238,6 +1238,7 @@ class BedrockInvokeAnthropicMessagesRequest(TypedDict, total=False):
thinking: dict thinking: dict
metadata: dict metadata: dict
output_config: dict output_config: dict
safeguards: list
# `context_management` is allowed for Bedrock InvokeModel only when it # `context_management` is allowed for Bedrock InvokeModel only when it
# carries `compact_20260112` edits paired with the `compact-2026-01-12` # carries `compact_20260112` edits paired with the `compact-2026-01-12`

View file

@ -2,7 +2,7 @@ import asyncio
import json import json
import os import os
import uuid import uuid
from typing import Any, Dict, List from typing import Any, Dict, Final, List
import httpx import httpx
import pytest import pytest
@ -1584,3 +1584,104 @@ async def test_anthropic_messages_streaming_forwards_safeguards_and_keeps_safegu
assert captured["body"]["safeguards"] == safeguards assert captured["body"]["safeguards"] == safeguards
assert events[0]["message"]["safeguard_results"] == safeguard_results assert events[0]["message"]["safeguard_results"] == safeguard_results
assert [e for e in events if e["type"] == "message_delta"][0]["delta"]["safeguard_results"] == safeguard_results assert [e for e in events if e["type"] == "message_delta"][0]["delta"]["safeguard_results"] == safeguard_results
def _claude_code_auto_mode_request() -> tuple[list[dict[str, object]], list[dict[str, object]]]:
"""Shapes are what Claude Code 2.1.278 sends and Bedrock Invoke / Vertex rawPredict return, captured 2026-09-21."""
safeguards = [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}]
tool_verdicts = {"toolu_01": {"type": "evaluated", "outcome": "not_flagged"}}
safeguard_results = [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": tool_verdicts}}]
return safeguards, safeguard_results
def _upstream_answering_with(safeguard_results: list[dict[str, object]], captured: dict[str, object]) -> AsyncHTTPHandler:
def upstream_records_the_request(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
captured["anthropic-beta"] = request.headers.get("anthropic-beta")
return httpx.Response(
200,
json={
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
"safeguard_results": safeguard_results,
},
request=request,
)
upstream = AsyncHTTPHandler()
upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_records_the_request))
return upstream
_CLIENT_BETA_HEADERS: Final = (
pytest.param({"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, id="client_sends_beta"),
pytest.param({"anthropic-beta": "interleaved-thinking-2025-05-14"}, id="client_omits_beta"),
pytest.param({}, id="client_sends_no_beta_header"),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("client_headers", _CLIENT_BETA_HEADERS)
async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_beta_to_bedrock_invoke(
local_beta_headers_config, client_headers
):
"""Bedrock Invoke takes betas in the body's `anthropic_beta` and 400s on `safeguards` without the beta, so the beta rides along with the field."""
from litellm.llms.anthropic.experimental_pass_through.messages import handler
safeguards, safeguard_results = _claude_code_auto_mode_request()
captured: dict[str, object] = {}
response = await handler.anthropic_messages(
max_tokens=16,
messages=[{"role": "user", "content": "hi"}],
model="bedrock/us.anthropic.claude-sonnet-5",
custom_llm_provider="bedrock",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
aws_region_name="us-east-1",
client=_upstream_answering_with(safeguard_results, captured),
safeguards=safeguards,
extra_headers=client_headers,
)
assert captured["body"]["safeguards"] == safeguards
assert captured["body"]["anthropic_beta"] == ["dangerous-tool-use-2026-09-03"]
assert response["safeguard_results"] == safeguard_results
@pytest.mark.asyncio
@pytest.mark.parametrize("client_headers", _CLIENT_BETA_HEADERS)
async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_beta_to_vertex(
local_beta_headers_config, client_headers
):
"""Vertex rawPredict takes the beta as the `anthropic-beta` header and 400s on `safeguards` without it, so the beta rides along with the field."""
from litellm.llms.anthropic.experimental_pass_through.messages import handler
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
safeguards, safeguard_results = _claude_code_auto_mode_request()
captured: dict[str, object] = {}
with patch.object(VertexBase, "_ensure_access_token", return_value=("test-token", "test-project")):
response = await handler.anthropic_messages(
max_tokens=16,
messages=[{"role": "user", "content": "hi"}],
model="vertex_ai/claude-sonnet-5",
custom_llm_provider="vertex_ai",
vertex_project="test-project",
vertex_location="global",
vertex_credentials="{}",
client=_upstream_answering_with(safeguard_results, captured),
safeguards=safeguards,
extra_headers=client_headers,
)
assert captured["body"]["safeguards"] == safeguards
assert "anthropic_beta" not in captured["body"]
assert captured["anthropic-beta"].split(",").count("dangerous-tool-use-2026-09-03") == 1
assert response["safeguard_results"] == safeguard_results

View file

@ -1651,6 +1651,92 @@ def test_bedrock_messages_allowlist_filters_anthropic_only_fields():
assert set(result).issubset(cfg.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS) assert set(result).issubset(cfg.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS)
@pytest.mark.parametrize(
"client_beta_header",
["dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14", "interleaved-thinking-2025-05-14"],
ids=["client_sends_beta", "client_omits_beta"],
)
def test_bedrock_messages_forwards_safeguards_with_dangerous_tool_use_beta(local_beta_headers_config, client_beta_header):
"""
Claude Code's server-side auto-mode classifier sends `safeguards` alongside the
dangerous-tool-use-2026-09-03 beta. Bedrock Invoke accepts the pair, answers
"safeguards: Extra inputs are not permitted" for the field alone, and returns
`safeguard_results: []` for the beta alone, so the field reaches it unchanged
and the beta rides along whether or not the client sent it, as every other
body-driven beta does here.
"""
from litellm.types.router import GenericLiteLLMParams
cfg = AmazonAnthropicClaudeMessagesConfig()
safeguards = [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}]
result = cfg.transform_anthropic_messages_request(
model="us.anthropic.claude-sonnet-5",
messages=[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
anthropic_messages_optional_request_params={"max_tokens": 64, "safeguards": safeguards},
litellm_params=GenericLiteLLMParams(),
headers={"anthropic-beta": client_beta_header},
)
assert result["safeguards"] == safeguards
assert result["anthropic_beta"].count("dangerous-tool-use-2026-09-03") == 1
def test_bedrock_messages_does_not_add_dangerous_tool_use_beta_without_safeguards(local_beta_headers_config):
from litellm.types.router import GenericLiteLLMParams
cfg = AmazonAnthropicClaudeMessagesConfig()
result = cfg.transform_anthropic_messages_request(
model="us.anthropic.claude-sonnet-5",
messages=[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
anthropic_messages_optional_request_params={"max_tokens": 64},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert "safeguards" not in result
assert "dangerous-tool-use-2026-09-03" not in result.get("anthropic_beta", [])
def test_bedrock_messages_stream_decoder_keeps_safeguard_results():
"""Bedrock streams the classifier verdicts on message_start and on the final message_delta, exactly as api.anthropic.com does."""
decoder = AmazonAnthropicClaudeMessagesStreamDecoder(model="us.anthropic.claude-sonnet-5")
tool_verdicts = {"toolu_01": {"type": "evaluated", "outcome": "not_flagged"}}
safeguard_results = [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": tool_verdicts}}]
message_start = decoder._chunk_parser(
{
"type": "message_start",
"message": {
"id": "msg_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 3, "output_tokens": 0},
"safeguard_results": safeguard_results,
},
}
)
assert isinstance(message_start, dict)
assert message_start["message"]["safeguard_results"] == safeguard_results
message_delta = decoder._chunk_parser(
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None, "safeguard_results": safeguard_results},
"usage": {"output_tokens": 1},
"amazon-bedrock-invocationMetrics": {"inputTokenCount": 3, "outputTokenCount": 1},
}
)
assert isinstance(message_delta, dict)
assert message_delta["delta"]["safeguard_results"] == safeguard_results
def test_bedrock_messages_filters_user_provided_unsupported_beta_header(): def test_bedrock_messages_filters_user_provided_unsupported_beta_header():
""" """
In proxy deployments the client (e.g. Claude Code) doesn't know the backend In proxy deployments the client (e.g. Claude Code) doesn't know the backend

View file

@ -439,6 +439,19 @@ class TestBetaHeadersOnTheWire:
assert _sent_betas(route) == ["context-1m-2025-08-07", "context-management-2025-06-27"] assert _sent_betas(route) == ["context-1m-2025-08-07", "context-management-2025-06-27"]
assert _sent_body(route)["context_management"] == {"edits": [{"type": "clear_tool_uses_20250919"}]} assert _sent_body(route)["context_management"] == {"edits": [{"type": "clear_tool_uses_20250919"}]}
@pytest.mark.asyncio
@respx.mock
async def test_safeguards_reach_mantle_with_the_dangerous_tool_use_beta(self):
"""Mantle answers 400 "safeguards: Extra inputs are not permitted" when the field
arrives without dangerous-tool-use-2026-09-03 (probed 2026-09-21), so the beta
has to ride along even when the client never sent the header."""
safeguards = [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}]
route = await self._send(safeguards=safeguards)
assert _sent_betas(route) == ["dangerous-tool-use-2026-09-03"]
assert _sent_body(route)["safeguards"] == safeguards
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_betas_and_version_never_travel_in_the_body(self): async def test_betas_and_version_never_travel_in_the_body(self):

View file

@ -3,6 +3,8 @@ import json
import os import os
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import ( from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import (
VertexAIPartnerModelsAnthropicMessagesConfig, VertexAIPartnerModelsAnthropicMessagesConfig,
) )
@ -67,6 +69,63 @@ def test_web_search_header_added_for_messages_endpoint():
) )
@pytest.mark.parametrize(
"client_headers",
[{"anthropic-beta": "dangerous-tool-use-2026-09-03"}, {}],
ids=["client_sends_beta", "client_omits_beta"],
)
def test_safeguards_add_dangerous_tool_use_beta_header(client_headers):
"""Vertex rejects `safeguards` without the dangerous-tool-use beta, so the beta rides along with the field the way the web search and context management betas do."""
config = VertexAIPartnerModelsAnthropicMessagesConfig()
litellm_params = {
"vertex_ai_project": "test-project",
"vertex_ai_location": "global",
"vertex_credentials": "{}",
}
optional_params = {
"safeguards": [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}]
}
with (
patch.object(config, "_ensure_access_token", return_value=("token", "test-project")),
patch.object(config, "get_complete_vertex_url", return_value="https://mock-url"),
):
updated_headers, _ = config.validate_anthropic_messages_environment(
headers=client_headers,
model="claude-sonnet-5",
messages=[],
optional_params=optional_params,
litellm_params=litellm_params,
api_base=None,
)
assert updated_headers["anthropic-beta"].split(",").count("dangerous-tool-use-2026-09-03") == 1
def test_no_safeguards_leaves_dangerous_tool_use_beta_header_out():
config = VertexAIPartnerModelsAnthropicMessagesConfig()
litellm_params = {
"vertex_ai_project": "test-project",
"vertex_ai_location": "global",
"vertex_credentials": "{}",
}
with (
patch.object(config, "_ensure_access_token", return_value=("token", "test-project")),
patch.object(config, "get_complete_vertex_url", return_value="https://mock-url"),
):
updated_headers, _ = config.validate_anthropic_messages_environment(
headers={},
model="claude-sonnet-5",
messages=[],
optional_params={"max_tokens": 64},
litellm_params=litellm_params,
api_base=None,
)
assert "dangerous-tool-use-2026-09-03" not in updated_headers.get("anthropic-beta", "")
def test_web_search_header_not_added_without_tool(): def test_web_search_header_not_added_without_tool():
"""Test that beta header is NOT added when web search tool is not present""" """Test that beta header is NOT added when web search tool is not present"""
config = VertexAIPartnerModelsAnthropicMessagesConfig() config = VertexAIPartnerModelsAnthropicMessagesConfig()

View file

@ -443,6 +443,20 @@ class TestAnthropicBetaHeadersFiltering:
assert filtered == ["thinking-binding-controls-2026-08-01"] assert filtered == ["thinking-binding-controls-2026-08-01"]
@pytest.mark.parametrize("provider", ["anthropic", "bedrock", "bedrock_mantle", "vertex_ai"])
def test_dangerous_tool_use_forwarded(self, provider):
"""Claude Code's server-side auto-mode classifier sends `safeguards` together with
dangerous-tool-use-2026-09-03. Bedrock Invoke, Bedrock Mantle, and Vertex rawPredict
all answer "safeguards: Extra inputs are not permitted" when the body field arrives
without the beta (probed 2026-09-21), so dropping the header turned every auto-mode
turn into a 400 on Vertex and silently disabled the classifier on Bedrock."""
filtered = filter_and_transform_beta_headers(
beta_headers=["dangerous-tool-use-2026-09-03"],
provider=provider,
)
assert filtered == ["dangerous-tool-use-2026-09-03"]
def test_null_value_headers_filtered(self): def test_null_value_headers_filtered(self):
"""Test that headers with null values are always filtered out.""" """Test that headers with null values are always filtered out."""
for provider in [ for provider in [