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:
commit
5833d3eadd
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user