fix(azure_ai): bridge gpt-5.4+ function tools with reasoning to the Foundry Responses API

This commit is contained in:
mateo-berri 2026-09-19 16:17:39 -07:00
parent ac281507fd
commit d7b1318e55
2 changed files with 159 additions and 6 deletions

View file

@ -100,6 +100,7 @@ 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.base_llm import BaseConfig, BaseImageGenerationConfig
from litellm.llms.base_llm.base_model_iterator import (
convert_model_response_to_streaming,
@ -1118,16 +1119,23 @@ 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. Resolve the effective 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, 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.
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 resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base)
custom_llm_provider == "azure"
or on_foundry_openai_endpoint
or resolved_api_base == ""
or _is_openai_backed_api_base(resolved_api_base)
)
if (
custom_llm_provider in ("openai", "azure")
(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)

View file

@ -1308,6 +1308,71 @@ def test_responses_api_bridge_check_azure_with_api_base_and_unset_effort_routes(
assert model_info.get("mode") == "responses"
_FOUNDRY_API_BASE: Final = "https://myproject.services.ai.azure.com"
_FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_weather"}},)
@pytest.mark.parametrize(
"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"),
],
)
def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_to_responses(api_base, reasoning_effort):
"""
An azure_ai deployment of a gpt-5.4+ model on a Foundry OpenAI v1 host is the same Azure OpenAI
backend the azure provider bridges: its chat surface rejects function tools whenever reasoning is
on, and for gpt-6-astra it rejects reasoning_effort "none" too, so the Responses route on the same
endpoint is the only way to serve the request. Regression guard: the gate used to bridge only the
openai and azure providers, so these requests died at Foundry's /models/chat/completions.
"""
from litellm.main import responses_api_bridge_check
model_info, model = responses_api_bridge_check(
model="gpt-6-astra",
custom_llm_provider="azure_ai",
tools=_FOUNDRY_FUNCTION_TOOL,
reasoning_effort=reasoning_effort,
api_base=api_base,
)
assert model == "gpt-6-astra"
assert model_info.get("mode") == "responses"
@pytest.mark.parametrize(
"model_name, api_base, reasoning_effort",
[
pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-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"),
],
)
def test_responses_api_bridge_check_azure_ai_without_foundry_responses_route_stays_chat(
model_name, api_base, reasoning_effort
):
"""
The azure_ai bridge fires only where the Foundry Responses config is selectable: a serverless
host, a non-OpenAI model, and claude-on-Foundry have no Responses route to bridge to, and an
explicit reasoning_effort "none" keeps the request chat-servable on the same terms as azure.
"""
from litellm.main import responses_api_bridge_check
model_info, model = responses_api_bridge_check(
model=model_name,
custom_llm_provider="azure_ai",
tools=_FOUNDRY_FUNCTION_TOOL,
reasoning_effort=reasoning_effort,
api_base=api_base,
)
assert model == model_name
assert model_info.get("mode") != "responses"
def test_responses_api_bridge_check_older_gpt_5_tools_without_reasoning_stays_chat():
"""Pre-5.4 GPT-5 names keep the old boundary: tools alone never bridge."""
from litellm.main import responses_api_bridge_check
@ -1488,6 +1553,86 @@ def test_responses_bridge_preserves_reasoning_effort_with_drop_params(
assert request_body["reasoning"] == {"effort": "high"}
_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY: Final = {
"id": "resp_foundry",
"object": "response",
"created_at": 1789852145,
"status": "completed",
"model": "gpt-6-astra",
"output": [
{
"id": "fc_1",
"type": "function_call",
"status": "completed",
"arguments": '{"city":"Paris"}',
"call_id": "call_1",
"name": "get_weather",
}
],
"parallel_tool_calls": True,
"usage": {
"input_tokens": 53,
"output_tokens": 18,
"total_tokens": 71,
"output_tokens_details": {"reasoning_tokens": 0},
},
"error": None,
"incomplete_details": None,
"instructions": None,
"metadata": {},
"temperature": 1.0,
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"max_output_tokens": 200,
"previous_response_id": None,
"reasoning": {"effort": "medium", "summary": None},
"truncation": "disabled",
"user": None,
}
def test_completion_bridges_azure_ai_foundry_gpt_5_4_plus_function_tools_to_responses(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
"""
The bridged azure_ai call is posted to <base>/openai/v1/responses with the tool in Responses
shape and Foundry's api-key header, never to <base>/models/chat/completions, and comes back as a
chat completion carrying the function call.
"""
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
responses_route: Final = respx_mock.post(f"{_FOUNDRY_API_BASE}/openai/v1/responses").respond(
json=_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY
)
response: Final = litellm.completion(
model="azure_ai/gpt-6-astra",
messages=[{"role": "user", "content": "What is the weather in Paris? Use the tool."}],
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
],
max_tokens=200,
api_base=_FOUNDRY_API_BASE,
api_key="fake-foundry-key",
)
assert [str(call.request.url) for call in respx_mock.calls] == [f"{_FOUNDRY_API_BASE}/openai/v1/responses"]
request: Final = responses_route.calls[0].request
request_body: Final = json.loads(request.content)
assert request_body["tools"][0]["type"] == "function"
assert request_body["tools"][0]["name"] == "get_weather"
assert request.headers["api-key"] == "fake-foundry-key"
assert response.choices[0].finish_reason == "tool_calls"
assert response.choices[0].message.tool_calls[0].function.name == "get_weather"
@pytest.mark.parametrize(
"model, model_info, expected_model_param, expected_base_model_param",
[