diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index 39fec46d5ad..ec5e71dd19e 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -7,8 +7,10 @@ from pydantic import TypeAdapter import litellm from litellm._logging import verbose_logger from litellm.anthropic_beta_gated_fields import ( + StrippedMessages, log_beta_gated_strips, strip_beta_gated_native_request, + strip_beta_gated_system_messages, unstripped_native_request, ) from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config @@ -53,6 +55,8 @@ DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01" _CALLER_CREDENTIAL_HEADERS: Final = frozenset({"x-api-key", "authorization"}) +_MESSAGE_MAPPINGS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) + def _carries_caller_credential(headers: Mapping[str, str]) -> bool: """Whether the caller sent their own Anthropic credential, in which case this passthrough @@ -265,14 +269,22 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): len(messages), ) hoisted: Final = messages[:leading_count] + keeps_system_turns: Final = supports_factory( + model=model, + custom_llm_provider=self.custom_llm_provider, + key="supports_mid_conversation_system", + ) + trailing_messages: Final = _MESSAGE_MAPPINGS.validate_python(messages[leading_count:]) # pyright: ignore[reportUnknownArgumentType] # messages comes from the bare request dict + gated: Final = ( + StrippedMessages(messages=trailing_messages, stripped=()) + if keeps_system_turns + else strip_beta_gated_system_messages(trailing_messages, frozenset()) + ) + log_beta_gated_strips(gated.stripped, model=model, provider=self.custom_llm_provider or "anthropic") remaining: Final = ( messages[leading_count:] - if supports_factory( - model=model, - custom_llm_provider=self.custom_llm_provider, - key="supports_mid_conversation_system", - ) - else list(convert_mid_conversation_system_turns(messages[leading_count:])) + if keeps_system_turns + else list(convert_mid_conversation_system_turns(_MESSAGE_MAPPINGS.validate_python(gated.messages))) ) if hoisted or remaining != messages: anthropic_messages_request["messages"] = remaining diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py index 5fdb5278b09..2204b5f417f 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py @@ -16,7 +16,7 @@ import httpx import pytest import requests_mock as requests_mock_lib import respx -from pydantic import TypeAdapter +from pydantic import BaseModel, ConfigDict, TypeAdapter import litellm from litellm.constants import ( @@ -4200,10 +4200,9 @@ def test_bedrock_messages_without_guardrail_config_sends_no_guardrail_headers() assert not any(name.startswith("X-Amzn-Bedrock-Guardrail") for name in headers) -def test_bedrock_messages_keeps_mid_conversation_output_config_its_auto_added_beta_gates(): +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +def test_bedrock_messages_keeps_mid_conversation_output_config_its_auto_added_beta_gates() -> None: """Invoke learns its body betas after the shared transform, so the gated field it supports must survive.""" - from unittest.mock import patch - from litellm.types.router import GenericLiteLLMParams messages: Final = [ @@ -4212,14 +4211,58 @@ def test_bedrock_messages_keeps_mid_conversation_output_config_its_auto_added_be {"role": "system", "content": "Answer briefly", "output_config": {"effort": "high"}}, {"role": "user", "content": [{"type": "text", "text": "Go on"}]}, ] - with patch("litellm.llms.bedrock.common_utils._bedrock_model_supports", return_value=True): - result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the base signature returns a bare dict - model="anthropic.claude-opus-4-8-v1:0", - messages=messages, - anthropic_messages_optional_request_params={"max_tokens": 4096}, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="anthropic.claude-opus-4-8-v1:0", + messages=messages, + anthropic_messages_optional_request_params={"max_tokens": 4096}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) assert "mid-conversation-output-config-2026-07-01" in result["anthropic_beta"] assert {"role": "system", "content": "Answer briefly", "output_config": {"effort": "high"}} in result["messages"] + + +class _SentMessage(BaseModel): + model_config = ConfigDict(frozen=True) + role: str + content: list[dict[str, object]] + + +_SENT_MESSAGES: Final = TypeAdapter(list[_SentMessage]) + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +def test_bedrock_messages_drops_tool_changes_from_a_system_turn_an_older_model_gets_as_a_user_turn() -> None: + """A model without mid-conversation system support sends no tool-changes beta, so the converted turn + must not carry the blocks that beta gates.""" + from litellm.types.router import GenericLiteLLMParams + + messages: Final = [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "Hi"}]}, + { + "role": "system", + "content": [ + {"type": "text", "text": "A weather tool is now available"}, + {"type": "tool_addition", "tool": {"type": "tool_reference", "name": "get_weather"}}, + ], + }, + {"role": "user", "content": [{"type": "text", "text": "Go on"}]}, + ] + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="anthropic.claude-3-7-sonnet-20250219-v1:0", + messages=messages, + anthropic_messages_optional_request_params={"max_tokens": 4096}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + sent: Final = _SENT_MESSAGES.validate_python(result["messages"]) + blocks: Final = [ + block for message in sent for block in message.content + ] # comprehension-ok: flattening the sent content blocks + assert "mid-conversation-tool-changes-2026-07-01" not in result.get("anthropic_beta", []) + assert all(block["type"] != "tool_addition" for block in blocks) + assert {"type": "text", "text": "A weather tool is now available"} in blocks + assert all(message.role != "system" for message in sent)