mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
Merge pull request #42152 from BerriAI/litellm_claude_code_safeguards_passthrough
fix(anthropic): forward safeguards and anthropic-beta unchanged on native /v1/messages
This commit is contained in:
commit
e912ebe999
6 changed files with 128 additions and 1 deletions
|
|
@ -35,7 +35,7 @@ if TYPE_CHECKING:
|
||||||
from litellm.router import Router
|
from litellm.router import Router
|
||||||
|
|
||||||
# Anthropic-only keys already mapped by the translator; strip on extra_kwargs re-merge.
|
# 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]]"
|
_AnthropicMessages: TypeAlias = "list[dict[str, object]]"
|
||||||
_AnthropicSystem: TypeAlias = "str | list[dict[str, object]] | None"
|
_AnthropicSystem: TypeAlias = "str | list[dict[str, object]] | None"
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,14 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
||||||
"speed",
|
"speed",
|
||||||
"output_config",
|
"output_config",
|
||||||
"reasoning_effort",
|
"reasoning_effort",
|
||||||
|
"safeguards",
|
||||||
# TODO: Add Anthropic `metadata` support
|
# TODO: Add Anthropic `metadata` support
|
||||||
# "metadata",
|
# "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:
|
def _remove_scope_from_cache_control(self, anthropic_messages_request: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Remove `scope` field from cache_control blocks.
|
Remove `scope` field from cache_control blocks.
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False):
|
||||||
output_config: AnthropicOutputConfig | None # Configuration for Claude's output behavior
|
output_config: AnthropicOutputConfig | None # Configuration for Claude's output behavior
|
||||||
cache_control: dict[str, Any] | None # Automatic prompt caching
|
cache_control: dict[str, Any] | None # Automatic prompt caching
|
||||||
reasoning_effort: str | None
|
reasoning_effort: str | None
|
||||||
|
safeguards: ReadOnly[list[dict[str, object]] | None]
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False):
|
class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False):
|
||||||
|
|
@ -530,6 +531,7 @@ class AnthropicStopDetails(TypedDict, total=False):
|
||||||
class MessageDelta(TypedDict, total=False):
|
class MessageDelta(TypedDict, total=False):
|
||||||
stop_reason: str | None
|
stop_reason: str | None
|
||||||
stop_details: ReadOnly[AnthropicStopDetails]
|
stop_details: ReadOnly[AnthropicStopDetails]
|
||||||
|
safeguard_results: ReadOnly[list[dict[str, object]]]
|
||||||
|
|
||||||
|
|
||||||
class ServerToolUsage(TypedDict, total=False):
|
class ServerToolUsage(TypedDict, total=False):
|
||||||
|
|
@ -600,6 +602,7 @@ class MessageChunk(TypedDict, total=False):
|
||||||
stop_reason: str | None
|
stop_reason: str | None
|
||||||
stop_sequence: str | None
|
stop_sequence: str | None
|
||||||
usage: UsageDelta
|
usage: UsageDelta
|
||||||
|
safeguard_results: ReadOnly[list[dict[str, object]]]
|
||||||
|
|
||||||
|
|
||||||
class MessageStartBlock(TypedDict):
|
class MessageStartBlock(TypedDict):
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,4 @@ class AnthropicMessagesResponse(TypedDict, total=False):
|
||||||
type: Literal["message"] | None
|
type: Literal["message"] | None
|
||||||
usage: AnthropicUsage | None
|
usage: AnthropicUsage | None
|
||||||
context_management: NotRequired[ContextManagementResponse]
|
context_management: NotRequired[ContextManagementResponse]
|
||||||
|
safeguard_results: NotRequired[ReadOnly[list[dict[str, object]]]]
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,19 @@ class TestOutputConfigStrippedFromCompletionKwargs:
|
||||||
"reject it with 400 'Extra inputs are not permitted'"
|
"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):
|
def test_output_config_format_translated_to_response_format(self):
|
||||||
"""When ``output_config`` carries structured-output ``format``, the
|
"""When ``output_config`` carries structured-output ``format``, the
|
||||||
translator now maps it to OpenAI's ``response_format`` so non-Anthropic
|
translator now maps it to OpenAI's ``response_format`` so non-Anthropic
|
||||||
|
|
|
||||||
|
|
@ -1438,3 +1438,109 @@ async def test_anthropic_messages_leaves_non_provider_failures_unmapped():
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "Traceback" not in str(excinfo.value)
|
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…
Add table
Reference in a new issue