Merge pull request #27618 from BerriAI/litellm_reasoning_summary_chat_bridge

fix(openai): route reasoningSummary for gpt-5.4+ chat without tools to Responses API
This commit is contained in:
Sameer Kankute 2026-05-12 00:23:51 +05:30 committed by GitHub
commit 5833d3eadd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 334 additions and 26 deletions

View File

@ -3,7 +3,10 @@
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,
)
from .gpt_transformation import OpenAIGPTConfig

View File

@ -61,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:
@ -948,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] = {}
@ -984,14 +991,23 @@ 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 chat-completions that need Responses-only fields (e.g.
# ``reasoningSummary`` in ``extra_body``) must be bridged; Chat Completions rejects
# those keys.
#
# - gpt-5.4+: tools + reasoning_effort (original) or any reasoning-summary alias.
# - Older GPT-5 names (e.g. ``gpt-5``, ``gpt-5.1``): bridge only when a reasoning
# summary alias is present with ``reasoning_effort`` (tools alone stay on chat).
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 model_info.get("mode") != "responses"
and OpenAIGPT5Config.is_model_gpt_5_model(model)
and not OpenAIGPT5Config.is_model_gpt_5_search_model(model)
and reasoning_effort is not None
and (
reasoning_summary is not None
or (OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) and tools)
)
):
model_info["mode"] = "responses"
model = model.replace("responses/", "")
@ -1636,8 +1652,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,
@ -1645,14 +1663,29 @@ 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,
}
else:
optional_params["reasoning_effort"] = {"summary": rs_val}
return responses_api_bridge.completion(
model=model,
@ -1671,6 +1704,16 @@ def completion( # type: ignore # noqa: PLR0915
encoding=_get_encoding(),
stream=stream,
)
elif (
custom_llm_provider == "openai"
and OpenAIGPT5Config.is_model_gpt_5_model(model)
) or (
custom_llm_provider == "azure"
and litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model)
):
optional_params, _ = strip_reasoning_summary_aliases_from_optional_params(
optional_params
)
if custom_llm_provider == "azure":
# azure configs

View File

