fix(bedrock): send every Mantle beta in the anthropic-beta header on the bedrock/mantle route (#42376)

- fix(bedrock): send every Mantle beta in the anthropic-beta header on the bedrock/mantle route
- refactor(bedrock): type the Mantle header helper and build the header fields in one comprehension
This commit is contained in:
devin-ai-integration[bot] 2026-09-21 18:52:29 -07:00 • committed by GitHub
parent 3bbbf7f693
commit b720909dac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 123 additions and 84 deletions

View file

@ -81,6 +81,10 @@ class AmazonAnthropicClaudeMessagesConfig(
def custom_llm_provider(self) -> str | None:
return "bedrock"
@property
def beta_headers_provider(self) -> str:
return self.custom_llm_provider or "bedrock"
BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS = frozenset(BedrockInvokeAnthropicMessagesRequest.__annotations__.keys())
def get_error_class(
@ -552,7 +556,7 @@ class AmazonAnthropicClaudeMessagesConfig(
if "tool-search-tool-2025-10-19" in beta_set:
beta_set.add("tool-examples-2025-10-29")
beta_provider: Final = self.custom_llm_provider or "bedrock"
beta_provider: Final = self.beta_headers_provider
filtered_betas: Final = sorted(
filter_and_transform_beta_headers(
beta_headers=list(beta_set),

View file

@ -2,16 +2,20 @@
Transformation for Bedrock Mantle (Claude Mythos Preview) - /messages endpoint
Inherits all Messages API request/response transformations from
AmazonAnthropicClaudeMessagesConfig. Overrides only the URL and model-prefix
stripping that are specific to the bedrock-mantle endpoint.
AmazonAnthropicClaudeMessagesConfig. Overrides the URL, the model-prefix
stripping, and the anthropic-version / anthropic-beta placement (headers,
never the body) that are specific to the bedrock-mantle endpoint.
"""
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
import httpx
from pydantic import TypeAdapter
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
DEFAULT_ANTHROPIC_API_VERSION,
AnthropicMessagesConfig,
)
from litellm.llms.bedrock.common_utils import build_mantle_messages_url
@ -31,6 +35,18 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
_BODY_FIELDS_MANTLE_READS_FROM_HEADERS: Final = frozenset({"anthropic_version", "anthropic_beta"})
_ANTHROPIC_BETAS: Final = TypeAdapter(tuple[str, ...])
_MANTLE_REQUEST: Final = TypeAdapter(dict[str, object])
def _move_betas_into_header(request: Mapping[str, object], headers: dict[str, str]) -> None:
betas: Final = _ANTHROPIC_BETAS.validate_python(request.get("anthropic_beta") or ())
if betas:
headers["anthropic-beta"] = ",".join(betas) # rebind-ok: the handler signs and sends this same dict
return
headers.pop("anthropic-beta", None) # rebind-ok: a caller header Mantle rejects in full must not reach it
class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
"""
@ -40,6 +56,13 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
model ID in the request body (unlike Bedrock Invoke which puts it in the URL).
"""
@property
def beta_headers_provider(self) -> str:
return "bedrock_mantle"
def should_filter_anthropic_beta_headers(self) -> bool:
return False
def get_complete_url(
self,
api_base: str | None,
@ -66,7 +89,7 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict, str | None]:
headers, api_base = super().validate_anthropic_messages_environment(
merged_headers, resolved_api_base = super().validate_anthropic_messages_environment(
headers=headers,
model=model,
messages=messages,
@ -76,9 +99,21 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
api_base=api_base,
)
project_id: Final = litellm_params.get("aws_bedrock_project_id")
if project_id:
headers["anthropic-workspace"] = project_id
return headers, api_base
has_version: Final = any(name.lower() == "anthropic-version" for name in merged_headers)
mantle_headers: Final = MappingProxyType(
{
name: value
for name, value in (
("anthropic-workspace", project_id),
("anthropic-version", None if has_version else DEFAULT_ANTHROPIC_API_VERSION),
)
if value
}
)
return { # mutable-ok: the base class contract returns a dict the handler signs into in place
**merged_headers,
**mantle_headers,
}, resolved_api_base
def transform_anthropic_messages_request(
self,
@ -88,25 +123,28 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> dict:
# Strip "mantle/" routing prefix to get the real model ID
model_id: Final = model.replace("mantle/", "", 1)
request: Final = super().transform_anthropic_messages_request(
model=model_id,
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params=litellm_params,
headers=headers,
request: Final = _MANTLE_REQUEST.validate_python(
super().transform_anthropic_messages_request(
model=model_id,
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params=litellm_params,
headers=headers,
),
)
# Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" and
# "stream" from the body (Bedrock Invoke puts the model in the URL and
# streams via a dedicated endpoint). The mantle endpoint (Messages API)
# requires both in the request body.
stream_fields: Final[dict[str, bool]] = (
{"stream": True} if anthropic_messages_optional_request_params.get("stream") is True else {}
_move_betas_into_header(request, headers)
body: Final = MappingProxyType(
{key: value for key, value in request.items() if key not in _BODY_FIELDS_MANTLE_READS_FROM_HEADERS}
)
return {**request, "model": model_id, **stream_fields}
streaming: Final = anthropic_messages_optional_request_params.get("stream") is True
mantle_fields: Final = MappingProxyType(
{key: value for key, value in (("model", model_id), ("stream", streaming)) if value}
)
return { # mutable-ok: the base class contract returns the dict the handler serializes as the body
**body,
**mantle_fields,
}
def transform_anthropic_messages_response(
self,

View file

@ -2,11 +2,6 @@ from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from pydantic import TypeAdapter
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
DEFAULT_ANTHROPIC_API_VERSION,
)
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import MANTLE_MESSAGES_PATH
from litellm.llms.bedrock.messages.mantle_transformation import AmazonMantleMessagesConfig
@ -17,7 +12,6 @@ from litellm.llms.bedrock_mantle.common_utils import (
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
from litellm.types.router import GenericLiteLLMParams
_BASE_SUFFIXES_TO_STRIP: Final = (
MANTLE_MESSAGES_PATH,
@ -27,9 +21,6 @@ _BASE_SUFFIXES_TO_STRIP: Final = (
"/openai/v1",
"/v1",
)
_BODY_FIELDS_MANTLE_READS_FROM_HEADERS: Final = frozenset({"anthropic_version", "anthropic_beta"})
_ANTHROPIC_BETAS: Final = TypeAdapter(tuple[str, ...])
_MANTLE_REQUEST: Final = TypeAdapter(dict[str, object])
def build_mantle_native_messages_url(api_base: str | None, litellm_params: Mapping[str, object]) -> str:
@ -74,54 +65,3 @@ class BedrockMantleAnthropicMessagesConfig(BedrockMantleAuthMixin, AmazonMantleM
stream: bool | None = None,
) -> str:
return build_mantle_native_messages_url(api_base=api_base, litellm_params=litellm_params)
def validate_anthropic_messages_environment(
self,
headers: dict,
model: str,
messages: list[dict],
optional_params: dict,
litellm_params: dict,
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict, str | None]:
merged_headers, resolved_api_base = super().validate_anthropic_messages_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
)
if any(name.lower() == "anthropic-version" for name in merged_headers):
return merged_headers, resolved_api_base
return { # mutable-ok: the base class contract returns a dict the handler signs into in place
**merged_headers,
"anthropic-version": DEFAULT_ANTHROPIC_API_VERSION,
}, resolved_api_base
def transform_anthropic_messages_request(
self,
model: str,
messages: list[dict],
anthropic_messages_optional_request_params: dict,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> dict:
request: Final = _MANTLE_REQUEST.validate_python(
super().transform_anthropic_messages_request(
model=model,
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params=litellm_params,
headers=headers,
),
)
betas: Final = request.get("anthropic_beta")
if betas is not None:
header_betas: Final = ",".join(_ANTHROPIC_BETAS.validate_python(betas))
headers["anthropic-beta"] = header_betas # rebind-ok: the handler signs and sends this same dict
return { # mutable-ok: the base class contract returns the dict the handler serializes as the body
key: value for key, value in request.items() if key not in _BODY_FIELDS_MANTLE_READS_FROM_HEADERS
}

View file

@ -447,6 +447,63 @@ async def test_mantle_anthropic_messages_sends_workspace_header_and_clean_body()
assert "aws_bedrock_project_id" not in requests[0]["body"]
async def _send_anthropic_messages_with_betas(**request_params: object) -> dict:
import litellm
requests = []
async def mock_post(self, url, data=None, headers=None, **kwargs):
requests.append(_capture_request(url=url, headers=headers or {}, data=data))
return _anthropic_response(url)
try:
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new=mock_post,
):
await litellm.anthropic_messages(
model="bedrock/mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "hello"}],
max_tokens=10,
aws_access_key_id="fake-key",
aws_secret_access_key="fake-secret",
aws_region_name="us-east-1",
**request_params,
)
finally:
await litellm.close_litellm_async_clients()
assert len(requests) == 1
return requests[0]
@pytest.mark.asyncio
@pytest.mark.usefixtures("local_beta_headers_config")
async def test_mantle_anthropic_messages_sends_every_beta_in_the_header_not_the_body():
sent = await _send_anthropic_messages_with_betas(
extra_headers={"anthropic-beta": "context-1m-2025-08-07,interleaved-thinking-2025-05-14"},
context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
)
assert (
sent["headers"]["anthropic-beta"]
== "context-1m-2025-08-07,context-management-2025-06-27,interleaved-thinking-2025-05-14"
)
assert sent["headers"]["anthropic-version"] == "2023-06-01"
assert sent["body"]["context_management"] == {"edits": [{"type": "clear_tool_uses_20250919"}]}
assert "anthropic_beta" not in sent["body"]
assert "anthropic_version" not in sent["body"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("local_beta_headers_config")
async def test_mantle_anthropic_messages_drops_the_beta_header_when_mantle_rejects_every_value():
sent = await _send_anthropic_messages_with_betas(extra_headers={"anthropic-beta": "code-execution-2025-08-25"})
assert "anthropic-beta" not in sent["headers"]
assert "anthropic_beta" not in sent["body"]
def _usageless_anthropic_response(url: str) -> httpx.Response:
return httpx.Response(
status_code=200,