diff --git a/litellm/__init__.py b/litellm/__init__.py index f7e1fb8f24d..bb53fd3a4d4 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1134,6 +1134,7 @@ from .llms.azure_ai.chat.transformation import AzureAIStudioConfig from .llms.mistral.chat.transformation import MistralConfig from .llms.openai.responses.transformation import OpenAIResponsesAPIConfig from .llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig +from .llms.azure.responses.o_series_transformation import AzureOpenAIOSeriesResponsesAPIConfig from .llms.openai.chat.o_series_transformation import ( OpenAIOSeriesConfig as OpenAIO1Config, # maintain backwards compatibility OpenAIOSeriesConfig, diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index 0ed4627908d..94abd2f814e 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -662,6 +662,11 @@ class BaseAzureLLM(BaseOpenAILLM): headers: dict, litellm_params: Optional[GenericLiteLLMParams] ) -> dict: litellm_params = litellm_params or GenericLiteLLMParams() + + # If api-key is already in headers, preserve it + if "api-key" in headers: + return headers + api_key = ( litellm_params.api_key or litellm.api_key diff --git a/litellm/llms/azure/responses/o_series_transformation.py b/litellm/llms/azure/responses/o_series_transformation.py new file mode 100644 index 00000000000..a0b2ef16300 --- /dev/null +++ b/litellm/llms/azure/responses/o_series_transformation.py @@ -0,0 +1,93 @@ +""" +Support for Azure OpenAI O-series models (o1, o3, etc.) in Responses API + +https://platform.openai.com/docs/guides/reasoning + +Translations handled by LiteLLM: +- temperature => drop param (if user opts in to dropping param) +- Other parameters follow base Azure OpenAI Responses API behavior +""" + +from typing import TYPE_CHECKING, Any, Dict + +from litellm._logging import verbose_logger +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams +from litellm.utils import supports_reasoning + +from .transformation import AzureOpenAIResponsesAPIConfig + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class AzureOpenAIOSeriesResponsesAPIConfig(AzureOpenAIResponsesAPIConfig): + """ + Configuration for Azure OpenAI O-series models in Responses API. + + O-series models (o1, o3, etc.) do not support the temperature parameter + in the responses API, so we need to drop it when drop_params is enabled. + """ + + def get_supported_openai_params(self, model: str) -> list: + """ + Get supported parameters for Azure OpenAI O-series Responses API. + + O-series models don't support temperature parameter in responses API. + """ + # Get the base Azure supported params + base_supported_params = super().get_supported_openai_params(model) + + # O-series models don't support temperature parameter in responses API + o_series_unsupported_params = ["temperature"] + + # Filter out unsupported parameters for O-series models + o_series_supported_params = [ + param for param in base_supported_params + if param not in o_series_unsupported_params + ] + + return o_series_supported_params + + def map_openai_params( + self, + response_api_optional_params: ResponsesAPIOptionalRequestParams, + model: str, + drop_params: bool, + ) -> Dict: + """ + Map OpenAI parameters for Azure OpenAI O-series Responses API. + + Drops temperature parameter if drop_params is True since O-series models + don't support temperature in the responses API. + """ + mapped_params = dict(response_api_optional_params) + + # If drop_params is enabled, remove temperature parameter for O-series models + if drop_params and "temperature" in mapped_params: + verbose_logger.debug( + f"Dropping unsupported parameter 'temperature' for Azure OpenAI O-series responses API model {model}" + ) + mapped_params.pop("temperature", None) + + return mapped_params + + def is_o_series_model(self, model: str) -> bool: + """ + Check if the model is an O-series model. + + Args: + model: The model name to check + + Returns: + True if it's an O-series model, False otherwise + """ + # Check if model name contains o_series or if it's a known O-series model + if "o_series" in model.lower(): + return True + + # Check if the model supports reasoning (which is O-series specific) + return supports_reasoning(model) \ No newline at end of file diff --git a/litellm/utils.py b/litellm/utils.py index 64d5f04a971..667625b68b0 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7074,7 +7074,11 @@ class ProviderConfigManager: if litellm.LlmProviders.OPENAI == provider: return litellm.OpenAIResponsesAPIConfig() elif litellm.LlmProviders.AZURE == provider: - return litellm.AzureOpenAIResponsesAPIConfig() + # Check if it's an O-series model + if model and ("o_series" in model.lower() or supports_reasoning(model)): + return litellm.AzureOpenAIOSeriesResponsesAPIConfig() + else: + return litellm.AzureOpenAIResponsesAPIConfig() return None @staticmethod diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py index de416bcf433..51edf91b70c 100644 --- a/tests/test_litellm/llms/azure/response/test_azure_transformation.py +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -9,7 +9,9 @@ sys.path.insert( ) # Adds the parent directory to the system path from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig +from litellm.llms.azure.responses.o_series_transformation import AzureOpenAIOSeriesResponsesAPIConfig from litellm.types.router import GenericLiteLLMParams +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams @pytest.mark.serial @@ -54,47 +56,172 @@ def test_validate_environment_azure_key_within_litellm(): assert result == expected @pytest.mark.serial -def test_validate_environment_azure_openai_api_key_within_secret_str(): +def test_validate_environment_azure_key_within_headers(): azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + headers = {"api-key": "test-api-key-from-headers"} + litellm_params = GenericLiteLLMParams() - with patch("litellm.api_key", None), \ - patch("litellm.azure_key", None), \ - patch("litellm.llms.azure.common_utils.get_secret_str") as mock_get_secret_str: - # Configure the mock to return "test-api-key" when called with "AZURE_OPENAI_API_KEY" - mock_get_secret_str.side_effect = ( - lambda key: "test-api-key" if key == "AZURE_OPENAI_API_KEY" else None - ) + result = azure_openai_responses_apiconfig.validate_environment( + headers=headers, model="", litellm_params=litellm_params + ) - litellm_params = GenericLiteLLMParams() - result = azure_openai_responses_apiconfig.validate_environment( - headers={}, model="", litellm_params=litellm_params - ) - expected = {"api-key": "test-api-key"} + expected = {"api-key": "test-api-key-from-headers"} + + assert result == expected - assert result == expected @pytest.mark.serial -def test_validate_environment_azure_api_key_within_secret_str(): +def test_get_complete_url(): + """ + Test the get_complete_url function + """ azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + api_base = "https://litellm8397336933.openai.azure.com" + litellm_params = {"api_version": "2024-05-01-preview"} - with patch("litellm.api_key", None), \ - patch("litellm.azure_key", None), \ - patch("litellm.llms.azure.common_utils.get_secret_str") as mock_get_secret_str: - # Configure the mock to return None for "AZURE_OPENAI_API_KEY" and "test-api-key" for "AZURE_API_KEY" - def mock_side_effect(key): - if key == "AZURE_OPENAI_API_KEY": - return None - elif key == "AZURE_API_KEY": - return "test-api-key" - else: - return None - - mock_get_secret_str.side_effect = mock_side_effect + result = azure_openai_responses_apiconfig.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) - litellm_params = GenericLiteLLMParams() - result = azure_openai_responses_apiconfig.validate_environment( - headers={}, model="", litellm_params=litellm_params - ) - expected = {"api-key": "test-api-key"} + expected = "https://litellm8397336933.openai.azure.com/openai/responses?api-version=2024-05-01-preview" - assert result == expected + assert result == expected + + +@pytest.mark.serial +def test_azure_o_series_responses_api_supported_params(): + """Test that Azure OpenAI O-series responses API excludes temperature from supported parameters.""" + config = AzureOpenAIOSeriesResponsesAPIConfig() + supported_params = config.get_supported_openai_params("o_series/gpt-o1") + + # Temperature should not be in supported params for O-series models + assert "temperature" not in supported_params + + # Other parameters should still be supported + assert "input" in supported_params + assert "max_output_tokens" in supported_params + assert "stream" in supported_params + assert "top_p" in supported_params + + +@pytest.mark.serial +def test_azure_o_series_responses_api_drop_temperature_param(): + """Test that temperature parameter is dropped when drop_params is True for O-series models.""" + config = AzureOpenAIOSeriesResponsesAPIConfig() + + # Create request params with temperature + request_params = ResponsesAPIOptionalRequestParams( + temperature=0.7, + max_output_tokens=1000, + stream=False, + top_p=0.9 + ) + + # Test with drop_params=True + mapped_params_with_drop = config.map_openai_params( + response_api_optional_params=request_params, + model="o_series/gpt-o1", + drop_params=True + ) + + # Temperature should be dropped + assert "temperature" not in mapped_params_with_drop + # Other params should remain + assert mapped_params_with_drop["max_output_tokens"] == 1000 + assert mapped_params_with_drop["top_p"] == 0.9 + + # Test with drop_params=False + mapped_params_without_drop = config.map_openai_params( + response_api_optional_params=request_params, + model="o_series/gpt-o1", + drop_params=False + ) + + # Temperature should still be present when drop_params=False + assert mapped_params_without_drop["temperature"] == 0.7 + assert mapped_params_without_drop["max_output_tokens"] == 1000 + assert mapped_params_without_drop["top_p"] == 0.9 + + +@pytest.mark.serial +def test_azure_o_series_responses_api_drop_params_no_temperature(): + """Test that map_openai_params works correctly when temperature is not present for O-series models.""" + config = AzureOpenAIOSeriesResponsesAPIConfig() + + # Create request params without temperature + request_params = ResponsesAPIOptionalRequestParams( + max_output_tokens=1000, + stream=False, + top_p=0.9 + ) + + # Should work fine even with drop_params=True + mapped_params = config.map_openai_params( + response_api_optional_params=request_params, + model="o_series/gpt-o1", + drop_params=True + ) + + assert "temperature" not in mapped_params + assert mapped_params["max_output_tokens"] == 1000 + assert mapped_params["top_p"] == 0.9 + + +@pytest.mark.serial +def test_azure_regular_responses_api_supports_temperature(): + """Test that regular Azure OpenAI responses API (non-O-series) supports temperature parameter.""" + config = AzureOpenAIResponsesAPIConfig() + supported_params = config.get_supported_openai_params("gpt-4o") + + # Regular Azure models should support temperature + assert "temperature" in supported_params + + # Other parameters should still be supported + assert "input" in supported_params + assert "max_output_tokens" in supported_params + assert "stream" in supported_params + assert "top_p" in supported_params + + +@pytest.mark.serial +def test_o_series_model_detection(): + """Test that the O-series configuration correctly identifies O-series models.""" + config = AzureOpenAIOSeriesResponsesAPIConfig() + + # Test explicit o_series naming + assert config.is_o_series_model("o_series/gpt-o1") == True + assert config.is_o_series_model("azure/o_series/gpt-o3") == True + + # Test regular models + assert config.is_o_series_model("gpt-4o") == False + assert config.is_o_series_model("gpt-3.5-turbo") == False + + +@pytest.mark.serial +def test_provider_config_manager_o_series_selection(): + """Test that ProviderConfigManager returns the correct config for O-series vs regular models.""" + from litellm.utils import ProviderConfigManager + import litellm + + # Test O-series model selection + o_series_config = ProviderConfigManager.get_provider_responses_api_config( + provider=litellm.LlmProviders.AZURE, + model="o_series/gpt-o1" + ) + assert isinstance(o_series_config, AzureOpenAIOSeriesResponsesAPIConfig) + + # Test regular model selection + regular_config = ProviderConfigManager.get_provider_responses_api_config( + provider=litellm.LlmProviders.AZURE, + model="gpt-4o" + ) + assert isinstance(regular_config, AzureOpenAIResponsesAPIConfig) + assert not isinstance(regular_config, AzureOpenAIOSeriesResponsesAPIConfig) + + # Test with no model specified (should default to regular) + default_config = ProviderConfigManager.get_provider_responses_api_config( + provider=litellm.LlmProviders.AZURE, + model=None + ) + assert isinstance(default_config, AzureOpenAIResponsesAPIConfig) + assert not isinstance(default_config, AzureOpenAIOSeriesResponsesAPIConfig) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 3e2e8fba2e8..5ddd59b6167 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -1117,9 +1117,9 @@ async def test_chat_completion_result_no_nested_none_values(): ) mock_model_response.choices = [mock_choice] - mock_model_response.usage = litellm.Usage( + setattr(mock_model_response, "usage", litellm.Usage( prompt_tokens=10, completion_tokens=5, total_tokens=15 - ) + )) # Verify the mock has None values before serialization raw_dict = mock_model_response.model_dump()