mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
parent
0af33fbe70
commit
157d81368f
4 changed files with 156 additions and 8 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue