From 1540d9398558084a8c56bd1dcdf22db3b72146da Mon Sep 17 00:00:00 2001 From: shrey kharbanda Date: Mon, 21 Sep 2026 23:48:32 +0000 Subject: [PATCH] fix(bedrock/claude_platform): keep workspace_id and AWS credentials out of the request body The claude_platform chat route inherits Anthropic's transform, whose body is optional_params, and workspace_id plus the aws_* signing kwargs were being put into that dict. The AWS gateway rejects unknown fields with 400. workspace_id now travels on litellm_params only, and the claude_platform config drops AWS credential kwargs before delegating body construction while sign_request keeps reading them Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../litellm_core_utils/get_litellm_params.py | 8 +- .../bedrock/claude_platform/common_utils.py | 18 ++--- .../messages_transformation.py | 2 +- .../bedrock/claude_platform/transformation.py | 18 ++++- litellm/llms/bedrock/common_utils.py | 14 ---- litellm/utils.py | 9 +-- .../llms/bedrock/test_bedrock_common_utils.py | 77 +++++++++++++++++++ .../bedrock/test_claude_platform_provider.py | 57 ++++++++++++-- 8 files changed, 160 insertions(+), 43 deletions(-) diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 9b2db9aad18..4e0050504be 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -23,10 +23,14 @@ AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset( } ) +# Kwargs a provider config turns into a request header (read from `litellm_params`); +# `get_optional_params` never lets them into the request body. +HEADER_ONLY_KWARGS_KEYS: Final = frozenset({"aws_bedrock_project_id", "workspace_id"}) + # Keys `completion()` forwards from its own kwargs into `get_litellm_params`, # which are otherwise invisible to it because that call site passes explicit # named arguments rather than `**kwargs`. -FORWARDED_KWARGS_KEYS: Final = AWS_CREDENTIAL_KWARGS_KEYS +FORWARDED_KWARGS_KEYS: Final = AWS_CREDENTIAL_KWARGS_KEYS | HEADER_ONLY_KWARGS_KEYS # Pre-define optional kwargs keys as frozenset for O(1) lookups # These are extracted from kwargs only if present, avoiding unnecessary .get() calls @@ -62,7 +66,7 @@ OPTIONAL_KWARGS_KEYS: Final = ( "use_xai_oauth", } ) - | AWS_CREDENTIAL_KWARGS_KEYS + | FORWARDED_KWARGS_KEYS ) # Backward-compatible alias for existing imports/tests. diff --git a/litellm/llms/bedrock/claude_platform/common_utils.py b/litellm/llms/bedrock/claude_platform/common_utils.py index fb7f2185ec5..7457cdbc8d4 100644 --- a/litellm/llms/bedrock/claude_platform/common_utils.py +++ b/litellm/llms/bedrock/claude_platform/common_utils.py @@ -3,6 +3,7 @@ from typing import Final import httpx import litellm +from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import BedrockError from litellm.secret_managers.main import get_secret_str @@ -27,21 +28,16 @@ class BedrockClaudePlatformMixin(BaseAWSLLM): return BedrockError(status_code=status_code, message=error_message, headers=headers) @staticmethod - def _get_workspace_id(optional_params: dict, litellm_params: dict) -> str | None: - workspace_id = ( - optional_params.get("workspace_id") - or litellm_params.get("workspace_id") - or optional_params.get("aws_workspace_id") - or litellm_params.get("aws_workspace_id") - or optional_params.get("anthropic-workspace-id") - or litellm_params.get("anthropic-workspace-id") - ) - if workspace_id is None: - workspace_id = optional_params.get("anthropic_workspace_id") or litellm_params.get("anthropic_workspace_id") + def _get_workspace_id(litellm_params: dict) -> str | None: + workspace_id: Final = litellm_params.get("workspace_id") if workspace_id is not None: return str(workspace_id) return get_secret_str("ANTHROPIC_AWS_WORKSPACE_ID") or get_secret_str("ANTHROPIC_WORKSPACE_ID") + @staticmethod + def _strip_aws_params(optional_params: dict) -> dict: + return {key: value for key, value in optional_params.items() if key not in AWS_CREDENTIAL_KWARGS_KEYS} + def _get_required_aws_region_name(self, optional_params: dict) -> str: aws_region_name: Final = ( optional_params.get("aws_region_name") diff --git a/litellm/llms/bedrock/claude_platform/messages_transformation.py b/litellm/llms/bedrock/claude_platform/messages_transformation.py index 1e3eea075f3..1448c87b125 100644 --- a/litellm/llms/bedrock/claude_platform/messages_transformation.py +++ b/litellm/llms/bedrock/claude_platform/messages_transformation.py @@ -25,7 +25,7 @@ class BedrockClaudePlatformMessagesConfig(BedrockClaudePlatformMixin, AnthropicM api_key: str | None = None, api_base: str | None = None, ) -> tuple[dict, str | None]: - workspace_id: Final = self._get_workspace_id(optional_params, litellm_params) + workspace_id: Final = self._get_workspace_id(litellm_params) if workspace_id is None: raise litellm.AuthenticationError( message=( diff --git a/litellm/llms/bedrock/claude_platform/transformation.py b/litellm/llms/bedrock/claude_platform/transformation.py index a57f309b605..b00d1b3ae75 100644 --- a/litellm/llms/bedrock/claude_platform/transformation.py +++ b/litellm/llms/bedrock/claude_platform/transformation.py @@ -30,7 +30,7 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict: - workspace_id: Final = self._get_workspace_id(optional_params, litellm_params) + workspace_id: Final = self._get_workspace_id(litellm_params) if workspace_id is None: raise litellm.AuthenticationError( message=( @@ -66,6 +66,22 @@ 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: + return super().transform_request( + model=model, + messages=messages, + optional_params=self._strip_aws_params(optional_params), + litellm_params=litellm_params, + headers=headers, + ) + def get_model_response_iterator( self, streaming_response: Any, diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index f1066643874..d89becfcd25 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -1198,20 +1198,6 @@ class BedrockModelInfo(BaseLLMModelInfo): """ return model.replace("claude_platform/", "", 1) - @staticmethod - def map_claude_platform_auth_params(passed_params: dict, optional_params: dict) -> dict: - """ - Map Claude Platform route auth params that are not OpenAI request params. - """ - for key in ( - "workspace_id", - "aws_workspace_id", - "anthropic_workspace_id", - ): - if key in passed_params: - optional_params[key] = passed_params[key] - return optional_params - @staticmethod def _explicit_invoke_route(model: str) -> bool: """ diff --git a/litellm/utils.py b/litellm/utils.py index 709f3f6d1dd..10dcee3679d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -86,6 +86,7 @@ from litellm.litellm_core_utils.fallback_generalizations import ( match_capability_generalizations, match_fill_missing_generalizations, ) +from litellm.litellm_core_utils.get_litellm_params import HEADER_ONLY_KWARGS_KEYS from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload _CachingHandlerResponse = None @@ -4091,9 +4092,9 @@ class PreProcessNonDefaultParams: additional_endpoint_specific_params: list[str], ) -> dict: for k, v in special_params.items(): - if k == "aws_bedrock_project_id": + if k in HEADER_ONLY_KWARGS_KEYS: # sent as a request header (read from litellm_params by the - # bedrock-mantle configs), never as a request body field + # bedrock-mantle and claude_platform configs), never as a request body field continue if ( k.startswith("aws_") @@ -4614,10 +4615,6 @@ def get_optional_params( model=model, drop_params=bool(drop_params), ) - if bedrock_route == "claude_platform": - optional_params = BedrockModelInfo.map_claude_platform_auth_params( - passed_params=passed_params, optional_params=optional_params - ) elif custom_llm_provider == "cloudflare": optional_params = litellm.CloudflareChatConfig().map_openai_params( model=model, diff --git a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py index df042ce5902..6bf4bd87705 100644 --- a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py @@ -926,3 +926,80 @@ def test_every_bedrock_config_get_error_class_keeps_provider_headers(config): def test_bedrock_get_error_class_audit_covers_every_surface(): assert len(_bedrock_configs_with_get_error_class()) >= 30 + + +_AWS_CREDENTIAL_KWARGS = { + "aws_region_name": "us-west-2", + "aws_access_key_id": "AKIATEST", + "aws_secret_access_key": "secret-test", + "aws_session_token": "session-test", +} + + +@pytest.mark.parametrize( + ("model", "response_json"), + [ + ( + "bedrock/converse/anthropic.claude-3-5-sonnet-20240620-v1:0", + { + "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}, + }, + ), + ( + "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0", + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-3-5-sonnet", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ), + ( + "bedrock/claude_platform/claude-sonnet-4-6", + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ), + ], +) +def test_aws_credential_kwargs_are_used_for_signing_but_never_serialized_into_body(monkeypatch, model, response_json): + import json + from unittest.mock import patch + + import httpx + + import litellm + + for env_key in ("AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_AWS_API_KEY", "AWS_PROFILE"): + monkeypatch.delenv(env_key, raising=False) + requests = [] + + def mock_post(self, url, data=None, headers=None, **kwargs): + requests.append({"body": json.loads(data), "headers": headers or {}}) + return httpx.Response(status_code=200, json=response_json, request=httpx.Request("POST", url)) + + with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post", mock_post): + litellm.completion( + model=model, + messages=[{"role": "user", "content": "hello"}], + max_tokens=10, + workspace_id="wrkspc_test", + **_AWS_CREDENTIAL_KWARGS, + ) + + assert len(requests) == 1 + assert requests[0]["headers"]["Authorization"].startswith("AWS4-HMAC-SHA256 Credential=AKIATEST/"), requests[0] + assert not (_AWS_CREDENTIAL_KWARGS.keys() | {"workspace_id"}) & requests[0]["body"].keys(), requests[0]["body"] 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 40f78c84ca3..0df8c6704a2 100644 --- a/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py +++ b/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py @@ -119,8 +119,8 @@ def test_claude_platform_api_key_auth_sets_workspace_and_key_headers(): headers={"anthropic-beta": "skills-2025-10-02"}, model="claude-sonnet-4-6", messages=[{"role": "user", "content": "hello"}], - optional_params={"workspace_id": "wrkspc_test"}, - litellm_params={}, + optional_params={}, + litellm_params={"workspace_id": "wrkspc_test"}, ) assert headers["x-api-key"] == "fake-platform-key" @@ -141,8 +141,8 @@ def test_claude_platform_does_not_use_standard_anthropic_api_key(monkeypatch): headers={}, model="claude-sonnet-4-6", messages=[{"role": "user", "content": "hello"}], - optional_params={"workspace_id": "wrkspc_test"}, - litellm_params={}, + optional_params={}, + litellm_params={"workspace_id": "wrkspc_test"}, ) assert "x-api-key" not in headers @@ -274,7 +274,45 @@ def test_chat_completion_routes_bedrock_claude_platform_to_messages_api(): assert requests[0]["path"] == "/v1/messages" assert requests[0]["headers"]["x-api-key"] == "fake-platform-key" assert requests[0]["headers"]["anthropic-workspace-id"] == "wrkspc_test" - assert requests[0]["body"]["model"] == "claude-sonnet-4-6" + assert requests[0]["body"] == { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + "max_tokens": 10, + } + + +def test_chat_completion_keeps_workspace_and_aws_credentials_out_of_body_but_in_headers(monkeypatch): + import litellm + + for env_key in ("AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_AWS_API_KEY", "AWS_PROFILE"): + monkeypatch.delenv(env_key, raising=False) + 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): + litellm.completion( + model="bedrock/claude_platform/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + max_tokens=10, + workspace_id="wrkspc_test", + aws_region_name="us-west-2", + aws_access_key_id="AKIATEST", + aws_secret_access_key="secret-test", + ) + + assert len(requests) == 1 + assert requests[0]["path"] == "/v1/messages" + assert requests[0]["headers"]["anthropic-workspace-id"] == "wrkspc_test" + assert requests[0]["headers"]["Authorization"].startswith("AWS4-HMAC-SHA256 Credential=AKIATEST/") + assert "/us-west-2/aws-external-anthropic/aws4_request" in requests[0]["headers"]["Authorization"] + assert requests[0]["body"] == { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + "max_tokens": 10, + } @pytest.mark.asyncio @@ -308,9 +346,12 @@ async def test_anthropic_messages_routes_bedrock_claude_platform_to_messages_api assert requests[0]["path"] == "/v1/messages" assert requests[0]["headers"]["x-api-key"] == "fake-platform-key" assert requests[0]["headers"]["anthropic-workspace-id"] == "wrkspc_test" - assert requests[0]["body"]["messages"] == [{"role": "user", "content": "hello"}] - assert requests[0]["body"]["max_tokens"] == 10 - assert requests[0]["body"]["model"] == "claude-sonnet-4-6" + assert requests[0]["body"] == { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 10, + "stream": False, + } @pytest.mark.asyncio