fix(anthropic): drop safeguards on routes without the classifier only under drop_params

A route whose upstream does not answer safeguards (a JSON provider, or a map
that marks the dangerous-tool-use beta unsupported) now forwards the field and
the beta as the merge base did unless drop_params is set or the proxy runs its
own classifier, matching how litellm treats any unsupported param. Databricks
keeps dropping both, since its merge base bridge never forwarded the field.
This commit is contained in:
mateo-berri 2026-10-17 16:19:27 -07:00
parent 6961b70cb3
commit 84efce3412
7 changed files with 93 additions and 34 deletions

View file

@ -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

View file

@ -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
)

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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(

View file

@ -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"