diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 03fbc35a97f..36ef5037255 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -35,7 +35,6 @@ from litellm.litellm_core_utils.core_helpers import ( filter_exceptions_from_params, filter_internal_params, map_finish_reason, - safe_deep_copy, ) from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.prompt_templates.common_utils import ( @@ -1823,10 +1822,13 @@ class AmazonConverseConfig(BaseConfig): # ``_handle_reasoning_effort_parameter`` so it does not linger on the # caller's ``optional_params`` after the transformation returns. anthropic_output_config_already_normalized = bool(optional_params.pop("_output_config_normalized", False)) - # Filter out exception objects before deepcopy to prevent deepcopy failures - # Exceptions should not be stored in optional_params (this is a defensive fix) - cleaned_params: Final = filter_exceptions_from_params(optional_params) - inference_params = safe_deep_copy(cleaned_params) + # Filter out exception objects (they should not be stored in optional_params; this is a + # defensive fix). The filter rebuilds every dict and list, so its result is already + # independent of the caller's params: deep-copying it again only doubles the allocation, + # which for large tool lists triggers long garbage-collection pauses on the event loop. + inference_params: dict = filter_exceptions_from_params( # pyright: ignore[reportMissingTypeArgument] # mutable-ok: popped and split below, untyped like the deep copy it replaces + optional_params + ) supported_converse_params: Final = list(AmazonConverseConfig.__annotations__.keys()) + ["top_k"] supported_tool_call_params: Final = ["tools", "tool_choice"] supported_config_params: Final = list(self.get_config_blocks().keys()) diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index b8eb5858fda..744af50ef39 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -426,6 +426,48 @@ def test_bedrock_empty_text_block_is_removed_from_converse_request() -> None: assert request["messages"] == [{"role": "user", "content": [{"text": "Hey, how's it going?"}]}] +class _DeepCopyCountingStr(str): + deep_copies = 0 + + def __deepcopy__(self, memo: dict) -> "_DeepCopyCountingStr": + type(self).deep_copies += 1 + return self + + +def test_bedrock_converse_request_copies_params_once() -> None: + description: Final = _DeepCopyCountingStr("City to look up") + tools: Final = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string", "description": description}}, + "required": ["city"], + }, + }, + } + ] + snapshot: Final = copy.deepcopy(tools) + _DeepCopyCountingStr.deep_copies = 0 + + request: Final = AmazonConverseConfig()._transform_request( + model="us.anthropic.claude-sonnet-4-5-20250929-v1:0", + messages=[{"role": "user", "content": "Weather in Paris?"}], + optional_params={"maxTokens": 10, "tools": tools}, + litellm_params={}, + headers={}, + ) + + # one copy, by the tool converter that edits the schema; none for the params as a whole + assert _DeepCopyCountingStr.deep_copies == 1 + tool_spec: Final = request["toolConfig"]["tools"][0]["toolSpec"] + assert tool_spec["name"] == "get_weather" + assert tool_spec["inputSchema"]["json"]["properties"]["city"]["description"] == "City to look up" + assert tools == snapshot + + @pytest.mark.asyncio async def test_bedrock_max_completion_tokens_become_converse_max_tokens() -> None: requests: Final[list[httpx.Request]] = []