fix(anthropic): strip beta-gated system fields before an older model's system turn becomes a user turn

Models without supports_mid_conversation_system send no mid-conversation beta,
so tool_addition and tool_removal blocks are dropped (and logged) before the
turn is converted instead of riding along as user content. The Invoke
output_config regression test reads the real cost map instead of patching
_bedrock_model_supports

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-10-27 21:26:08 +00:00
parent a4950a1fcf
commit 409735806a
2 changed files with 73 additions and 18 deletions

View file

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

View file

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