diff --git a/litellm/__init__.py b/litellm/__init__.py index ec2960c196e..f1e2210d4ff 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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, ) diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 1c833256598..18e2ccd79ae 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -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", diff --git a/litellm/llms/azure_ai/responses/__init__.py b/litellm/llms/azure_ai/responses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/azure_ai/responses/transformation.py b/litellm/llms/azure_ai/responses/transformation.py new file mode 100644 index 00000000000..0a0c61cfb2e --- /dev/null +++ b/litellm/llms/azure_ai/responses/transformation.py @@ -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://.services.ai.azure.com/api/projects/``. + + 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://.services.ai.azure.us/api/projects/`` + -> ``.../api/projects//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) diff --git a/litellm/utils.py b/litellm/utils.py index 520c40f67c0..27499762a81 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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: diff --git a/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses.py b/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses.py new file mode 100644 index 00000000000..2ffc8cd6f9c --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses.py @@ -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