From 3ebd5add3230f0acb01fb134434c55bbda4e4104 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 20 Sep 2026 17:20:16 +0000 Subject: [PATCH 1/3] fix(anthropic): forward safeguards and anthropic-beta unchanged on native /v1/messages Native Anthropic Messages requests derived their allowlist from AnthropicMessagesRequestOptionalParams, which lacked safeguards, and the shared beta-header filter dropped betas unknown to the provider mapping even when the upstream is api.anthropic.com itself. Claude Code auto mode then saw no safeguard_results and fell back to billed classifier calls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../messages/transformation.py | 4 + litellm/types/llms/anthropic.py | 1 + .../anthropic_messages/anthropic_response.py | 1 + ...erimental_pass_through_messages_handler.py | 108 ++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 5fa686b7560a..eed30c2698c0 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -79,10 +79,14 @@ def get_supported_anthropic_messages_params(self, model: str) -> list: "speed", "output_config", "reasoning_effort", + "safeguards", # TODO: Add Anthropic `metadata` support # "metadata", ] + def should_filter_anthropic_beta_headers(self) -> bool: + return self._resolved_provider != "anthropic" + def _remove_scope_from_cache_control(self, anthropic_messages_request: dict) -> None: """ Remove `scope` field from cache_control blocks. diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index bcd24695f252..f4fe7a0bf144 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -411,6 +411,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): output_config: AnthropicOutputConfig | None # Configuration for Claude's output behavior cache_control: dict[str, Any] | None # Automatic prompt caching reasoning_effort: str | None + safeguards: ReadOnly[dict[str, object] | None] class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False): diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 038a23a3ca28..41060e96d85d 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -97,3 +97,4 @@ class AnthropicMessagesResponse(TypedDict, total=False): type: Literal["message"] | None usage: AnthropicUsage | None context_management: NotRequired[ContextManagementResponse] + safeguard_results: NotRequired[ReadOnly[dict[str, object]]] 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 997a97c6fd3e..4246e70bbbfb 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 @@ -1438,3 +1438,111 @@ def upstream_must_not_be_called(request: httpx.Request) -> httpx.Response: ) assert "Traceback" not in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_anthropic_messages_forwards_safeguards_and_unknown_beta_to_anthropic(): + """Regression test for LIT-8232. Claude Code auto mode sends a `safeguards` body + field paired with a beta value the gateway has never seen. Both must reach + api.anthropic.com unchanged or the session falls back to billed classifier calls.""" + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + safeguards = {"auto_mode": {"enabled": True, "version": "2026-09-01"}} + client_betas = "safeguards-2026-09-01,interleaved-thinking-2025-05-14" + captured: dict[str, object] = {} + + 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-haiku-4-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + "safeguard_results": {"verdict": "allow"}, + }, + request=request, + ) + + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_records_the_request)) + + response = await handler.anthropic_messages( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + model="anthropic/claude-haiku-4-5", + custom_llm_provider="anthropic", + api_key="sk-test", + client=upstream, + safeguards=safeguards, + extra_headers={"anthropic-beta": client_betas}, + ) + + assert captured["body"]["safeguards"] == safeguards + assert set(captured["anthropic-beta"].split(",")) == set(client_betas.split(",")) + assert response["safeguard_results"] == {"verdict": "allow"} + + +@pytest.mark.asyncio +async def test_anthropic_messages_streaming_forwards_safeguards_and_keeps_safeguard_results(): + """Streaming sibling of the LIT-8232 regression: the request must still carry + `safeguards` and the `safeguard_results` Anthropic emits on `message_start` and + `message_delta` must reach the client byte for byte.""" + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + safeguards = {"auto_mode": {"enabled": True, "version": "2026-09-01"}} + safeguard_results = {"verdict": "allow", "checks": ["shell_command"]} + captured: dict[str, object] = {} + message_start = { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 0}, + "safeguard_results": safeguard_results, + }, + } + message_delta = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None, "safeguard_results": safeguard_results}, + "usage": {"output_tokens": 1}, + } + sse = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" + for event in (message_start, message_delta, {"type": "message_stop"}) + ) + + def upstream_streams_safeguard_results(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=sse.encode(), request=request) + + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_streams_safeguard_results)) + + stream = await handler.anthropic_messages( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + model="anthropic/claude-haiku-4-5", + custom_llm_provider="anthropic", + api_key="sk-test", + client=upstream, + stream=True, + safeguards=safeguards, + ) + raw = b"".join([chunk async for chunk in stream]).decode() + events = [json.loads(line[len("data: ") :]) for line in raw.splitlines() if line.startswith("data: ")] + + 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 From b59028d525352454de672621c2b223c5d75e57b1 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 20 Sep 2026 17:37:56 +0000 Subject: [PATCH 2/3] fix(anthropic): strip safeguards on the adapter path and type it on streaming chunks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../experimental_pass_through/adapters/handler.py | 2 +- litellm/types/llms/anthropic.py | 2 ++ .../test_handler_output_config_passthrough.py | 13 +++++++++++++ ...ic_experimental_pass_through_messages_handler.py | 6 ------ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 87a29ca50ba7..54d10837d749 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -35,7 +35,7 @@ from litellm.router import Router # Anthropic-only keys already mapped by the translator; strip on extra_kwargs re-merge. -ANTHROPIC_ONLY_REQUEST_KEYS: Final[frozenset[str]] = frozenset({"output_config"}) +ANTHROPIC_ONLY_REQUEST_KEYS: Final[frozenset[str]] = frozenset({"output_config", "safeguards"}) _AnthropicMessages: TypeAlias = "list[dict[str, object]]" _AnthropicSystem: TypeAlias = "str | list[dict[str, object]] | None" diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index f4fe7a0bf144..f57591d02621 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -531,6 +531,7 @@ class AnthropicStopDetails(TypedDict, total=False): class MessageDelta(TypedDict, total=False): stop_reason: str | None stop_details: ReadOnly[AnthropicStopDetails] + safeguard_results: ReadOnly[dict[str, object]] class ServerToolUsage(TypedDict, total=False): @@ -601,6 +602,7 @@ class MessageChunk(TypedDict, total=False): stop_reason: str | None stop_sequence: str | None usage: UsageDelta + safeguard_results: ReadOnly[dict[str, object]] class MessageStartBlock(TypedDict): diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py index a944afc61521..d6de6372e0b3 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py @@ -110,6 +110,19 @@ def test_output_config_with_effort_is_stripped(self): "reject it with 400 'Extra inputs are not permitted'" ) + def test_safeguards_is_stripped_for_non_anthropic_target(self): + extra_kwargs = { + "custom_llm_provider": "azure", + "safeguards": {"auto_mode": {"enabled": True, "version": "2026-09-01"}}, + } + + result = _call_prepare(extra_kwargs=extra_kwargs) + + completion_kwargs = result[0] if isinstance(result, tuple) else result + assert "safeguards" not in completion_kwargs, ( + "safeguards is an Anthropic-only field; OpenAI-format backends reject it with 400" + ) + def test_output_config_format_translated_to_response_format(self): """When ``output_config`` carries structured-output ``format``, the translator now maps it to OpenAI's ``response_format`` so non-Anthropic 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 4246e70bbbfb..0acb9d634a3e 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 @@ -1442,9 +1442,6 @@ def upstream_must_not_be_called(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_anthropic_messages_forwards_safeguards_and_unknown_beta_to_anthropic(): - """Regression test for LIT-8232. Claude Code auto mode sends a `safeguards` body - field paired with a beta value the gateway has never seen. Both must reach - api.anthropic.com unchanged or the session falls back to billed classifier calls.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler safeguards = {"auto_mode": {"enabled": True, "version": "2026-09-01"}} @@ -1491,9 +1488,6 @@ def upstream_records_the_request(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_anthropic_messages_streaming_forwards_safeguards_and_keeps_safeguard_results(): - """Streaming sibling of the LIT-8232 regression: the request must still carry - `safeguards` and the `safeguard_results` Anthropic emits on `message_start` and - `message_delta` must reach the client byte for byte.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler safeguards = {"auto_mode": {"enabled": True, "version": "2026-09-01"}} From 1ac4d7ae042129f29aaf1a0d05b20b823888f24e Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 21 Sep 2026 09:55:09 -0500 Subject: [PATCH 3/3] fix(anthropic): type safeguards and safeguard_results as the arrays Anthropic sends Driving a real Claude Code 2.1.278 through the proxy, and a direct call to api.anthropic.com, both show these two fields are JSON arrays on the wire rather than objects. The request carries safeguards as [{"type": "dangerous_tool_use", "classifier_context": {...}}] under beta dangerous-tool-use-2026-09-03, and the 200 comes back with safeguard_results as [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": {...}}}]. No runtime change: the request filter matches on TypedDict keys and never inspects the value. The test fixtures move to the captured shapes so the regression tests pin what the client and the provider actually exchange. --- litellm/types/llms/anthropic.py | 6 +++--- .../anthropic_messages/anthropic_response.py | 2 +- .../test_handler_output_config_passthrough.py | 2 +- ...experimental_pass_through_messages_handler.py | 16 ++++++++++------ 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index f57591d02621..c59c88698f72 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -411,7 +411,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): output_config: AnthropicOutputConfig | None # Configuration for Claude's output behavior cache_control: dict[str, Any] | None # Automatic prompt caching reasoning_effort: str | None - safeguards: ReadOnly[dict[str, object] | None] + safeguards: ReadOnly[list[dict[str, object]] | None] class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False): @@ -531,7 +531,7 @@ class AnthropicStopDetails(TypedDict, total=False): class MessageDelta(TypedDict, total=False): stop_reason: str | None stop_details: ReadOnly[AnthropicStopDetails] - safeguard_results: ReadOnly[dict[str, object]] + safeguard_results: ReadOnly[list[dict[str, object]]] class ServerToolUsage(TypedDict, total=False): @@ -602,7 +602,7 @@ class MessageChunk(TypedDict, total=False): stop_reason: str | None stop_sequence: str | None usage: UsageDelta - safeguard_results: ReadOnly[dict[str, object]] + safeguard_results: ReadOnly[list[dict[str, object]]] class MessageStartBlock(TypedDict): diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 41060e96d85d..1d4c3cdc8648 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -97,4 +97,4 @@ class AnthropicMessagesResponse(TypedDict, total=False): type: Literal["message"] | None usage: AnthropicUsage | None context_management: NotRequired[ContextManagementResponse] - safeguard_results: NotRequired[ReadOnly[dict[str, object]]] + safeguard_results: NotRequired[ReadOnly[list[dict[str, object]]]] diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py index d6de6372e0b3..6246f5023441 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_output_config_passthrough.py @@ -113,7 +113,7 @@ def test_output_config_with_effort_is_stripped(self): def test_safeguards_is_stripped_for_non_anthropic_target(self): extra_kwargs = { "custom_llm_provider": "azure", - "safeguards": {"auto_mode": {"enabled": True, "version": "2026-09-01"}}, + "safeguards": [{"type": "dangerous_tool_use", "classifier_context": {"v": 1}}], } result = _call_prepare(extra_kwargs=extra_kwargs) 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 0acb9d634a3e..e8bfcb86bf69 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 @@ -1442,10 +1442,12 @@ def upstream_must_not_be_called(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_anthropic_messages_forwards_safeguards_and_unknown_beta_to_anthropic(): + """Shapes are what Claude Code 2.1.278 sends and api.anthropic.com returns, captured 2026-09-21.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler - safeguards = {"auto_mode": {"enabled": True, "version": "2026-09-01"}} - client_betas = "safeguards-2026-09-01,interleaved-thinking-2025-05-14" + safeguards = [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}] + client_betas = "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14" + safeguard_results = [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": {}}}] captured: dict[str, object] = {} def upstream_records_the_request(request: httpx.Request) -> httpx.Response: @@ -1462,7 +1464,7 @@ def upstream_records_the_request(request: httpx.Request) -> httpx.Response: "stop_reason": "end_turn", "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}, - "safeguard_results": {"verdict": "allow"}, + "safeguard_results": safeguard_results, }, request=request, ) @@ -1483,15 +1485,17 @@ def upstream_records_the_request(request: httpx.Request) -> httpx.Response: assert captured["body"]["safeguards"] == safeguards assert set(captured["anthropic-beta"].split(",")) == set(client_betas.split(",")) - assert response["safeguard_results"] == {"verdict": "allow"} + assert response["safeguard_results"] == safeguard_results @pytest.mark.asyncio async def test_anthropic_messages_streaming_forwards_safeguards_and_keeps_safeguard_results(): + """Shapes are what Claude Code 2.1.278 sends and api.anthropic.com returns, captured 2026-09-21.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler - safeguards = {"auto_mode": {"enabled": True, "version": "2026-09-01"}} - safeguard_results = {"verdict": "allow", "checks": ["shell_command"]} + 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}}] captured: dict[str, object] = {} message_start = { "type": "message_start",