From e833bdccdeb2e782b8482a612282027e224b35d2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:19:02 -0700 Subject: [PATCH] fix(azure_ai): bridge Foundry function-tool requests only where the chat surface rejects them Foundry's OpenAI v1 chat surface rejects function tools with an explicit reasoning_effort from gpt-5.6 on and with reasoning left on from gpt-6 on, while gpt-5.4, gpt-5.5 and unset-effort gpt-5.6 serve them. Key the azure_ai bridge on those measured boundaries instead of the azure provider's gpt-5.4+ rule so working chat traffic keeps its n, logprobs, seed and chatcmpl ids. --- litellm/llms/azure_ai/common_utils.py | 9 ++++ .../llms/openai/chat/gpt_5_transformation.py | 33 ++++++++---- litellm/main.py | 45 +++++++++------- .../llms/openai/test_is_model_gpt_5_model.py | 52 +++++++++++++++++++ tests/test_litellm/test_main.py | 23 +++++--- 5 files changed, 125 insertions(+), 37 deletions(-) diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index d5a05cb8ea5..cffe9049de6 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -6,6 +6,7 @@ from urllib.parse import urlparse import litellm from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues from litellm.types.router import GenericLiteLLMParams @@ -150,6 +151,14 @@ def azure_ai_supports_native_responses(model: str | None, api_base: str | None) return AzureFoundryModelInfo.get_azure_ai_route(model) == "default" +def foundry_chat_rejects_function_tools_while_reasoning( + model: str, reasoning_effort: str | Mapping[str, object] | None +) -> bool: + if reasoning_effort is None: + return OpenAIGPT5Config.is_model_gpt_6_plus_model(model) + return OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model) + + class AzureFoundryModelInfo(BaseLLMModelInfo): """Model info for Azure AI / Azure Foundry models.""" diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index 1b93df95341..d0e5ff01e71 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -1,5 +1,6 @@ """Support for OpenAI gpt-5 model family.""" +import re from typing import Final import litellm @@ -11,6 +12,8 @@ from litellm.utils import ( from .gpt_transformation import OpenAIGPTConfig +_GPT_SERIES_VERSION: Final = re.compile(r"^gpt-(\d+)(?:\.(\d+))?(?=[.-]|$)") + def _catalogue_declares_default_effort() -> bool: """Whether the loaded cost map carries default_reasoning_effort for ANY entry. @@ -112,20 +115,28 @@ class OpenAIGPT5Config(OpenAIGPTConfig): model_name: Final = model.split("/")[-1] return model_name.startswith("gpt-5.4") + @staticmethod + def _gpt_series_version(model: str) -> tuple[int, int] | None: + match: Final = _GPT_SERIES_VERSION.match(model.split("/")[-1]) + if match is None: + return None + return int(match.group(1)), int(match.group(2) or 0) + @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).""" - model_name: Final = model.split("/")[-1] - if model_name.startswith("gpt-6"): - return True - if not model_name.startswith("gpt-5."): - return False - try: - version_str: Final = model_name.replace("gpt-5.", "").split("-")[0] - major: Final = version_str.split(".")[0] - return int(major) >= 4 - except (ValueError, IndexError): - return False + version: Final = cls._gpt_series_version(model) + return version is not None and version >= (5, 4) + + @classmethod + def is_model_gpt_5_6_plus_model(cls, model: str) -> bool: + version: Final = cls._gpt_series_version(model) + return version is not None and version >= (5, 6) + + @classmethod + def is_model_gpt_6_plus_model(cls, model: str) -> bool: + version: Final = cls._gpt_series_version(model) + return version is not None and version >= (6, 0) @classmethod def _model_map_lookup_name(cls, model: str) -> str: diff --git a/litellm/main.py b/litellm/main.py index 6ab2fcd4b03..93b6c730d86 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -100,7 +100,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( from litellm.litellm_core_utils.request_timeout_resolver import ( get_configured_request_timeout, ) -from litellm.llms.azure_ai.common_utils import azure_ai_supports_native_responses +from litellm.llms.azure_ai.common_utils import ( + azure_ai_supports_native_responses, + foundry_chat_rejects_function_tools_while_reasoning, +) from litellm.llms.base_llm import BaseConfig, BaseImageGenerationConfig from litellm.llms.base_llm.base_model_iterator import ( convert_model_response_to_streaming, @@ -1107,6 +1110,10 @@ def responses_api_bridge_check( # provider with a custom api_base and gpt-5.4+ model names serve tools without # reasoning fine and have no /responses route, so they keep pre-existing # behavior (bridge only on an explicit reasoning_effort). + # - Azure AI Foundry's OpenAI v1 hosts (azure_ai provider) enforce it later in the series: + # an explicit effort with function tools is rejected from gpt-5.6 on, and the unset + # effort only from gpt-6 on (gpt-5.6 serves tools with reasoning silently off), so the + # azure_ai gate keys on those measured boundaries instead of gpt-5.4+. # - 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). has_function_tool: Final = any( @@ -1119,35 +1126,35 @@ def responses_api_bridge_check( reasoning_active = reasoning_effort != "none" # The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com # host (the default URL or a PrivateLink hostname such as .privatelink.api.openai.com) and - # by Azure OpenAI, whether reached through the azure provider or as a Foundry OpenAI v1 host through - # the azure_ai provider. Resolve the effective OpenAI base arg>global>env>default exactly as the chat - # handler does, so a custom base set via litellm.api_base or OPENAI_BASE_URL/OPENAI_API_BASE isn't - # misread as the default and bridged to a /responses route it lacks. A whitespace-only base - # collapses to the default too. + # by Azure OpenAI through the azure provider. Resolve the effective OpenAI base arg>global>env>default + # exactly as the chat handler does, so a custom base set via litellm.api_base or + # OPENAI_BASE_URL/OPENAI_API_BASE isn't misread as the default and bridged to a /responses route it + # lacks. A whitespace-only base collapses to the default too. resolved_api_base: Final = _resolve_openai_api_base(api_base).strip() on_foundry_openai_endpoint: Final = custom_llm_provider == "azure_ai" and azure_ai_supports_native_responses( model, api_base ) on_constraint_enforcing_endpoint: Final = ( - custom_llm_provider == "azure" - or on_foundry_openai_endpoint - or resolved_api_base == "" - or _is_openai_backed_api_base(resolved_api_base) + custom_llm_provider == "azure" or resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base) + ) + chat_rejects_function_tools: Final = ( + has_function_tool + and reasoning_active + and ( + foundry_chat_rejects_function_tools_while_reasoning(model, reasoning_effort) + if on_foundry_openai_endpoint + else ( + OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) + and (reasoning_effort is not None or on_constraint_enforcing_endpoint) + ) + ) ) if ( (custom_llm_provider in ("openai", "azure") or on_foundry_openai_endpoint) 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 has_function_tool - and reasoning_active - and (reasoning_effort is not None or on_constraint_enforcing_endpoint) - ) - ) + and ((reasoning_effort is not None and reasoning_summary is not None) or chat_rejects_function_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 107a1afb2c6..0bb8425d95e 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 @@ -159,6 +159,58 @@ class TestOpenAIGPT5ConfigIsModelGpt54PlusModel: ), f"Expected '{model}' NOT to be classified as gpt-5.4-or-newer" +GPT5_6_PLUS_MODELS = [ + "gpt-6-astra", + "openai/gpt-6-astra", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.10-preview", +] + +GPT5_PRE_5_6_MODELS = [ + "gpt-5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-4o", +] + +GPT6_PLUS_MODELS = [ + "gpt-6-astra", + "openai/gpt-6-astra", + "gpt-6", + "gpt-6.1-preview", +] + +GPT_PRE_6_MODELS = [ + "gpt-5.6-sol", + "gpt-5.5", + "gpt-5", + "gpt-4o", +] + + +class TestOpenAIGPT5ConfigSeriesBoundaries: + + @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) + + @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) + + @pytest.mark.parametrize("model", GPT6_PLUS_MODELS) + def test_gpt6_plus_models_are_classified_as_6_plus(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_6_plus_model(model) + + @pytest.mark.parametrize("model", GPT_PRE_6_MODELS) + def test_pre_6_models_are_not_classified_as_6_plus(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_6_plus_model(model) + + # --------------------------------------------------------------------------- # AzureOpenAIGPT5Config # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index ab09d242e98..c2b45aac488 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1313,25 +1313,29 @@ _FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_ @pytest.mark.parametrize( - "api_base, reasoning_effort", + "model_name, api_base, reasoning_effort", [ - pytest.param(_FOUNDRY_API_BASE, None, id="foundry-host-unset-effort"), - pytest.param(_FOUNDRY_API_BASE, "low", id="foundry-host-explicit-effort"), - pytest.param("https://myresource.openai.azure.com", None, id="azure-openai-host-unset-effort"), + pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, None, id="gpt-6-unset-effort"), + pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "low", id="gpt-6-explicit-effort"), + pytest.param("gpt-6-astra", "https://myresource.openai.azure.com", None, id="gpt-6-azure-openai-host"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "low", id="gpt-5.6-explicit-effort"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, {"effort": "high"}, id="gpt-5.6-explicit-effort-dict"), ], ) -def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_to_responses(api_base, reasoning_effort): +def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_responses( + model_name, api_base, reasoning_effort +): from litellm.main import responses_api_bridge_check model_info, model = responses_api_bridge_check( - model="gpt-6-astra", + model=model_name, custom_llm_provider="azure_ai", tools=_FOUNDRY_FUNCTION_TOOL, reasoning_effort=reasoning_effort, api_base=api_base, ) - assert model == "gpt-6-astra" + assert model == model_name assert model_info.get("mode") == "responses" @@ -1339,6 +1343,11 @@ def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_t "model_name, api_base, reasoning_effort", [ pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-stays-chat"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, None, id="gpt-5.6-unset-effort-stays-chat"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "none", id="gpt-5.6-explicit-none-stays-chat"), + pytest.param("gpt-5.5", _FOUNDRY_API_BASE, "high", id="gpt-5.5-explicit-effort-stays-chat"), + pytest.param("gpt-5.4-mini", _FOUNDRY_API_BASE, None, id="gpt-5.4-mini-unset-effort-stays-chat"), + pytest.param("gpt-5.4-mini", _FOUNDRY_API_BASE, "low", id="gpt-5.4-mini-explicit-effort-stays-chat"), pytest.param("gpt-6-astra", "https://myproject.models.ai.azure.com", None, id="serverless-host-stays-chat"), pytest.param("Mistral-large-2411", _FOUNDRY_API_BASE, None, id="non-gpt-5-model-stays-chat"), pytest.param("claude-opus-4-1", _FOUNDRY_API_BASE, None, id="claude-on-foundry-stays-chat"),