@ -9492,6 +9492,49 @@ 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.
Uses key membership (not ``or`` chains) so falsy values like ``""`` are not skipped.
"""
if "reasoningSummary" in optional_params:
return optional_params["reasoningSummary"]
if "reasoning_summary" in optional_params:
return optional_params["reasoning_summary"]
extra_body = optional_params.get("extra_body")
if isinstance(extra_body, dict):
if "reasoningSummary" in extra_body:
return extra_body["reasoningSummary"]
if "reasoning_summary" in extra_body:
return extra_body["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)
snake_rs_val = op.pop("reasoning_summary", None)
if rs_val is None:
rs_val = snake_rs_val
eb = op.get("extra_body")
if isinstance(eb, dict):
eb = dict(eb)
eb_rs_val = eb.pop("reasoningSummary", None)
eb_snake_rs_val = eb.pop("reasoning_summary", None)
if rs_val is None:
rs_val = eb_rs_val
if rs_val is None:
rs_val = eb_snake_rs_val
if eb:
op["extra_body"] = eb
else:
op.pop("extra_body", None)
return op, rs_val
def get_non_default_transcription_params(kwargs: dict) -> dict:
from litellm.constants import OPENAI_TRANSCRIPTION_PARAMS

View File

@ -94,27 +94,33 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
@patch("litellm.llms.github_copilot.authenticator.Authenticator.get_api_key")
@patch("litellm.main.openai_chat_completions.completion")
@patch("litellm.llms.openai.openai.OpenAIChatCompletion.completion")
def test_completion_github_copilot_mock_response(mock_completion, mock_get_api_key):
def test_completion_github_copilot_mock_response(
mock_class_completion, mock_instance_completion, mock_get_api_key, monkeypatch
):
"""Test the completion function with GitHub Copilot provider."""
# Mock the API key return value
# Force chat path through the patched openai_chat_completions instance even if
# a previous test left EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER set in the env.
monkeypatch.delenv("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", raising=False)
mock_api_key = "gh.test-key-123456789"
mock_get_api_key.return_value = mock_api_key
# Mock completion response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Hello, I'm GitHub Copilot!"
mock_completion.return_value = mock_response
# Patch both the class method and the live module-level instance to survive
# conftest module reloads that can swap which class object is in use.
mock_class_completion.return_value = mock_response
mock_instance_completion.return_value = mock_response
# Test non-streaming completion
messages = [
{"role": "system", "content": "You're GitHub Copilot, an AI assistant."},
{"role": "user", "content": "Hello, who are you?"},
]
# Create a properly formatted headers dictionary
headers = {
"editor-version": "Neovim/0.9.0",
"Copilot-Integration-Id": "vscode-chat",
@ -128,19 +134,16 @@ def test_completion_github_copilot_mock_response(mock_completion, mock_get_api_k
assert response is not None
# Verify the get_api_key call was made (can be called multiple times)
assert mock_get_api_key.call_count >= 1
# Verify the completion call was made with the expected params
mock_completion.assert_called_once()
args, kwargs = mock_completion.call_args
# Exactly one of the two patched targets should have been used.
invoked = [m for m in (mock_class_completion, mock_instance_completion) if m.called]
assert len(invoked) == 1
invoked[0].assert_called_once()
_, kwargs = invoked[0].call_args
# Check that the proper authorization header is set
assert "headers" in kwargs
# Check that the model name is correctly formatted
assert (
kwargs.get("model") == "gpt-4"
) # Model name should be without provider prefix
assert kwargs.get("model") == "gpt-4"
assert kwargs.get("messages") == messages

View File

@ -1,10 +1,15 @@
import pytest
import litellm
import litellm.main as litellm_main
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.openai.openai import OpenAIConfig
from litellm.utils import _is_explicitly_disabled_factory
from litellm.utils import (
_is_explicitly_disabled_factory,
peek_reasoning_summary_aliases,
strip_reasoning_summary_aliases_from_optional_params,
)
@pytest.fixture()
@ -1007,6 +1012,76 @@ def test_gpt5_search_drops_unsupported_params(config: OpenAIConfig):
assert "tools" not in params
def test_gpt5_chat_strips_reasoning_summary_aliases_after_bridge_check(
monkeypatch: pytest.MonkeyPatch,
):
"""Non-bridged GPT-5 chat calls strip Responses-only reasoning summary aliases."""
captured_kwargs = {}
def fake_openai_completion(**kwargs):
captured_kwargs.update(kwargs)
return {}
monkeypatch.setattr(
litellm_main.openai_chat_completions,
"completion",
fake_openai_completion,
)
litellm.completion(
model="gpt-5",
messages=[{"role": "user", "content": "ok"}],
reasoningSummary="auto",
extra_body={"reasoning_summary": "ignored", "metadata": "ok"},
api_key="fake-key",
)
optional_params = captured_kwargs["optional_params"]
assert "reasoningSummary" not in optional_params
assert "reasoning_summary" not in optional_params
assert optional_params["extra_body"] == {"metadata": "ok"}
def test_reasoning_summary_alias_helpers_preserve_falsy_and_strip_all_aliases():
optional_params = {"reasoningSummary": False, "reasoning_summary": "ignored"}
assert peek_reasoning_summary_aliases(optional_params) is False
stripped, rs_val = strip_reasoning_summary_aliases_from_optional_params(
optional_params
)
assert rs_val is False
assert stripped == {}
optional_params = {
"extra_body": {"reasoningSummary": False, "reasoning_summary": "ignored"}
}
assert peek_reasoning_summary_aliases(optional_params) is False
stripped, rs_val = strip_reasoning_summary_aliases_from_optional_params(
optional_params
)
assert rs_val is False
assert stripped == {}
optional_params = {
"extra_body": {
"reasoningSummary": "auto",
"reasoning_summary": "ignored",
"metadata": "ok",
}
}
assert peek_reasoning_summary_aliases(optional_params) == "auto"
stripped, rs_val = strip_reasoning_summary_aliases_from_optional_params(
optional_params
)
assert rs_val == "auto"
assert stripped == {"extra_body": {"metadata": "ok"}}
# GPT-5 unsupported params audit (validated via direct API calls)
def test_gpt5_rejects_params_unsupported_by_openai(config: OpenAIConfig):
"""Params that OpenAI rejects for all GPT-5 reasoning models."""

View File

@ -757,6 +757,60 @@ 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"
def test_responses_api_bridge_check_gpt_5_reasoning_summary_routes_to_responses():
"""Bare ``gpt-5`` with reasoning_effort + reasoningSummary should bridge (not 5.4+)."""
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",
custom_llm_provider="openai",
tools=None,
reasoning_effort="medium",
reasoning_summary="auto",
)
assert model == "gpt-5"
assert model_info.get("mode") == "responses"
def test_responses_api_bridge_check_gpt_5_tools_without_summary_stays_chat():
"""gpt-5 with tools + reasoning_effort but no summary should stay on chat."""
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",
custom_llm_provider="openai",
tools=[{"type": "function", "function": {"name": "get_capital"}}],
reasoning_effort="medium",
reasoning_summary=None,
)
assert model == "gpt-5"
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 +848,93 @@ 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
@patch("litellm.completion_extras.responses_api_bridge.completion")
def test_responses_bridge_preserves_reasoning_summary_without_effort(
mock_responses_completion,
):
"""Reasoning summary should survive responses routing even without effort."""
mock_responses_completion.return_value = MagicMock()
import litellm
with patch.object(litellm, "route_all_chat_openai_to_responses", True):
litellm.completion(
model="gpt-4o",
messages=[{"role": "user", "content": "ok"}],
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"] == {"summary": "auto"}
assert "reasoningSummary" not in optional_params
assert "reasoning_summary" not in optional_params
@patch("litellm.completion_extras.responses_api_bridge.completion")
def test_gpt_5_responses_bridge_tools_and_reasoning_summary(
mock_responses_completion,
):
"""Bare gpt-5 with tools + reasoningSummary should bridge (OpenCode-style)."""
mock_responses_completion.return_value = MagicMock()
import litellm
litellm.completion(
model="gpt-5",
messages=[{"role": "user", "content": "ok"}],
tools=[
{
"type": "function",
"function": {
"name": "apply_patch",
"parameters": {"type": "object", "properties": {}},
},
}
],
tool_choice="auto",
reasoning_effort="medium",
reasoningSummary="auto",
stream=True,
api_key="fake-key",
)
assert mock_responses_completion.called is True
optional_params = mock_responses_completion.call_args.kwargs["optional_params"]
assert optional_params.get("reasoning_effort") == {
"effort": "medium",
"summary": "auto",
}
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