fix(openai): route reasoningSummary on gpt-5.4+ chat without tools to Responses API

- Extend responses_api_bridge_check when reasoning_effort + summary aliases
  (including nested extra_body) without tools
- Merge summary into reasoning_effort for responses bridge; helpers in utils
- Strip summary aliases in GPT-5 chat mapping when not bridged
- Tests for bridge + merge behavior

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-05-11 10:42:19 +05:30
parent 0af33fbe70
commit 157d81368f
No known key found for this signature in database
4 changed files with 156 additions and 8 deletions

View file

@ -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"}

View file

@ -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,

View file

@ -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

View file

@ -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