From 4afb271ba3412a33fb185a0ff405e69195c8131a Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 14 Sep 2026 15:51:23 -0700 Subject: [PATCH 1/3] Merge pull request #41018 from BerriAI/litellm_fix_model_alias_map_leak fix(utils): keep litellm params out of provider request bodies (cherry picked from commit 1f46e584947fb259dc7272c996c2bbe4d2e09305) --- litellm/types/utils.py | 10 ++++++++++ tests/test_litellm/test_utils.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 00c55b35182..e35f3022f5d 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3754,6 +3754,16 @@ all_litellm_params = ( "model_file_id_mapping", "litellm_logging_obj", "litellm_call_id", + "completion_call_id", + "model_alias_map", + "custom_prompt_dict", + "stream_response", + "cost_per_query", + "ssl_verify", + "data_residency", + "async_call", + "aembedding", + "allm_passthrough_route", "_litellm_strip_stream_usage", "use_client", "id", diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index fc4207fb26b..174b42433d5 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -29,6 +29,7 @@ from litellm._logging import ( verbose_logger, ) from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.get_litellm_params import get_litellm_params from litellm.proxy.utils import is_valid_api_key from litellm.types.utils import ( CallTypes, @@ -5274,6 +5275,26 @@ def test_websearch_interception_control_fields_never_reach_the_provider(): assert set(WEBSEARCH_INTERNAL_CONTROL_FIELDS) <= set(all_litellm_params) +def test_get_litellm_params_keys_never_reach_the_provider(): + """Bridges (chat <-> Responses, agentic loop follow-ups) forward litellm_params as + `completion()` kwargs. Any key the param builder does not recognize is swept into + extra_body, and OpenAI rejects the call with `Unknown parameter: 'model_alias_map'`. + """ + litellm_param_keys = frozenset(get_litellm_params()) - {"drop_params"} + kwargs = { + "a_real_provider_specific_param": 1, + "model_alias_map": {"alias": "gpt-5.4"}, + **{key: "configured-value" for key in litellm_param_keys - {"model_alias_map"}}, + } + + non_default = get_non_default_completion_params(kwargs) + + assert non_default == {"a_real_provider_specific_param": 1}, ( + "litellm params leaked into the provider params: " + f"{sorted(set(non_default) - {'a_real_provider_specific_param'})}" + ) + + def test_bedrock_batch_params_never_reach_the_provider(): """A Bedrock managed-batch deployment carries aws_batch_role_arn / s3_* / bedrock_tags in its litellm_params, and the same deployment also serves chat. From 8a23af9bc65790fe453e0ef62b8af235711550c2 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 14 Sep 2026 15:52:31 -0700 Subject: [PATCH 2/3] Merge pull request #41141 from BerriAI/litellm_lit7694_forwarded_headers_body_leak fix(openai): keep extra_headers out of the chat request body on the httpx handler path (cherry picked from commit de55e22899eca90d2b5c0c5a5311fe8741030371) --- litellm/main.py | 6 ++-- tests/test_litellm/test_main.py | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 17edafcdfca..f6f4ec1bf63 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2592,7 +2592,9 @@ def _complete_custom_openai( copilot_headers.update(extra_headers) extra_headers = copilot_headers - if extra_headers is not None: + use_base_llm_http_handler: Final = get_secret_bool("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER") + + if extra_headers is not None and not use_base_llm_http_handler: optional_params["extra_headers"] = extra_headers if litellm.enable_preview_features and metadata is not None: # [PREVIEW] allow metadata to be passed to OPENAI @@ -2609,8 +2611,6 @@ def _complete_custom_openai( optional_params[k] = v ## COMPLETION CALL - use_base_llm_http_handler: Final = get_secret_bool("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER") - try: if use_base_llm_http_handler: response = base_llm_http_handler.completion( diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 4f7a51eb531..3dccb2b35bf 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -3795,3 +3795,58 @@ def test_azure_ai_speech_on_a_foundry_host_uses_the_azure_openai_deployment_rout assert route.called assert response.content == b"mp3-bytes" + + +FORWARDED_CLIENT_HEADERS: Final = {"x-forwarded-for": "10.0.0.1", "x-amzn-trace-id": "Root=1-lit7694"} + + +def _chat_completion_json() -> Mapping[str, object]: + return { + "id": "chatcmpl-lit7694", + "object": "chat.completion", + "created": 1, + "model": "gpt-5.4", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + +def _chat_completion_sse() -> bytes: + chunk: Final = { + "id": "chatcmpl-lit7694", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-5.4", + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + } + return f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode() + + +@pytest.mark.parametrize("stream", [False, True]) +def test_bridged_responses_with_openai_http_handler_keeps_forwarded_headers_out_of_the_body( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, stream: bool +): + monkeypatch.setenv("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", "true") + route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock( + return_value=httpx.Response(200, content=_chat_completion_sse(), headers={"content-type": "text/event-stream"}) + if stream + else httpx.Response(200, json=_chat_completion_json()) + ) + + response: Final = litellm.responses( + model="openai/gpt-5.4", + input="Reply with the single word ok", + stream=stream, + use_chat_completions_api=True, + headers=dict(FORWARDED_CLIENT_HEADERS), + api_key="sk-test", + ) + if stream: + list(response) + + assert route.called + request: Final = route.calls.last.request + body: Final = json.loads(request.content) + assert "extra_headers" not in body + assert body["model"] == "gpt-5.4" + assert {k: request.headers[k] for k in FORWARDED_CLIENT_HEADERS} == FORWARDED_CLIENT_HEADERS From 262978eebc803472602c504b5960ec9f66879245 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 15 Sep 2026 11:32:38 -0700 Subject: [PATCH 3/3] Merge pull request #41144 from BerriAI/litellm_responses_bridge_filters_unknown_params fix(responses): filter bridged kwargs like the native Responses path (cherry picked from commit 41b5d47c71cf82c5979a927011c38cc040462d0c) --- litellm/responses/main.py | 28 ++++- .../test_responses_api_bridge_flag.py | 109 ++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index a68cd02e61b..93bc41f3646 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1,9 +1,10 @@ import asyncio import contextvars -from collections.abc import Coroutine, Generator, Iterable, Mapping +from collections.abc import Coroutine, Generator, Iterable, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass from functools import partial +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Optional, TypeAlias, cast import httpx @@ -15,7 +16,7 @@ from litellm._logging import verbose_logger from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, ) -from litellm.constants import request_timeout +from litellm.constants import DEFAULT_CHAT_COMPLETION_PARAM_VALUES, request_timeout from litellm.integrations.anthropic_cache_control_hook import CARRY_UNMATCHED_MESSAGE_POINTS from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.core_helpers import normalize_drop_params @@ -52,6 +53,7 @@ from litellm.llms.openai.data_residency import infer_openai_data_residency from litellm.secret_managers.main import get_secret_str from litellm.types.responses.main import * from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import all_litellm_params from litellm.utils import ( ProviderConfigManager, client, @@ -408,6 +410,25 @@ def _bridges_to_chat_completions( return responses_api_provider_config is None or use_chat_completions_api is True +def _bridge_kwargs( + kwargs: Mapping[str, object], + responses_api_provider_config: BaseResponsesAPIConfig | None, + allowed_openai_params: Sequence[str] | None, +) -> Mapping[str, object]: + if responses_api_provider_config is None: + return kwargs + forwarded_keys: Final = frozenset( + ( + *litellm.OPENAI_CHAT_COMPLETION_PARAMS, + *DEFAULT_CHAT_COMPLETION_PARAM_VALUES, + *all_litellm_params, + *GenericLiteLLMParams.model_fields, + *(allowed_openai_params or ()), + ) + ) + return MappingProxyType({key: value for key, value in kwargs.items() if key in forwarded_keys}) + + _ResponsesCompatibilityFailure: TypeAlias = Literal["encrypted_task_unsupported"] @@ -1281,6 +1302,7 @@ def responses( return _file_search_dispatch if _bridges_to_chat_completions(responses_api_provider_config, use_chat_completions_api): + bridge_kwargs: Final = _bridge_kwargs(kwargs, responses_api_provider_config, allowed_openai_params) return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, @@ -1292,7 +1314,7 @@ def responses( extra_body=extra_body, timeout=timeout if timeout is not None else request_timeout, allowed_openai_params=allowed_openai_params, - **kwargs, + **bridge_kwargs, ) # Get optional parameters for the responses API diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index ed44a9f4545..2f64cc8debc 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -6,12 +6,14 @@ Includes file_search emulation: the flag must be forwarded on inner aresponses calls so routed requests do not hit a custom api_base /v1/responses endpoint. """ +import json from importlib import import_module from typing import Final from unittest.mock import MagicMock, patch import httpx import pytest +import respx import litellm from litellm.llms.custom_httpx.http_handler import HTTPHandler @@ -189,6 +191,113 @@ class TestUseResponsesApiBridgeFlag: "reasoning_effort" ] + @pytest.mark.parametrize( + ("model", "upstream_url", "use_chat_completions_api", "allowed_openai_params", "expected_chat_template_kwargs"), + [ + pytest.param( + "openai/my-custom-model", + "https://api.openai.com/v1/chat/completions", + True, + None, + None, + id="native-config-drops-unknown-param", + ), + pytest.param( + "openai/my-custom-model", + "https://api.openai.com/v1/chat/completions", + True, + ["chat_template_kwargs"], + {"thinking": True}, + id="native-config-keeps-allowed-param", + ), + pytest.param( + "together_ai/my-custom-model", + "https://api.together.ai/v1/chat/completions", + False, + None, + {"thinking": True}, + id="no-native-config-keeps-passthrough", + ), + ], + ) + def test_bridge_forwards_same_params_as_native_dispatch( + self, + model: str, + upstream_url: str, + use_chat_completions_api: bool, + allowed_openai_params: list[str] | None, + expected_chat_template_kwargs: dict[str, bool] | None, + respx_mock: respx.MockRouter, + ): + upstream: Final = respx_mock.post(upstream_url).mock( + return_value=httpx.Response( + status_code=200, + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "my-custom-model", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, + }, + ) + ) + + response: Final = litellm.responses( + model=model, + input="Hello", + use_chat_completions_api=use_chat_completions_api, + allowed_openai_params=allowed_openai_params, + chat_template_kwargs={"thinking": True}, + drop_params=True, + api_key="fake-provider-api-key", + num_retries=0, + ) + + assert upstream.call_count == 1 + request_body: Final = json.loads(upstream.calls[0].request.read()) + assert request_body.get("chat_template_kwargs") == expected_chat_template_kwargs + assert request_body["messages"] == [{"role": "user", "content": "Hello"}] + assert response.output[0].content[0].text == "Answer" + + def test_bridge_keeps_deployment_credentials_while_dropping_unknown_params(self, respx_mock: respx.MockRouter): + upstream: Final = respx_mock.post( + "https://example-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions", + params={"api-version": "2024-10-21"}, + ).mock( + return_value=httpx.Response( + status_code=200, + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "my-deployment", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, + }, + ) + ) + + litellm.responses( + model="azure/my-deployment", + input="Hello", + use_chat_completions_api=True, + api_base="https://example-resource.openai.azure.com", + api_version="2024-10-21", + azure_ad_token="fake-azure-ad-token", + chat_template_kwargs={"thinking": True}, + num_retries=0, + ) + + assert upstream.call_count == 1 + request: Final = upstream.calls[0].request + assert request.headers["authorization"] == "Bearer fake-azure-ad-token" + assert "chat_template_kwargs" not in json.loads(request.read()) + @patch.object(import_module("litellm.responses.file_search.emulated_handler"), "_call_aresponses") @patch.object( import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"