fix(bedrock/claude_platform): strip body params the AWS endpoint rejects

The bedrock/claude_platform/ route forwards params into the Messages API
request body that the Claude Platform on AWS (aws-external-anthropic)
endpoint rejects as unknown fields ("Extra inputs are not permitted"). Two
categories:

1. Auth/routing config. workspace_id and aws_* litellm_params are auth/routing
   config: workspace_id is sent as the anthropic-workspace-id header and aws_*
   params feed SigV4 signing. A standard proxy config

     - model_name: claude-sonnet-4-6
       litellm_params:
         model: bedrock/claude_platform/claude-sonnet-4-6
         aws_region_name: us-east-1
         workspace_id: wrkspc_xxx

   fails with 400 'workspace_id: Extra inputs are not permitted' (and after
   removing that, 'aws_region_name: ...'). BedrockClaudePlatformConfig inherits
   AnthropicConfig, which forwards optional_params into the body verbatim.

2. Unsupported Messages API fields. context_management is a valid first-party
   Anthropic beta field that the AWS endpoint does not support yet, so it is
   rejected the same way. It must be stripped on this route specifically.

Add filter_claude_platform_request_body() in common_utils and route both the
chat-completions transform_request and the native /v1/messages
transform_anthropic_messages_request through it. Auth/routing params are
dropped silently; unsupported feature params (context_management) are dropped
with a verbose_logger.warning since they carry user intent. Filters a copy so
sign_request still sees aws_region_name and callers' dicts are not mutated.

Affects v1.85.0 through v1.89.3 and current main.
This commit is contained in:
Venkata Donavalli 2026-06-11 16:31:56 -04:00 • committed by mateo-berri
parent 8bca05d311
commit a2c0cf9a46
No known key found for this signature in database
4 changed files with 268 additions and 2 deletions

View file

@ -1,6 +1,7 @@
from typing import Literal, Optional, Tuple
import litellm
from litellm._logging import verbose_logger
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.secret_managers.main import get_secret_str
@ -9,6 +10,58 @@ CLAUDE_PLATFORM_SERVICE_NAME: Literal["aws-external-anthropic"] = (
)
CLAUDE_PLATFORM_BEDROCK_ROUTE = "claude_platform/"
# Auth/routing params consumed by validate_environment / sign_request that
# must not be forwarded in the Messages API request body (together with any
# key prefixed "aws_") — the API rejects unknown fields with
# "Extra inputs are not permitted".
CLAUDE_PLATFORM_NON_REQUEST_PARAMS = {
"workspace_id",
"anthropic_workspace_id",
"anthropic-workspace-id",
}
# Messages API fields that are valid on Anthropic's first-party API but are
# not yet supported by the Claude Platform on AWS (aws-external-anthropic)
# endpoint, which rejects them with "Extra inputs are not permitted". Unlike
# the auth params above these carry user intent, so dropping them is logged at
# WARNING — the request succeeds but the requested feature is not applied.
CLAUDE_PLATFORM_UNSUPPORTED_REQUEST_PARAMS = {
"context_management",
}
def filter_claude_platform_request_body(params: dict) -> dict:
"""Return a copy of ``params`` with fields the Claude Platform on AWS
endpoint rejects removed.
Strips auth/routing config (workspace-id aliases and any ``aws_``-prefixed
key) silently, since those are consumed by validate_environment /
sign_request and never belong in the body. Strips Messages API fields the
AWS endpoint does not support yet (e.g. ``context_management``) with a
WARNING, since those reflect user intent that will not be applied on this
route.
Filters a copy so callers' ``sign_request`` still sees ``aws_region_name``.
"""
dropped_unsupported = [
k for k in params if k in CLAUDE_PLATFORM_UNSUPPORTED_REQUEST_PARAMS
]
if dropped_unsupported:
verbose_logger.warning(
"bedrock/claude_platform: dropping unsupported Messages API "
"param(s) %s from the request body — the Claude Platform on AWS "
"(aws-external-anthropic) endpoint does not support them and "
"rejects unknown fields. The request will proceed without them.",
dropped_unsupported,
)
return {
k: v
for k, v in params.items()
if k not in CLAUDE_PLATFORM_NON_REQUEST_PARAMS
and k not in CLAUDE_PLATFORM_UNSUPPORTED_REQUEST_PARAMS
and not k.startswith("aws_")
}
def strip_claude_platform_route(model: str) -> str:
if model.startswith(CLAUDE_PLATFORM_BEDROCK_ROUTE):

View file

