mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
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:
parent
2bb603ab4c
commit
e833bdccde
5 changed files with 125 additions and 37 deletions
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue