From 4ffb145435043c302780935c102f80d05e79409e Mon Sep 17 00:00:00 2001 From: mateo Date: Sat, 31 Oct 2026 23:03:03 +0000 Subject: [PATCH] fix(openai): take the early Responses route decision on the request minus additional_drop_params Filter the keys named in additional_drop_params out of the top level and extra_body before the hoisted bridge check so a dropped reasoning_effort cannot move a chat-only custom endpoint onto /responses Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/main.py | 8 +++- litellm/responses/bridge_check.py | 18 +++++++++ ...ions_params_openai_responses_web_search.py | 38 +++++++++++++++++++ tests/unit/responses/test_bridge_check.py | 35 +++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/unit/responses/test_bridge_check.py diff --git a/litellm/main.py b/litellm/main.py index a9f0f8bfccc..fa7705231ca 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1315,6 +1315,7 @@ from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the o _resolve_openai_api_base as _resolve_openai_api_base, # pyright: ignore[reportPrivateUsage] # noqa: PLC0414 # re-export of moved helper ) from litellm.responses.bridge_check import ( + bridge_check_request_params, bridge_reasoning_effort, keep_registered_mode, str_keyed_mapping, @@ -6045,13 +6046,16 @@ def completion( if dynamic_api_key is not None: api_key = dynamic_api_key if responses_api_model_info.get("mode") != "responses": + route_request_params: Final = bridge_check_request_params(routing_kwargs) responses_api_model_info, model = responses_api_bridge_check( model=model, custom_llm_provider=custom_llm_provider, web_search_options=web_search_options, tools=tools, - reasoning_effort=bridge_reasoning_effort(resolved_reasoning_effort, routing_kwargs.get("extra_body")), - reasoning_summary=peek_reasoning_summary_aliases(routing_kwargs), + reasoning_effort=bridge_reasoning_effort( + resolved_reasoning_effort, route_request_params.get("extra_body") + ), + reasoning_summary=peek_reasoning_summary_aliases(route_request_params), api_base=api_base, deployment_model_info=deployment_model_info, ) diff --git a/litellm/responses/bridge_check.py b/litellm/responses/bridge_check.py index 826de8cfdc9..39a283c72d8 100644 --- a/litellm/responses/bridge_check.py +++ b/litellm/responses/bridge_check.py @@ -102,6 +102,24 @@ def str_keyed_mapping(value: object) -> Mapping[str, object] | None: ) +def bridge_check_request_params(request_params: Mapping[str, object]) -> Mapping[str, object]: + """The request as ``get_optional_params`` will hand it to the provider: every key named in + ``additional_drop_params`` removed at the top level and inside ``extra_body``, so the Responses route + decision taken before that call reads the same effort and summary alias the parsed params will carry.""" + raw_dropped: Final = request_params.get("additional_drop_params") + dropped_params: Final = ( + cast(Sequence[object], raw_dropped) # cast-ok: isinstance only narrows to Sequence[Unknown] + if isinstance(raw_dropped, Sequence) and not isinstance(raw_dropped, str) + else () + ) + dropped: Final = frozenset(param for param in dropped_params if isinstance(param, str)) + kept: Final = {key: value for key, value in request_params.items() if key not in dropped} + extra_body: Final = str_keyed_mapping(kept.get("extra_body")) + if extra_body is None: + return kept + return {**kept, "extra_body": {key: value for key, value in extra_body.items() if key not in dropped}} + + def bridge_reasoning_effort( reasoning_effort: str | Mapping[str, object] | None, extra_body: object ) -> str | Mapping[str, object] | None: diff --git a/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py b/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py index bdcef8db036..068c1bd189b 100644 --- a/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py +++ b/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py @@ -5,6 +5,7 @@ import pytest from integration._support.client import Gateway from integration._support.provider import SharedProvider from integration.translation.case import TranslationTestCase +from integration.translation.chat_completions.bases.openai import GPT_5_6_SOL_TEST_CASE as GPT_5_6_SOL_CHAT_TEST_CASE from integration.translation.chat_completions.bases.openai_responses import GPT_5_6_SOL_TEST_CASE from integration.translation.runner import assert_translation @@ -78,10 +79,47 @@ GPT_5_6_SOL_LATE_BRIDGE_WEB_SEARCH_TEST_CASE: Final = replace( expected_litellm_response={**GPT_5_6_SOL_TEST_CASE.expected_litellm_response, "model": "openai/gpt-5.6-sol"}, ) +# The rig's deployment has a custom api_base, which the bridge treats as a chat-only endpoint unless the +# request names an effort. An effort the caller drops through additional_drop_params must not pick the +# Responses route, so this request stays on /chat/completions exactly as before the early route decision +GPT_5_6_SOL_DROPPED_EFFORT_STAYS_ON_CHAT_TEST_CASE: Final = replace( + GPT_5_6_SOL_CHAT_TEST_CASE, + scenario="dropped_extra_body_effort_stays_on_chat", + litellm_request={ + **GPT_5_6_SOL_CHAT_TEST_CASE.litellm_request, + "extra_body": {"reasoning_effort": "low"}, + "additional_drop_params": ["reasoning_effort"], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, + } + ], + }, + expected_provider_request={ + **GPT_5_6_SOL_CHAT_TEST_CASE.expected_provider_request, + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, + } + ], + }, +) + CASES: Final = ( GPT_5_6_SOL_WEB_SEARCH_TEST_CASE, GPT_5_6_SOL_WEB_SEARCH_USER_LOCATION_TEST_CASE, GPT_5_6_SOL_LATE_BRIDGE_WEB_SEARCH_TEST_CASE, + GPT_5_6_SOL_DROPPED_EFFORT_STAYS_ON_CHAT_TEST_CASE, ) diff --git a/tests/unit/responses/test_bridge_check.py b/tests/unit/responses/test_bridge_check.py new file mode 100644 index 00000000000..a0b09024aad --- /dev/null +++ b/tests/unit/responses/test_bridge_check.py @@ -0,0 +1,35 @@ +from litellm.responses.bridge_check import bridge_check_request_params + + +def test_a_dropped_effort_inside_extra_body_is_not_read_by_the_route_decision(): + request = { + "extra_body": {"reasoning_effort": "low", "reasoningSummary": "auto"}, + "additional_drop_params": ["reasoning_effort"], + "tools": [{"type": "function", "function": {"name": "get_weather"}}], + } + + params = bridge_check_request_params(request) + + assert params["extra_body"] == {"reasoningSummary": "auto"}, params + assert params["tools"] is request["tools"] + + +def test_a_dropped_top_level_alias_is_removed_too(): + params = bridge_check_request_params({"reasoningSummary": "auto", "additional_drop_params": ["reasoningSummary"]}) + + assert "reasoningSummary" not in params, params + + +def test_a_request_without_drops_is_returned_whole(): + request = {"extra_body": {"reasoning_effort": "low"}, "tools": []} + + params = bridge_check_request_params(request) + + assert params == request + assert params["extra_body"] == {"reasoning_effort": "low"} + + +def test_a_non_list_additional_drop_params_drops_nothing(): + request = {"extra_body": {"reasoning_effort": "low"}, "additional_drop_params": "reasoning_effort"} + + assert bridge_check_request_params(request)["extra_body"] == {"reasoning_effort": "low"}