diff --git a/litellm/llms/bedrock/claude_platform/common_utils.py b/litellm/llms/bedrock/claude_platform/common_utils.py index fb7f2185ec5..d9237972b84 100644 --- a/litellm/llms/bedrock/claude_platform/common_utils.py +++ b/litellm/llms/bedrock/claude_platform/common_utils.py @@ -10,6 +10,16 @@ from litellm.secret_managers.main import get_secret_str CLAUDE_PLATFORM_SERVICE_NAME: Final = "aws-external-anthropic" CLAUDE_PLATFORM_BEDROCK_ROUTE: Final = "claude_platform/" +# Aliases litellm accepts for the workspace identifier; all of them are +# consumed only as the `anthropic-workspace-id` HTTP header and must be +# stripped from request-body params before transformation (#29272). +_WORKSPACE_ID_PARAM_KEYS: Tuple[str, ...] = ( + "workspace_id", + "aws_workspace_id", + "anthropic-workspace-id", + "anthropic_workspace_id", +) + def strip_claude_platform_route(model: str) -> str: if model.startswith(CLAUDE_PLATFORM_BEDROCK_ROUTE): @@ -42,6 +52,20 @@ class BedrockClaudePlatformMixin(BaseAWSLLM): return str(workspace_id) return get_secret_str("ANTHROPIC_AWS_WORKSPACE_ID") or get_secret_str("ANTHROPIC_WORKSPACE_ID") + @staticmethod + def _pop_workspace_id_params(optional_params: dict, litellm_params: dict) -> None: + """Strip every workspace_id alias from both param dicts. + + ``workspace_id`` (and its aliases) is consumed only as the + ``anthropic-workspace-id`` HTTP header. If we leave the keys in + ``optional_params``, the inherited ``AnthropicConfig.transform_request`` + serializes them into the JSON body, and Anthropic's ``/v1/messages`` + rejects unknown top-level fields (#29272). + """ + for key in _WORKSPACE_ID_PARAM_KEYS: + optional_params.pop(key, None) + litellm_params.pop(key, None) + 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/transformation.py b/litellm/llms/bedrock/claude_platform/transformation.py index a57f309b605..4a831dc1a39 100644 --- a/litellm/llms/bedrock/claude_platform/transformation.py +++ b/litellm/llms/bedrock/claude_platform/transformation.py @@ -41,6 +41,13 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig): model=model, ) + # Drop workspace_id alias keys from optional_params/litellm_params now + # that we've turned the value into the `anthropic-workspace-id` header. + # Without this they survive into `AnthropicConfig.transform_request` and + # ship as a top-level field in the JSON body, which Anthropic's + # /v1/messages rejects (#29272). + self._pop_workspace_id_params(optional_params, litellm_params) + api_key = api_key or get_secret_str("ANTHROPIC_AWS_API_KEY") anthropic_headers: Final = self.get_anthropic_headers( api_key=api_key, 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..0abc2af9441 100644 --- a/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py +++ b/tests/test_litellm/llms/bedrock/test_claude_platform_provider.py @@ -362,3 +362,61 @@ def test_sigv4_no_duplicate_content_type_when_caller_sets_lowercase(): "Duplicate keys produce 'application/json, application/json' in the " "SigV4 canonical string and cause a 401." ) + + +def test_claude_platform_strips_workspace_id_aliases_from_request_body(): + """Regression for #29272. + + `workspace_id` (and its aliases `aws_workspace_id`, `anthropic-workspace-id`, + `anthropic_workspace_id`) is consumed only as the + `anthropic-workspace-id` header. They were left in `optional_params`, so + the inherited `AnthropicConfig.transform_request` serialized them as + top-level fields of the JSON body and Anthropic's `/v1/messages` + rejected the request with `unknown field`. + + Verify every alias is popped from both `optional_params` and + `litellm_params` once `validate_environment` runs. + """ + from litellm.llms.bedrock.claude_platform.transformation import ( + BedrockClaudePlatformConfig, + ) + + config = BedrockClaudePlatformConfig() + + optional_params = { + "workspace_id": "wrkspc_a", + "aws_workspace_id": "wrkspc_b", + "anthropic-workspace-id": "wrkspc_c", + "anthropic_workspace_id": "wrkspc_d", + "max_tokens": 1024, # must survive + } + litellm_params = { + "workspace_id": "wrkspc_e", + "anthropic_workspace_id": "wrkspc_f", + "metadata": {"trace_id": "abc"}, # must survive + } + + headers = config.validate_environment( + api_key="fake-platform-key", + headers={}, + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + optional_params=optional_params, + litellm_params=litellm_params, + ) + + assert headers["anthropic-workspace-id"] == "wrkspc_a" + + # No alias survives into params that downstream transformers serialize. + for key in ( + "workspace_id", + "aws_workspace_id", + "anthropic-workspace-id", + "anthropic_workspace_id", + ): + assert key not in optional_params, f"{key} leaked into optional_params" + assert key not in litellm_params, f"{key} leaked into litellm_params" + + # Unrelated params untouched. + assert optional_params["max_tokens"] == 1024 + assert litellm_params["metadata"] == {"trace_id": "abc"}