@ -8,7 +8,11 @@ from litellm.llms.anthropic.experimental_pass_through.messages.transformation im
from litellm.secret_managers.main import get_secret_str
from litellm.types.router import GenericLiteLLMParams
from .common_utils import BedrockClaudePlatformMixin, strip_claude_platform_route
from .common_utils import (
BedrockClaudePlatformMixin,
filter_claude_platform_request_body,
strip_claude_platform_route,
)
class BedrockClaudePlatformMessagesConfig(
@ -62,6 +66,15 @@ class BedrockClaudePlatformMessagesConfig(
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Dict:
# Strip auth/routing config (workspace_id, aws_*) and Messages API
# fields the AWS endpoint does not support (e.g. context_management)
# from the body — the API rejects unknown fields with "Extra inputs
# are not permitted".
anthropic_messages_optional_request_params = (
filter_claude_platform_request_body(
anthropic_messages_optional_request_params
)
)
return super().transform_anthropic_messages_request(
model=strip_claude_platform_route(model),
messages=messages,

View file

@ -5,7 +5,10 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from .common_utils import BedrockClaudePlatformMixin
from .common_utils import (
BedrockClaudePlatformMixin,
filter_claude_platform_request_body,
)
class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig):
@ -82,6 +85,28 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig):
anthropic_headers["anthropic-workspace-id"] = workspace_id
return {**headers, **anthropic_headers}
def transform_request(
self,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
# Strip auth/routing config (workspace_id, aws_*) and Messages API
# fields the AWS endpoint does not support (e.g. context_management)
# from the body — the API rejects unknown fields with "Extra inputs
# are not permitted". Filters a copy so sign_request still sees
# aws_region_name.
optional_params = filter_claude_platform_request_body(optional_params)
return super().transform_request(
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers=headers,
)
def get_model_response_iterator(
self,
streaming_response: Any,

View file

@ -313,6 +313,181 @@ async def test_anthropic_messages_routes_bedrock_claude_platform_to_messages_api
assert requests[0]["body"]["model"] == "claude-sonnet-4-6"
def test_claude_platform_strips_auth_params_from_request_body():
"""
Regression: workspace_id is consumed by validate_environment (sent as the
anthropic-workspace-id header) and aws_* params by sign_request, but
transform_request used to forward them into the Messages API body, which
rejects unknown fields: "workspace_id: Extra inputs are not permitted".
"""
from litellm.llms.bedrock.claude_platform.transformation import (
BedrockClaudePlatformConfig,
)
config = BedrockClaudePlatformConfig()
optional_params = {
"workspace_id": "wrkspc_test",
"aws_region_name": "us-west-2",
"max_tokens": 10,
}
request_body = config.transform_request(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "hello"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "workspace_id" not in request_body
assert "aws_region_name" not in request_body
assert request_body["max_tokens"] == 10
# sign_request still needs the aws_* params — the original dict must not
# be mutated by the body transformation.
assert optional_params["aws_region_name"] == "us-west-2"
assert optional_params["workspace_id"] == "wrkspc_test"
def test_claude_platform_messages_strips_auth_params_from_request_body():
"""
Same regression as above for the native /v1/messages path.
"""
import litellm
from litellm.types.utils import LlmProviders
config = litellm.ProviderConfigManager.get_provider_anthropic_messages_config(
model="claude_platform/claude-sonnet-4-6",
provider=LlmProviders.BEDROCK,
)
assert config is not None
request_body = config.transform_anthropic_messages_request(
model="claude_platform/claude-sonnet-4-6",
messages=[{"role": "user", "content": "hello"}],
anthropic_messages_optional_request_params={
"workspace_id": "wrkspc_test",
"aws_region_name": "us-west-2",
"max_tokens": 10,
},
litellm_params={},
headers={},
)
assert "workspace_id" not in request_body
assert "aws_region_name" not in request_body
assert request_body["max_tokens"] == 10
def test_claude_platform_strips_unsupported_context_management_param(caplog):
"""
Regression: context_management is a valid first-party Anthropic Messages
API field, but the Claude Platform on AWS (aws-external-anthropic)
endpoint does not support it and rejects it with
"context_management: Extra inputs are not permitted". It must be dropped
from the body, and — unlike the silent auth-param strip — the drop is
logged at WARNING because it reflects user intent that won't be applied.
"""
import logging
from litellm.llms.bedrock.claude_platform.transformation import (
BedrockClaudePlatformConfig,
)
config = BedrockClaudePlatformConfig()
optional_params = {
"workspace_id": "wrkspc_test",
"context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]},
"max_tokens": 10,
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
request_body = config.transform_request(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "hello"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "context_management" not in request_body
assert request_body["max_tokens"] == 10
# original dict is not mutated
assert "context_management" in optional_params
# the drop is surfaced to the user
assert any(
"context_management" in record.message and record.levelno == logging.WARNING
for record in caplog.records
)
def test_claude_platform_messages_strips_unsupported_context_management_param():
"""
Same context_management strip on the native /v1/messages path.
"""
import litellm
from litellm.types.utils import LlmProviders
config = litellm.ProviderConfigManager.get_provider_anthropic_messages_config(
model="claude_platform/claude-sonnet-4-6",
provider=LlmProviders.BEDROCK,
)
assert config is not None
request_body = config.transform_anthropic_messages_request(
model="claude_platform/claude-sonnet-4-6",
messages=[{"role": "user", "content": "hello"}],
anthropic_messages_optional_request_params={
"context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]},
"max_tokens": 10,
},
litellm_params={},
headers={},
)
assert "context_management" not in request_body
assert request_body["max_tokens"] == 10
def test_chat_completion_claude_platform_sigv4_body_has_no_auth_params():
"""
End-to-end (mocked transport): a config-driven SigV4 call with
workspace_id + aws_region_name must not leak either param into the wire
body. Uses SigV4 (no api_key) since that is how proxy configs pass
aws_region_name.
"""
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)
mock_credentials = Credentials("test-key", "test-secret", "test-token")
with (
patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post", mock_post),
patch(
"litellm.llms.bedrock.base_aws_llm.BaseAWSLLM.get_credentials",
return_value=mock_credentials,
),
):
response = litellm.completion(
model="bedrock/claude_platform/claude-sonnet-4-6",
messages=[{"role": "user", "content": "hello"}],
max_tokens=10,
aws_region_name="us-west-2",
workspace_id="wrkspc_test",
)
assert response.choices[0].message.content == "ok"
assert len(requests) == 1
body = requests[0]["body"]
assert "workspace_id" not in body
assert "aws_region_name" not in body
assert requests[0]["headers"]["anthropic-workspace-id"] == "wrkspc_test"
def test_sigv4_no_duplicate_content_type_when_caller_sets_lowercase():
"""
Regression: get_anthropic_headers() supplies "content-type" (lowercase).