Merge pull request #23576 from Sameerlite/litellm_gpt54-tools-reasoning-routing

Litellm gpt54 tools reasoning routing
This commit is contained in:
yuneng-jiang 2026-03-13 11:38:50 -07:00 committed by GitHub
commit 57397e0d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 134 additions and 37 deletions

View file

@ -188,11 +188,9 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
) or optional_params.get("reasoning_effort")
effective_effort = _get_effort_level(raw_reasoning_effort)
# Normalize to string for Chat Completions API when dict has only "effort".
# Preserve full dict (e.g. {"effort": "high", "summary": "detailed"}) for Responses API.
if isinstance(raw_reasoning_effort, dict) and set(
raw_reasoning_effort.keys()
) <= {"effort"}:
# Normalize dict reasoning_effort to string for Chat Completions API.
# Example: {"effort": "high", "summary": "detailed"} -> "high"
if isinstance(raw_reasoning_effort, dict) and "effort" in raw_reasoning_effort:
normalized = _normalize_reasoning_effort_for_chat_completion(
raw_reasoning_effort
)
@ -223,16 +221,6 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
"max_tokens"
)
# gpt-5.4: reasoning_effort + tools is only supported in the Responses API
# Drop reasoning_effort when tools are present in chat completions
if self.is_model_gpt_5_4_model(model):
has_tools = bool(
non_default_params.get("tools") or optional_params.get("tools")
)
if has_tools and effective_effort is not None:
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
# gpt-5.1/5.2 support logprobs, top_p, top_logprobs only when reasoning_effort="none"
supports_none = self._supports_reasoning_effort_level(model, "none")
if supports_none:

View file

