This commit is contained in:
Ore Poran 2026-08-26 14:34:13 -04:00 committed by GitHub
commit 74b3bd34d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 467 additions and 2 deletions

View file

@ -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,
)

View file

@ -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",

View file

@ -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/<agent_id>` 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

View file

@ -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."""

View file

@ -0,0 +1,3 @@
from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig
__all__ = ["AzureAIResponsesAPIConfig"]

View file

@ -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/<agent_name>:<version>
"""
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()

View file

@ -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,

View file

@ -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

View file

@ -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():

View file

@ -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,
)