diff --git a/litellm/__init__.py b/litellm/__init__.py index eebd2dad91e..9b06c5041bd 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1801,6 +1801,9 @@ if TYPE_CHECKING: from .llms.databricks.responses.transformation import ( DatabricksResponsesAPIConfig as DatabricksResponsesAPIConfig, ) + from .llms.azure_ai.responses.transformation import ( + AzureAIResponsesAPIConfig as AzureAIResponsesAPIConfig, + ) from .llms.openrouter.responses.transformation import ( OpenRouterResponsesAPIConfig as OpenRouterResponsesAPIConfig, ) diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 1c833256598..20405ec0811 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -240,6 +240,7 @@ LLM_CONFIG_NAMES: Final = ( "VolcEngineResponsesAPIConfig", "PerplexityResponsesConfig", "DatabricksResponsesAPIConfig", + "AzureAIResponsesAPIConfig", "OpenRouterResponsesAPIConfig", "BedrockMantleResponsesAPIConfig", "GoogleAIStudioInteractionsConfig", @@ -971,6 +972,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = { ".llms.databricks.responses.transformation", "DatabricksResponsesAPIConfig", ), + "AzureAIResponsesAPIConfig": ( + ".llms.azure_ai.responses.transformation", + "AzureAIResponsesAPIConfig", + ), "OpenRouterResponsesAPIConfig": ( ".llms.openrouter.responses.transformation", "OpenRouterResponsesAPIConfig", diff --git a/litellm/llms/azure_ai/agents/transformation.py b/litellm/llms/azure_ai/agents/transformation.py index b81e6b0d62d..17ae22e6d60 100644 --- a/litellm/llms/azure_ai/agents/transformation.py +++ b/litellm/llms/azure_ai/agents/transformation.py @@ -335,8 +335,19 @@ class AzureAIAgentsConfig(BaseConfig): """ from litellm.llms.azure.common_utils import get_azure_ad_token from litellm.llms.azure_ai.agents.handler import azure_ai_agents_handler + from litellm.llms.azure_ai.common_utils import is_agents_v2_model from litellm.types.router import GenericLiteLLMParams + if is_agents_v2_model(model): + raise AzureAIAgentsError( + status_code=400, + message=( + f"Azure AI Foundry Agents v2 model '{model}' is served by the Responses API. " + "Call it via /v1/responses (litellm.responses) instead of chat completions. " + "Assistants-based agents use `azure_ai/agents/` without a version." + ), + ) + # If no api_key is provided, try to get Azure AD token if api_key is None: # Try to get Azure AD token using the existing Azure auth mechanisms diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index 26a90157455..65b27c5ab83 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -54,6 +54,19 @@ def get_azure_ai_auth_headers( AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: Final = "azure_model_router_selected_model" +def is_agents_v2_model(model: str) -> bool: + if "agents/" not in model: + return False + agent_segment: Final = model.split("agents/", 1)[1] + return ":" in agent_segment + + +def parse_agent_reference(model: str) -> tuple[str, str]: + agent_segment: Final = model.split("agents/", 1)[1] + name, version = agent_segment.rsplit(":", 1) + return name, version + + class AzureFoundryModelInfo(BaseLLMModelInfo): """Model info for Azure AI / Azure Foundry models.""" diff --git a/litellm/llms/azure_ai/responses/__init__.py b/litellm/llms/azure_ai/responses/__init__.py new file mode 100644 index 00000000000..00f40da3733 --- /dev/null +++ b/litellm/llms/azure_ai/responses/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig + +__all__ = ["AzureAIResponsesAPIConfig"] diff --git a/litellm/llms/azure_ai/responses/transformation.py b/litellm/llms/azure_ai/responses/transformation.py new file mode 100644 index 00000000000..48d7f74efc1 --- /dev/null +++ b/litellm/llms/azure_ai/responses/transformation.py @@ -0,0 +1,109 @@ +""" +Azure AI Foundry Agents v2 Responses API configuration. + +Uses the project-level Responses API with agent_reference for Foundry Agents v2. +Model format: azure_ai/agents/: +""" + +from collections.abc import Mapping +from typing import Final + +from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.azure_ai.common_utils import ( + AzureFoundryModelInfo, + is_agents_v2_model, + parse_agent_reference, +) +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.types.llms.openai import ResponseInputParam +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders + + +class AzureAIResponsesAPIConfig(OpenAIResponsesAPIConfig): + DEFAULT_API_VERSION: Final = "2025-05-01" + + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.AZURE_AI + + def validate_environment( + self, + headers: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig + model: str, + litellm_params: GenericLiteLLMParams | None, + ) -> dict: # mutable-ok: outbound HTTP headers + resolved_params: Final = litellm_params or GenericLiteLLMParams() + api_key: Final = AzureFoundryModelInfo.get_api_key(resolved_params.api_key) + + return { + **headers, + "Content-Type": "application/json", + **({"Authorization": f"Bearer {api_key}"} if api_key else {}), + } + + def get_complete_url( + self, + api_base: str | None, + litellm_params: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig + ) -> str: + resolved_api_base: Final = AzureFoundryModelInfo.get_api_base(api_base) + if resolved_api_base is None: + raise ValueError( + "api_base is required for Azure AI Foundry Agents v2. " + "Set AZURE_AI_API_BASE or pass api_base (project endpoint)." + ) + + return BaseAzureLLM._get_base_azure_url( # pyright: ignore[reportPrivateUsage] # shared azure url builder, called this way by every azure config + api_base=resolved_api_base, + litellm_params=litellm_params, + route="/openai/responses", + default_api_version=self.DEFAULT_API_VERSION, + ) + + def transform_responses_api_request( + self, + model: str, + input: str | ResponseInputParam, + response_api_optional_request_params: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig + litellm_params: GenericLiteLLMParams, + headers: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig + ) -> dict: # mutable-ok: JSON request body + request: Final = super().transform_responses_api_request( + model=model, + input=input, + response_api_optional_request_params=response_api_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + if not is_agents_v2_model(model): + return request + + agent_name, agent_version = parse_agent_reference(model) + return { + **{key: value for key, value in request.items() if key != "model"}, + "agent_reference": { + "name": agent_name, + "version": agent_version, + "type": "agent_reference", + }, + } + + def merge_extra_body( + self, + data: Mapping[str, object], + extra_body: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: JSON request body + """Keep the model-derived agent, so `extra_body` cannot invoke a different one.""" + merged: Final = super().merge_extra_body(data=data, extra_body=extra_body) + agent_reference: Final = data.get("agent_reference") + if agent_reference is None: + return merged + return {**merged, "agent_reference": agent_reference} + + +def get_azure_ai_responses_api_config(model: str | None) -> AzureAIResponsesAPIConfig | None: + """Azure AI serves the Responses API for Foundry Agents v2 references only.""" + if model is None or not is_agents_v2_model(model): + return None + return AzureAIResponsesAPIConfig() diff --git a/litellm/llms/base_llm/responses/transformation.py b/litellm/llms/base_llm/responses/transformation.py index 1365941fe2a..39bf2f01be9 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -1,5 +1,6 @@ import types from abc import ABC, abstractmethod +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, cast import httpx @@ -127,6 +128,18 @@ class BaseResponsesAPIConfig(ABC): ) -> dict: pass + def merge_extra_body( + self, + data: Mapping[str, object], + extra_body: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: JSON request body + """Merge caller-supplied `extra_body` into the transformed request body. + + Providers whose body carries a routing decision derived from the + authorized model override this to keep that decision authoritative. + """ + return {**data, **extra_body} + @abstractmethod def transform_response_api_response( self, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 1e8a3f00986..b3ee9ce0891 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -2622,7 +2622,7 @@ class BaseLLMHTTPHandler: data = BaseResponsesAPIConfig.normalize_responses_api_request_dict(data) if extra_body: - data.update(extra_body) + data = responses_api_provider_config.merge_extra_body(data=data, extra_body=extra_body) stream = bool(stream or data.get("stream")) # Preserve the OpenAI-style request context (not sent to the provider) for streaming @@ -2800,7 +2800,7 @@ class BaseLLMHTTPHandler: data = BaseResponsesAPIConfig.normalize_responses_api_request_dict(data) if extra_body: - data.update(extra_body) + data = responses_api_provider_config.merge_extra_body(data=data, extra_body=extra_body) stream = bool(stream or data.get("stream")) # Preserve the OpenAI-style request context (not sent to the provider) for streaming diff --git a/litellm/utils.py b/litellm/utils.py index 1b672018507..10a3577d9d4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8579,6 +8579,12 @@ class ProviderConfigManager: return litellm.ManusResponsesAPIConfig() elif litellm.LlmProviders.PERPLEXITY == provider: return litellm.PerplexityResponsesConfig() + elif litellm.LlmProviders.AZURE_AI == provider: + from litellm.llms.azure_ai.responses.transformation import ( + get_azure_ai_responses_api_config, + ) + + return get_azure_ai_responses_api_config(model) elif litellm.LlmProviders.DATABRICKS == provider: # Databricks Responses API is only compatible with OpenAI GPT models if model and "gpt" in model.lower(): diff --git a/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py b/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py new file mode 100644 index 00000000000..cc4f6b71c7f --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py @@ -0,0 +1,302 @@ +import json +import os +import sys + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../../../../../..")) + +import litellm +from litellm.llms.azure_ai.common_utils import ( + is_agents_v2_model, + parse_agent_reference, +) +from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +PROJECT_API_BASE = "https://example.services.ai.azure.com/api/projects/my-project" + +AGENT_RESPONSE = { + "id": "resp_agent123", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "model": "my-agent", + "output": [ + { + "type": "message", + "id": "msg_agent123", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hi there", "annotations": []}], + } + ], +} + + +class TestAgentsV2Helpers: + def test_is_agents_v2_model_true_for_name_version(self): + assert is_agents_v2_model("azure_ai/agents/my-agent:1") is True + assert is_agents_v2_model("agents/my-agent:2") is True + + def test_is_agents_v2_model_false_for_v1_assistant_ids(self): + assert is_agents_v2_model("azure_ai/agents/asst_123") is False + assert is_agents_v2_model("agents/asst_abc") is False + + def test_is_agents_v2_model_false_for_non_agents(self): + assert is_agents_v2_model("azure_ai/gpt-4o") is False + + def test_parse_agent_reference(self): + assert parse_agent_reference("azure_ai/agents/my-agent:1") == ("my-agent", "1") + assert parse_agent_reference("agents/other-agent:42") == ("other-agent", "42") + + +class TestAzureAIResponsesAPIConfig: + def test_custom_llm_provider(self): + config = AzureAIResponsesAPIConfig() + assert config.custom_llm_provider == LlmProviders.AZURE_AI + + def test_get_complete_url(self): + config = AzureAIResponsesAPIConfig() + url = config.get_complete_url( + api_base=PROJECT_API_BASE, + litellm_params={"api_version": "2025-05-01"}, + ) + assert ( + url + == "https://example.services.ai.azure.com/api/projects/my-project/openai/responses?api-version=2025-05-01" + ) + + def test_get_complete_url_default_api_version(self): + config = AzureAIResponsesAPIConfig() + url = config.get_complete_url(api_base=PROJECT_API_BASE, litellm_params={}) + assert url.endswith("/openai/responses?api-version=2025-05-01") + + def test_get_complete_url_falls_back_to_env_api_base(self, monkeypatch): + monkeypatch.setenv("AZURE_AI_API_BASE", PROJECT_API_BASE) + config = AzureAIResponsesAPIConfig() + url = config.get_complete_url(api_base=None, litellm_params={}) + assert url.startswith(PROJECT_API_BASE) + + def test_get_complete_url_requires_api_base(self, monkeypatch): + monkeypatch.delenv("AZURE_AI_API_BASE", raising=False) + monkeypatch.setattr("litellm.api_base", None) + config = AzureAIResponsesAPIConfig() + with pytest.raises(ValueError, match="api_base is required"): + config.get_complete_url(api_base=None, litellm_params={}) + + def test_validate_environment_bearer_token(self): + config = AzureAIResponsesAPIConfig() + headers = config.validate_environment( + headers={}, + model="azure_ai/agents/my-agent:1", + litellm_params=GenericLiteLLMParams(api_key="test-azure-ad-token"), + ) + assert headers["Authorization"] == "Bearer test-azure-ad-token" + assert headers["Content-Type"] == "application/json" + + def test_transform_request_injects_agent_reference(self): + config = AzureAIResponsesAPIConfig() + request = config.transform_responses_api_request( + model="azure_ai/agents/my-agent:1", + input=[{"role": "user", "content": "Hello"}], + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["agent_reference"] == { + "name": "my-agent", + "version": "1", + "type": "agent_reference", + } + + def test_transform_request_strips_model_for_agent_reference(self): + config = AzureAIResponsesAPIConfig() + request = config.transform_responses_api_request( + model="azure_ai/agents/my-agent:1", + input="Hello", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert "model" not in request + + def test_transform_request_passthrough_for_non_v2_model(self): + config = AzureAIResponsesAPIConfig() + request = config.transform_responses_api_request( + model="azure_ai/gpt-4o", + input="Hello", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["model"] == "azure_ai/gpt-4o" + assert "agent_reference" not in request + + +class TestAgentReferenceCannotBeOverridden: + def test_extra_body_cannot_replace_model_derived_agent(self): + config = AzureAIResponsesAPIConfig() + data = config.transform_responses_api_request( + model="azure_ai/agents/my-agent:1", + input="Hello", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + merged = config.merge_extra_body( + data=data, + extra_body={ + "agent_reference": { + "name": "someone-elses-agent", + "version": "9", + "type": "agent_reference", + } + }, + ) + + assert merged["agent_reference"] == { + "name": "my-agent", + "version": "1", + "type": "agent_reference", + } + + def test_extra_body_still_merges_other_keys(self): + config = AzureAIResponsesAPIConfig() + data = config.transform_responses_api_request( + model="azure_ai/agents/my-agent:1", + input="Hello", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + merged = config.merge_extra_body(data=data, extra_body={"custom_flag": True}) + + assert merged["custom_flag"] is True + assert merged["agent_reference"]["name"] == "my-agent" + + def test_extra_body_wins_when_no_agent_reference(self): + config = AzureAIResponsesAPIConfig() + merged = config.merge_extra_body( + data={"model": "azure_ai/gpt-4o"}, + extra_body={"model": "azure_ai/gpt-4o-mini"}, + ) + assert merged["model"] == "azure_ai/gpt-4o-mini" + + +class TestProviderConfigManagerAzureAIResponses: + def test_agents_v2_model_returns_responses_config(self): + config = ProviderConfigManager.get_provider_responses_api_config( + provider=LlmProviders.AZURE_AI, + model="azure_ai/agents/my-agent:1", + ) + assert config is not None + assert isinstance(config, AzureAIResponsesAPIConfig) + + def test_v1_agents_model_returns_none(self): + config = ProviderConfigManager.get_provider_responses_api_config( + provider=LlmProviders.AZURE_AI, + model="azure_ai/agents/asst_123", + ) + assert config is None + + def test_missing_model_returns_none(self): + from litellm.llms.azure_ai.responses.transformation import ( + get_azure_ai_responses_api_config, + ) + + assert get_azure_ai_responses_api_config(None) is None + + def test_default_azure_ai_model_returns_none(self): + config = ProviderConfigManager.get_provider_responses_api_config( + provider=LlmProviders.AZURE_AI, + model="azure_ai/gpt-4o", + ) + assert config is None + + +class TestOutboundRequest: + def _call_responses(self, **overrides): + requests: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=AGENT_RESPONSE) + + client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(capture))) + litellm.responses( + model="azure_ai/agents/my-agent:1", + input="Hello", + api_base=PROJECT_API_BASE, + api_key="test-azure-ad-token", + client=client, + **overrides, + ) + return requests[0] + + def test_request_targets_project_responses_route_with_agent_reference(self): + request = self._call_responses() + body = json.loads(request.content) + + assert str(request.url) == ( + "https://example.services.ai.azure.com/api/projects/my-project" + "/openai/responses?api-version=2025-05-01" + ) + assert request.headers["authorization"] == "Bearer test-azure-ad-token" + assert body["agent_reference"] == { + "name": "my-agent", + "version": "1", + "type": "agent_reference", + } + assert "model" not in body + + def test_hostile_extra_body_cannot_switch_agent(self): + request = self._call_responses( + extra_body={ + "agent_reference": { + "name": "someone-elses-agent", + "version": "9", + "type": "agent_reference", + } + } + ) + body = json.loads(request.content) + + assert body["agent_reference"]["name"] == "my-agent" + assert body["agent_reference"]["version"] == "1" + + +class TestV1AgentsCompletionRoutingUnchanged: + def test_v1_agents_still_detected_as_agents_route(self): + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + + assert ( + AzureFoundryModelInfo.get_azure_ai_route("azure_ai/agents/asst_123") == "agents" + ) + + def test_v2_agents_completion_raises_pointing_at_responses_api(self): + from litellm.llms.azure_ai.agents.transformation import ( + AzureAIAgentsConfig, + AzureAIAgentsError, + ) + from litellm.utils import ModelResponse + + with pytest.raises(AzureAIAgentsError, match="Responses API"): + AzureAIAgentsConfig.completion( + model="azure_ai/agents/my-agent:1", + messages=[{"role": "user", "content": "Hello"}], + api_base=PROJECT_API_BASE, + api_key="test-azure-ad-token", + model_response=ModelResponse(), + logging_obj=None, + optional_params={}, + litellm_params={}, + timeout=60.0, + acompletion=False, + )