mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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>
This commit is contained in:
parent
8c8fb73eb8
commit
1540d93985
8 changed files with 160 additions and 43 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue