mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
3bbbf7f693
commit
b720909dac
4 changed files with 123 additions and 84 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue