From 21b5e2d8a1abf865fb22b0b99032d7aeffd0c6bd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:52:38 -0700 Subject: [PATCH] fix(anthropic): add the dangerous-tool-use beta when safeguards arrive without it on Bedrock Invoke and Vertex --- .../anthropic_claude3_transformation.py | 3 + .../transformation.py | 3 + litellm/types/llms/anthropic.py | 1 + ...erimental_pass_through_messages_handler.py | 28 +++++---- .../test_anthropic_claude3_transformation.py | 32 ++++++++-- ...artner_models_anthropic_messages_config.py | 59 +++++++++++++++++++ 6 files changed, 111 insertions(+), 15 deletions(-) diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index d2be1ad9156..0202d0949a9 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -530,6 +530,9 @@ class AmazonAnthropicClaudeMessagesConfig( if anthropic_model_info.is_eager_input_streaming_used(tools): 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( anthropic_messages_request=anthropic_messages_request, beta_set=beta_set, diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py index 978daf119ce..785f4dcefce 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py @@ -108,6 +108,9 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert if anthropic_model_info.is_tool_search_used(tools): 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: headers["anthropic-beta"] = ",".join(beta_values) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index c59c88698f7..d6d12e12bf4 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -751,6 +751,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): FAST_MODE_2026_02_01 = "fast-mode-2026-02-01" ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-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) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 8d940e5efad..d92236ba264 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -2,7 +2,7 @@ import asyncio import json import os import uuid -from typing import Any, Dict, List +from typing import Any, Dict, Final, List import httpx import pytest @@ -1579,11 +1579,19 @@ def _upstream_answering_with(safeguard_results: list[dict[str, object]], capture 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, + local_beta_headers_config, client_headers ): - """Bedrock Invoke takes betas in the body's `anthropic_beta` and 400s on `safeguards` without the beta.""" + """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() @@ -1599,7 +1607,7 @@ async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_bet aws_region_name="us-east-1", client=_upstream_answering_with(safeguard_results, captured), safeguards=safeguards, - extra_headers={"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, + extra_headers=client_headers, ) assert captured["body"]["safeguards"] == safeguards @@ -1608,10 +1616,11 @@ async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_bet @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, + local_beta_headers_config, client_headers ): - """Vertex rawPredict takes the beta as the `anthropic-beta` header and 400s on `safeguards` without it.""" + """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 @@ -1629,13 +1638,10 @@ async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_bet vertex_credentials="{}", client=_upstream_answering_with(safeguard_results, captured), safeguards=safeguards, - extra_headers={"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, + extra_headers=client_headers, ) assert captured["body"]["safeguards"] == safeguards assert "anthropic_beta" not in captured["body"] - assert set(captured["anthropic-beta"].split(",")) == { - "dangerous-tool-use-2026-09-03", - "interleaved-thinking-2025-05-14", - } + assert captured["anthropic-beta"].split(",").count("dangerous-tool-use-2026-09-03") == 1 assert response["safeguard_results"] == safeguard_results diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index 42823a17bc5..ea8b722b849 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -1651,12 +1651,19 @@ def test_bedrock_messages_allowlist_filters_anthropic_only_fields(): assert set(result).issubset(cfg.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS) -def test_bedrock_messages_forwards_safeguards_with_dangerous_tool_use_beta(local_beta_headers_config): +@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 both must reach it unchanged. + `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 @@ -1668,11 +1675,28 @@ def test_bedrock_messages_forwards_safeguards_with_dangerous_tool_use_beta(local messages=[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}], anthropic_messages_optional_request_params={"max_tokens": 64, "safeguards": safeguards}, litellm_params=GenericLiteLLMParams(), - headers={"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, + headers={"anthropic-beta": client_beta_header}, ) assert result["safeguards"] == safeguards - assert "dangerous-tool-use-2026-09-03" in result["anthropic_beta"] + 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(): diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py index f6da1bbcd0e..e3ae891f0d9 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py @@ -3,6 +3,8 @@ import json import os from unittest.mock import MagicMock, patch +import pytest + from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import ( 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(): """Test that beta header is NOT added when web search tool is not present""" config = VertexAIPartnerModelsAnthropicMessagesConfig()