diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 944cf58df1c..a2441adbaac 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1028,7 +1028,10 @@ def responses( local_vars["reasoning"] = _mapped # Get ResponsesAPIOptionalRequestParams with only valid parameters response_api_optional_params: ResponsesAPIOptionalRequestParams = ( - ResponsesAPIRequestUtils.get_requested_response_api_optional_param(local_vars) + ResponsesAPIRequestUtils.get_requested_response_api_optional_param( + local_vars, + allowed_openai_params=allowed_openai_params, + ) ) _file_search_dispatch = _responses_try_dispatch_emulated_file_search( diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 429ddeef36a..3a92663e2f1 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -7,6 +7,7 @@ from typing import ( List, Mapping, Optional, + Sequence, Type, Union, cast, @@ -185,19 +186,26 @@ class ResponsesAPIRequestUtils: @staticmethod def get_requested_response_api_optional_param( params: Dict[str, Any], + allowed_openai_params: Sequence[str] | None = None, ) -> ResponsesAPIOptionalRequestParams: """ - Filter parameters to only include those defined in ResponsesAPIOptionalRequestParams. + Filter parameters to only include those defined in ResponsesAPIOptionalRequestParams, + plus any request/deployment level `allowed_openai_params` the caller opted into. Args: params: Dictionary of parameters to filter + allowed_openai_params: Extra request fields to keep even though they are not + part of the Responses API spec Returns: ResponsesAPIOptionalRequestParams instance with only the valid parameters """ + from litellm.types.utils import all_litellm_params from litellm.utils import PreProcessNonDefaultParams - valid_keys = get_type_hints(ResponsesAPIOptionalRequestParams).keys() + valid_keys = frozenset(get_type_hints(ResponsesAPIOptionalRequestParams)) | frozenset( + param for param in allowed_openai_params or () if param not in all_litellm_params + ) custom_llm_provider = params.pop("custom_llm_provider", None) special_params = params.pop("kwargs", {}) diff --git a/tests/test_litellm/responses/test_responses_api_request_body.py b/tests/test_litellm/responses/test_responses_api_request_body.py index 83b9c34636e..040eae7cadc 100644 --- a/tests/test_litellm/responses/test_responses_api_request_body.py +++ b/tests/test_litellm/responses/test_responses_api_request_body.py @@ -246,6 +246,84 @@ async def test_aresponses_keeps_include_obfuscation_in_stream_options(): assert request_body["stream_options"] == {"include_obfuscation": False} +@pytest.mark.asyncio +async def test_aresponses_allowed_openai_params_forwards_extension_param(): + """ + allowed_openai_params must preserve request fields that are not part of the + Responses API spec, so OpenAI-compatible upstreams that require them still + receive them. Regression test for https://github.com/BerriAI/litellm/issues/34926 + """ + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse( + _minimal_responses_api_payload("resp_allowed_params_test", "gpt-5.5"), 200 + ) + + await litellm.aresponses( + model="openai/gpt-5.5", + api_key="fake-api-key", + input="ping", + allowed_openai_params=["client_metadata"], + client_metadata={"probe": "keep-me"}, + ) + + mock_post.assert_called_once() + post_kwargs = mock_post.call_args.kwargs + request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"]) + assert request_body["client_metadata"] == {"probe": "keep-me"} + + +@pytest.mark.asyncio +async def test_aresponses_drops_extension_param_without_allowlist(): + """Without an allowlist, unknown extension params stay off the wire.""" + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse( + _minimal_responses_api_payload("resp_no_allowlist_test", "gpt-5.5"), 200 + ) + + await litellm.aresponses( + model="openai/gpt-5.5", + api_key="fake-api-key", + input="ping", + client_metadata={"probe": "keep-me"}, + ) + + mock_post.assert_called_once() + post_kwargs = mock_post.call_args.kwargs + request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"]) + assert "client_metadata" not in request_body + + +@pytest.mark.asyncio +async def test_aresponses_allowed_openai_params_cannot_leak_litellm_params(): + """allowed_openai_params must never promote internal litellm params (e.g. credentials) into the request body.""" + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse( + _minimal_responses_api_payload("resp_no_credential_leak_test", "gpt-5.5"), 200 + ) + + await litellm.aresponses( + model="openai/gpt-5.5", + api_key="fake-api-key", + input="ping", + allowed_openai_params=["api_key", "api_base"], + ) + + mock_post.assert_called_once() + post_kwargs = mock_post.call_args.kwargs + request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"]) + assert "api_key" not in request_body + assert "api_base" not in request_body + + @pytest.mark.asyncio async def test_aresponses_request_level_drop_params_drops_bedrock_mantle_service_tier( monkeypatch,