diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index ca4f000fc42..dca93855724 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -487,8 +487,9 @@ def strip_internal_params_from_chat_request_body(data: dict) -> dict: Strip variant for the chat-completion boundary that preserves keys consumed inside `transform_request` (currently `cache_control_injection_points`, which `AmazonConverseConfig` reads to append a `cachePoint` to Bedrock tool_config). - Splat-style transforms still call `strip_internal_params_from_request_body` - themselves before serialization, so the param never leaks into the wire body. + The shared chat handler re-applies `strip_internal_params_from_request_body` + to the body returned by `transform_request`, so splat-style transforms that + splat `**optional_params` into the wire body cannot leak the preserved key. """ if not isinstance(data, dict): return data diff --git a/litellm/llms/custom_httpx/aiohttp_handler.py b/litellm/llms/custom_httpx/aiohttp_handler.py index 992da949874..ceae2877fc4 100644 --- a/litellm/llms/custom_httpx/aiohttp_handler.py +++ b/litellm/llms/custom_httpx/aiohttp_handler.py @@ -10,6 +10,7 @@ import litellm.types import litellm.types.utils from litellm.litellm_core_utils.core_helpers import ( strip_internal_params_from_chat_request_body, + strip_internal_params_from_request_body, ) from litellm.llms.base_llm.chat.transformation import BaseConfig from litellm.llms.base_llm.image_variations.transformation import ( @@ -376,6 +377,7 @@ class BaseLLMAIOHTTPHandler: litellm_params=litellm_params, headers=headers, ) + data = strip_internal_params_from_request_body(data) ## LOGGING logging_obj.pre_call( diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ff65fc0c165..c3831a31ec2 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -454,6 +454,7 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, headers=headers, ) + data = strip_internal_params_from_request_body(data) if extra_body is not None: data = {**data, **extra_body} diff --git a/litellm/types/internal_params.py b/litellm/types/internal_params.py index 3ad7a9451d0..51287cb684f 100644 --- a/litellm/types/internal_params.py +++ b/litellm/types/internal_params.py @@ -31,8 +31,9 @@ LITELLM_CHAT_REQUEST_BODY_STRIP_PARAMS: frozenset[str] = ( boundary. `cache_control_injection_points` is consumed inside `transform_request` by `AmazonConverseConfig` (it appends a `cachePoint` to the Bedrock tool list for ``location: "tool_config"``), so it must reach the transform on the -``converse_like/`` and other shared HTTP handler routes. Splat-style transforms -that never consume it strip the full set themselves before serialization.""" +``converse_like/`` and other shared HTTP handler routes. The shared HTTP handler +re-applies the full strip to the body returned by `transform_request`, so +splat-style transforms cannot leak the preserved key into the wire payload.""" MCP_INTERNAL_PARAMS: frozenset[str] = frozenset( { diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 9afc1379891..13a21e26d03 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -137,6 +137,65 @@ def test_chat_boundary_preserves_cache_control_injection_points(): ), f"{param.value} leaked past the chat-completion strip" +def test_chat_boundary_strips_internal_params_from_splat_body(): + """Regression: `cache_control_injection_points` is preserved in `optional_params` + so AmazonConverseConfig can consume it, but splat-style transforms (OpenAI, + Anthropic, OpenAI-compatible) build the wire body with `**optional_params` and + never pop it. The shared handler must therefore strip internal params from the + body returned by `transform_request` to prevent extraneous-field 400s on + strict-schema providers.""" + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + from litellm.types.internal_params import LiteLLMInternalParam + from litellm.types.utils import ModelResponse + + handler = BaseLLMHTTPHandler() + + captured: dict = {} + + provider_config = Mock() + provider_config.should_fake_stream.return_value = False + provider_config.validate_environment.return_value = {} + provider_config.get_complete_url.return_value = "https://example.invalid/chat" + + def _splat_transform_request(*, model, messages, optional_params, **_): + return {"model": model, "messages": messages, **optional_params} + + provider_config.transform_request.side_effect = _splat_transform_request + + def _capture_sign_request(*, request_data, headers, **_): + captured["body"] = request_data + raise RuntimeError("stop after capture") + + provider_config.sign_request.side_effect = _capture_sign_request + + seeded = {param.value: "internal" for param in LiteLLMInternalParam} + seeded["cache_control_injection_points"] = [{"location": "tool_config"}] + seeded["temperature"] = 0.5 + + with pytest.raises(RuntimeError, match="stop after capture"): + handler.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + api_base=None, + custom_llm_provider="openai", + model_response=ModelResponse(), + encoding=None, + logging_obj=Mock(), + optional_params=seeded, + timeout=10.0, + litellm_params={}, + acompletion=False, + provider_config=provider_config, + ) + + body = captured["body"] + for param in LiteLLMInternalParam: + assert ( + param.value not in body + ), f"{param.value} leaked into the wire body past the splat transform" + assert body["temperature"] == 0.5 + + def test_prepare_fake_stream_request(): # Initialize the BaseLLMHTTPHandler handler = BaseLLMHTTPHandler()