diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index f0a859deba04..a871275b362e 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -102,18 +102,33 @@ def is_model_gpt_5_4_model(cls, model: str) -> bool: return model_name.startswith("gpt-5.4") @classmethod - def is_model_gpt_5_4_plus_model(cls, model: str) -> bool: - """Check if the model is gpt-5.4 or newer (5.4, 5.5, 5.6, etc., including pro).""" + def _gpt_5_minor_version_at_least(cls, model: str, minimum: int) -> bool: + """Return True when a versioned gpt-5. name is at or above ``minimum``. + + Non-versioned names (e.g. ``gpt-5``, ``gpt-5-codex``) and unparseable names + return False. Named or pro variants such as ``gpt-5.6-sol`` and ``gpt-5.4-pro`` + are handled by taking the minor version before the first ``-``. + """ model_name = model.split("/")[-1] if not model_name.startswith("gpt-5."): return False try: version_str = model_name.replace("gpt-5.", "").split("-")[0] major = version_str.split(".")[0] - return int(major) >= 4 + return int(major) >= minimum except (ValueError, IndexError): return False + @classmethod + def is_model_gpt_5_4_plus_model(cls, model: str) -> bool: + """Check if the model is gpt-5.4 or newer (5.4, 5.5, 5.6, etc., including pro).""" + return cls._gpt_5_minor_version_at_least(model, 4) + + @classmethod + def is_model_gpt_5_6_plus_model(cls, model: str) -> bool: + """Check if the model is gpt-5.6 or newer (5.6, 5.7, etc., including named/pro variants).""" + return cls._gpt_5_minor_version_at_least(model, 6) + @classmethod def _supports_reasoning_effort_level(cls, model: str, level: str) -> bool: """Check if the model supports a specific reasoning_effort level. diff --git a/litellm/main.py b/litellm/main.py index c2cc01f6ecde..254c0296611b 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1023,7 +1023,11 @@ def responses_api_bridge_check( # ``reasoningSummary`` in ``extra_body``) must be bridged; Chat Completions rejects # those keys. # - # - gpt-5.4+: tools + reasoning_effort (original) or any reasoning-summary alias. + # - gpt-5.6+: tools alone bridge, even without reasoning_effort. OpenAI applies a + # default reasoning_effort server-side for this family, so tools on Chat + # Completions are rejected outright (see BerriAI/litellm#33221). + # - gpt-5.4/5.5: tools + reasoning_effort, or any reasoning-summary alias. Tools + # alone (no reasoning_effort) stay on Chat Completions. # - 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 ( @@ -1031,8 +1035,16 @@ def responses_api_bridge_check( 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)) + and ( + ( + reasoning_effort is not None + and ( + reasoning_summary is not None + or (OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) and tools) + ) + ) + or (OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model) and tools) + ) ): model_info["mode"] = "responses" model = model.replace("responses/", "") diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py index 1095819c98c7..14bc15948c3c 100644 --- a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -155,6 +155,44 @@ def test_pre_5_4_models_are_not_classified_as_5_4_plus(self, model: str): ), f"Expected '{model}' NOT to be classified as gpt-5.4-or-newer" +# Models that are gpt-5.6 or newer. main.py bridges tools-only (no reasoning_effort) +# requests to /v1/responses for exactly this set, so gpt-5.4/5.5 must land on the +# False side while the gpt-5.6 family (including named variants) lands on True. +GPT5_6_PLUS_MODELS = [ + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "openai/gpt-5.6-sol", +] + +GPT5_PRE_5_6_MODELS = [ + "gpt-5", + "gpt-5.1", + "gpt-5.3", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-4o", +] + + +class TestOpenAIGPT5ConfigIsModelGpt56PlusModel: + + @pytest.mark.parametrize("model", GPT5_6_PLUS_MODELS) + def test_gpt5_6_plus_models_are_classified_as_5_6_plus(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_5_6_plus_model( + model + ), f"Expected '{model}' to be classified as gpt-5.6-or-newer" + + @pytest.mark.parametrize("model", GPT5_PRE_5_6_MODELS) + def test_pre_5_6_models_are_not_classified_as_5_6_plus(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_5_6_plus_model( + model + ), f"Expected '{model}' NOT to be classified as gpt-5.6-or-newer" + + # --------------------------------------------------------------------------- # AzureOpenAIGPT5Config # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 4611aafa3c1c..1764d8c61fa3 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -844,6 +844,72 @@ def test_responses_api_bridge_check_gpt_5_4_tools_without_reasoning_stays_chat() assert model_info.get("mode") != "responses" +@pytest.mark.parametrize( + "model", + ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra"], +) +@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure"]) +def test_responses_api_bridge_check_gpt_5_6_tools_without_reasoning_routes_to_responses( + model, custom_llm_provider +): + """gpt-5.6+ with tools but no reasoning_effort must bridge to Responses API. + + Regression test for https://github.com/BerriAI/litellm/issues/33221 + OpenAI applies a default reasoning_effort server-side for the gpt-5.6 family, + so function tools on /v1/chat/completions are rejected with a 400. + """ + 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, returned_model = responses_api_bridge_check( + model=model, + custom_llm_provider=custom_llm_provider, + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort=None, + ) + + assert returned_model == model + assert model_info.get("mode") == "responses" + + +def test_responses_api_bridge_check_gpt_5_6_tools_plus_reasoning_routes_to_responses(): + """gpt-5.6 with tools and explicit reasoning_effort still bridges.""" + 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.6-sol", + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort="high", + ) + + assert model == "gpt-5.6-sol" + assert model_info.get("mode") == "responses" + + +@pytest.mark.parametrize("model", ["gpt-5.4", "gpt-5.5", "gpt-5.5-pro"]) +def test_responses_api_bridge_check_gpt_5_4_and_5_5_tools_without_reasoning_stay_chat( + model, +): + """gpt-5.4/5.5 tools-only (no reasoning_effort) must NOT bridge; only 5.6+ does.""" + 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, returned_model = responses_api_bridge_check( + model=model, + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort=None, + ) + + assert returned_model == model + 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