diff --git a/litellm/llms/anthropic/pass_through/messages/handler.py b/litellm/llms/anthropic/pass_through/messages/handler.py index e3a79433460..527718ba2c1 100644 --- a/litellm/llms/anthropic/pass_through/messages/handler.py +++ b/litellm/llms/anthropic/pass_through/messages/handler.py @@ -40,8 +40,7 @@ from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler from ..safeguards import ( SafeguardsEvaluator, native_route_classifies, - read_classifier_model_setting, - requested_dangerous_tool_use, + proxy_serves_safeguards, with_safeguard_results, ) from .interceptors import get_messages_interceptors @@ -166,7 +165,7 @@ def _native_messages_config( async def _proxy_classifies(model: str, safeguards: object, native_config: BaseAnthropicMessagesConfig | None) -> bool: - if requested_dangerous_tool_use(safeguards) is None or read_classifier_model_setting() is None: + if not proxy_serves_safeguards(safeguards): return False if native_config is None: return True diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index a31af2ecec3..30d45f3cec1 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -3,10 +3,12 @@ from typing import ClassVar, Final import httpx +import litellm from litellm._logging import verbose_logger from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.anthropic.pass_through.safeguards import proxy_serves_safeguards from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) @@ -112,17 +114,25 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): def supports_safeguards(self) -> bool: """ - The ``safeguards`` body field and the dangerous-tool-use beta travel together: a - platform whose beta header map column marks the beta unsupported gets neither, so the - turn stays a 200 without a verdict instead of a 400 on an unknown field. A platform the - map does not list keeps the body field, and the beta goes with it only where that - platform's config forwards betas unfiltered. + Whether the upstream platform answers the ``safeguards`` body field itself. A platform + whose beta header map column marks the dangerous-tool-use beta unsupported does not, and + a platform the map does not list is trusted to unless its config says otherwise. """ return not self.should_filter_anthropic_beta_headers() or ( ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value not in get_unsupported_headers(self._resolved_provider) ) + def drops_safeguards(self, safeguards: object, drop_params: bool) -> bool: + """ + ``safeguards`` and the dangerous-tool-use beta leave together for a platform that does not + answer them, when the proxy classifies in its place or drop_params is set. Otherwise both + are forwarded as sent and the platform answers its own 400, like any unsupported param. + """ + return not self.supports_safeguards() and ( + drop_params or litellm.drop_params is True or proxy_serves_safeguards(safeguards) + ) + def _remove_scope_from_cache_control(self, anthropic_messages_request: dict) -> None: """ Remove `scope` field from cache_control blocks. @@ -610,7 +620,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): status_code=400, ) - if "safeguards" in anthropic_messages_optional_request_params and not self.supports_safeguards(): + if "safeguards" in anthropic_messages_optional_request_params and self.drops_safeguards( + anthropic_messages_optional_request_params["safeguards"], # pyright: ignore[reportUnknownArgumentType] # the request params arrive as a bare dict + litellm_params.drop_params is True, + ): anthropic_messages_optional_request_params.pop("safeguards") # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict self._translate_reasoning_effort_to_anthropic( @@ -731,6 +744,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): custom_llm_provider: str = "anthropic", messages: Sequence[object] = (), beta_map_provider: str | None = None, + drop_params: bool = False, ) -> dict: """ Auto-inject anthropic-beta headers based on features used. @@ -748,6 +762,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): custom_llm_provider: Provider name for looking up correct tool search header messages: Request messages, scanned for per-message output_config beta_map_provider: Provider whose beta map column gates safeguards, defaults to custom_llm_provider + drop_params: Whether the deployment or request set drop_params """ beta_values: Final[set[str]] = set() @@ -829,7 +844,11 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): dangerous_tool_use: Final = ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value forwarded_beta_values: Final = ( # pyright: ignore[reportUnknownVariableType] # derived from the bare beta set all_beta_values - {dangerous_tool_use} - if dangerous_tool_use in all_beta_values and not self.supports_safeguards() + if dangerous_tool_use in all_beta_values + and self.drops_safeguards( + optional_params.get("safeguards"), # pyright: ignore[reportUnknownArgumentType] # the optional params arrive as a bare dict + drop_params, + ) else all_beta_values ) diff --git a/litellm/llms/anthropic/pass_through/safeguards.py b/litellm/llms/anthropic/pass_through/safeguards.py index 6eef313dc92..eededde7e92 100644 --- a/litellm/llms/anthropic/pass_through/safeguards.py +++ b/litellm/llms/anthropic/pass_through/safeguards.py @@ -273,12 +273,12 @@ def read_classifier_model_setting() -> str | None: return configured if isinstance(configured, str) and configured else None +def proxy_serves_safeguards(safeguards: object) -> bool: + return requested_dangerous_tool_use(safeguards) is not None and read_classifier_model_setting() is not None + + def native_route_classifies(model: str, safeguards: object) -> bool: - return ( - "claude" not in model.lower() - and requested_dangerous_tool_use(safeguards) is not None - and read_classifier_model_setting() is not None - ) + return "claude" not in model.lower() and proxy_serves_safeguards(safeguards) def build_safeguards_evaluator( diff --git a/litellm/llms/databricks/messages/transformation.py b/litellm/llms/databricks/messages/transformation.py index 889332023e3..cb8d2b652a2 100644 --- a/litellm/llms/databricks/messages/transformation.py +++ b/litellm/llms/databricks/messages/transformation.py @@ -93,6 +93,9 @@ class DatabricksAnthropicMessagesConfig(DatabricksBase, AnthropicMessagesConfig) def handles_web_search_natively(self) -> bool: return False + def drops_safeguards(self, safeguards: object, drop_params: bool) -> bool: + return not self.supports_safeguards() + def serves_api_base(self, model: str, api_base: str | None) -> bool: resolved_api_base: Final = self.resolve_api_base(api_base) return resolved_api_base is None or self.recognizes_workspace_route(resolved_api_base, model) diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index 9549206f0ed..ef501720e2d 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -62,6 +62,7 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): optional_params=optional_params, messages=messages, beta_map_provider=LlmProviders.OPENAI_LIKE.value, + drop_params=litellm_params.get("drop_params") is True, # pyright: ignore[reportUnknownMemberType] # the litellm params arrive as a bare dict ) return merged, api_base diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py index 88d46d88bb5..cabde4f1e92 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py @@ -533,12 +533,23 @@ _PROVIDERS: Final = MappingProxyType( client_beta=_CLIENT_BETAS_WITH_DANGEROUS, auth=("authorization", "Bearer synthetic-sail-key"), expected_beta=None, + ), + "D18_sail_with_claude_code_header_and_drop_params": _ProviderCase( + parameters={"model": "sail/sail-claude", "api_key": "synthetic-sail-key", "drop_params": True}, + client_beta=_CLIENT_BETAS_WITH_DANGEROUS, + auth=("authorization", "Bearer synthetic-sail-key"), + expected_beta=None, forwards_safeguards=False, ), "D19_edenai": _ProviderCase( parameters={"model": "edenai/claude-sonnet-4-5", "api_key": "synthetic-edenai-key"}, auth=("authorization", "Bearer synthetic-edenai-key"), expected_beta=None, + ), + "D19_edenai_with_drop_params": _ProviderCase( + parameters={"model": "edenai/claude-sonnet-4-5", "api_key": "synthetic-edenai-key", "drop_params": True}, + auth=("authorization", "Bearer synthetic-edenai-key"), + expected_beta=None, forwards_safeguards=False, ), "D21_bedrock_claude_platform": _ProviderCase( diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py index 4f85dcd4094..c63bfbdd301 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py @@ -1,5 +1,7 @@ +from collections.abc import Mapping from pathlib import Path from typing import Final +from unittest.mock import patch import pytest @@ -160,12 +162,12 @@ def _configs_that_forward_safeguards_as_sent() -> tuple[AnthropicMessagesConfig, ) -def _safeguards_request(config: AnthropicMessagesConfig) -> dict: +def _safeguards_request(config: AnthropicMessagesConfig, litellm_params: GenericLiteLLMParams | None = None) -> dict: return config.transform_anthropic_messages_request( model="claude-sonnet-4-6", messages=[{"role": "user", "content": "Use the Bash tool to run: echo hello"}], anthropic_messages_optional_request_params={"max_tokens": 64, "safeguards": list(AUTO_MODE_SAFEGUARDS)}, - litellm_params=GenericLiteLLMParams(), + litellm_params=litellm_params or GenericLiteLLMParams(), headers={}, ) @@ -206,27 +208,50 @@ def test_unjudged_routes_forward_safeguards_and_the_beta_as_sent( assert _derived_beta_header(config)["anthropic-beta"] == DANGEROUS_TOOL_USE_BETA +_CLASSIFIER_SETTINGS: Final = {"safeguards_classifier_model": "classifier"} + + @pytest.mark.parametrize("config", _no_classifier_configs(), ids=type) -def test_routes_without_the_classifier_drop_safeguards_together_with_the_beta( - config: AnthropicMessagesConfig, local_beta_headers_config: None +@pytest.mark.parametrize( + ("litellm_params", "global_drop_params", "general_settings", "forwarded"), + ( + pytest.param(GenericLiteLLMParams(), False, {}, AUTO_MODE_SAFEGUARDS, id="as_sent"), + pytest.param(GenericLiteLLMParams(drop_params=True), False, {}, None, id="deployment_drop_params"), + pytest.param(GenericLiteLLMParams(), True, {}, None, id="global_drop_params"), + pytest.param(GenericLiteLLMParams(), False, _CLASSIFIER_SETTINGS, None, id="proxy_classifier"), + ), +) +def test_a_route_without_the_classifier_drops_safeguards_only_under_drop_params_or_the_proxy_classifier( + config: AnthropicMessagesConfig, + litellm_params: GenericLiteLLMParams, + global_drop_params: bool, + general_settings: Mapping[str, str], + forwarded: list[dict] | None, + monkeypatch: pytest.MonkeyPatch, + local_beta_headers_config: None, ) -> None: - """A platform without the classifier answers 400 "safeguards: Extra inputs are not permitted" - to the body field whatever the header says, so the pair is dropped together and the turn - stays a 200 without a verdict, which Claude Code's gateway contract treats as "feature off".""" - assert "safeguards" not in _safeguards_request(config) - assert "anthropic-beta" not in _beta_header(config, DANGEROUS_TOOL_USE_BETA) - assert "anthropic-beta" not in _derived_beta_header(config) - assert _beta_header(config, f"context-1m-2025-08-07,{DANGEROUS_TOOL_USE_BETA}") == { - "anthropic-beta": "context-1m-2025-08-07" - } + """A platform without the classifier answers 400 to `safeguards`, which is how litellm treats any + unsupported param: dropped under drop_params, forwarded as sent without it. The proxy's own + classifier answers in the platform's place, so the field is dropped then too.""" + monkeypatch.setattr(litellm, "drop_params", global_drop_params) + with patch("litellm.proxy.proxy_server.general_settings", general_settings): + request: Final = _safeguards_request(config, litellm_params) + assert request.get("safeguards") == forwarded @pytest.mark.asyncio -async def test_no_classifier_route_sends_neither_safeguards_nor_the_beta_on_the_wire( - local_beta_headers_config: None, +@pytest.mark.parametrize( + ("drop_params", "forwarded", "forwarded_beta"), + ( + pytest.param(False, AUTO_MODE_SAFEGUARDS, f"context-1m-2025-08-07,{DANGEROUS_TOOL_USE_BETA}", id="as_sent"), + pytest.param(True, None, "context-1m-2025-08-07", id="drop_params"), + ), +) +async def test_a_json_provider_gets_safeguards_and_the_beta_together_unless_drop_params_drops_both( + drop_params: bool, forwarded: list[dict] | None, forwarded_beta: str, local_beta_headers_config: None ) -> None: - """JSON-configured OpenAI-compatible providers forward every beta header untouched, so the - pair has to be dropped by the request transform itself, not by the beta header filter.""" + """JSON-configured OpenAI-compatible providers forward every beta header untouched, so under + drop_params the request transform itself drops the beta along with the field.""" import json import httpx @@ -263,6 +288,7 @@ async def test_no_classifier_route_sends_neither_safeguards_nor_the_beta_on_the_ api_base="https://sail.example.test/v1", safeguards=list(AUTO_MODE_SAFEGUARDS), extra_headers={"anthropic-beta": f"context-1m-2025-08-07,{DANGEROUS_TOOL_USE_BETA}"}, + drop_params=drop_params, client=upstream, ) finally: @@ -270,7 +296,7 @@ async def test_no_classifier_route_sends_neither_safeguards_nor_the_beta_on_the_ assert len(received) == 1 sent: Final = received[0] - assert "safeguards" not in json.loads(sent.content) - assert sent.headers["anthropic-beta"] == "context-1m-2025-08-07" + assert json.loads(sent.content).get("safeguards") == forwarded + assert sent.headers["anthropic-beta"] == forwarded_beta assert "safeguard_results" not in response assert response["content"][0]["text"] == "hello"