diff --git a/litellm/llms/bedrock/claude_platform/common_utils.py b/litellm/llms/bedrock/claude_platform/common_utils.py index 3abb8710de7..af340a0efbe 100644 --- a/litellm/llms/bedrock/claude_platform/common_utils.py +++ b/litellm/llms/bedrock/claude_platform/common_utils.py @@ -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): diff --git a/litellm/llms/bedrock/claude_platform/messages_transformation.py b/litellm/llms/bedrock/claude_platform/messages_transformation.py index 66158196322..a4f315af8f6 100644 --- a/litellm/llms/bedrock/claude_platform/messages_transformation.py +++ b/litellm/llms/bedrock/claude_platform/messages_transformation.py @@ -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, diff --git a/litellm/llms/bedrock/claude_platform/transformation.py b/litellm/llms/bedrock/claude_platform/transformation.py index c20dc63444f..7c7c41358ea 100644 --- a/litellm/llms/bedrock/claude_platform/transformation.py +++ b/litellm/llms/bedrock/claude_platform/transformation.py @@ -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, diff --git a/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py b/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py index dbded8e0a2e..111aab961f1 100644 --- a/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py +++ b/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py @@ -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).