Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions litellm/types/llms/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[list[dict[str, object]] | None]


class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False):
Expand Down Expand Up @@ -530,6 +531,7 @@ class AnthropicStopDetails(TypedDict, total=False):
class MessageDelta(TypedDict, total=False):
stop_reason: str | None
stop_details: ReadOnly[AnthropicStopDetails]
safeguard_results: ReadOnly[list[dict[str, object]]]


class ServerToolUsage(TypedDict, total=False):
Expand Down Expand Up @@ -600,6 +602,7 @@ class MessageChunk(TypedDict, total=False):
stop_reason: str | None
stop_sequence: str | None
usage: UsageDelta
safeguard_results: ReadOnly[list[dict[str, object]]]


class MessageStartBlock(TypedDict):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ class AnthropicMessagesResponse(TypedDict, total=False):
type: Literal["message"] | None
usage: AnthropicUsage | None
context_management: NotRequired[ContextManagementResponse]
safeguard_results: NotRequired[ReadOnly[list[dict[str, object]]]]
Original file line number Diff line number Diff line change
Expand Up @@ -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": [{"type": "dangerous_tool_use", "classifier_context": {"v": 1}}],
}

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1438,3 +1438,109 @@ 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():
"""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 = [{"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:
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": safeguard_results,
},
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"] == 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 = [{"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",
"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
Loading