diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 1f37fafde01..f46edc766c7 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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), diff --git a/litellm/llms/bedrock/messages/mantle_transformation.py b/litellm/llms/bedrock/messages/mantle_transformation.py index 7c8758960ad..052eb90a833 100644 --- a/litellm/llms/bedrock/messages/mantle_transformation.py +++ b/litellm/llms/bedrock/messages/mantle_transformation.py @@ -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, diff --git a/litellm/llms/bedrock_mantle/messages/transformation.py b/litellm/llms/bedrock_mantle/messages/transformation.py index 6e975d072ed..78881153e91 100644 --- a/litellm/llms/bedrock_mantle/messages/transformation.py +++ b/litellm/llms/bedrock_mantle/messages/transformation.py @@ -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 - } diff --git a/tests/test_litellm/llms/bedrock/test_mantle.py b/tests/test_litellm/llms/bedrock/test_mantle.py index 09be2118001..37cf49a85ec 100644 --- a/tests/test_litellm/llms/bedrock/test_mantle.py +++ b/tests/test_litellm/llms/bedrock/test_mantle.py @@ -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,