From 0820bc2fd309f630c06e8667a6dbad70c633bea3 Mon Sep 17 00:00:00 2001 From: Ore Poran Date: Mon, 17 Aug 2026 12:44:26 +0300 Subject: [PATCH 1/3] feat(azure_ai): support Foundry Agents v2 via Responses agent_reference Route azure_ai/agents/: through litellm.responses with agent_reference so Foundry v2 agents work before Assistants retirement (#25372). --- litellm/__init__.py | 3 + litellm/_lazy_imports_registry.py | 5 + litellm/llms/azure_ai/README.md | 27 +++- litellm/llms/azure_ai/common_utils.py | 13 ++ litellm/llms/azure_ai/responses/__init__.py | 3 + .../llms/azure_ai/responses/transformation.py | 91 +++++++++++ litellm/main.py | 5 +- litellm/utils.py | 6 + .../test_azure_ai_responses_transformation.py | 143 ++++++++++++++++++ 9 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 litellm/llms/azure_ai/responses/__init__.py create mode 100644 litellm/llms/azure_ai/responses/transformation.py create mode 100644 tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 8961de940a0..19a356323df 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1785,6 +1785,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 89c72acc06d..9e4b42ba520 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -239,6 +239,7 @@ LLM_CONFIG_NAMES: Final = ( "VolcEngineResponsesAPIConfig", "PerplexityResponsesConfig", "DatabricksResponsesAPIConfig", + "AzureAIResponsesAPIConfig", "OpenRouterResponsesAPIConfig", "BedrockMantleResponsesAPIConfig", "GoogleAIStudioInteractionsConfig", @@ -965,6 +966,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/README.md b/litellm/llms/azure_ai/README.md index 8c521519da1..9fb967ff2be 100644 --- a/litellm/llms/azure_ai/README.md +++ b/litellm/llms/azure_ai/README.md @@ -1 +1,26 @@ -`/chat/completion` calls routed via `openai.py`. \ No newline at end of file +`/chat/completion` calls routed via `openai.py`. + +## Azure AI Foundry Agents v2 (Responses API) + +Foundry Agents v2 uses the Responses API with `agent_reference` instead of the Assistants thread/run flow. + +**Model format:** `azure_ai/agents/:` (e.g. `azure_ai/agents/my-agent:1`) + +**API base:** project endpoint, e.g. `https://.services.ai.azure.com/api/projects/` + +**Auth:** Azure AD bearer token via `api_key` or `AZURE_AI_API_KEY` (Entra ID token from `az account get-access-token --resource 'https://ai.azure.com'`) + +**Example:** + +```python +import litellm + +response = litellm.responses( + model="azure_ai/agents/my-agent:1", + input=[{"role": "user", "content": "Tell me what you can help with."}], + api_base="https://.services.ai.azure.com/api/projects/", + api_key="", +) +``` + +v1 Assistants agents (`azure_ai/agents/asst_*`, no `:` in the model) continue to use `litellm.completion()`. diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index d25a8fd6561..35e82b5afd1 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -6,6 +6,19 @@ from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues +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..d86ccbdc7fb --- /dev/null +++ b/litellm/llms/azure_ai/responses/transformation.py @@ -0,0 +1,91 @@ +""" +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 typing import Final +from urllib.parse import urlencode + +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, + model: str, + litellm_params: GenericLiteLLMParams | None, + ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() + api_key: Final = AzureFoundryModelInfo.get_api_key(litellm_params.api_key) + + headers["Content-Type"] = "application/json" + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + return headers + + def get_complete_url( + self, + api_base: str | None, + litellm_params: dict, + ) -> str: + if 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)." + ) + + api_version: Final = litellm_params.get("api_version", self.DEFAULT_API_VERSION) + normalized_api_base: Final = api_base.rstrip("/") + query: Final = urlencode({"api-version": api_version}) + return f"{normalized_api_base}/openai/responses?{query}" + + def transform_responses_api_request( + self, + model: str, + input: str | ResponseInputParam, + response_api_optional_request_params: dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> dict: + if not is_agents_v2_model(model): + return 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, + ) + + agent_name, agent_version = parse_agent_reference(model) + request = 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, + ) + request.pop("model", None) + request["agent_reference"] = { + "name": agent_name, + "version": agent_version, + "type": "agent_reference", + } + return request diff --git a/litellm/main.py b/litellm/main.py index 2a8ed6c87b6..7f04e578149 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1532,12 +1532,13 @@ def _complete_azure_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchRe stream: Final = ctx.stream timeout: Final = ctx.timeout - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo, is_agents_v2_model azure_ai_route: Final = AzureFoundryModelInfo.get_azure_ai_route(model) # Check if this is an agents route - model format: azure_ai/agents/ - if azure_ai_route == "agents": + # v2 agents (name:version) use litellm.responses(), not completion + if azure_ai_route == "agents" and not is_agents_v2_model(model): from litellm.llms.azure_ai.agents import AzureAIAgentsConfig api_base = AzureFoundryModelInfo.get_api_base(api_base) diff --git a/litellm/utils.py b/litellm/utils.py index d91d3092624..9be40911a88 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8440,6 +8440,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.common_utils import is_agents_v2_model + + if model and is_agents_v2_model(model): + return litellm.AzureAIResponsesAPIConfig() + return None 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..7d447f08b7c --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py @@ -0,0 +1,143 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../../../../../..")) + +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.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" + + +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_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 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_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 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_not_routed_to_v1_completion(self): + from litellm.llms.azure_ai.agents.transformation import AzureAIAgentsConfig + + assert AzureAIAgentsConfig.is_azure_ai_agents_route("azure_ai/agents/my-agent:1") is True + assert is_agents_v2_model("azure_ai/agents/my-agent:1") is True From f18e364a51aac90453ee5583b448dd470213204d Mon Sep 17 00:00:00 2001 From: Ore Poran Date: Mon, 17 Aug 2026 16:47:20 +0300 Subject: [PATCH 2/3] fix(azure_ai): keep model-derived agent authoritative, drop core coupling Model-derived agent_reference now survives the extra_body merge, so a caller cannot swap in a different Foundry agent than the one their model authorizes. Chat completions on a v2 model fail with a message pointing at /v1/responses instead of falling into the Assistants thread/run flow. --- litellm/llms/azure_ai/README.md | 27 +----- .../llms/azure_ai/agents/transformation.py | 11 +++ .../llms/azure_ai/responses/transformation.py | 90 +++++++++++------- .../llms/base_llm/responses/transformation.py | 13 +++ litellm/llms/custom_httpx/llm_http_handler.py | 4 +- litellm/main.py | 5 +- litellm/utils.py | 8 +- .../test_azure_ai_responses_transformation.py | 95 ++++++++++++++++++- 8 files changed, 178 insertions(+), 75 deletions(-) diff --git a/litellm/llms/azure_ai/README.md b/litellm/llms/azure_ai/README.md index 9fb967ff2be..8c521519da1 100644 --- a/litellm/llms/azure_ai/README.md +++ b/litellm/llms/azure_ai/README.md @@ -1,26 +1 @@ -`/chat/completion` calls routed via `openai.py`. - -## Azure AI Foundry Agents v2 (Responses API) - -Foundry Agents v2 uses the Responses API with `agent_reference` instead of the Assistants thread/run flow. - -**Model format:** `azure_ai/agents/:` (e.g. `azure_ai/agents/my-agent:1`) - -**API base:** project endpoint, e.g. `https://.services.ai.azure.com/api/projects/` - -**Auth:** Azure AD bearer token via `api_key` or `AZURE_AI_API_KEY` (Entra ID token from `az account get-access-token --resource 'https://ai.azure.com'`) - -**Example:** - -```python -import litellm - -response = litellm.responses( - model="azure_ai/agents/my-agent:1", - input=[{"role": "user", "content": "Tell me what you can help with."}], - api_base="https://.services.ai.azure.com/api/projects/", - api_key="", -) -``` - -v1 Assistants agents (`azure_ai/agents/asst_*`, no `:` in the model) continue to use `litellm.completion()`. +`/chat/completion` calls routed via `openai.py`. \ No newline at end of file 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/responses/transformation.py b/litellm/llms/azure_ai/responses/transformation.py index d86ccbdc7fb..48d7f74efc1 100644 --- a/litellm/llms/azure_ai/responses/transformation.py +++ b/litellm/llms/azure_ai/responses/transformation.py @@ -5,9 +5,10 @@ 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 urllib.parse import urlencode +from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.azure_ai.common_utils import ( AzureFoundryModelInfo, is_agents_v2_model, @@ -28,64 +29,81 @@ class AzureAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def validate_environment( self, - headers: dict, + headers: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig model: str, litellm_params: GenericLiteLLMParams | None, - ) -> dict: - litellm_params = litellm_params or GenericLiteLLMParams() - api_key: Final = AzureFoundryModelInfo.get_api_key(litellm_params.api_key) + ) -> dict: # mutable-ok: outbound HTTP headers + resolved_params: Final = litellm_params or GenericLiteLLMParams() + api_key: Final = AzureFoundryModelInfo.get_api_key(resolved_params.api_key) - headers["Content-Type"] = "application/json" - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - return headers + 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, + litellm_params: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig ) -> str: - if api_base is None: + 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)." ) - api_version: Final = litellm_params.get("api_version", self.DEFAULT_API_VERSION) - normalized_api_base: Final = api_base.rstrip("/") - query: Final = urlencode({"api-version": api_version}) - return f"{normalized_api_base}/openai/responses?{query}" + 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, + response_api_optional_request_params: dict, # mutable-ok: signature fixed by BaseResponsesAPIConfig litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> dict: - if not is_agents_v2_model(model): - return 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, - ) - - agent_name, agent_version = parse_agent_reference(model) - request = super().transform_responses_api_request( + 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, ) - request.pop("model", None) - request["agent_reference"] = { - "name": agent_name, - "version": agent_version, - "type": "agent_reference", + 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", + }, } - return request + + 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 721b9545ac1..42105dd018e 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -2542,7 +2542,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 @@ -2720,7 +2720,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/main.py b/litellm/main.py index 7f04e578149..2a8ed6c87b6 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1532,13 +1532,12 @@ def _complete_azure_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchRe stream: Final = ctx.stream timeout: Final = ctx.timeout - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo, is_agents_v2_model + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo azure_ai_route: Final = AzureFoundryModelInfo.get_azure_ai_route(model) # Check if this is an agents route - model format: azure_ai/agents/ - # v2 agents (name:version) use litellm.responses(), not completion - if azure_ai_route == "agents" and not is_agents_v2_model(model): + if azure_ai_route == "agents": from litellm.llms.azure_ai.agents import AzureAIAgentsConfig api_base = AzureFoundryModelInfo.get_api_base(api_base) diff --git a/litellm/utils.py b/litellm/utils.py index 9be40911a88..0b7fe4df881 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8441,11 +8441,11 @@ class ProviderConfigManager: elif litellm.LlmProviders.PERPLEXITY == provider: return litellm.PerplexityResponsesConfig() elif litellm.LlmProviders.AZURE_AI == provider: - from litellm.llms.azure_ai.common_utils import is_agents_v2_model + from litellm.llms.azure_ai.responses.transformation import ( + get_azure_ai_responses_api_config, + ) - if model and is_agents_v2_model(model): - return litellm.AzureAIResponsesAPIConfig() - return None + 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 index 7d447f08b7c..6d82d70d0bd 100644 --- 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 @@ -55,6 +55,19 @@ class TestAzureAIResponsesAPIConfig: 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( @@ -104,6 +117,58 @@ class TestAzureAIResponsesAPIConfig: 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( @@ -120,6 +185,13 @@ class TestProviderConfigManagerAzureAIResponses: ) 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, @@ -136,8 +208,23 @@ class TestV1AgentsCompletionRoutingUnchanged: AzureFoundryModelInfo.get_azure_ai_route("azure_ai/agents/asst_123") == "agents" ) - def test_v2_agents_not_routed_to_v1_completion(self): - from litellm.llms.azure_ai.agents.transformation import AzureAIAgentsConfig + 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 - assert AzureAIAgentsConfig.is_azure_ai_agents_route("azure_ai/agents/my-agent:1") is True - assert is_agents_v2_model("azure_ai/agents/my-agent:1") is True + 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, + ) From 1ac942c3701ef7ddcd9b47e8ad30ec56e9c36b8b Mon Sep 17 00:00:00 2001 From: Ore Poran Date: Mon, 17 Aug 2026 17:16:53 +0300 Subject: [PATCH 3/3] test(azure_ai): assert the outbound v2 agent request over a mock transport Covers the full body the provider sends: project responses route, bearer header, agent_reference, stripped model, and a hostile extra_body that must not switch agents. --- .../test_azure_ai_responses_transformation.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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 index 6d82d70d0bd..cc4f6b71c7f 100644 --- 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 @@ -1,21 +1,42 @@ +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): @@ -200,6 +221,57 @@ class TestProviderConfigManagerAzureAIResponses: 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