fix(openai): bridge gpt-5.6+ tools to /v1/responses without reasoning_effort

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Krrish Dholakia 2026-07-20 22:59:00 +00:00
parent 583ddaf199
commit 5369fe101a
4 changed files with 136 additions and 6 deletions

View file

@ -102,18 +102,33 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
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.<minor> 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.

View file

@ -1022,7 +1022,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 issue #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 (
@ -1030,8 +1034,13 @@ 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/", "")

View file

@ -155,6 +155,44 @@ class TestOpenAIGPT5ConfigIsModelGpt54PlusModel:
), 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
# ---------------------------------------------------------------------------

View file

@ -898,6 +898,74 @@ def test_responses_api_bridge_check_gpt_5_tools_without_summary_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 (explicit-effort path)."""
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.
Guards against widening the gpt-5.6 tools-only bridge back onto older families,
which is the behavior the maintainer regression tests above pin down.
"""
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"
@patch("litellm.completion_extras.responses_api_bridge.completion")
def test_gpt_5_4_responses_bridge_preserves_reasoning_summary_dict(
mock_responses_completion,