diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index 3f33d6183f3..bb5783011a3 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -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: diff --git a/litellm/main.py b/litellm/main.py index 569a9133d2f..722b4a7aaec 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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, diff --git a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py b/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py index 0f743b1a93b..81ffa1aa428 100644 --- a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py +++ b/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py @@ -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, diff --git a/tests/test_litellm/llms/openai/test_gpt5_transformation.py b/tests/test_litellm/llms/openai/test_gpt5_transformation.py index 7c731e4e00a..47ae3c44c9e 100644 --- a/tests/test_litellm/llms/openai/test_gpt5_transformation.py +++ b/tests/test_litellm/llms/openai/test_gpt5_transformation.py @@ -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 diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 3a43b1229de..6ac988b2c21 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -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