fix(responses): filter bridged kwargs like the native Responses path

A Responses request for a provider with a native Responses config that is served
through the chat-completions bridge (use_chat_completions_api or the
openai/chat_completions/ prefix) forwarded every raw kwarg, so a deployment-level
chat_template_kwargs reached OpenAI chat completions and got a 400. The bridge
now keeps only the keys a native dispatch would forward plus allowed_openai_params.
Providers with no native Responses config keep the passthrough

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-14 22:56:58 +00:00
parent 7fd541efb9
commit 04c003c098
2 changed files with 126 additions and 3 deletions

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,26 @@ 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]:
"""Drop the provider-specific kwargs a native Responses dispatch would never forward, unless explicitly allowed."""
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 +1303,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 +1315,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

@ -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,104 @@ class TestUseResponsesApiBridgeFlag:
"reasoning_effort"
]
@pytest.mark.parametrize(
("provider_config", "allowed_openai_params", "expected_chat_template_kwargs"),
[
pytest.param(litellm.OpenAIResponsesAPIConfig(), None, None, id="native-config-drops-unknown-param"),
pytest.param(
litellm.OpenAIResponsesAPIConfig(),
["chat_template_kwargs"],
{"thinking": True},
id="native-config-keeps-allowed-param",
),
pytest.param(None, None, {"thinking": True}, id="no-native-config-keeps-passthrough"),
],
)
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
def test_bridge_forwards_same_params_as_native_dispatch(
self,
mock_get_config,
provider_config,
allowed_openai_params,
expected_chat_template_kwargs,
respx_mock: respx.MockRouter,
):
"""A deployment-supplied provider-specific kwarg (``chat_template_kwargs``) reaches the
provider through the bridge only when the native Responses path would forward it too:
never for a provider with a native config, unless the caller allowed it explicitly, and
always for a provider without one, whose only Responses path is the bridge."""
mock_get_config.return_value = provider_config
upstream: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").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="openai/my-custom-model",
input="Hello",
use_chat_completions_api=True,
allowed_openai_params=allowed_openai_params,
chat_template_kwargs={"thinking": True},
drop_params=True,
api_key="fake-openai-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):
"""Azure has a native Responses config, so its bridged request drops the unknown param but still
authenticates with the deployment credential, which the native path reads from the same kwargs."""
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"