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.
This commit is contained in:
mateo-berri 2026-09-19 18:19:02 -07:00
parent 2bb603ab4c
commit e833bdccde
5 changed files with 125 additions and 37 deletions

View file

@ -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."""

View file

@ -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:

View file

@ -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 <region>.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/", "")

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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"),