mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
commit
3f965660a1
6 changed files with 223 additions and 6 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue