Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/litellm_core_utils/get_litellm_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"aws_sts_endpoint",
"aws_external_id",
"aws_bedrock_runtime_endpoint",
"aws_bedrock_project_id",
Comment thread
veria-ai[bot] marked this conversation as resolved.
"tpm",
"rpm",
"use_xai_oauth",
Expand Down
24 changes: 24 additions & 0 deletions litellm/llms/bedrock/chat/mantle/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ def get_complete_url(
region = self._get_aws_region_name(optional_params=optional_params, model=model)
return MANTLE_ENDPOINT_TEMPLATE.format(region=region)

def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
headers = super().validate_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
)
project_id = litellm_params.get("aws_bedrock_project_id")
if project_id:
headers["anthropic-workspace"] = project_id
return headers

def transform_request(
self,
model: str,
Expand Down
26 changes: 25 additions & 1 deletion litellm/llms/bedrock/messages/mantle_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
stripping that are specific to the bedrock-mantle endpoint.
"""

from typing import TYPE_CHECKING, Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
Expand Down Expand Up @@ -45,6 +45,30 @@ def get_complete_url(
region = self._get_aws_region_name(optional_params=optional_params, model=model)
return MANTLE_ENDPOINT_TEMPLATE.format(region=region)

def validate_anthropic_messages_environment(
self,
headers: dict,
model: str,
messages: List[Any],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> Tuple[dict, Optional[str]]:
headers, 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,
)
project_id = litellm_params.get("aws_bedrock_project_id")
if project_id:
headers["anthropic-workspace"] = project_id
return headers, api_base

def transform_anthropic_messages_request(
self,
model: str,
Expand Down
27 changes: 26 additions & 1 deletion litellm/llms/bedrock_mantle/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
or region-aware key via BEDROCK_MANTLE_{REGION}_API_KEY.
"""

from typing import Iterator, AsyncIterator, Any, Optional, Tuple, Union
from typing import Iterator, AsyncIterator, Any, List, Optional, Tuple, Union

import litellm
from litellm._logging import verbose_logger
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues

from ...openai_like.chat.transformation import OpenAILikeChatConfig

Expand Down Expand Up @@ -48,6 +49,30 @@ def _get_openai_compatible_provider_info(
dynamic_api_key = api_key or get_secret_str("BEDROCK_MANTLE_API_KEY")
return api_base, dynamic_api_key

def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
headers = super().validate_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
)
project_id = litellm_params.get("aws_bedrock_project_id")
if project_id:
headers["OpenAI-Project"] = project_id
return headers

def get_supported_openai_params(self, model: str) -> list:
base_params = super().get_supported_openai_params(model)
try:
Expand Down
2 changes: 2 additions & 0 deletions litellm/llms/bedrock_mantle/responses/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ def validate_environment(
)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if litellm_params.aws_bedrock_project_id:
headers["OpenAI-Project"] = litellm_params.aws_bedrock_project_id
return headers

def supports_native_file_search(self) -> bool:
Expand Down
1 change: 1 addition & 0 deletions litellm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,7 @@ def completion( # type: ignore # noqa: PLR0915
tpm=kwargs.get("tpm"),
rpm=kwargs.get("rpm"),
use_xai_oauth=kwargs.get("use_xai_oauth", False),
aws_bedrock_project_id=kwargs.get("aws_bedrock_project_id"),
)
cast(LiteLLMLoggingObj, logging).update_environment_variables(
model=model,
Expand Down
5 changes: 5 additions & 0 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ def _build_banned_observability_params() -> FrozenSet[str]:
# tokens) to the attacker's host, or coerces the proxy into
# authenticating against the attacker's host with admin secrets.
"aws_bedrock_runtime_endpoint",
# Bedrock project/workspace association. Deployments pin this to
# enforce a data-retention policy, so a caller-supplied value would
# re-route the request's retention and accounting to any project
# reachable with the deployment's shared AWS credentials.
"aws_bedrock_project_id",
# Provider-specific endpoint overrides that flow into the outbound
# request via ``optional_params``. Same threat as ``api_base``:
# ``s3_endpoint_url`` redirects Bedrock file uploads to attacker
Expand Down
2 changes: 2 additions & 0 deletions litellm/types/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ class CredentialLiteLLMParams(BaseModel):
aws_secret_access_key: Optional[str] = None
aws_region_name: Optional[str] = None
aws_bedrock_runtime_endpoint: Optional[str] = None
aws_bedrock_project_id: Optional[str] = None
## IBM WATSONX ##
watsonx_region_name: Optional[str] = None

Expand Down Expand Up @@ -364,6 +365,7 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
aws_access_key_id: Optional[str]
aws_secret_access_key: Optional[str]
aws_region_name: Optional[str]
aws_bedrock_project_id: Optional[str]
## AWS S3 VECTORS ##
vector_bucket_name: Optional[str]
index_name: Optional[str]
Expand Down
4 changes: 4 additions & 0 deletions litellm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3837,6 +3837,10 @@ def base_pre_process_non_default_params(
additional_endpoint_specific_params: List[str],
) -> dict:
for k, v in special_params.items():
if k == "aws_bedrock_project_id":
# sent as a request header (read from litellm_params by the
# bedrock-mantle configs), never as a request body field
continue
Comment thread
mateo-berri marked this conversation as resolved.
if k.startswith("aws_") and (
custom_llm_provider != "bedrock"
and not custom_llm_provider.startswith("sagemaker")
Expand Down
148 changes: 146 additions & 2 deletions tests/test_litellm/llms/bedrock/test_mantle.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
"""
Unit tests for the Bedrock Mantle (Claude Mythos Preview) integration.

Tests cover route detection, URL construction, and config dispatch for both
the /chat/completions and /messages endpoints.
Tests cover route detection, URL construction, config dispatch for both
the /chat/completions and /messages endpoints, and project (workspace)
association via `aws_bedrock_project_id`.
"""

import json
from unittest.mock import patch

import httpx
import pytest

from litellm.llms.bedrock.common_utils import BedrockModelInfo, get_bedrock_chat_config
from litellm.llms.bedrock.chat.mantle.transformation import AmazonMantleConfig
from litellm.llms.bedrock.messages.mantle_transformation import (
AmazonMantleMessagesConfig,
)


def _anthropic_response(url: str) -> httpx.Response:
return httpx.Response(
status_code=200,
json={
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "anthropic.claude-mythos-preview",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
},
request=httpx.Request("POST", url),
)


def _capture_request(url: str, headers: dict, data) -> dict:
raw_body = data.decode("utf-8") if isinstance(data, bytes) else data or "{}"
return {
"path": httpx.URL(url).path,
"headers": headers,
"body": json.loads(raw_body),
}


def test_get_bedrock_route_mantle():
assert (
BedrockModelInfo.get_bedrock_route("mantle/anthropic.claude-mythos-preview")
Expand Down Expand Up @@ -103,3 +136,114 @@ def test_mantle_transform_request_strips_prefix_and_adds_model():
)
assert request["model"] == "anthropic.claude-mythos-preview"
assert "mantle/" not in request["model"]


def test_mantle_validate_environment_sets_workspace_header():
config = AmazonMantleConfig()
headers = config.validate_environment(
headers={},
model="mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "Hello"}],
optional_params={},
litellm_params={"aws_bedrock_project_id": "proj_abc123def456"},
)
assert headers["anthropic-workspace"] == "proj_abc123def456"


def test_mantle_validate_environment_without_project_id():
config = AmazonMantleConfig()
headers = config.validate_environment(
headers={},
model="mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "Hello"}],
optional_params={},
litellm_params={"aws_bedrock_project_id": None},
)
assert "anthropic-workspace" not in headers


def test_mantle_messages_validate_environment_sets_workspace_header():
config = AmazonMantleMessagesConfig()
headers, api_base = config.validate_anthropic_messages_environment(
headers={},
model="mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "Hello"}],
optional_params={},
litellm_params={"aws_bedrock_project_id": "proj_abc123def456"},
api_base="https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
)
assert headers["anthropic-workspace"] == "proj_abc123def456"
assert api_base == "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages"


def test_mantle_messages_validate_environment_without_project_id():
config = AmazonMantleMessagesConfig()
headers, _ = config.validate_anthropic_messages_environment(
headers={},
model="mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "Hello"}],
optional_params={},
litellm_params={},
)
assert "anthropic-workspace" not in headers


def test_mantle_completion_sends_workspace_header_and_clean_body():
import litellm

requests = []

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)

with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post", mock_post):
response = litellm.completion(
model="bedrock/mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "hello"}],
max_tokens=10,
aws_bedrock_project_id="proj_abc123def456",
aws_access_key_id="fake-key",
aws_secret_access_key="fake-secret",
aws_region_name="us-east-1",
)

assert response.choices[0].message.content == "ok"
assert len(requests) == 1
assert requests[0]["path"] == "/anthropic/v1/messages"
assert requests[0]["headers"]["anthropic-workspace"] == "proj_abc123def456"
assert "aws_bedrock_project_id" not in requests[0]["body"]


@pytest.mark.asyncio
async def test_mantle_anthropic_messages_sends_workspace_header_and_clean_body():
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,
):
response = await litellm.anthropic_messages(
model="bedrock/mantle/anthropic.claude-mythos-preview",
messages=[{"role": "user", "content": "hello"}],
max_tokens=10,
aws_bedrock_project_id="proj_abc123def456",
aws_access_key_id="fake-key",
aws_secret_access_key="fake-secret",
aws_region_name="us-east-1",
)
finally:
await litellm.close_litellm_async_clients()

assert response["content"][0]["text"] == "ok"
assert len(requests) == 1
assert requests[0]["path"] == "/anthropic/v1/messages"
assert requests[0]["headers"]["anthropic-workspace"] == "proj_abc123def456"
assert "aws_bedrock_project_id" not in requests[0]["body"]
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,26 @@ def test_missing_bearer_does_not_raise_in_validate_environment(self, monkeypatch
)
assert "Authorization" not in headers

def test_project_id_sets_openai_project_header(self):
cfg = BedrockMantleResponsesAPIConfig()
headers = cfg.validate_environment(
headers={},
model="openai.gpt-5.5",
litellm_params=GenericLiteLLMParams(
api_key="fake-key", aws_bedrock_project_id="proj_abc123def456"
),
)
assert headers["OpenAI-Project"] == "proj_abc123def456"

def test_no_project_id_no_openai_project_header(self):
cfg = BedrockMantleResponsesAPIConfig()
headers = cfg.validate_environment(
headers={},
model="openai.gpt-5.5",
litellm_params=GenericLiteLLMParams(api_key="fake-key"),
)
assert "OpenAI-Project" not in headers

def test_custom_llm_provider(self):
cfg = BedrockMantleResponsesAPIConfig()
assert cfg.custom_llm_provider == LlmProviders.BEDROCK_MANTLE
Expand Down
Loading
Loading