@ -99,6 +99,7 @@ from litellm.llms.base_llm.base_model_iterator import (
from litellm.llms.bedrock.common_utils import BedrockModelInfo
from litellm.llms.cohere.common_utils import CohereModelInfo
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.vertex_ai.common_utils import (
VertexAIModelRoute,
@ -934,6 +935,8 @@ def responses_api_bridge_check(
model: str,
custom_llm_provider: str,
web_search_options: Optional[OpenAIWebSearchOptions] = None,
tools: Optional[List[Any]] = None,
reasoning_effort: Optional[Any] = None,
) -> Tuple[dict, str]:
model_info: Dict[str, Any] = {}
try:
@ -951,6 +954,17 @@ def responses_api_bridge_check(
if web_search_options is not None and custom_llm_provider == "xai":
model_info["mode"] = "responses"
model = model.replace("responses/", "")
# OpenAI gpt-5.4+ chat-completions calls with both tools + reasoning_effort
# must be bridged to Responses API.
if (
custom_llm_provider == "openai"
and OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model)
and tools
and reasoning_effort is not None
):
model_info["mode"] = "responses"
model = model.replace("responses/", "")
except Exception as e:
verbose_logger.debug("Error getting model info: {}".format(e))
@ -1596,11 +1610,17 @@ def completion( # type: ignore # noqa: PLR0915
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
tools=tools,
reasoning_effort=reasoning_effort,
)
if model_info.get("mode") == "responses":
from litellm.completion_extras import responses_api_bridge
if isinstance(reasoning_effort, dict) and "summary" in reasoning_effort:
optional_params = dict(optional_params)
optional_params["reasoning_effort"] = reasoning_effort
return responses_api_bridge.completion(
model=model,
messages=messages,

View file

@ -9,6 +9,7 @@ import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.openai.chat.gpt_transformation import (
OpenAIChatCompletionStreamingHandler,
OpenAIGPTConfig,

View file

@ -324,19 +324,15 @@ def test_gpt5_4_pro_allows_reasoning_effort_xhigh(config: OpenAIConfig):
assert params["reasoning_effort"] == "xhigh"
def test_gpt5_preserves_reasoning_effort_dict_with_summary(config: OpenAIConfig):
"""Dict with summary/generate_summary is preserved for Responses API.
Config/deployments may pass Responses API format: {'effort': 'high', 'summary': 'detailed'}.
We preserve the full dict so it reaches the Responses API transformation.
"""
def test_gpt5_normalizes_reasoning_effort_dict_with_summary(config: OpenAIConfig):
"""Dict with summary/generate_summary is normalized for chat completions."""
params = config.map_openai_params(
non_default_params={"reasoning_effort": {"effort": "high", "summary": "detailed"}},
optional_params={},
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "high", "summary": "detailed"}
assert params["reasoning_effort"] == "high"
def test_gpt5_xhigh_dict_triggers_validation(config: OpenAIConfig):
@ -362,14 +358,14 @@ def test_gpt5_xhigh_dict_accepted_for_supported_model(config: OpenAIConfig):
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "xhigh", "summary": "detailed"}
assert params["reasoning_effort"] == "xhigh"
def test_gpt5_none_dict_with_tools_no_tool_drop(config: OpenAIConfig):
"""Dict with effort='none' and tools: reasoning_effort dropped for gpt-5.4.
"""Dict with effort='none' and tools: no tool-drop, reasoning_effort preserved.
gpt-5.4 drops all reasoning_effort when tools are present,
since that combination is only supported in the Responses API.
Regression: effective_effort='none' must be used for tool-drop guard so
{"effort": "none", "summary": "detailed"} is not incorrectly treated as non-none.
"""
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
params = config.map_openai_params(
@ -378,7 +374,7 @@ def test_gpt5_none_dict_with_tools_no_tool_drop(config: OpenAIConfig):
model="gpt-5.4",
drop_params=False,
)
assert "reasoning_effort" not in params
assert params["reasoning_effort"] == "none"
assert params["tools"] == tools
@ -398,24 +394,28 @@ def test_gpt5_none_dict_with_sampling_params_allowed(config: OpenAIConfig):
model="gpt-5.1",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "none", "summary": "detailed"}
assert params["reasoning_effort"] == "none"
assert params["logprobs"] is True
assert params["top_p"] == 0.9
def test_gpt5_preserves_reasoning_effort_dict_with_summary_from_optional_params(config: OpenAIConfig):
"""reasoning_effort dict with summary in optional_params is preserved."""
def test_gpt5_normalizes_reasoning_effort_dict_with_summary_from_optional_params(config: OpenAIConfig):
"""reasoning_effort dict with summary in optional_params is normalized."""
params = config.map_openai_params(
non_default_params={},
optional_params={"reasoning_effort": {"effort": "medium", "summary": "detailed"}},
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "medium", "summary": "detailed"}
assert params["reasoning_effort"] == "medium"
def test_gpt5_4_drops_reasoning_effort_when_user_sends_reasoning_and_tools(config: OpenAIConfig):
"""gpt-5.4: function calls not supported with reasoning_effort != 'none'. Drop reasoning_effort."""
def test_gpt5_4_passes_through_reasoning_effort_with_tools(config: OpenAIConfig):
"""gpt-5.4 with tools + reasoning_effort: map_openai_params passes through both.
Routing to Responses API (which supports tools + reasoning) happens at completion()
level (responses_api_bridge_check). See test_responses_api_bridge_check_gpt_5_4_tools_plus_reasoning_routes_to_responses.
"""
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
params = config.map_openai_params(
non_default_params={"reasoning_effort": "high", "tools": tools},
@ -423,7 +423,7 @@ def test_gpt5_4_drops_reasoning_effort_when_user_sends_reasoning_and_tools(confi
model="gpt-5.4",
drop_params=False,
)
assert "reasoning_effort" not in params
assert params["reasoning_effort"] == "high"
assert params["tools"] == tools
@ -438,8 +438,8 @@ def test_gpt5_4_keeps_reasoning_effort_when_no_tools(config: OpenAIConfig):
assert params["reasoning_effort"] == "high"
def test_gpt5_4_drops_reasoning_effort_none_with_tools(config: OpenAIConfig):
"""reasoning_effort='none' is also dropped when tools are present for gpt-5.4."""
def test_gpt5_4_keeps_reasoning_effort_none_with_tools(config: OpenAIConfig):
"""reasoning_effort='none' is kept when tools are present."""
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
params = config.map_openai_params(
non_default_params={"reasoning_effort": "none", "tools": tools},
@ -447,7 +447,7 @@ def test_gpt5_4_drops_reasoning_effort_none_with_tools(config: OpenAIConfig):
model="gpt-5.4",
drop_params=False,
)
assert "reasoning_effort" not in params
assert params["reasoning_effort"] == "none"
assert params["tools"] == tools

View file

@ -627,6 +627,94 @@ def test_responses_api_bridge_check_gpt_5_4_pro():
)
def test_responses_api_bridge_check_gpt_5_4_tools_plus_reasoning_routes_to_responses():
"""gpt-5.4 with both tools and reasoning_effort should route to Responses API."""
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=[{"type": "function", "function": {"name": "get_capital"}}],
reasoning_effort="xhigh",
)
assert model == "gpt-5.4"
assert model_info.get("mode") == "responses"
def test_responses_api_bridge_check_gpt_5_5_tools_plus_reasoning_routes_to_responses():
"""gpt-5.5+ with both tools and reasoning_effort should route to Responses API."""
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.5-pro",
custom_llm_provider="openai",
tools=[{"type": "function", "function": {"name": "get_capital"}}],
reasoning_effort="xhigh",
)
assert model == "gpt-5.5-pro"
assert model_info.get("mode") == "responses"
def test_responses_api_bridge_check_gpt_5_4_tools_without_reasoning_stays_chat():
"""gpt-5.4 with tools only should not be force-routed to Responses API."""
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=[{"type": "function", "function": {"name": "get_capital"}}],
reasoning_effort=None,
)
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,
):
"""When routed to Responses, preserve reasoning_effort summary dict."""
mock_responses_completion.return_value = MagicMock()
import litellm
litellm.completion(
model="gpt-5.4",
messages=[{"role": "user", "content": "What is the capital of France?"}],
tools=[
{
"type": "function",
"function": {
"name": "get_capital",
"description": "Get the capital of a country",
"parameters": {
"type": "object",
"properties": {"country": {"type": "string"}},
},
},
}
],
reasoning_effort={"effort": "xhigh", "summary": "detailed"},
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": "xhigh",
"summary": "detailed",
}
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