From 0c83e831db6f420845d3deec0484da7ff717036b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 12 Sep 2026 17:49:30 -0700 Subject: [PATCH] fix(responses): carry the reasoning summary as an alias, not inside reasoning_effort The bridge probe asked `responses_api_bridge_check` with the summary read straight off the Responses object, but `litellm.completion` reads it from `optional_params` via `peek_reasoning_summary_aliases`, which the bridged request never populated. So gpt-5, gpt-5.1 and azure/gpt-5 answered "bridging" to the probe and "not bridging" for real, and the object still landed on Chat Completions, which only takes a string `reasoning_effort` is now always the effort string, and `summary` rides the `reasoning_summary` alias that main.py already reassembles into `{effort, summary}` on the bridged path. The alias is emitted only when the probe says the model bridges, so no chat provider ever sees it, and the probe is now asked with the exact params this transform emits --- .../transformation.py | 75 +++++++++------- .../test_litellm_completion_responses.py | 88 +++++++++++++++++-- 2 files changed, 125 insertions(+), 38 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index c7f8ab24de4..e8aacac9e67 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -117,6 +117,14 @@ class ResponsesToolChatForm: web_search_options: OpenAIWebSearchOptions | None +@dataclass(frozen=True, slots=True) +class ResponsesReasoningChatForm: + """The Responses ``reasoning`` object as the two params Chat Completions takes.""" + + effort: str | None + summary: str | None + + if TYPE_CHECKING: from openai.types.responses.response_apply_patch_tool_call import ( ResponseApplyPatchToolCall, @@ -315,14 +323,15 @@ class LiteLLMCompletionResponsesConfig: custom_llm_provider: str | None, tools: Sequence[ChatCompletionToolParam | OpenAIMcpServerTool] | None, web_search_options: OpenAIWebSearchOptions | None, - reasoning_param: Reasoning, + reasoning_effort: str | None, + reasoning_summary: str | None, api_base: str | None, ) -> bool: """ Whether ``litellm.completion`` will route this model back onto the Responses API. - Delegates to the same check ``litellm.completion`` itself runs, so the two cannot - disagree about which models take the Responses-shaped params. + Delegates to the same check ``litellm.completion`` itself runs, and is asked with the + params this transform is about to emit, so the two cannot reach different answers. """ from litellm.main import responses_api_bridge_check @@ -332,8 +341,8 @@ class LiteLLMCompletionResponsesConfig: custom_llm_provider=custom_llm_provider or "", web_search_options=web_search_options, tools=tools, - reasoning_effort=reasoning_param, - reasoning_summary=reasoning_param.get("summary"), + reasoning_effort=reasoning_effort, + reasoning_summary=reasoning_summary, api_base=api_base, ) except Exception as e: # noqa: BLE001 # a capability probe must never fail the request it probes for @@ -342,37 +351,44 @@ class LiteLLMCompletionResponsesConfig: return model_info.get("mode") == "responses" @staticmethod - def _transform_reasoning_to_reasoning_effort( + def _transform_reasoning_for_chat_completion( reasoning_param: Reasoning | str | None, model: str, custom_llm_provider: str | None, tools: Sequence[ChatCompletionToolParam | OpenAIMcpServerTool] | None = None, web_search_options: OpenAIWebSearchOptions | None = None, api_base: str | None = None, - ) -> Reasoning | str | None: + ) -> ResponsesReasoningChatForm: """ - Map the Responses ``reasoning`` param onto Chat Completions ``reasoning_effort``. + Split the Responses ``reasoning`` object into the params Chat Completions understands. - Chat Completions defines ``reasoning_effort`` as a string enum, and ``summary`` is a - Responses-only field with no Chat Completions equivalent. Sending the whole object to a - chat provider is rejected or silently discarded, which turns reasoning off. The object is - kept only when ``litellm.completion`` will bridge this model back onto the Responses API, - the one caller that can consume it. + ``reasoning_effort`` is a string enum there, so the object is never forwarded whole: a chat + provider either rejects it or silently drops it, and dropping it turns reasoning off while + still billing for the turn. ``summary`` has no chat equivalent, so it rides the + ``reasoning_summary`` alias, which ``litellm.completion`` reassembles into ``{effort, + summary}`` when it bridges the model back onto the Responses API, and is sent to nothing + else. """ if not reasoning_param: - return None + return ResponsesReasoningChatForm(effort=None, summary=None) if isinstance(reasoning_param, str): - return reasoning_param - if LiteLLMCompletionResponsesConfig._completion_bridges_back_to_responses_api( + return ResponsesReasoningChatForm(effort=reasoning_param, summary=None) + + effort: Final = reasoning_param.get("effort") + summary: Final = reasoning_param.get("summary") + if summary is None: + return ResponsesReasoningChatForm(effort=effort, summary=None) + + bridges_back: Final = LiteLLMCompletionResponsesConfig._completion_bridges_back_to_responses_api( model=model, custom_llm_provider=custom_llm_provider, tools=tools, web_search_options=web_search_options, - reasoning_param=reasoning_param, + reasoning_effort=effort, + reasoning_summary=summary, api_base=api_base, - ): - return reasoning_param - return reasoning_param.get("effort") + ) + return ResponsesReasoningChatForm(effort=effort, summary=summary if bridges_back else None) @staticmethod def transform_responses_api_request_to_chat_completion_request( @@ -404,15 +420,13 @@ class LiteLLMCompletionResponsesConfig: if text_param: response_format = LiteLLMCompletionResponsesConfig._transform_text_format_to_response_format(text_param) - reasoning_effort: Final[Reasoning | str | None] = ( - LiteLLMCompletionResponsesConfig._transform_reasoning_to_reasoning_effort( - reasoning_param=responses_api_request.get("reasoning"), - model=model, - custom_llm_provider=custom_llm_provider, - tools=tools, - web_search_options=web_search_options, - api_base=kwargs.get("api_base"), - ) + reasoning: Final = LiteLLMCompletionResponsesConfig._transform_reasoning_for_chat_completion( + reasoning_param=responses_api_request.get("reasoning"), + model=model, + custom_llm_provider=custom_llm_provider, + tools=tools, + web_search_options=web_search_options, + api_base=kwargs.get("api_base"), ) litellm_completion_request: dict = { @@ -436,7 +450,8 @@ class LiteLLMCompletionResponsesConfig: "service_tier": kwargs.get("service_tier"), "web_search_options": web_search_options, "response_format": response_format, - "reasoning_effort": reasoning_effort, + "reasoning_effort": reasoning.effort, + "reasoning_summary": reasoning.summary, "context_management": responses_api_request.get("context_management"), # litellm specific params "custom_llm_provider": custom_llm_provider, diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 8e76ee315b7..3c78bbf79d7 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2555,22 +2555,94 @@ class TestToolTransformation: assert result["reasoning_effort"] == "medium" - def test_responses_mode_model_keeps_the_whole_reasoning_object(self): + @pytest.mark.parametrize( + "model, custom_llm_provider", + [ + ("gpt-5.4-pro", "azure_ai"), + ("gpt-5", "openai"), + ("gpt-5.1", "openai"), + ("gpt-5", "azure"), + ], + ) + def test_bridged_model_carries_the_summary_as_an_alias(self, model, custom_llm_provider): """ - The one consumer of the object form is ``litellm.completion`` bridging a ``mode: responses`` - model back onto the Responses API, which has no native Responses config of its own. That - path reassembles ``{effort, summary}``, so the object must survive for it. + ``summary`` reaches a bridged model through the ``reasoning_summary`` alias, never smuggled + inside ``reasoning_effort``. ``litellm.completion`` reads that alias back with + ``peek_reasoning_summary_aliases`` and reassembles ``{effort, summary}``, so the far end + gets the same object it always did while no chat provider ever sees a non-string effort. """ - responses_api_request = {"reasoning": {"effort": "medium", "summary": "auto"}} + result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( + model=model, + input="hi", + responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}}, + custom_llm_provider=custom_llm_provider, + ) + + assert result["reasoning_effort"] == "medium" + assert result["reasoning_summary"] == "auto" + + @pytest.mark.parametrize( + "model, custom_llm_provider", + [ + ("gpt-5", "openai"), + ("gpt-5.1", "openai"), + ("gpt-5", "azure"), + ], + ) + def test_gpt_5_summary_survives_the_bridge_it_claims_to_take(self, model, custom_llm_provider): + """ + Regression for the probe disagreeing with the real decision. The transform asked + ``responses_api_bridge_check`` with ``reasoning_summary`` taken straight off the Responses + object, but ``litellm.completion`` reads it from ``optional_params`` via + ``peek_reasoning_summary_aliases``, which the bridged request never populated. So these + models answered "bridging" to the probe and "not bridging" for real, and the object landed + on Chat Completions, which only takes a string. Emitting the alias makes the two agree. + """ + from litellm.main import responses_api_bridge_check + from litellm.utils import get_optional_params, peek_reasoning_summary_aliases + + result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( + model=model, + input="hi", + responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}}, + custom_llm_provider=custom_llm_provider, + ) + optional_params = get_optional_params( + model=model, + custom_llm_provider=custom_llm_provider, + reasoning_effort=result["reasoning_effort"], + reasoning_summary=result["reasoning_summary"], + ) + model_info, _ = responses_api_bridge_check( + model=model, + custom_llm_provider=custom_llm_provider, + reasoning_effort=result["reasoning_effort"], + reasoning_summary=peek_reasoning_summary_aliases(optional_params), + ) + + assert model_info.get("mode") == "responses" + + def test_a_failing_bridge_probe_falls_back_to_the_string_effort(self, monkeypatch): + """ + The probe is a capability question, so a model-info lookup blowing up must not fail the + request. It degrades to the chat-safe form: a string effort and no alias. + """ + import litellm.main + + def _boom(**_kwargs): + raise RuntimeError("model info unavailable") + + monkeypatch.setattr(litellm.main, "responses_api_bridge_check", _boom) result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( model="gpt-5.4-pro", input="hi", - responses_api_request=responses_api_request, + responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}}, custom_llm_provider="azure_ai", ) - assert result["reasoning_effort"] == {"effort": "medium", "summary": "auto"} + assert result["reasoning_effort"] == "medium" + assert "reasoning_summary" not in result @pytest.mark.parametrize( "reasoning, expected", @@ -2627,7 +2699,7 @@ class TestToolTransformation: {"reasoning_effort": bridged["reasoning_effort"]}, {}, model, True ) - assert mapped["thinking"] == expected_thinking + assert expected_thinking.items() <= mapped["thinking"].items() def test_bedrock_anthropic_responses_tools_yield_only_function_toolspec(self): """