Merge pull request #41342 from BerriAI/litellm_backport_param_leaks_rc_1_102_0

fix(responses): backport request-param leak fixes to rc/1.102.0 (#41018, #41141, #41144)
This commit is contained in:
yuneng-jiang 2026-09-15 18:06:20 -07:00 • committed by GitHub
commit 3f965660a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 223 additions and 6 deletions

View file

@ -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(

View file

@ -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

View file

@ -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",

View file

@ -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"

View file

@ -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

View file

@ -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.