feat(bedrock): aws_bedrock_project_id for bedrock-mantle project / workspace association (#30163)

* feat(bedrock): support aws_bedrock_project_id for bedrock-mantle project association

Adds a litellm_params field to associate bedrock-mantle requests with an
Amazon Bedrock project, sent as the OpenAI-Project header on the
OpenAI-compatible chat and responses paths and as the anthropic-workspace
header on the Anthropic messages paths. This lets a single model entry opt
into a project-scoped data retention mode (e.g. provider_data_share for
Claude Fable 5) while the account-wide setting stays on default.

The param is carried via litellm_params only and is explicitly excluded
from optional_params so it can never leak into a request body.

Fixes #30070

* chore(ui): regenerate schema.d.ts for aws_bedrock_project_id

Generated with npm run gen:api after adding the field to LiteLLM_Params

* fix(proxy): ban client-supplied aws_bedrock_project_id in request bodies

The deployment pins aws_bedrock_project_id so the project's data
retention policy applies to its requests. Without this guard an
authenticated caller could supply the field in the request body and,
since client kwargs win the router merge, run requests under any
project reachable with the deployment's shared AWS credentials.

Adds the field to _BANNED_REQUEST_BODY_PARAMS so it is rejected at the
auth boundary by default while remaining available through the existing
admin opt-ins (allow_client_side_credentials proxy-wide or
configurable_clientside_auth_params per deployment).
This commit is contained in:
Mateo Wang
2026-06-11 10:01:08 +05:30
committed by GitHub
parent 7a96b3490d
commit 49ca04d8c3
15 changed files with 388 additions and 4 deletions
@@ -32,6 +32,7 @@ _OPTIONAL_KWARGS_KEYS = frozenset(
"aws_sts_endpoint",
"aws_external_id",
"aws_bedrock_runtime_endpoint",
"aws_bedrock_project_id",
"tpm",
"rpm",
"use_xai_oauth",
@@ -48,6 +48,30 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
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,
@@ -6,7 +6,7 @@ AmazonAnthropicClaudeMessagesConfig. Overrides only the URL and model-prefix
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,
@@ -45,6 +45,30 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
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,
@@ -8,11 +8,12 @@ Auth: AWS Bedrock API key as Bearer token (set via BEDROCK_MANTLE_API_KEY env va
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
@@ -48,6 +49,30 @@ class BedrockMantleChatConfig(OpenAILikeChatConfig):
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:
@@ -115,6 +115,8 @@ class BedrockMantleResponsesAPIConfig(OpenAIResponsesAPIConfig):
)
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:
+1
View File
@@ -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,
+5
View File
@@ -271,6 +271,11 @@ _BANNED_REQUEST_BODY_PARAMS: Tuple[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
+2
View File
@@ -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
@@ -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]
+4
View File
@@ -3837,6 +3837,10 @@ class PreProcessNonDefaultParams:
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
if k.startswith("aws_") and (
custom_llm_provider != "bedrock"
and not custom_llm_provider.startswith("sagemaker")
+146 -2
View File
@@ -1,10 +1,17 @@
"""
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 (
@@ -12,6 +19,32 @@ from litellm.llms.bedrock.messages.mantle_transformation import (
)
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")
@@ -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"]
@@ -167,6 +167,26 @@ class TestBedrockMantleResponsesAuth:
)
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
@@ -5,11 +5,14 @@ Bedrock Mantle is Amazon Bedrock's OpenAI-compatible inference engine (Project M
API docs: https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html
"""
import json
import os
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.abspath("../../../../.."))
import httpx
import pytest
import litellm
@@ -96,6 +99,79 @@ class TestBedrockMantleConfig:
assert "max_tokens" in params
class TestBedrockMantleProjectHeader:
def test_validate_environment_sets_openai_project_header(self):
cfg = BedrockMantleChatConfig()
headers = cfg.validate_environment(
headers={},
model="openai.gpt-oss-120b",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={"aws_bedrock_project_id": "proj_abc123def456"},
api_key="fake-key",
)
assert headers["OpenAI-Project"] == "proj_abc123def456"
assert headers["Authorization"] == "Bearer fake-key"
def test_validate_environment_without_project_id(self):
cfg = BedrockMantleChatConfig()
headers = cfg.validate_environment(
headers={},
model="openai.gpt-oss-120b",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={},
api_key="fake-key",
)
assert "OpenAI-Project" not in headers
def test_completion_sends_openai_project_header_and_clean_body(self):
requests = []
def mock_post(self, url, data=None, headers=None, **kwargs):
raw_body = data.decode("utf-8") if isinstance(data, bytes) else data
requests.append(
{"headers": headers or {}, "body": json.loads(raw_body or "{}")}
)
return httpx.Response(
status_code=200,
json={
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1733529600,
"model": "openai.gpt-oss-120b",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
},
request=httpx.Request("POST", url),
)
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post", mock_post
):
response = litellm.completion(
model="bedrock_mantle/openai.gpt-oss-120b",
messages=[{"role": "user", "content": "hello"}],
api_key="fake-key",
aws_bedrock_project_id="proj_abc123def456",
)
assert response.choices[0].message.content == "ok"
assert len(requests) == 1
assert requests[0]["headers"]["OpenAI-Project"] == "proj_abc123def456"
assert "aws_bedrock_project_id" not in requests[0]["body"]
class TestBedrockMantleProviderResolution:
def test_get_llm_provider_resolves_correctly(self):
model, provider, _, _ = litellm.get_llm_provider(
@@ -1551,6 +1551,40 @@ class TestIsRequestBodySafeBlocksEndpointTargetingFields:
)
class TestIsRequestBodySafeBlocksBedrockProjectOverride:
"""``aws_bedrock_project_id`` pins a deployment to a Bedrock project so
that project's data-retention policy applies to its requests. A
caller-supplied value would run the request under any project reachable
with the deployment's shared AWS credentials, bypassing the configured
retention/accounting association."""
def test_project_id_in_request_body_is_rejected(self):
with pytest.raises(ValueError, match="aws_bedrock_project_id"):
is_request_body_safe(
request_body={
"model": "gpt-4",
"aws_bedrock_project_id": "proj_attacker000000",
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_admin_opt_in_proxy_wide_allows_project_id(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"aws_bedrock_project_id": "proj_byok000000",
},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="gpt-4",
)
is True
)
# ── is_request_body_safe nested-config recursion (VERIA-6) ────────────────────
+18
View File
@@ -4198,3 +4198,21 @@ class TestBedrockBaseModelLabelKeepsTools:
)
assert "tools" not in result
def test_aws_bedrock_project_id_excluded_from_bedrock_optional_params():
"""`aws_bedrock_project_id` is sent as a bedrock-mantle request header, so it
must never reach optional_params (and from there the request body), while
other aws_* params keep flowing for boto3 auth."""
from litellm.utils import get_optional_params
result = get_optional_params(
model="mantle/anthropic.claude-mythos-preview",
custom_llm_provider="bedrock",
max_tokens=10,
aws_bedrock_project_id="proj_abc123def456",
aws_region_name="us-east-1",
)
assert "aws_bedrock_project_id" not in result
assert result["aws_region_name"] == "us-east-1"
+4
View File
@@ -24891,6 +24891,8 @@ export interface components {
auto_router_embedding_model?: string | null;
/** Aws Access Key Id */
aws_access_key_id?: string | null;
/** Aws Bedrock Project Id */
aws_bedrock_project_id?: string | null;
/** Aws Bedrock Runtime Endpoint */
aws_bedrock_runtime_endpoint?: string | null;
/** Aws Region Name */
@@ -32495,6 +32497,8 @@ export interface components {
auto_router_embedding_model?: string | null;
/** Aws Access Key Id */
aws_access_key_id?: string | null;
/** Aws Bedrock Project Id */
aws_bedrock_project_id?: string | null;
/** Aws Bedrock Runtime Endpoint */
aws_bedrock_runtime_endpoint?: string | null;
/** Aws Region Name */