Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion litellm/llms/azure/common_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import re
from typing import Any, Callable, Dict, Literal, Optional, Union, cast

import httpx
Expand Down Expand Up @@ -255,12 +256,33 @@ def get_azure_ad_token_from_oidc(


def select_azure_base_url_or_endpoint(azure_client_params: dict):
"""
Determines whether to use azure_endpoint or base_url for the Azure OpenAI client.

When api_base contains deployment-specific paths (e.g., /openai/deployments/gpt-4o/chat/completions),
it should be used as base_url. However, operation-specific suffixes like /chat/completions must be
stripped because the Azure SDK will append them again when making requests.

Args:
azure_client_params: Dictionary containing azure_endpoint and other client parameters.

Returns:
Updated azure_client_params with either azure_endpoint or base_url set correctly.
"""
azure_endpoint = azure_client_params.get("azure_endpoint", None)
if azure_endpoint is not None:
# see : https://github.com/openai/openai-python/blob/3d61ed42aba652b547029095a7eb269ad4e1e957/src/openai/lib/azure.py#L192
if "/openai/deployments" in azure_endpoint:
# this is base_url, not an azure_endpoint
azure_client_params["base_url"] = azure_endpoint
# Strip operation-specific suffixes that the SDK will append again
# e.g., /chat/completions, /completions, /embeddings, etc.
base_url = re.sub(
r"/(chat/completions|completions|embeddings|audio/speech|audio/transcriptions|images/generations)/?$",
"",
azure_endpoint,
)
base_url = base_url.rstrip("/")
azure_client_params["base_url"] = base_url
azure_client_params.pop("azure_endpoint")

return azure_client_params
Expand Down
166 changes: 166 additions & 0 deletions tests/test_litellm/llms/azure/test_azure_common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1524,3 +1524,169 @@ def test_is_azure_v1_api_version(api_version, expected):
"""
result = BaseAzureLLM._is_azure_v1_api_version(api_version=api_version)
assert result == expected


# Tests for select_azure_base_url_or_endpoint URL sanitization
from litellm.llms.azure.common_utils import select_azure_base_url_or_endpoint


class TestSelectAzureBaseUrlOrEndpoint:
"""Tests for select_azure_base_url_or_endpoint URL sanitization."""

def test_strips_chat_completions_suffix(self):
"""
Test that /chat/completions is stripped from deployment URLs.

When api_base is configured as:
https://xxx.openai.azure.com/openai/deployments/gpt-4o/chat/completions

The Azure SDK will append /chat/completions again, causing:
https://xxx.openai.azure.com/openai/deployments/gpt-4o/chat/completions/chat/completions

This results in a 404 error from Azure.
"""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o/chat/completions"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o"
)
assert "base_url" in result, "Should have base_url when deployment path detected"
assert "azure_endpoint" not in result, "Should remove azure_endpoint"
assert (
result["base_url"] == expected_base_url
), f"Expected {expected_base_url}, got {result['base_url']}"

def test_strips_completions_suffix(self):
"""Test that /completions suffix is stripped for text completion endpoints."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o/completions"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o"
)
assert result["base_url"] == expected_base_url

def test_strips_embeddings_suffix(self):
"""Test that /embeddings suffix is stripped for embedding endpoints."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = "https://ai-azure-product-dev.openai.azure.com/openai/deployments/text-embedding-ada-002"
assert result["base_url"] == expected_base_url

def test_strips_audio_speech_suffix(self):
"""Test that /audio/speech suffix is stripped for TTS endpoints."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/tts-1/audio/speech"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/tts-1"
)
assert result["base_url"] == expected_base_url

def test_strips_audio_transcriptions_suffix(self):
"""Test that /audio/transcriptions suffix is stripped for transcription endpoints."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/whisper-1/audio/transcriptions"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/whisper-1"
)
assert result["base_url"] == expected_base_url

def test_strips_images_generations_suffix(self):
"""Test that /images/generations suffix is stripped for image generation endpoints."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/dall-e-3/images/generations"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/dall-e-3"
)
assert result["base_url"] == expected_base_url

def test_preserves_deployment_path_without_suffix(self):
"""Test that deployment paths without operation suffixes are preserved."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o"
)
assert result["base_url"] == expected_base_url

def test_no_deployment_path_keeps_azure_endpoint(self):
"""Test that URLs without deployment paths keep azure_endpoint."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

# Should keep azure_endpoint since there's no deployment path
assert "azure_endpoint" in result
assert "base_url" not in result
assert (
result["azure_endpoint"]
== "https://ai-azure-product-dev.openai.azure.com"
)

def test_handles_trailing_slash(self):
"""Test that trailing slashes are handled correctly."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o/chat/completions/"
}
result = select_azure_base_url_or_endpoint(azure_client_params)

expected_base_url = (
"https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o"
)
assert result["base_url"] == expected_base_url

def test_preserves_other_params(self):
"""Test that other parameters in the dict are preserved."""
azure_client_params = {
"azure_endpoint": "https://ai-azure-product-dev.openai.azure.com/openai/deployments/gpt-4o/chat/completions",
"api_key": "test-key",
"api_version": "2023-05-15",
"azure_ad_token": "test-token",
}
result = select_azure_base_url_or_endpoint(azure_client_params)

assert result["api_key"] == "test-key"
assert result["api_version"] == "2023-05-15"
assert result["azure_ad_token"] == "test-token"
assert "base_url" in result
assert "azure_endpoint" not in result

def test_none_azure_endpoint(self):
"""Test that None azure_endpoint is handled gracefully."""
azure_client_params = {"azure_endpoint": None, "api_key": "test-key"}
result = select_azure_base_url_or_endpoint(azure_client_params)

# Should return params unchanged
assert result["azure_endpoint"] is None
assert result["api_key"] == "test-key"
assert "base_url" not in result

def test_missing_azure_endpoint(self):
"""Test that missing azure_endpoint is handled gracefully."""
azure_client_params = {"api_key": "test-key"}
result = select_azure_base_url_or_endpoint(azure_client_params)

# Should return params unchanged
assert result["api_key"] == "test-key"
assert "base_url" not in result
Loading