mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
Merge pull request #33856 from BerriAI/litellm_azure_ai_responses_native
fix(azure_ai): route Responses API to native /openai/v1/responses for Foundry Models
This commit is contained in:
commit
913ef6ed49
11 changed files with 442 additions and 20 deletions
|
|
@ -1818,6 +1818,9 @@ if TYPE_CHECKING:
|
|||
from .llms.azure.responses.o_series_transformation import (
|
||||
AzureOpenAIOSeriesResponsesAPIConfig as AzureOpenAIOSeriesResponsesAPIConfig,
|
||||
)
|
||||
from .llms.azure_ai.responses.transformation import (
|
||||
AzureAIResponsesAPIConfig as AzureAIResponsesAPIConfig,
|
||||
)
|
||||
from .llms.xai.responses.transformation import (
|
||||
XAIResponsesAPIConfig as XAIResponsesAPIConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ LLM_CONFIG_NAMES: Final = (
|
|||
"OpenAIResponsesAPIConfig",
|
||||
"AzureOpenAIResponsesAPIConfig",
|
||||
"AzureOpenAIOSeriesResponsesAPIConfig",
|
||||
"AzureAIResponsesAPIConfig",
|
||||
"XAIResponsesAPIConfig",
|
||||
"LiteLLMProxyResponsesAPIConfig",
|
||||
"HostedVLLMResponsesAPIConfig",
|
||||
|
|
@ -946,6 +947,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
|
|||
".llms.azure.responses.o_series_transformation",
|
||||
"AzureOpenAIOSeriesResponsesAPIConfig",
|
||||
),
|
||||
"AzureAIResponsesAPIConfig": (
|
||||
".llms.azure_ai.responses.transformation",
|
||||
"AzureAIResponsesAPIConfig",
|
||||
),
|
||||
"XAIResponsesAPIConfig": (
|
||||
".llms.xai.responses.transformation",
|
||||
"XAIResponsesAPIConfig",
|
||||
|
|
|
|||
|
|
@ -49,12 +49,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
return BaseAzureLLM._base_validate_azure_environment(headers=headers, litellm_params=litellm_params)
|
||||
|
||||
def get_stripped_model_name(self, model: str) -> str:
|
||||
# if "responses/" is in the model name, remove it
|
||||
if "responses/" in model:
|
||||
model = model.replace("responses/", "")
|
||||
if "o_series" in model:
|
||||
model = model.replace("o_series/", "")
|
||||
return model
|
||||
return model.replace("responses/", "").replace("o_series/", "").replace("azure_ai/", "")
|
||||
|
||||
def _handle_reasoning_item(self, item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.types.llms.openai import AllMessageValues
|
|||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
AzureAIApiKeyHeader = Literal["Authorization", "api-key", "Api-Key", "Ocp-Apim-Subscription-Key"]
|
||||
AZURE_OPENAI_V1_HOST_SUFFIXES: Final = (".services.ai.azure.com", ".openai.azure.com")
|
||||
|
||||
|
||||
def is_foundry_model_inference_base(api_base: str) -> bool:
|
||||
|
|
@ -19,11 +20,13 @@ def is_foundry_model_inference_base(api_base: str) -> bool:
|
|||
return "/openai/deployments" not in parsed.path
|
||||
|
||||
|
||||
def api_key_header_for_base(api_base: str | None) -> AzureAIApiKeyHeader:
|
||||
def is_azure_openai_v1_host(api_base: str | None) -> bool:
|
||||
host: Final = urlparse(api_base).hostname if api_base else None
|
||||
if host and (host.endswith(".services.ai.azure.com") or host.endswith(".openai.azure.com")):
|
||||
return "api-key"
|
||||
return "Authorization"
|
||||
return host is not None and host.endswith(AZURE_OPENAI_V1_HOST_SUFFIXES)
|
||||
|
||||
|
||||
def api_key_header_for_base(api_base: str | None) -> AzureAIApiKeyHeader:
|
||||
return "api-key" if is_azure_openai_v1_host(api_base) else "Authorization"
|
||||
|
||||
|
||||
def get_azure_ai_entra_token(litellm_params: Mapping[str, object] | None = None) -> str | None:
|
||||
|
|
@ -70,6 +73,17 @@ def get_azure_ai_auth_headers(
|
|||
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: Final = "azure_model_router_selected_model"
|
||||
|
||||
|
||||
def azure_ai_supports_native_responses(model: str | None, api_base: str | None) -> bool:
|
||||
resolved_base: Final = AzureFoundryModelInfo.get_api_base(api_base)
|
||||
if resolved_base is not None and not is_azure_openai_v1_host(resolved_base):
|
||||
return False
|
||||
if model is None:
|
||||
return True
|
||||
if "claude" in model.lower():
|
||||
return False
|
||||
return AzureFoundryModelInfo.get_azure_ai_route(model) == "default"
|
||||
|
||||
|
||||
class AzureFoundryModelInfo(BaseLLMModelInfo):
|
||||
"""Model info for Azure AI / Azure Foundry models."""
|
||||
|
||||
|
|
|
|||
0
litellm/llms/azure_ai/responses/__init__.py
Normal file
0
litellm/llms/azure_ai/responses/__init__.py
Normal file
53
litellm/llms/azure_ai/responses/transformation.py
Normal file
53
litellm/llms/azure_ai/responses/transformation.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
api_key_header_for_base,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
_PROJECT_PATH_PREFIX: Final = ("api", "projects")
|
||||
_RESPONSES_PATH: Final = ("openai", "v1", "responses")
|
||||
|
||||
|
||||
def _responses_url(api_base: str) -> str:
|
||||
base_url: Final = httpx.URL(api_base)
|
||||
segments: Final = tuple(segment for segment in base_url.path.split("/") if segment)
|
||||
project_root: Final = segments[:3] if segments[:2] == _PROJECT_PATH_PREFIX else ()
|
||||
return str(base_url.copy_with(path="/" + "/".join((*project_root, *_RESPONSES_PATH)), query=None))
|
||||
|
||||
|
||||
class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
|
||||
@property
|
||||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.AZURE_AI
|
||||
|
||||
def validate_environment(self, headers: dict, model: str, litellm_params: GenericLiteLLMParams | None) -> dict:
|
||||
params: Final = litellm_params or GenericLiteLLMParams()
|
||||
auth_headers: Final = get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(params.api_key),
|
||||
litellm_params=params.model_dump(),
|
||||
api_key_header=api_key_header_for_base(AzureFoundryModelInfo.get_api_base(params.api_base)),
|
||||
)
|
||||
return { # mutable-ok: the handler updates the returned headers in place per the dict contract
|
||||
**headers,
|
||||
**auth_headers,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def supports_native_websocket(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_complete_url(self, api_base: str | None, litellm_params: dict) -> str:
|
||||
resolved_base: Final = AzureFoundryModelInfo.get_api_base(api_base)
|
||||
if resolved_base is None:
|
||||
raise ValueError(
|
||||
"api_base is required for the Azure AI Foundry Responses API. "
|
||||
"Set the api_base parameter or the AZURE_AI_API_BASE environment variable."
|
||||
)
|
||||
return _responses_url(resolved_base)
|
||||
|
|
@ -482,18 +482,27 @@ class _AsyncPromptManagementOutcome:
|
|||
|
||||
|
||||
def _resolve_responses_api_provider_config(
|
||||
model: str, custom_llm_provider: str, model_info: object
|
||||
model: str, custom_llm_provider: str, model_info: object, api_base: str | None
|
||||
) -> BaseResponsesAPIConfig | None:
|
||||
provider_config: Final = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=model, provider=custom_llm_provider
|
||||
model=model, provider=custom_llm_provider, api_base=api_base
|
||||
)
|
||||
if provider_config is not None or not _deployment_passes_through_responses(model_info):
|
||||
return provider_config
|
||||
return OpenAILikeResponsesConfig()
|
||||
|
||||
|
||||
def _api_base_kwarg(kwargs: Mapping[str, object]) -> str | None:
|
||||
api_base: Final = kwargs.get("api_base")
|
||||
return api_base if isinstance(api_base, str) else None
|
||||
|
||||
|
||||
def _will_bridge_to_chat_completions(
|
||||
model: str, custom_llm_provider: str | None, use_chat_completions_api: bool, model_info: object
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
use_chat_completions_api: bool,
|
||||
model_info: object,
|
||||
api_base: str | None,
|
||||
) -> bool:
|
||||
"""``_bridges_to_chat_completions`` for callers running before the provider config is resolved.
|
||||
|
||||
|
|
@ -507,7 +516,7 @@ def _will_bridge_to_chat_completions(
|
|||
if custom_llm_provider is None:
|
||||
return True
|
||||
return _bridges_to_chat_completions(
|
||||
_resolve_responses_api_provider_config(normalized_model[0], custom_llm_provider, model_info),
|
||||
_resolve_responses_api_provider_config(normalized_model[0], custom_llm_provider, model_info, api_base),
|
||||
use_chat_completions_api or normalized_model[1],
|
||||
)
|
||||
|
||||
|
|
@ -618,6 +627,7 @@ async def aresponses(
|
|||
custom_llm_provider,
|
||||
bool(kwargs.get("use_chat_completions_api")),
|
||||
kwargs.get("model_info"),
|
||||
_api_base_kwarg(kwargs),
|
||||
),
|
||||
):
|
||||
(
|
||||
|
|
@ -783,7 +793,11 @@ def _apply_prompt_management_to_responses_call(
|
|||
with _prompt_management_sees_a_provisional_message_list(
|
||||
kwargs,
|
||||
bridged=_will_bridge_to_chat_completions(
|
||||
model, custom_llm_provider, use_chat_completions_api, kwargs.get("model_info")
|
||||
model,
|
||||
custom_llm_provider,
|
||||
use_chat_completions_api,
|
||||
kwargs.get("model_info"),
|
||||
_api_base_kwarg(kwargs),
|
||||
),
|
||||
):
|
||||
(
|
||||
|
|
@ -1237,7 +1251,7 @@ def responses(
|
|||
responses_api_provider_config = None
|
||||
else:
|
||||
responses_api_provider_config = _resolve_responses_api_provider_config(
|
||||
model, custom_llm_provider, deployment_model_info
|
||||
model, custom_llm_provider, deployment_model_info, litellm_params.api_base
|
||||
)
|
||||
|
||||
if (
|
||||
|
|
@ -1496,6 +1510,7 @@ def delete_responses(
|
|||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
api_base=litellm_params.api_base,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1667,6 +1682,7 @@ def get_responses(
|
|||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
api_base=litellm_params.api_base,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1811,6 +1827,7 @@ def list_input_items(
|
|||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
api_base=litellm_params.api_base,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1960,6 +1977,7 @@ def cancel_responses(
|
|||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
api_base=litellm_params.api_base,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -2132,6 +2150,7 @@ def compact_responses(
|
|||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=model,
|
||||
provider=custom_llm_provider,
|
||||
api_base=litellm_params.api_base,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -2270,14 +2289,15 @@ async def _aresponses_websocket(
|
|||
custom_llm_provider=_custom_llm_provider,
|
||||
)
|
||||
|
||||
resolved_api_base: Final = dynamic_api_base or litellm_params.api_base or litellm.api_base or None
|
||||
responses_api_provider_config: BaseResponsesAPIConfig | None = None
|
||||
if _custom_llm_provider is not None:
|
||||
responses_api_provider_config = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=resolved_model,
|
||||
provider=litellm.LlmProviders(_custom_llm_provider),
|
||||
api_base=resolved_api_base,
|
||||
)
|
||||
|
||||
resolved_api_base: Final = dynamic_api_base or litellm_params.api_base or litellm.api_base or None
|
||||
resolved_api_key: Final = (
|
||||
dynamic_api_key
|
||||
or litellm_params.api_key
|
||||
|
|
|
|||
|
|
@ -8746,6 +8746,7 @@ class ProviderConfigManager:
|
|||
def get_provider_responses_api_config(
|
||||
provider: LlmProviders | str,
|
||||
model: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> BaseResponsesAPIConfig | None:
|
||||
from litellm.llms.openai_like.dynamic_config import (
|
||||
create_responses_config_class,
|
||||
|
|
@ -8767,7 +8768,7 @@ class ProviderConfigManager:
|
|||
pass
|
||||
|
||||
# Check Python classes first (custom overrides take priority)
|
||||
result: Final = ProviderConfigManager._get_python_responses_api_config(provider_enum, model)
|
||||
result: Final = ProviderConfigManager._get_python_responses_api_config(provider_enum, model, api_base)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
|
|
@ -8783,6 +8784,7 @@ class ProviderConfigManager:
|
|||
def _get_python_responses_api_config(
|
||||
provider: LlmProviders | None,
|
||||
model: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> BaseResponsesAPIConfig | None:
|
||||
"""Check for Python-class-based responses API configs (custom overrides)."""
|
||||
if provider is None:
|
||||
|
|
@ -8801,6 +8803,14 @@ class ProviderConfigManager:
|
|||
return litellm.AzureOpenAIOSeriesResponsesAPIConfig()
|
||||
else:
|
||||
return litellm.AzureOpenAIResponsesAPIConfig()
|
||||
elif litellm.LlmProviders.AZURE_AI == provider:
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
azure_ai_supports_native_responses,
|
||||
)
|
||||
|
||||
if azure_ai_supports_native_responses(model, api_base):
|
||||
return litellm.AzureAIResponsesAPIConfig()
|
||||
return None
|
||||
elif litellm.LlmProviders.XAI == provider:
|
||||
return litellm.XAIResponsesAPIConfig()
|
||||
elif litellm.LlmProviders.GITHUB_COPILOT == provider:
|
||||
|
|
|
|||
|
|
@ -677,3 +677,14 @@ def test_azure_responses_gpt6_astra_rejects_temperature_while_reasoning(local_mo
|
|||
model="gpt-6-astra",
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
|
||||
def test_azure_responses_sends_the_deployment_name_when_azure_ai_prefix_survives_provider_remap():
|
||||
request = AzureOpenAIResponsesAPIConfig().transform_responses_api_request(
|
||||
model="azure_ai/gpt-5.4-nano",
|
||||
input="hi",
|
||||
response_api_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert request["model"] == "gpt-5.4-nano"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig
|
||||
from litellm.responses.main import _will_bridge_to_chat_completions
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
FOUNDRY_PROJECT_BASE = "https://res.services.ai.azure.com/api/projects/proj"
|
||||
FOUNDRY_RESPONSES_URL = f"{FOUNDRY_PROJECT_BASE}/openai/v1/responses"
|
||||
SERVERLESS_BASE = "https://endpoint.eastus.models.ai.azure.com"
|
||||
WEATHER_TOOL = {
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_azure_ai_env(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "api_base", None)
|
||||
monkeypatch.setattr(litellm, "api_key", None)
|
||||
monkeypatch.setattr(litellm, "enable_azure_ad_token_refresh", False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
for env_var in (
|
||||
"AZURE_AI_API_BASE",
|
||||
"AZURE_AI_API_KEY",
|
||||
"AZURE_AD_TOKEN",
|
||||
"AZURE_TENANT_ID",
|
||||
"AZURE_CLIENT_ID",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
|
||||
|
||||
def _responses_payload(model: str) -> dict:
|
||||
return {
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"created_at": 1741369938,
|
||||
"status": "completed",
|
||||
"model": model,
|
||||
"output": [],
|
||||
"parallel_tool_calls": False,
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
"error": None,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"metadata": None,
|
||||
"temperature": None,
|
||||
"top_p": None,
|
||||
"max_output_tokens": None,
|
||||
"previous_response_id": None,
|
||||
"reasoning": None,
|
||||
"truncation": None,
|
||||
"instructions": None,
|
||||
"incomplete_details": None,
|
||||
"user": None,
|
||||
}
|
||||
|
||||
|
||||
def _chat_completion_payload(model: str) -> dict:
|
||||
return {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1741369938,
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["gpt-5.6-luna-20260710154139", "gpt-5.5-20260504143601", "DeepSeek-R1-0528", None])
|
||||
@pytest.mark.parametrize(
|
||||
"api_base", [FOUNDRY_PROJECT_BASE, "https://res.services.ai.azure.com", "https://res.openai.azure.com"]
|
||||
)
|
||||
def test_azure_openai_v1_hosts_resolve_native_config(model, api_base):
|
||||
config = ProviderConfigManager.get_provider_responses_api_config(provider="azure_ai", model=model, api_base=api_base)
|
||||
assert isinstance(config, AzureAIResponsesAPIConfig)
|
||||
|
||||
|
||||
def test_api_base_from_env_resolves_native_config(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_AI_API_BASE", FOUNDRY_PROJECT_BASE)
|
||||
config = ProviderConfigManager.get_provider_responses_api_config(provider="azure_ai", model="gpt-5.6-luna", api_base=None)
|
||||
assert isinstance(config, AzureAIResponsesAPIConfig)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["gpt-5.6-luna", None])
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
[SERVERLESS_BASE, "https://endpoint.eastus.inference.ml.azure.com/score", "https://res.cognitiveservices.azure.com"],
|
||||
)
|
||||
def test_other_hosts_keep_chat_bridge(model, api_base):
|
||||
config = ProviderConfigManager.get_provider_responses_api_config(provider="azure_ai", model=model, api_base=api_base)
|
||||
assert config is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["claude-3-5-sonnet", "model_router/gpt-5", "agents/my-agent"])
|
||||
def test_non_openai_surfaces_keep_chat_bridge(model):
|
||||
config = ProviderConfigManager.get_provider_responses_api_config(
|
||||
provider="azure_ai", model=model, api_base=FOUNDRY_PROJECT_BASE
|
||||
)
|
||||
assert config is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_base,bridged", [(FOUNDRY_PROJECT_BASE, False), (SERVERLESS_BASE, True)])
|
||||
def test_will_bridge_to_chat_completions_follows_host(api_base, bridged):
|
||||
assert _will_bridge_to_chat_completions("gpt-5.6-luna", "azure_ai", False, None, api_base) is bridged
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base,expected",
|
||||
[
|
||||
(FOUNDRY_PROJECT_BASE, FOUNDRY_RESPONSES_URL),
|
||||
(f"{FOUNDRY_PROJECT_BASE}/", FOUNDRY_RESPONSES_URL),
|
||||
(f"{FOUNDRY_PROJECT_BASE}/openai/v1", FOUNDRY_RESPONSES_URL),
|
||||
(FOUNDRY_RESPONSES_URL, FOUNDRY_RESPONSES_URL),
|
||||
("https://res.services.ai.azure.com", "https://res.services.ai.azure.com/openai/v1/responses"),
|
||||
("https://res.services.ai.azure.com/models", "https://res.services.ai.azure.com/openai/v1/responses"),
|
||||
(
|
||||
"https://res.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview",
|
||||
"https://res.services.ai.azure.com/openai/v1/responses",
|
||||
),
|
||||
("https://res.openai.azure.com", "https://res.openai.azure.com/openai/v1/responses"),
|
||||
(
|
||||
"https://res.openai.azure.com/openai/deployments/gpt-5?api-version=2025-04-01-preview",
|
||||
"https://res.openai.azure.com/openai/v1/responses",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_complete_url(api_base, expected):
|
||||
assert AzureAIResponsesAPIConfig().get_complete_url(api_base=api_base, litellm_params={}) == expected
|
||||
|
||||
|
||||
def test_get_complete_url_ignores_api_version():
|
||||
url = AzureAIResponsesAPIConfig().get_complete_url(
|
||||
api_base=FOUNDRY_PROJECT_BASE, litellm_params={"api_version": "2025-04-01-preview"}
|
||||
)
|
||||
assert url == FOUNDRY_RESPONSES_URL
|
||||
|
||||
|
||||
def test_get_complete_url_uses_env_api_base(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_AI_API_BASE", FOUNDRY_PROJECT_BASE)
|
||||
assert AzureAIResponsesAPIConfig().get_complete_url(api_base=None, litellm_params={}) == FOUNDRY_RESPONSES_URL
|
||||
|
||||
|
||||
def test_get_complete_url_raises_without_api_base():
|
||||
with pytest.raises(ValueError, match="AZURE_AI_API_BASE"):
|
||||
AzureAIResponsesAPIConfig().get_complete_url(api_base=None, litellm_params={})
|
||||
|
||||
|
||||
def test_native_websocket_stays_off():
|
||||
assert AzureAIResponsesAPIConfig().supports_native_websocket() is False
|
||||
|
||||
|
||||
def test_validate_environment_sends_api_key_header():
|
||||
headers = AzureAIResponsesAPIConfig().validate_environment(
|
||||
headers={"x-custom": "1"},
|
||||
model="gpt-5.6-luna",
|
||||
litellm_params=GenericLiteLLMParams(api_key="secret", api_base=FOUNDRY_PROJECT_BASE),
|
||||
)
|
||||
assert headers == {"x-custom": "1", "api-key": "secret", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def test_validate_environment_reads_api_key_from_env(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_AI_API_KEY", "env-secret")
|
||||
headers = AzureAIResponsesAPIConfig().validate_environment(
|
||||
headers={}, model="gpt-5.6-luna", litellm_params=GenericLiteLLMParams(api_base=FOUNDRY_PROJECT_BASE)
|
||||
)
|
||||
assert headers["api-key"] == "env-secret"
|
||||
|
||||
|
||||
def test_validate_environment_uses_entra_token_without_api_key():
|
||||
headers = AzureAIResponsesAPIConfig().validate_environment(
|
||||
headers={},
|
||||
model="gpt-5.6-luna",
|
||||
litellm_params=GenericLiteLLMParams(azure_ad_token="entra-token", api_base=FOUNDRY_PROJECT_BASE),
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
assert "api-key" not in headers
|
||||
|
||||
|
||||
def test_validate_environment_raises_without_credentials():
|
||||
with pytest.raises(ValueError, match="AZURE_AI_API_KEY"):
|
||||
AzureAIResponsesAPIConfig().validate_environment(
|
||||
headers={}, model="gpt-5.6-luna", litellm_params=GenericLiteLLMParams(api_base=FOUNDRY_PROJECT_BASE)
|
||||
)
|
||||
|
||||
|
||||
NATIVE_RESPONSES_CASES = [
|
||||
("azure_ai/gpt-5.6-luna-20260710154139", FOUNDRY_PROJECT_BASE, FOUNDRY_RESPONSES_URL, "gpt-5.6-luna-20260710154139"),
|
||||
(
|
||||
"azure_ai/gpt-5.6-luna",
|
||||
"https://res.services.ai.azure.com/models",
|
||||
"https://res.services.ai.azure.com/openai/v1/responses",
|
||||
"gpt-5.6-luna",
|
||||
),
|
||||
(
|
||||
"azure_ai/gpt-5.6-sol",
|
||||
"https://res.services.ai.azure.com",
|
||||
"https://res.services.ai.azure.com/openai/v1/responses",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
(
|
||||
"azure_ai/gpt-5.6-luna-20260710154139",
|
||||
"https://res.openai.azure.com",
|
||||
"https://res.openai.azure.com/openai/v1/responses",
|
||||
"gpt-5.6-luna-20260710154139",
|
||||
),
|
||||
(
|
||||
"azure_ai/gpt-5.6-sol",
|
||||
"https://res.openai.azure.com",
|
||||
"https://res.openai.azure.com/openai/v1/responses",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _assert_native_responses_request(route, expected_url, expected_model):
|
||||
request = route.calls.last.request
|
||||
body = json.loads(request.content)
|
||||
assert f"{request.url.scheme}://{request.url.host}{request.url.path}" == expected_url
|
||||
assert request.headers["api-key"] == "fake-key"
|
||||
assert body["model"] == expected_model
|
||||
assert body["input"] == "What is the weather in SF?"
|
||||
assert "messages" not in body
|
||||
assert body["reasoning"] == {"effort": "high"}
|
||||
assert body["tools"] == [WEATHER_TOOL]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
@pytest.mark.parametrize("model,api_base,expected_url,expected_model", NATIVE_RESPONSES_CASES)
|
||||
async def test_aresponses_sends_reasoning_and_tools_to_native_endpoint(model, api_base, expected_url, expected_model):
|
||||
route = respx.post(url__regex=r".*/openai/v1/responses(\?.*)?$").mock(
|
||||
return_value=httpx.Response(200, json=_responses_payload(expected_model))
|
||||
)
|
||||
|
||||
await litellm.aresponses(
|
||||
model=model,
|
||||
input="What is the weather in SF?",
|
||||
reasoning_effort="high",
|
||||
tools=[WEATHER_TOOL],
|
||||
api_base=api_base,
|
||||
api_key="fake-key",
|
||||
)
|
||||
|
||||
_assert_native_responses_request(route, expected_url, expected_model)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_aresponses_catalog_name_remapped_to_azure_sends_bare_deployment_name(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_AI_API_BASE", "https://res.openai.azure.com")
|
||||
route = respx.post(url__regex=r".*/openai/v1/responses(\?.*)?$").mock(
|
||||
return_value=httpx.Response(200, json=_responses_payload("gpt-5.4-nano"))
|
||||
)
|
||||
|
||||
await litellm.aresponses(
|
||||
model="azure_ai/gpt-5.4-nano",
|
||||
input="What is the weather in SF?",
|
||||
api_base="https://res.openai.azure.com",
|
||||
api_key="fake-key",
|
||||
)
|
||||
|
||||
assert json.loads(route.calls.last.request.content)["model"] == "gpt-5.4-nano"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
@pytest.mark.parametrize("model,api_base,expected_url,expected_model", NATIVE_RESPONSES_CASES)
|
||||
async def test_router_aresponses_sends_bare_deployment_name(model, api_base, expected_url, expected_model):
|
||||
route = respx.post(url__regex=r".*/openai/v1/responses(\?.*)?$").mock(
|
||||
return_value=httpx.Response(200, json=_responses_payload(expected_model))
|
||||
)
|
||||
router = litellm.Router(
|
||||
model_list=[{"model_name": "gpt-5.6", "litellm_params": {"model": model, "api_base": api_base, "api_key": "fake-key"}}],
|
||||
num_retries=0,
|
||||
)
|
||||
|
||||
await router.aresponses(
|
||||
model="gpt-5.6", input="What is the weather in SF?", reasoning={"effort": "high"}, tools=[WEATHER_TOOL]
|
||||
)
|
||||
|
||||
_assert_native_responses_request(route, expected_url, expected_model)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_aresponses_serverless_host_stays_on_chat_bridge():
|
||||
chat_route = respx.post(url__regex=r".*/chat/completions$").mock(
|
||||
return_value=httpx.Response(200, json=_chat_completion_payload("gpt-5.6-luna"))
|
||||
)
|
||||
responses_route = respx.post(url__regex=r".*/responses$")
|
||||
|
||||
await litellm.aresponses(
|
||||
model="azure_ai/gpt-5.6-luna-20260710154139",
|
||||
input="What is the weather in SF?",
|
||||
tools=[WEATHER_TOOL],
|
||||
api_base=SERVERLESS_BASE,
|
||||
api_key="fake-key",
|
||||
)
|
||||
|
||||
assert chat_route.called
|
||||
assert not responses_route.called
|
||||
assert chat_route.calls.last.request.headers["Authorization"] == "Bearer fake-key"
|
||||
|
|
@ -115,13 +115,13 @@ def _respx_interceptable_httpx_client(monkeypatch):
|
|||
],
|
||||
)
|
||||
def test_resolver_opt_in_gates_openai_like_config(model_info, expected_type):
|
||||
config = _resolve_responses_api_provider_config("my-model", "custom_openai", model_info)
|
||||
config = _resolve_responses_api_provider_config("my-model", "custom_openai", model_info, None)
|
||||
assert type(config) is expected_type
|
||||
|
||||
|
||||
def test_resolver_keeps_native_provider_config():
|
||||
"""`openai/` already routes /v1/responses natively; the opt-in must not swap its config."""
|
||||
config = _resolve_responses_api_provider_config("gpt-4.1", "openai", OPT_IN)
|
||||
config = _resolve_responses_api_provider_config("gpt-4.1", "openai", OPT_IN, None)
|
||||
assert type(config) is OpenAIResponsesAPIConfig
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue