mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(azure_ai): route to native /responses endpoint when api_base contains /projects/
When azure_ai api_base includes /projects/ (Azure AI Foundry project-based endpoints), litellm.responses() now routes to the real upstream /responses endpoint instead of falling back to the completions-style bridge. Fixes #25407
This commit is contained in:
parent
4e12d3c562
commit
297c7a0bc6
6 changed files with 450 additions and 79 deletions
|
|
@ -167,12 +167,12 @@ prometheus_initialize_budget_metrics: Optional[bool] = False
|
|||
require_auth_for_metrics_endpoint: Optional[bool] = False
|
||||
argilla_batch_size: Optional[int] = None
|
||||
datadog_use_v1: Optional[bool] = False # if you want to use v1 datadog logged payload.
|
||||
gcs_pub_sub_use_v1: Optional[
|
||||
bool
|
||||
] = False # if you want to use v1 gcs pubsub logged payload
|
||||
generic_api_use_v1: Optional[
|
||||
bool
|
||||
] = False # if you want to use v1 generic api logged payload
|
||||
gcs_pub_sub_use_v1: Optional[bool] = (
|
||||
False # if you want to use v1 gcs pubsub logged payload
|
||||
)
|
||||
generic_api_use_v1: Optional[bool] = (
|
||||
False # if you want to use v1 generic api logged payload
|
||||
)
|
||||
argilla_transformation_object: Optional[Dict[str, Any]] = None
|
||||
_async_input_callback: List[
|
||||
Union[str, Callable, "CustomLogger"]
|
||||
|
|
@ -192,25 +192,25 @@ _async_failure_callback: List[
|
|||
pre_call_rules: List[Callable] = []
|
||||
post_call_rules: List[Callable] = []
|
||||
turn_off_message_logging: Optional[bool] = False
|
||||
standard_logging_payload_excluded_fields: Optional[
|
||||
List[str]
|
||||
] = None # Fields to exclude from StandardLoggingPayload before callbacks receive it
|
||||
standard_logging_payload_excluded_fields: Optional[List[str]] = (
|
||||
None # Fields to exclude from StandardLoggingPayload before callbacks receive it
|
||||
)
|
||||
log_raw_request_response: bool = False
|
||||
redact_messages_in_exceptions: Optional[bool] = False
|
||||
redact_user_api_key_info: Optional[bool] = False
|
||||
filter_invalid_headers: Optional[bool] = False
|
||||
add_user_information_to_llm_headers: Optional[
|
||||
bool
|
||||
] = None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers
|
||||
add_user_information_to_llm_headers: Optional[bool] = (
|
||||
None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers
|
||||
)
|
||||
store_audit_logs = False # Enterprise feature, allow users to see audit logs
|
||||
### end of callbacks #############
|
||||
|
||||
email: Optional[
|
||||
str
|
||||
] = None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
token: Optional[
|
||||
str
|
||||
] = None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
email: Optional[str] = (
|
||||
None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
)
|
||||
token: Optional[str] = (
|
||||
None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
)
|
||||
telemetry = True
|
||||
max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults
|
||||
drop_params = bool(os.getenv("LITELLM_DROP_PARAMS", False))
|
||||
|
|
@ -272,9 +272,9 @@ use_client: bool = False
|
|||
ssl_verify: Union[str, bool] = True
|
||||
ssl_security_level: Optional[str] = None
|
||||
ssl_certificate: Optional[str] = None
|
||||
ssl_ecdh_curve: Optional[
|
||||
str
|
||||
] = None # Set to 'X25519' to disable PQC and improve performance
|
||||
ssl_ecdh_curve: Optional[str] = (
|
||||
None # Set to 'X25519' to disable PQC and improve performance
|
||||
)
|
||||
disable_streaming_logging: bool = False
|
||||
disable_token_counter: bool = False
|
||||
disable_add_transform_inline_image_block: bool = False
|
||||
|
|
@ -328,20 +328,24 @@ enable_loadbalancing_on_batch_endpoints: Optional[bool] = None
|
|||
enable_caching_on_provider_specific_optional_params: bool = (
|
||||
False # feature-flag for caching on optional params - e.g. 'top_k'
|
||||
)
|
||||
caching: bool = False # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
caching_with_models: bool = False # # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
cache: Optional[
|
||||
"Cache"
|
||||
] = None # cache object <- use this - https://docs.litellm.ai/docs/caching
|
||||
caching: bool = (
|
||||
False # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
)
|
||||
caching_with_models: bool = (
|
||||
False # # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648
|
||||
)
|
||||
cache: Optional["Cache"] = (
|
||||
None # cache object <- use this - https://docs.litellm.ai/docs/caching
|
||||
)
|
||||
default_in_memory_ttl: Optional[float] = None
|
||||
default_redis_ttl: Optional[float] = None
|
||||
default_redis_batch_cache_expiry: Optional[float] = None
|
||||
model_alias_map: Dict[str, str] = {}
|
||||
model_group_settings: Optional["ModelGroupSettings"] = None
|
||||
max_budget: float = 0.0 # set the max budget across all providers
|
||||
budget_duration: Optional[
|
||||
str
|
||||
] = None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
budget_duration: Optional[str] = (
|
||||
None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
)
|
||||
default_soft_budget: float = (
|
||||
DEFAULT_SOFT_BUDGET # by default all litellm proxy keys have a soft budget of 50.0
|
||||
)
|
||||
|
|
@ -350,7 +354,9 @@ forward_traceparent_to_llm_provider: bool = False
|
|||
|
||||
_current_cost = 0.0 # private variable, used if max budget is set
|
||||
error_logs: Dict = {}
|
||||
add_function_to_prompt: bool = False # if function calling not supported by api, append function call details to system prompt
|
||||
add_function_to_prompt: bool = (
|
||||
False # if function calling not supported by api, append function call details to system prompt
|
||||
)
|
||||
client_session: Optional[httpx.Client] = None
|
||||
aclient_session: Optional[httpx.AsyncClient] = None
|
||||
model_fallbacks: Optional[List] = None # Deprecated for 'litellm.fallbacks'
|
||||
|
|
@ -397,7 +403,9 @@ prometheus_emit_stream_label: bool = False
|
|||
disable_add_prefix_to_prompt: bool = (
|
||||
False # used by anthropic, to disable adding prefix to prompt
|
||||
)
|
||||
disable_copilot_system_to_assistant: bool = False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior.
|
||||
disable_copilot_system_to_assistant: bool = (
|
||||
False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior.
|
||||
)
|
||||
public_mcp_servers: Optional[List[str]] = None
|
||||
public_model_groups: Optional[List[str]] = None
|
||||
public_agent_groups: Optional[List[str]] = None
|
||||
|
|
@ -406,9 +414,9 @@ public_agent_groups: Optional[List[str]] = None
|
|||
# Old format: { "displayName": "url" } (for backward compatibility)
|
||||
public_model_groups_links: Dict[str, Union[str, Dict[str, Any]]] = {}
|
||||
#### REQUEST PRIORITIZATION #######
|
||||
priority_reservation: Optional[
|
||||
Dict[str, Union[float, "PriorityReservationDict"]]
|
||||
] = None
|
||||
priority_reservation: Optional[Dict[str, Union[float, "PriorityReservationDict"]]] = (
|
||||
None
|
||||
)
|
||||
# priority_reservation_settings is lazy-loaded via __getattr__
|
||||
# Only declare for type checking - at runtime __getattr__ handles it
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -416,13 +424,17 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
######## Networking Settings ########
|
||||
use_aiohttp_transport: bool = True # Older variable, aiohttp is now the default. use disable_aiohttp_transport instead.
|
||||
use_aiohttp_transport: bool = (
|
||||
True # Older variable, aiohttp is now the default. use disable_aiohttp_transport instead.
|
||||
)
|
||||
aiohttp_trust_env: bool = False # set to true to use HTTP_ Proxy settings
|
||||
disable_aiohttp_transport: bool = False # Set this to true to use httpx instead
|
||||
disable_aiohttp_trust_env: bool = (
|
||||
False # When False, aiohttp will respect HTTP(S)_PROXY env vars
|
||||
)
|
||||
force_ipv4: bool = False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6.
|
||||
force_ipv4: bool = (
|
||||
False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6.
|
||||
)
|
||||
network_mock: bool = False # When True, use mock transport — no real network calls
|
||||
|
||||
####### STOP SEQUENCE LIMIT #######
|
||||
|
|
@ -437,13 +449,13 @@ context_window_fallbacks: Optional[List] = None
|
|||
content_policy_fallbacks: Optional[List] = None
|
||||
allowed_fails: int = 3
|
||||
allow_dynamic_callback_disabling: bool = True
|
||||
num_retries_per_request: Optional[
|
||||
int
|
||||
] = None # for the request overall (incl. fallbacks + model retries)
|
||||
num_retries_per_request: Optional[int] = (
|
||||
None # for the request overall (incl. fallbacks + model retries)
|
||||
)
|
||||
####### SECRET MANAGERS #####################
|
||||
secret_manager_client: Optional[
|
||||
Any
|
||||
] = None # list of instantiated key management clients - e.g. azure kv, infisical, etc.
|
||||
secret_manager_client: Optional[Any] = (
|
||||
None # list of instantiated key management clients - e.g. azure kv, infisical, etc.
|
||||
)
|
||||
_google_kms_resource_name: Optional[str] = None
|
||||
_key_management_system: Optional["KeyManagementSystem"] = None
|
||||
# Note: KeyManagementSettings must be eagerly imported because _key_management_settings
|
||||
|
|
@ -456,12 +468,12 @@ output_parse_pii: bool = False
|
|||
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
|
||||
|
||||
model_cost = get_model_cost_map(url=model_cost_map_url)
|
||||
cost_discount_config: Dict[
|
||||
str, float
|
||||
] = {} # Provider-specific cost discounts {"vertex_ai": 0.05} = 5% discount
|
||||
cost_margin_config: Dict[
|
||||
str, Union[float, Dict[str, float]]
|
||||
] = {} # Provider-specific or global cost margins. Examples:
|
||||
cost_discount_config: Dict[str, float] = (
|
||||
{}
|
||||
) # Provider-specific cost discounts {"vertex_ai": 0.05} = 5% discount
|
||||
cost_margin_config: Dict[str, Union[float, Dict[str, float]]] = (
|
||||
{}
|
||||
) # Provider-specific or global cost margins. Examples:
|
||||
# Percentage: {"openai": 0.10} = 10% margin
|
||||
# Fixed: {"openai": {"fixed_amount": 0.001}} = $0.001 per request
|
||||
# Global: {"global": 0.05} = 5% global margin on all providers
|
||||
|
|
@ -1310,12 +1322,12 @@ from . import rag
|
|||
from .types.llms.custom_llm import CustomLLMItem
|
||||
|
||||
custom_provider_map: List[CustomLLMItem] = []
|
||||
_custom_providers: List[
|
||||
str
|
||||
] = [] # internal helper util, used to track names of custom providers
|
||||
disable_hf_tokenizer_download: Optional[
|
||||
bool
|
||||
] = None # disable huggingface tokenizer download. Defaults to openai clk100
|
||||
_custom_providers: List[str] = (
|
||||
[]
|
||||
) # internal helper util, used to track names of custom providers
|
||||
disable_hf_tokenizer_download: Optional[bool] = (
|
||||
None # disable huggingface tokenizer download. Defaults to openai clk100
|
||||
)
|
||||
global_disable_no_log_param: bool = False
|
||||
|
||||
### CLI UTILITIES ###
|
||||
|
|
@ -1809,6 +1821,9 @@ if TYPE_CHECKING:
|
|||
from .llms.hosted_vllm.responses.transformation import (
|
||||
HostedVLLMResponsesAPIConfig as HostedVLLMResponsesAPIConfig,
|
||||
)
|
||||
from .llms.azure_ai.responses.transformation import (
|
||||
AzureAIResponsesAPIConfig as AzureAIResponsesAPIConfig,
|
||||
)
|
||||
from .llms.github_copilot.chat.transformation import (
|
||||
GithubCopilotConfig as GithubCopilotConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ LLM_CONFIG_NAMES = (
|
|||
"XAIResponsesAPIConfig",
|
||||
"LiteLLMProxyResponsesAPIConfig",
|
||||
"HostedVLLMResponsesAPIConfig",
|
||||
"AzureAIResponsesAPIConfig",
|
||||
"VolcEngineResponsesAPIConfig",
|
||||
"PerplexityResponsesConfig",
|
||||
"DatabricksResponsesAPIConfig",
|
||||
|
|
@ -921,6 +922,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
|
|||
".llms.hosted_vllm.responses.transformation",
|
||||
"HostedVLLMResponsesAPIConfig",
|
||||
),
|
||||
"AzureAIResponsesAPIConfig": (
|
||||
".llms.azure_ai.responses.transformation",
|
||||
"AzureAIResponsesAPIConfig",
|
||||
),
|
||||
"VolcEngineResponsesAPIConfig": (
|
||||
".llms.volcengine.responses.transformation",
|
||||
"VolcEngineResponsesAPIConfig",
|
||||
|
|
|
|||
0
litellm/llms/azure_ai/responses/__init__.py
Normal file
0
litellm/llms/azure_ai/responses/__init__.py
Normal file
133
litellm/llms/azure_ai/responses/transformation.py
Normal file
133
litellm/llms/azure_ai/responses/transformation.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""
|
||||
Responses API transformation for Azure AI provider.
|
||||
|
||||
When api_base includes /projects/, route to the real upstream /responses
|
||||
endpoint instead of falling back to the completions-style bridge.
|
||||
|
||||
Ref: https://learn.microsoft.com/en-us/azure/foundry/foundry-models/how-to/generate-responses
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure.common_utils import BaseAzureLLM
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import _add_path_to_api_base
|
||||
|
||||
|
||||
class AzureAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
||||
"""
|
||||
Configuration for Azure AI Foundry Responses API.
|
||||
|
||||
Extends OpenAI's responses config because Azure AI Foundry project-based
|
||||
endpoints follow the OpenAI /responses spec. Uses Azure-specific auth
|
||||
(api-key header for *.services.ai.azure.com hosts) and constructs the
|
||||
correct URL path for project-based endpoints.
|
||||
"""
|
||||
|
||||
@property
|
||||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.AZURE_AI
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
litellm_params: Optional[GenericLiteLLMParams],
|
||||
) -> dict:
|
||||
litellm_params = litellm_params or GenericLiteLLMParams()
|
||||
api_key = AzureFoundryModelInfo.get_api_key(
|
||||
api_key=litellm_params.api_key,
|
||||
)
|
||||
api_base = AzureFoundryModelInfo.get_api_base(
|
||||
api_base=litellm_params.api_base,
|
||||
)
|
||||
|
||||
if api_key:
|
||||
if api_base and self._should_use_api_key_header(api_base):
|
||||
headers["api-key"] = api_key
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
else:
|
||||
# Fall back to Azure AD token-based auth
|
||||
headers = BaseAzureLLM._base_validate_azure_environment(
|
||||
headers=headers, litellm_params=litellm_params
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _should_use_api_key_header(api_base: str) -> bool:
|
||||
"""
|
||||
Returns True if the request should use the ``api-key`` header.
|
||||
|
||||
Azure AI Foundry endpoints under *.services.ai.azure.com and
|
||||
*.openai.azure.com expect the ``api-key`` header instead of
|
||||
``Authorization: Bearer ...``.
|
||||
"""
|
||||
parsed_url = urlparse(api_base)
|
||||
host = parsed_url.hostname
|
||||
if host and (
|
||||
host.endswith(".services.ai.azure.com")
|
||||
or host.endswith(".openai.azure.com")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: Optional[str],
|
||||
litellm_params: dict,
|
||||
) -> str:
|
||||
"""
|
||||
Build the full URL for the Azure AI Foundry Responses API.
|
||||
|
||||
For project-based endpoints (api_base contains ``/projects/``),
|
||||
appends ``/openai/v1/responses`` to the base.
|
||||
|
||||
Example:
|
||||
api_base = "https://<resource>.services.ai.azure.com/api/projects/<project>"
|
||||
-> "https://<resource>.services.ai.azure.com/api/projects/<project>/openai/v1/responses"
|
||||
"""
|
||||
api_base = AzureFoundryModelInfo.get_api_base(api_base=api_base)
|
||||
|
||||
if api_base is None:
|
||||
raise ValueError(
|
||||
"api_base is required for Azure AI Responses API. "
|
||||
"Set via api_base parameter or AZURE_AI_API_BASE environment variable."
|
||||
)
|
||||
|
||||
# Extract api_version
|
||||
api_version = litellm_params.get("api_version")
|
||||
|
||||
# Parse query params from existing URL
|
||||
original_url = httpx.URL(api_base)
|
||||
query_params = dict(original_url.params)
|
||||
|
||||
if "api-version" not in query_params and api_version:
|
||||
query_params["api-version"] = api_version
|
||||
|
||||
# Build the responses endpoint path
|
||||
if "/projects/" in api_base:
|
||||
new_url = _add_path_to_api_base(
|
||||
api_base=api_base, ending_path="/openai/v1/responses"
|
||||
)
|
||||
elif "services.ai.azure.com" in api_base:
|
||||
new_url = _add_path_to_api_base(
|
||||
api_base=api_base, ending_path="/models/responses"
|
||||
)
|
||||
else:
|
||||
new_url = _add_path_to_api_base(
|
||||
api_base=api_base, ending_path="/v1/responses"
|
||||
)
|
||||
|
||||
final_url = httpx.URL(new_url).copy_with(params=query_params)
|
||||
return str(final_url)
|
||||
|
|
@ -783,9 +783,9 @@ def function_setup( # noqa: PLR0915
|
|||
coroutine_checker = get_coroutine_checker_fn()
|
||||
|
||||
## DYNAMIC CALLBACKS ##
|
||||
dynamic_callbacks: Optional[
|
||||
List[Union[str, Callable, "CustomLogger"]]
|
||||
] = kwargs.pop("callbacks", None)
|
||||
dynamic_callbacks: Optional[List[Union[str, Callable, "CustomLogger"]]] = (
|
||||
kwargs.pop("callbacks", None)
|
||||
)
|
||||
all_callbacks = get_dynamic_callbacks(dynamic_callbacks=dynamic_callbacks)
|
||||
|
||||
if len(all_callbacks) > 0:
|
||||
|
|
@ -1691,9 +1691,9 @@ def client(original_function): # noqa: PLR0915
|
|||
exception=e,
|
||||
retry_policy=kwargs.get("retry_policy"),
|
||||
)
|
||||
kwargs[
|
||||
"retry_policy"
|
||||
] = reset_retry_policy() # prevent infinite loops
|
||||
kwargs["retry_policy"] = (
|
||||
reset_retry_policy()
|
||||
) # prevent infinite loops
|
||||
litellm.num_retries = (
|
||||
None # set retries to None to prevent infinite loops
|
||||
)
|
||||
|
|
@ -1740,9 +1740,9 @@ def client(original_function): # noqa: PLR0915
|
|||
exception=e,
|
||||
retry_policy=kwargs.get("retry_policy"),
|
||||
)
|
||||
kwargs[
|
||||
"retry_policy"
|
||||
] = reset_retry_policy() # prevent infinite loops
|
||||
kwargs["retry_policy"] = (
|
||||
reset_retry_policy()
|
||||
) # prevent infinite loops
|
||||
litellm.num_retries = (
|
||||
None # set retries to None to prevent infinite loops
|
||||
)
|
||||
|
|
@ -3771,10 +3771,10 @@ def pre_process_non_default_params(
|
|||
|
||||
if "response_format" in non_default_params:
|
||||
if provider_config is not None:
|
||||
non_default_params[
|
||||
"response_format"
|
||||
] = provider_config.get_json_schema_from_pydantic_object(
|
||||
response_format=non_default_params["response_format"]
|
||||
non_default_params["response_format"] = (
|
||||
provider_config.get_json_schema_from_pydantic_object(
|
||||
response_format=non_default_params["response_format"]
|
||||
)
|
||||
)
|
||||
else:
|
||||
non_default_params["response_format"] = type_to_response_format_param(
|
||||
|
|
@ -3903,16 +3903,16 @@ def pre_process_optional_params(
|
|||
True # so that main.py adds the function call to the prompt
|
||||
)
|
||||
if "tools" in non_default_params:
|
||||
optional_params[
|
||||
"functions_unsupported_model"
|
||||
] = non_default_params.pop("tools")
|
||||
optional_params["functions_unsupported_model"] = (
|
||||
non_default_params.pop("tools")
|
||||
)
|
||||
non_default_params.pop(
|
||||
"tool_choice", None
|
||||
) # causes ollama requests to hang
|
||||
elif "functions" in non_default_params:
|
||||
optional_params[
|
||||
"functions_unsupported_model"
|
||||
] = non_default_params.pop("functions")
|
||||
optional_params["functions_unsupported_model"] = (
|
||||
non_default_params.pop("functions")
|
||||
)
|
||||
elif (
|
||||
litellm.add_function_to_prompt
|
||||
): # if user opts to add it to prompt instead
|
||||
|
|
@ -4893,9 +4893,7 @@ def _get_order_filtered_deployments(
|
|||
) -> List:
|
||||
if target_order is not None:
|
||||
filtered = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if _get_deployment_order(d) == target_order
|
||||
d for d in healthy_deployments if _get_deployment_order(d) == target_order
|
||||
]
|
||||
if filtered:
|
||||
return filtered
|
||||
|
|
@ -7549,9 +7547,9 @@ class ModelResponseIterator:
|
|||
if convert_to_delta is True:
|
||||
_stream_response = ModelResponseStream()
|
||||
_stream_response.choices[0].delta.content = model_response.choices[0].message.content # type: ignore
|
||||
self.model_response: Union[
|
||||
ModelResponse, ModelResponseStream
|
||||
] = _stream_response
|
||||
self.model_response: Union[ModelResponse, ModelResponseStream] = (
|
||||
_stream_response
|
||||
)
|
||||
else:
|
||||
self.model_response = model_response
|
||||
self.is_done = False
|
||||
|
|
@ -8545,6 +8543,8 @@ class ProviderConfigManager:
|
|||
return None
|
||||
elif litellm.LlmProviders.OPENROUTER == provider:
|
||||
return litellm.OpenRouterResponsesAPIConfig()
|
||||
elif litellm.LlmProviders.AZURE_AI == provider:
|
||||
return litellm.AzureAIResponsesAPIConfig()
|
||||
elif litellm.LlmProviders.HOSTED_VLLM == provider:
|
||||
return litellm.HostedVLLMResponsesAPIConfig()
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
"""
|
||||
Tests for azure_ai Responses API support.
|
||||
|
||||
Verifies that when api_base contains /projects/, litellm.responses()
|
||||
routes to the real upstream /responses endpoint instead of falling
|
||||
back to the completions-style bridge.
|
||||
|
||||
Ref: https://github.com/BerriAI/litellm/issues/25407
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
import litellm
|
||||
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
|
||||
|
||||
|
||||
def _make_mock_responses_api_response(content: str = "Hello!") -> dict:
|
||||
return {
|
||||
"id": "resp-test-azure-ai",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"model": "DeepSeek-R1",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg-test-azure-ai",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": content,
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"status": "completed",
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_http_client(response_body: dict) -> MagicMock:
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.json.return_value = response_body
|
||||
mock_response.text = json.dumps(response_body)
|
||||
mock_client.post.return_value = mock_response
|
||||
return mock_client
|
||||
|
||||
|
||||
class TestAzureAIResponsesProviderConfig:
|
||||
"""Test that ProviderConfigManager returns AzureAIResponsesAPIConfig for azure_ai."""
|
||||
|
||||
def test_provider_config_registration(self):
|
||||
config = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model="DeepSeek-R1",
|
||||
provider=LlmProviders.AZURE_AI,
|
||||
)
|
||||
assert config is not None
|
||||
assert isinstance(config, AzureAIResponsesAPIConfig)
|
||||
assert config.custom_llm_provider == LlmProviders.AZURE_AI
|
||||
|
||||
|
||||
class TestAzureAIResponsesURL:
|
||||
"""Test get_complete_url() constructs the correct URL for various api_base patterns."""
|
||||
|
||||
def test_project_based_url(self):
|
||||
"""When api_base contains /projects/, append /openai/v1/responses."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
url = config.get_complete_url(
|
||||
api_base="https://myresource.services.ai.azure.com/api/projects/my-project",
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == (
|
||||
"https://myresource.services.ai.azure.com/api/projects/my-project"
|
||||
"/openai/v1/responses"
|
||||
)
|
||||
|
||||
def test_project_based_url_with_api_version(self):
|
||||
"""api-version query param should be appended."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
url = config.get_complete_url(
|
||||
api_base="https://myresource.services.ai.azure.com/api/projects/my-project",
|
||||
litellm_params={"api_version": "2025-03-01-preview"},
|
||||
)
|
||||
assert "api-version=2025-03-01-preview" in url
|
||||
assert "/openai/v1/responses" in url
|
||||
|
||||
def test_services_ai_azure_url_without_projects(self):
|
||||
"""Standard services.ai.azure.com base (no /projects/) uses /models/responses."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
url = config.get_complete_url(
|
||||
api_base="https://myresource.services.ai.azure.com",
|
||||
litellm_params={},
|
||||
)
|
||||
assert "/models/responses" in url
|
||||
|
||||
def test_generic_api_base(self):
|
||||
"""Non-Azure-Foundry base uses /v1/responses."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
url = config.get_complete_url(
|
||||
api_base="https://custom-proxy.example.com",
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://custom-proxy.example.com/v1/responses"
|
||||
|
||||
def test_missing_api_base_raises(self):
|
||||
"""ValueError raised when api_base is None and no env var set."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Ensure env var is not set
|
||||
os.environ.pop("AZURE_AI_API_BASE", None)
|
||||
with pytest.raises(ValueError, match="api_base is required"):
|
||||
config.get_complete_url(api_base=None, litellm_params={})
|
||||
|
||||
|
||||
class TestAzureAIResponsesAuth:
|
||||
"""Test validate_environment() sets correct auth headers."""
|
||||
|
||||
def test_api_key_header_for_azure_foundry_host(self):
|
||||
"""*.services.ai.azure.com should use api-key header."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
headers = config.validate_environment(
|
||||
headers={},
|
||||
model="DeepSeek-R1",
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
api_key="test-key-123",
|
||||
api_base="https://myresource.services.ai.azure.com/api/projects/proj",
|
||||
),
|
||||
)
|
||||
assert headers.get("api-key") == "test-key-123"
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_bearer_auth_for_non_azure_host(self):
|
||||
"""Non-Azure hosts should use Bearer auth."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
headers = config.validate_environment(
|
||||
headers={},
|
||||
model="DeepSeek-R1",
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
api_key="test-key-456",
|
||||
api_base="https://custom-proxy.example.com",
|
||||
),
|
||||
)
|
||||
assert headers.get("Authorization") == "Bearer test-key-456"
|
||||
assert "api-key" not in headers
|
||||
|
||||
def test_api_key_header_for_openai_azure_host(self):
|
||||
"""*.openai.azure.com should also use api-key header."""
|
||||
config = AzureAIResponsesAPIConfig()
|
||||
headers = config.validate_environment(
|
||||
headers={},
|
||||
model="gpt-4",
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
api_key="test-key-789",
|
||||
api_base="https://myresource.openai.azure.com",
|
||||
),
|
||||
)
|
||||
assert headers.get("api-key") == "test-key-789"
|
||||
|
||||
|
||||
class TestAzureAIResponsesE2E:
|
||||
"""End-to-end test with mocked HTTP client."""
|
||||
|
||||
def test_responses_create_routes_to_native_endpoint(self):
|
||||
"""
|
||||
Verify litellm.responses() uses native /responses routing for azure_ai
|
||||
with a project-based api_base, rather than the completions bridge.
|
||||
"""
|
||||
mock_client = _make_mock_http_client(
|
||||
_make_mock_responses_api_response("Hello from Azure AI Foundry!")
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
response = litellm.responses(
|
||||
model="azure_ai/DeepSeek-R1",
|
||||
input="Hello, how are you?",
|
||||
api_base="https://myresource.services.ai.azure.com/api/projects/my-project",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ResponsesAPIResponse)
|
||||
assert len(response.output) > 0
|
||||
output_message = response.output[0]
|
||||
assert output_message.role == "assistant"
|
||||
assert "Azure AI Foundry" in output_message.content[0].text
|
||||
|
||||
# Verify the URL used contains /responses (native endpoint)
|
||||
call_args = mock_client.post.call_args
|
||||
called_url = call_args[1].get("url", call_args[0][0] if call_args[0] else "")
|
||||
assert "/responses" in str(called_url)
|
||||
Loading…
Add table
Reference in a new issue