From 0e1605010052a54b5a2b9e01fee89499e10858d9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:21:50 -0700 Subject: [PATCH 1/4] fix(anthropic): forward Claude Code safeguards and dangerous-tool-use beta to Bedrock Invoke and Vertex on /v1/messages Claude Code's server-side auto-mode classifier sends a `safeguards` body field together with the `dangerous-tool-use-2026-09-03` beta. PR #42152 made the first-party anthropic route pass them through, but the beta header mapping left the other two Claude platforms at null, so Bedrock Invoke dropped both (classifier silently disabled) and Vertex forwarded the body field without the beta, which the platform rejects with "safeguards: Extra inputs are not permitted" (a 400 Claude Code hides by retrying without them). Map the beta for bedrock and vertex_ai in the beta headers config and add `safeguards` to the Bedrock Invoke request allowlist so the pair reaches both platforms unchanged. Nothing is injected: a client that sends `safeguards` without the beta still gets the platform's 400, exactly as api.anthropic.com answers it. --- litellm/anthropic_beta_headers_config.json | 6 ++ litellm/types/llms/bedrock.py | 1 + ...erimental_pass_through_messages_handler.py | 95 +++++++++++++++++++ .../test_anthropic_claude3_transformation.py | 43 +++++++++ .../test_anthropic_beta_headers_filtering.py | 14 +++ 5 files changed, 159 insertions(+) diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index eb31cc17a15..c4c60ae715b 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -11,6 +11,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "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", "fast-mode-2026-02-01": "fast-mode-2026-02-01", "files-api-2025-04-14": "files-api-2025-04-14", @@ -44,6 +45,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "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", "fast-mode-2026-02-01": null, "files-api-2025-04-14": "files-api-2025-04-14", @@ -76,6 +78,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "context-management-2025-06-27": null, + "dangerous-tool-use-2026-09-03": null, "effort-2025-11-24": "effort-2025-11-24", "fast-mode-2026-02-01": null, "files-api-2025-04-14": null, @@ -109,6 +112,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "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", "fast-mode-2026-02-01": null, "files-api-2025-04-14": null, @@ -142,6 +146,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "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, "fast-mode-2026-02-01": null, "files-api-2025-04-14": null, @@ -175,6 +180,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "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", "fast-mode-2026-02-01": "fast-mode-2026-02-01", "files-api-2025-04-14": "files-api-2025-04-14", diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index 10082cf2373..f7518fefae4 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -1236,6 +1236,7 @@ class BedrockInvokeAnthropicMessagesRequest(TypedDict, total=False): thinking: dict metadata: dict output_config: dict + safeguards: list # `context_management` is allowed for Bedrock InvokeModel only when it # carries `compact_20260112` edits paired with the `compact-2026-01-12` 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 e8bfcb86bf6..8d940e5efad 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 @@ -1544,3 +1544,98 @@ async def test_anthropic_messages_streaming_forwards_safeguards_and_keeps_safegu assert captured["body"]["safeguards"] == safeguards 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 + + +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 + + +@pytest.mark.asyncio +async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_beta_to_bedrock_invoke( + local_beta_headers_config, +): + """Bedrock Invoke takes betas in the body's `anthropic_beta` and 400s on `safeguards` without the beta.""" + 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={"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, + ) + + 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 +async def test_anthropic_messages_forwards_safeguards_and_dangerous_tool_use_beta_to_vertex( + local_beta_headers_config, +): + """Vertex rawPredict takes the beta as the `anthropic-beta` header and 400s on `safeguards` without it.""" + 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={"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, + ) + + 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 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 7be005c0efe..e3c4a84cc60 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,6 +1651,49 @@ 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): + """ + 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. + """ + 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": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, + ) + + assert result["safeguards"] == safeguards + assert result["anthropic_beta"] == ["dangerous-tool-use-2026-09-03"] + + +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_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(): """ In proxy deployments the client (e.g. Claude Code) doesn't know the backend diff --git a/tests/test_litellm/test_anthropic_beta_headers_filtering.py b/tests/test_litellm/test_anthropic_beta_headers_filtering.py index 3c967283abf..d600d2b734b 100644 --- a/tests/test_litellm/test_anthropic_beta_headers_filtering.py +++ b/tests/test_litellm/test_anthropic_beta_headers_filtering.py @@ -442,6 +442,20 @@ class TestAnthropicBetaHeadersFiltering: assert filtered == ["thinking-binding-controls-2026-08-01"] + @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "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 and Vertex rawPredict both 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): """Test that headers with null values are always filtered out.""" for provider in [ From deca6aea79ed6b791e6f3e5f364cde88aa3b8d03 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:30:11 -0700 Subject: [PATCH 2/4] test(bedrock): assert safeguard_results survive message_start and check the beta by membership --- .../test_anthropic_claude3_transformation.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 e3c4a84cc60..42823a17bc5 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 @@ -1672,7 +1672,7 @@ def test_bedrock_messages_forwards_safeguards_with_dangerous_tool_use_beta(local ) assert result["safeguards"] == safeguards - assert result["anthropic_beta"] == ["dangerous-tool-use-2026-09-03"] + assert "dangerous-tool-use-2026-09-03" in result["anthropic_beta"] def test_bedrock_messages_stream_decoder_keeps_safeguard_results(): @@ -1681,6 +1681,25 @@ def test_bedrock_messages_stream_decoder_keeps_safeguard_results(): 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", 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 3/4] 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() From 61fcfd986d7deb0e7ef9ca7ced616750ea36d614 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:24:52 -0700 Subject: [PATCH 4/4] fix(anthropic): map the dangerous-tool-use beta for Bedrock Mantle so safeguards never reach it without the beta --- litellm/anthropic_beta_headers_config.json | 1 + .../test_bedrock_mantle_messages_transformation.py | 13 +++++++++++++ .../test_anthropic_beta_headers_filtering.py | 10 +++++----- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index 2e82b2a759f..917bfbd5ae9 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -147,6 +147,7 @@ "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", "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", "fast-mode-2026-02-01": null, "files-api-2025-04-14": null, diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py index 6bacf8f3d94..5f69b36c87a 100644 --- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py +++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py @@ -439,6 +439,19 @@ class TestBetaHeadersOnTheWire: 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"}]} + @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 @respx.mock async def test_betas_and_version_never_travel_in_the_body(self): diff --git a/tests/test_litellm/test_anthropic_beta_headers_filtering.py b/tests/test_litellm/test_anthropic_beta_headers_filtering.py index cbdde79a66a..19b26120672 100644 --- a/tests/test_litellm/test_anthropic_beta_headers_filtering.py +++ b/tests/test_litellm/test_anthropic_beta_headers_filtering.py @@ -443,13 +443,13 @@ class TestAnthropicBetaHeadersFiltering: assert filtered == ["thinking-binding-controls-2026-08-01"] - @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "vertex_ai"]) + @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 and Vertex rawPredict both 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.""" + 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,