mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge fa7f53a858 into 44d84360fb
This commit is contained in:
commit
c9790a3ef8
6 changed files with 348 additions and 0 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
0
litellm/llms/azure_ai/responses/__init__.py
Normal file
0
litellm/llms/azure_ai/responses/__init__.py
Normal file
156
litellm/llms/azure_ai/responses/transformation.py
Normal file
156
litellm/llms/azure_ai/responses/transformation.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue