diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index 4e34d10b187..eb7bb384a7e 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -3,7 +3,11 @@ from typing import Optional, Union import litellm -from litellm.utils import _is_explicitly_disabled_factory, _supports_factory +from litellm.utils import ( + _is_explicitly_disabled_factory, + _supports_factory, + strip_reasoning_summary_aliases_from_openai_completion_params, +) from .gpt_transformation import OpenAIGPTConfig @@ -191,6 +195,16 @@ class OpenAIGPT5Config(OpenAIGPTConfig): if param not in non_supported_params ] + @staticmethod + def _strip_reasoning_summary_aliases_for_chat_completions( + non_default_params: dict, + optional_params: dict, + ) -> None: + """Remove Responses-style reasoning summary keys; invalid on Chat Completions.""" + strip_reasoning_summary_aliases_from_openai_completion_params( + non_default_params, optional_params + ) + def map_openai_params( self, non_default_params: dict, @@ -210,6 +224,12 @@ class OpenAIGPT5Config(OpenAIGPTConfig): drop_params=drop_params, ) + # AI SDK / Responses-style aliases; never valid on Chat Completions when not + # bridged to Responses API (see main.responses_api_bridge_check). + self._strip_reasoning_summary_aliases_for_chat_completions( + non_default_params, optional_params + ) + # Get raw reasoning_effort and effective effort level for all guards. # Use effective_effort (extracted string) for xhigh validation, "none" checks, and # tool/sampling guards — dict inputs like {"effort": "none", "summary": "detailed"} diff --git a/litellm/main.py b/litellm/main.py index 051a82fdd19..6a3e5db2057 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1,3 +1,5 @@ +# LiteLLM main module: public completion, embedding, streaming, and moderation entrypoints. +# # +-----------------------------------------------+ # | | # | Give Feedback / Get Help | @@ -59,7 +61,13 @@ import litellm from litellm import client # Other utils are imported directly to avoid circular imports -from litellm.utils import exception_type, get_litellm_params, get_optional_params +from litellm.utils import ( + exception_type, + get_litellm_params, + get_optional_params, + peek_reasoning_summary_aliases, + strip_reasoning_summary_aliases_from_optional_params, +) # Logging is imported lazily when needed to avoid loading litellm_logging at import time if TYPE_CHECKING: @@ -946,6 +954,7 @@ def responses_api_bridge_check( web_search_options: Optional[OpenAIWebSearchOptions] = None, tools: Optional[List[Any]] = None, reasoning_effort: Optional[Any] = None, + reasoning_summary: Optional[Any] = None, ) -> Tuple[dict, str]: model_info: Dict[str, Any] = {} @@ -982,13 +991,16 @@ def responses_api_bridge_check( mode = "responses" model_info["mode"] = mode - # OpenAI/Azure gpt-5.4+ chat-completions calls with both tools + reasoning_effort - # must be bridged to Responses API. + # OpenAI/Azure gpt-5.4+ chat-completions calls that need Responses-only fields + # (e.g. reasoning summary) must be bridged. SDKs send ``reasoningSummary`` / + # ``reasoning_summary`` alongside ``reasoning_effort``; Chat Completions rejects + # those keys, so route when tools+reasoning_effort (original case) or when a + # reasoning summary is requested without tools. if ( custom_llm_provider in ("openai", "azure") and OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) - and tools and reasoning_effort is not None + and (tools or reasoning_summary is not None) and model_info.get("mode") != "responses" ): model_info["mode"] = "responses" @@ -1634,8 +1646,10 @@ def completion( # type: ignore # noqa: PLR0915 ## RESPONSES API BRIDGE LOGIC ## - check if model has 'mode: responses' in litellm.model_cost map # Only run the second bridge check if the first one didn't already # detect responses mode (e.g. via the "responses/" prefix). The second - # check handles cases like gpt-5.4+ with tools+reasoning_effort that - # the first (early) check doesn't cover. + # check handles cases like gpt-5.4+ with tools+reasoning_effort or + # reasoningSummary/reasoning_summary without tools (AI SDK) that the first + # (early) check doesn't cover. + _reasoning_summary_for_bridge = peek_reasoning_summary_aliases(optional_params) if responses_api_model_info.get("mode") != "responses": responses_api_model_info, model = responses_api_bridge_check( model=model, @@ -1643,14 +1657,27 @@ def completion( # type: ignore # noqa: PLR0915 web_search_options=web_search_options, tools=tools, reasoning_effort=reasoning_effort, + reasoning_summary=_reasoning_summary_for_bridge, ) if responses_api_model_info.get("mode") == "responses": from litellm.completion_extras import responses_api_bridge + optional_params, rs_val = ( + strip_reasoning_summary_aliases_from_optional_params(optional_params) + ) + if isinstance(reasoning_effort, dict) and "summary" in reasoning_effort: - optional_params = dict(optional_params) optional_params["reasoning_effort"] = reasoning_effort + elif rs_val is not None: + eff = optional_params.get("reasoning_effort", reasoning_effort) + if isinstance(eff, dict): + optional_params["reasoning_effort"] = {**eff, "summary": rs_val} + elif eff is not None: + optional_params["reasoning_effort"] = { + "effort": eff, + "summary": rs_val, + } return responses_api_bridge.completion( model=model, diff --git a/litellm/utils.py b/litellm/utils.py index 019fbc2add8..d473446b180 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1,3 +1,5 @@ +"""Utility helpers for LiteLLM core request handling and provider support.""" + # from __future__ import annotations must be the first non-comment statement from __future__ import annotations @@ -9490,6 +9492,60 @@ def get_non_default_completion_params(kwargs: dict) -> dict: return non_default_params +def peek_reasoning_summary_aliases(optional_params: dict) -> Optional[Any]: + """Read AI-SDK-style reasoning summary from optional_params or nested extra_body.""" + rs = optional_params.get("reasoningSummary") or optional_params.get( + "reasoning_summary" + ) + if rs is not None: + return rs + extra_body = optional_params.get("extra_body") + if isinstance(extra_body, dict): + return extra_body.get("reasoningSummary") or extra_body.get("reasoning_summary") + return None + + +def strip_reasoning_summary_aliases_from_optional_params( + optional_params: dict, +) -> Tuple[dict, Optional[Any]]: + """Copy optional_params; remove reasoningSummary aliases from top-level and extra_body.""" + op = dict(optional_params) + rs_val = op.pop("reasoningSummary", None) + if rs_val is None: + rs_val = op.pop("reasoning_summary", None) + eb = op.get("extra_body") + if isinstance(eb, dict): + eb = dict(eb) + if rs_val is None: + rs_val = eb.pop("reasoningSummary", None) or eb.pop( + "reasoning_summary", None + ) + else: + eb.pop("reasoningSummary", None) + eb.pop("reasoning_summary", None) + if eb: + op["extra_body"] = eb + else: + op.pop("extra_body", None) + return op, rs_val + + +def strip_reasoning_summary_aliases_from_openai_completion_params( + non_default_params: dict, + optional_params: dict, +) -> None: + """Drop AI-SDK reasoning summary keys from chat completion param dicts (in-place). + + These aliases are not valid on OpenAI Chat Completions and may appear on + ``non_default_params`` or ``optional_params`` (including nested ``extra_body``). + """ + non_default_params.pop("reasoningSummary", None) + non_default_params.pop("reasoning_summary", None) + stripped, _ = strip_reasoning_summary_aliases_from_optional_params(optional_params) + optional_params.clear() + optional_params.update(stripped) + + def get_non_default_transcription_params(kwargs: dict) -> dict: from litellm.constants import OPENAI_TRANSCRIPTION_PARAMS diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 4358d0dc193..b4c2d0b9df2 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -757,6 +757,24 @@ def test_responses_api_bridge_check_gpt_5_4_tools_without_reasoning_stays_chat() assert model_info.get("mode") != "responses" +def test_responses_api_bridge_check_gpt_5_4_reasoning_summary_without_tools_routes_to_responses(): + """gpt-5.4+ with reasoning_effort + reasoningSummary but no tools should bridge (AI SDK).""" + from litellm.main import responses_api_bridge_check + + with patch("litellm.main._get_model_info_helper") as mock_get_model_info: + mock_get_model_info.return_value = {"max_tokens": 128000} + model_info, model = responses_api_bridge_check( + model="gpt-5.4", + custom_llm_provider="openai", + tools=None, + reasoning_effort="medium", + reasoning_summary="auto", + ) + + assert model == "gpt-5.4" + assert model_info.get("mode") == "responses" + + @patch("litellm.completion_extras.responses_api_bridge.completion") def test_gpt_5_4_responses_bridge_preserves_reasoning_summary_dict( mock_responses_completion, @@ -794,6 +812,33 @@ def test_gpt_5_4_responses_bridge_preserves_reasoning_summary_dict( } +@patch("litellm.completion_extras.responses_api_bridge.completion") +def test_gpt_5_4_responses_bridge_merges_reasoning_summary_kwarg_without_tools( + mock_responses_completion, +): + """reasoningSummary without tools should route and merge into reasoning_effort dict.""" + mock_responses_completion.return_value = MagicMock() + + import litellm + + litellm.completion( + model="gpt-5.4", + messages=[{"role": "user", "content": "ok"}], + reasoning_effort="medium", + reasoningSummary="auto", + api_key="fake-key", + ) + + assert mock_responses_completion.called is True + optional_params = mock_responses_completion.call_args.kwargs["optional_params"] + assert optional_params["reasoning_effort"] == { + "effort": "medium", + "summary": "auto", + } + assert "reasoningSummary" not in optional_params + assert "reasoning_summary" not in optional_params + + def test_responses_api_bridge_check_handles_exception(): """Test that responses_api_bridge_check handles exceptions and still processes responses/ models.""" from litellm.main import responses_api_bridge_check