This commit is contained in:
Graham Primm 2026-08-27 18:09:30 -05:00 committed by GitHub
commit c9790a3ef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 348 additions and 0 deletions

View file

@ -1781,6 +1781,9 @@ if TYPE_CHECKING:
from .llms.azure.responses.transformation import (
AzureOpenAIResponsesAPIConfig as AzureOpenAIResponsesAPIConfig,
)
from .llms.azure_ai.responses.transformation import (
AzureAIResponsesAPIConfig as AzureAIResponsesAPIConfig,
)
from .llms.azure.responses.o_series_transformation import (
AzureOpenAIOSeriesResponsesAPIConfig as AzureOpenAIOSeriesResponsesAPIConfig,
)

View file

@ -233,6 +233,7 @@ LLM_CONFIG_NAMES: Final = (
"MistralConfig",
"OpenAIResponsesAPIConfig",
"AzureOpenAIResponsesAPIConfig",
"AzureAIResponsesAPIConfig",
"AzureOpenAIOSeriesResponsesAPIConfig",
"XAIResponsesAPIConfig",
"LiteLLMProxyResponsesAPIConfig",
@ -939,6 +940,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.azure.responses.transformation",
"AzureOpenAIResponsesAPIConfig",
),
"AzureAIResponsesAPIConfig": (
".llms.azure_ai.responses.transformation",
"AzureAIResponsesAPIConfig",
),
"AzureOpenAIOSeriesResponsesAPIConfig": (
".llms.azure.responses.o_series_transformation",
"AzureOpenAIOSeriesResponsesAPIConfig",

View file

@ -0,0 +1,156 @@
"""
Responses API transformation for the Azure AI (Azure AI Foundry) provider.
Azure AI Foundry exposes a native `/responses` endpoint for models that support
it. Without this config, `azure_ai` has no Responses API implementation, so
`litellm.responses(...)` (and `/chat/completions` requests bridged to the
Responses API via ``model_info: {mode: responses}``) never reach a real upstream
`/responses` endpoint and fall back to the completions-style bridge.
This inherits from ``AzureOpenAIResponsesAPIConfig`` so it reuses Azure's
Responses request handling (flattening function tools to the top level, filtering
the ``status`` field from reasoning input items, etc.) and overrides only the
Azure AI Foundry-specific URL construction and auth. Both Azure commercial
(`*.azure.com`) and Azure Government (`*.azure.us`) hosts are supported.
Refs:
- https://learn.microsoft.com/en-us/azure/foundry/foundry-models/how-to/generate-responses
- Azure Government domains: https://learn.microsoft.com/en-us/azure/azure-government/compare-azure-government-global-azure
"""
from typing import Optional
from urllib.parse import urlparse
import httpx
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import _add_path_to_api_base
# Azure AI Foundry / Azure OpenAI resource host suffixes across clouds. Commercial
# (.azure.com) and US Government (.azure.us) are both included so the same auth +
# URL logic applies regardless of sovereign cloud. These hosts serve the Foundry
# ``/models/responses`` route and expect the ``api-key`` header.
_AZURE_FOUNDRY_HOST_SUFFIXES = (
# Azure commercial
".services.ai.azure.com",
".openai.azure.com",
".cognitiveservices.azure.com",
# Azure US Government
".services.ai.azure.us",
".openai.azure.us",
".cognitiveservices.azure.us",
)
def _azure_host(api_base: Optional[str]) -> Optional[str]:
if not api_base:
return None
return urlparse(api_base).hostname
def _is_azure_foundry_host(api_base: Optional[str]) -> bool:
"""
Return True when ``api_base`` points at an Azure Foundry / Azure OpenAI
resource (commercial or government), which expect the ``api-key`` header.
"""
host = _azure_host(api_base)
return bool(host) and host.endswith(_AZURE_FOUNDRY_HOST_SUFFIXES)
def _is_project_endpoint(api_base: str) -> bool:
"""
True for Azure AI Foundry project-based endpoints, e.g.
``https://<res>.services.ai.azure.com/api/projects/<proj>``.
Matches on the URL *path* only so a ``/projects/`` substring in a query
string does not trigger a false positive.
"""
return "/projects/" in urlparse(api_base).path
class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
"""
Configuration for the Azure AI Foundry Responses API.
Reuses ``AzureOpenAIResponsesAPIConfig`` request handling (tool flattening,
reasoning-item ``status`` filtering) and overrides only the Foundry-specific
URL construction and auth so that both commercial (`*.azure.com`) and
government (`*.azure.us`) hosts route to the correct `/responses` endpoint.
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.AZURE_AI
def validate_environment(
self,
headers: dict,
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = AzureFoundryModelInfo.get_api_key(api_key=litellm_params.api_key)
api_base = AzureFoundryModelInfo.get_api_base(api_base=litellm_params.api_base)
if api_key:
if api_base and _is_azure_foundry_host(api_base):
headers["api-key"] = api_key
else:
headers["Authorization"] = f"Bearer {api_key}"
else:
# Fall back to Azure AD token-based auth (entra id / managed identity).
headers = BaseAzureLLM._base_validate_azure_environment(headers=headers, litellm_params=litellm_params)
headers.setdefault("Content-Type", "application/json")
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""
Build the full URL for the Azure AI Foundry Responses API.
Path selection (commercial and government hosts are treated identically):
- Project-based endpoints (path contains ``/projects/``) append
``/openai/v1/responses``.
e.g. ``https://<res>.services.ai.azure.us/api/projects/<proj>``
-> ``.../api/projects/<proj>/openai/v1/responses``
- Azure Foundry / Azure OpenAI hosts append ``/models/responses``.
- Any other (generic OpenAI-compatible) base appends ``/v1/responses``.
"""
api_base = AzureFoundryModelInfo.get_api_base(api_base=api_base)
if api_base is None:
raise ValueError(
"api_base is required for the Azure AI Responses API. "
"Set it via the api_base parameter or the AZURE_AI_API_BASE "
"environment variable."
)
# Preserve any query params already on the base and propagate api-version.
original_url = httpx.URL(api_base)
query_params = dict(original_url.params)
api_version = litellm_params.get("api_version")
if "api-version" not in query_params and api_version:
query_params["api-version"] = api_version
# IMPORTANT: the project check MUST come before the host check, because
# project URLs are also on Azure Foundry hosts. Checking the host first
# would send project endpoints to /models/responses instead of
# /openai/v1/responses.
if _is_project_endpoint(api_base):
new_url = _add_path_to_api_base(api_base=api_base, ending_path="/openai/v1/responses")
elif _is_azure_foundry_host(api_base):
new_url = _add_path_to_api_base(api_base=api_base, ending_path="/models/responses")
else:
new_url = _add_path_to_api_base(api_base=api_base, ending_path="/v1/responses")
final_url = httpx.URL(new_url).copy_with(params=query_params)
return str(final_url)

View file

@ -8584,6 +8584,8 @@ class ProviderConfigManager:
return litellm.AzureOpenAIOSeriesResponsesAPIConfig()
else:
return litellm.AzureOpenAIResponsesAPIConfig()
elif litellm.LlmProviders.AZURE_AI == provider:
return litellm.AzureAIResponsesAPIConfig()
elif litellm.LlmProviders.XAI == provider:
return litellm.XAIResponsesAPIConfig()
elif litellm.LlmProviders.GITHUB_COPILOT == provider:

View file

@ -0,0 +1,182 @@
"""Unit tests for the Azure AI (Azure AI Foundry) Responses API config."""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
import litellm
from litellm.llms.azure_ai.responses.transformation import (
AzureAIResponsesAPIConfig,
_is_azure_foundry_host,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
def test_provider_config_registration():
"""azure_ai resolves to AzureAIResponsesAPIConfig for the responses API."""
cfg = ProviderConfigManager.get_provider_responses_api_config(provider=LlmProviders.AZURE_AI, model="gpt-5.6")
assert isinstance(cfg, AzureAIResponsesAPIConfig)
assert cfg.custom_llm_provider == LlmProviders.AZURE_AI
@pytest.mark.parametrize(
"api_base,expected",
[
# Commercial
(
"https://res.services.ai.azure.com/api/projects/proj",
"https://res.services.ai.azure.com/api/projects/proj/openai/v1/responses",
),
(
"https://res.services.ai.azure.com",
"https://res.services.ai.azure.com/models/responses",
),
(
"https://res.openai.azure.com",
"https://res.openai.azure.com/models/responses",
),
# Azure Government (.azure.us)
(
"https://res.services.ai.azure.us/api/projects/proj",
"https://res.services.ai.azure.us/api/projects/proj/openai/v1/responses",
),
(
"https://res.services.ai.azure.us",
"https://res.services.ai.azure.us/models/responses",
),
(
"https://res.openai.azure.us",
"https://res.openai.azure.us/models/responses",
),
# Generic OpenAI-compatible base
(
"https://gateway.example.com/v1",
"https://gateway.example.com/v1/responses",
),
],
)
def test_get_complete_url_paths(api_base, expected):
cfg = AzureAIResponsesAPIConfig()
url = cfg.get_complete_url(api_base=api_base, litellm_params={})
assert url == expected
def test_get_complete_url_project_check_precedes_host_check():
"""A project URL on an Azure Foundry host must use /openai/v1/responses,
not /models/responses (the /projects/ branch must win)."""
cfg = AzureAIResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="https://res.services.ai.azure.us/api/projects/proj",
litellm_params={},
)
assert url.endswith("/api/projects/proj/openai/v1/responses")
def test_get_complete_url_propagates_api_version():
cfg = AzureAIResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="https://res.services.ai.azure.us",
litellm_params={"api_version": "preview"},
)
assert "api-version=preview" in url
def test_get_complete_url_requires_api_base(monkeypatch):
monkeypatch.delenv("AZURE_AI_API_BASE", raising=False)
monkeypatch.setattr(litellm, "api_base", None, raising=False)
cfg = AzureAIResponsesAPIConfig()
with pytest.raises(ValueError, match="api_base is required"):
cfg.get_complete_url(api_base=None, litellm_params={})
@pytest.mark.parametrize(
"api_base,expect_api_key_header",
[
("https://res.services.ai.azure.com", True),
("https://res.openai.azure.com", True),
("https://res.cognitiveservices.azure.com", True),
("https://res.services.ai.azure.us", True),
("https://res.openai.azure.us", True),
("https://res.cognitiveservices.azure.us", True),
("https://gateway.example.com/v1", False),
],
)
def test_validate_environment_auth_header_selection(api_base, expect_api_key_header):
cfg = AzureAIResponsesAPIConfig()
headers = cfg.validate_environment(
headers={},
model="gpt-5.6",
litellm_params=GenericLiteLLMParams(api_key="secret", api_base=api_base),
)
if expect_api_key_header:
assert headers.get("api-key") == "secret"
assert "Authorization" not in headers
else:
assert headers.get("Authorization") == "Bearer secret"
assert "api-key" not in headers
assert headers["Content-Type"] == "application/json"
@pytest.mark.parametrize(
"api_base,expected",
[
("https://res.services.ai.azure.com", True),
("https://res.openai.azure.us", True),
("https://res.cognitiveservices.azure.us", True),
("https://gateway.example.com/v1", False),
(None, False),
("", False),
],
)
def test_is_azure_foundry_host(api_base, expected):
assert _is_azure_foundry_host(api_base) is expected
def test_projects_check_ignores_query_string():
"""A ``/projects/`` substring in the query string must not be treated as a
project endpoint (path-only match)."""
cfg = AzureAIResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="https://res.services.ai.azure.com?foo=/projects/x",
litellm_params={},
)
# Host-based route, not the project route.
assert "/models/responses" in url
assert "/openai/v1/responses" not in url
def test_transform_request_flattens_function_tools():
"""Inherited from AzureOpenAIResponsesAPIConfig: Azure's Responses API needs
function tool params at the top level, not nested under 'function'. This is
essential for the reasoning + tools use case."""
cfg = AzureAIResponsesAPIConfig()
params = {
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
}
out = cfg.transform_responses_api_request(
model="gpt-5.6",
input="weather in Paris?",
response_api_optional_request_params=params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
tool = out["tools"][0]
assert tool["name"] == "get_weather" # flattened to top level
assert "function" not in tool