From 1c80fa9c3b83b485eb3becafee3eeb6536a3e3a2 Mon Sep 17 00:00:00 2001 From: withomasmicrosoft Date: Fri, 22 May 2026 04:50:08 -0700 Subject: [PATCH] [Feature][Bug Fix] Decouple Azure OpenAI Deployment ID from model name via base_model to fix gpt5 model routing (#28490) * feat(azure): decouple deployment ID from model name via base_model Azure OpenAI deployments have arbitrary names (deployment IDs) that may not match the underlying model. Previously, model-type detection (o-series, gpt-5, etc.) relied on substring matching against the deployment name, causing misrouted configs and rejected params when deployment names were non-standard (e.g. 'my-deployment-id' for gpt-5.2). This change extends the existing base_model field to drive model-type detection, config selection, supported param resolution, and param mapping throughout the Azure call path: - _get_azure_config() uses base_model for is_o_series/is_gpt_5 checks - get_provider_chat_config() threads base_model for Azure - get_supported_openai_params() accepts and uses base_model - get_optional_params() accepts base_model and passes it to all Azure config method calls (get_supported_openai_params, map_openai_params) - azure.py completion handler uses base_model for GPT-5 detection - Config internal methods (e.g. is_model_gpt_5_2_model) now receive base_model so features like logprobs are correctly enabled Fully backward compatible - when base_model is unset, behavior is identical. Existing o_series/ and gpt5_series/ prefix workarounds continue to work. Usage in proxy config: model_list: - model_name: my-gpt5 litellm_params: model: azure/my-deployment-id model_info: base_model: azure/gpt-5.2 Fixes: non-standard deployment names like 'prefix-gpt-5.2' rejecting logprobs/top_logprobs despite the underlying model supporting them. * Addressing Greptile comments. --- .../get_supported_openai_params.py | 29 +- litellm/llms/azure/azure.py | 4 +- litellm/main.py | 12 +- litellm/utils.py | 61 +++- .../chat/test_azure_base_model_routing.py | 274 ++++++++++++++++++ 5 files changed, 354 insertions(+), 26 deletions(-) create mode 100644 tests/test_litellm/llms/azure/chat/test_azure_base_model_routing.py diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index 9d8bd7523db..46c643f3e37 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -11,6 +11,7 @@ def get_supported_openai_params( # noqa: PLR0915 request_type: Literal[ "chat_completion", "embeddings", "transcription" ] = "chat_completion", + base_model: Optional[str] = None, ) -> Optional[list]: """ Returns the supported openai params for a given model + provider @@ -20,6 +21,11 @@ def get_supported_openai_params( # noqa: PLR0915 get_supported_openai_params(model="anthropic.claude-3", custom_llm_provider="bedrock") ``` + Args: + base_model: For Azure, the true underlying model (e.g. ``"azure/gpt-5.2"``) + when the deployment name differs. Used for model-type detection so that + non-standard deployment names route to the correct config. + Returns: - List if custom_llm_provider is mapped - None if unmapped @@ -32,17 +38,21 @@ def get_supported_openai_params( # noqa: PLR0915 if custom_llm_provider in LlmProvidersSet: provider_config = litellm.ProviderConfigManager.get_provider_chat_config( - model=model, provider=LlmProviders(custom_llm_provider) + model=model, + provider=LlmProviders(custom_llm_provider), + base_model=base_model, ) elif custom_llm_provider.split("/")[0] in LlmProvidersSet: provider_config = litellm.ProviderConfigManager.get_provider_chat_config( - model=model, provider=LlmProviders(custom_llm_provider.split("/")[0]) + model=model, + provider=LlmProviders(custom_llm_provider.split("/")[0]), + base_model=base_model, ) else: provider_config = None if provider_config and request_type == "chat_completion": - return provider_config.get_supported_openai_params(model=model) + return provider_config.get_supported_openai_params(model=base_model or model) if custom_llm_provider == "bedrock": return litellm.AmazonConverseConfig().get_supported_openai_params(model=model) @@ -130,13 +140,18 @@ def get_supported_openai_params( # noqa: PLR0915 model=model ) elif custom_llm_provider == "azure": - if litellm.AzureOpenAIO1Config().is_o_series_model(model=model): + _azure_detection_model = base_model or model + if litellm.AzureOpenAIO1Config().is_o_series_model( + model=_azure_detection_model + ): return litellm.AzureOpenAIO1Config().get_supported_openai_params( - model=model + model=_azure_detection_model ) - elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=model): + elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model( + model=_azure_detection_model + ): return litellm.AzureOpenAIGPT5Config().get_supported_openai_params( - model=model + model=_azure_detection_model ) else: return litellm.AzureOpenAIConfig().get_supported_openai_params(model=model) diff --git a/litellm/llms/azure/azure.py b/litellm/llms/azure/azure.py index 9291269d153..734b8ecef16 100644 --- a/litellm/llms/azure/azure.py +++ b/litellm/llms/azure/azure.py @@ -239,7 +239,9 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): ) data = {"model": None, "messages": messages, **optional_params} - elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=model): + elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model( + model=litellm_params.get("base_model") or model + ): data = litellm.AzureOpenAIGPT5Config().transform_request( model=model, messages=messages, diff --git a/litellm/main.py b/litellm/main.py index b5364f8ba17..16528c7cb3e 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1491,7 +1491,9 @@ def completion( # type: ignore # noqa: PLR0915 provider.value for provider in LlmProviders ]: provider_config = ProviderConfigManager.get_provider_chat_config( - model=model, provider=LlmProviders(custom_llm_provider) + model=model, + provider=LlmProviders(custom_llm_provider), + base_model=base_model, ) if provider_config is not None: @@ -1550,6 +1552,7 @@ def completion( # type: ignore # noqa: PLR0915 "safety_identifier": safety_identifier, "service_tier": service_tier, "allowed_openai_params": kwargs.get("allowed_openai_params"), + "base_model": base_model, } optional_params = get_optional_params( **optional_param_args, **non_default_params @@ -1766,7 +1769,12 @@ def completion( # type: ignore # noqa: PLR0915 if max_retries is not None: optional_params["max_retries"] = max_retries - if litellm.AzureOpenAIO1Config().is_o_series_model(model=model): + # Use base_model (the true underlying model) for model-type + # detection when the deployment name differs from the model name. + _azure_detection_model = base_model or model + if litellm.AzureOpenAIO1Config().is_o_series_model( + model=_azure_detection_model + ): ## LOAD CONFIG - if set config = litellm.AzureOpenAIO1Config.get_config() for k, v in config.items(): diff --git a/litellm/utils.py b/litellm/utils.py index 2487d39bd0d..03ca4bac5b6 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4019,16 +4019,23 @@ def get_optional_params( # noqa: PLR0915 thinking: Optional[AnthropicThinkingParam] = None, web_search_options: Optional[OpenAIWebSearchOptions] = None, safety_identifier: Optional[str] = None, + base_model: Optional[str] = None, **kwargs, ): passed_params = locals().copy() special_params = passed_params.pop("kwargs") + # Remove base_model from passed_params so it doesn't interfere with + # non_default_params / _check_valid_arg — it's a routing hint, not an + # OpenAI param. + passed_params.pop("base_model", None) provider_config: Optional[BaseConfig] = None if custom_llm_provider is not None and custom_llm_provider in [ provider.value for provider in LlmProviders ]: provider_config = ProviderConfigManager.get_provider_chat_config( - model=model, provider=LlmProviders(custom_llm_provider) + model=model, + provider=LlmProviders(custom_llm_provider), + base_model=base_model, ) non_default_params = pre_process_non_default_params( passed_params=passed_params, @@ -4091,7 +4098,7 @@ def get_optional_params( # noqa: PLR0915 sys.modules[__name__], "get_supported_openai_params" ) supported_params = get_supported_openai_params( - model=model, custom_llm_provider=custom_llm_provider + model=model, custom_llm_provider=custom_llm_provider, base_model=base_model ) if supported_params is None: supported_params = get_supported_openai_params( @@ -4702,22 +4709,27 @@ def get_optional_params( # noqa: PLR0915 ), ) elif custom_llm_provider == "azure": - if litellm.AzureOpenAIO1Config().is_o_series_model(model=model): + _azure_detection_model = base_model or model + if litellm.AzureOpenAIO1Config().is_o_series_model( + model=_azure_detection_model + ): optional_params = litellm.AzureOpenAIO1Config().map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=model, + model=_azure_detection_model, drop_params=( drop_params if drop_params is not None and isinstance(drop_params, bool) else False ), ) - elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=model): + elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model( + model=_azure_detection_model + ): optional_params = litellm.AzureOpenAIGPT5Config().map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=model, + model=_azure_detection_model, drop_params=( drop_params if drop_params is not None and isinstance(drop_params, bool) @@ -4739,7 +4751,7 @@ def get_optional_params( # noqa: PLR0915 optional_params = litellm.AzureOpenAIConfig().map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=model, + model=_azure_detection_model, api_version=api_version, # type: ignore drop_params=( drop_params @@ -8124,10 +8136,8 @@ class ProviderConfigManager: # Format: (factory_function, needs_model_parameter: bool) LlmProviders.OPENAI: (lambda: litellm.OpenAIGPTConfig(), False), LlmProviders.ANTHROPIC: (lambda: litellm.AnthropicConfig(), False), - LlmProviders.AZURE: ( - lambda model: ProviderConfigManager._get_azure_config(model), - True, - ), + # AZURE is handled as a special case in get_provider_chat_config() + # so that base_model can be threaded through for model-type detection. LlmProviders.AZURE_AI: ( lambda model: ProviderConfigManager._get_azure_ai_config(model), True, @@ -8267,11 +8277,19 @@ class ProviderConfigManager: } @staticmethod - def _get_azure_config(model: str) -> BaseConfig: - """Get Azure config based on model type.""" - if litellm.AzureOpenAIO1Config().is_o_series_model(model=model): + def _get_azure_config(model: str, base_model: Optional[str] = None) -> BaseConfig: + """Get Azure config based on model type. + + When *base_model* is provided (e.g. ``"azure/gpt-5.2"``), it is used + for model-type detection instead of *model* (the deployment name). + This allows non-standard deployment names like ``"azure/foo"`` to be + routed through the correct config when the user specifies the true + underlying model via ``base_model``. + """ + detection_model = base_model or model + if litellm.AzureOpenAIO1Config().is_o_series_model(model=detection_model): return litellm.AzureOpenAIO1Config() - if litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=model): + if litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=detection_model): return litellm.AzureOpenAIGPT5Config() return litellm.AzureOpenAIConfig() @@ -8329,13 +8347,18 @@ class ProviderConfigManager: @staticmethod def get_provider_chat_config( # noqa: PLR0915 - model: str, provider: LlmProviders + model: str, + provider: LlmProviders, + base_model: Optional[str] = None, ) -> Optional[BaseConfig]: """ Returns the provider config for a given provider. Uses O(1) dictionary lookup for fast provider resolution. Python classes take priority over JSON (they have custom overrides). + + For Azure, *base_model* (when set) drives model-type detection so that + non-standard deployment names still route to the correct config. """ # Handle OpenAI special cases (O-series and GPT-5 models) if provider == LlmProviders.OPENAI: @@ -8344,6 +8367,12 @@ class ProviderConfigManager: if litellm.OpenAIGPT5Config.is_model_gpt_5_model(model=model): return litellm.OpenAIGPT5Config() + # Handle Azure before the generic map so base_model can be threaded through + if provider == LlmProviders.AZURE: + return ProviderConfigManager._get_azure_config( + model=model, base_model=base_model + ) + # Initialize provider config map lazily (avoids circular imports) if ProviderConfigManager._PROVIDER_CONFIG_MAP is None: ProviderConfigManager._PROVIDER_CONFIG_MAP = ( diff --git a/tests/test_litellm/llms/azure/chat/test_azure_base_model_routing.py b/tests/test_litellm/llms/azure/chat/test_azure_base_model_routing.py new file mode 100644 index 00000000000..1e8e23c38ca --- /dev/null +++ b/tests/test_litellm/llms/azure/chat/test_azure_base_model_routing.py @@ -0,0 +1,274 @@ +"""Tests for decoupling Azure deployment IDs from underlying model names. + +When users name their Azure deployment something non-standard (e.g. "my-deployment-id"), +setting ``base_model`` should drive model-type detection (o-series, gpt-5, +etc.) so the correct config, supported params, and param mapping are used. +""" + +import pytest + +import litellm +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config +from litellm.llms.azure.chat.o_series_transformation import AzureOpenAIO1Config +from litellm.utils import ProviderConfigManager, get_optional_params + + +# --------------------------------------------------------------------------- +# _get_azure_config — routes to the correct config based on base_model +# --------------------------------------------------------------------------- +class TestGetAzureConfigWithBaseModel: + """ProviderConfigManager._get_azure_config should use base_model for detection.""" + + def test_should_return_gpt5_config_when_base_model_is_gpt5(self): + config = ProviderConfigManager._get_azure_config( + model="my-deployment-id", base_model="azure/gpt-5.2" + ) + assert isinstance(config, AzureOpenAIGPT5Config) + + def test_should_return_o_series_config_when_base_model_is_o_series(self): + config = ProviderConfigManager._get_azure_config( + model="my-deployment-id", base_model="azure/o4-mini" + ) + assert isinstance(config, AzureOpenAIO1Config) + + def test_should_return_default_config_when_base_model_is_regular(self): + config = ProviderConfigManager._get_azure_config( + model="my-deployment-id", base_model="azure/gpt-4o" + ) + assert type(config).__name__ == "AzureOpenAIConfig" + + def test_should_fallback_to_model_when_base_model_is_none(self): + config = ProviderConfigManager._get_azure_config( + model="gpt-5.2", base_model=None + ) + assert isinstance(config, AzureOpenAIGPT5Config) + + def test_should_return_default_config_when_both_are_non_standard(self): + config = ProviderConfigManager._get_azure_config( + model="my-deployment-id", base_model=None + ) + assert type(config).__name__ == "AzureOpenAIConfig" + + +# --------------------------------------------------------------------------- +# get_provider_chat_config — threads base_model through for Azure +# --------------------------------------------------------------------------- +class TestGetProviderChatConfigWithBaseModel: + """get_provider_chat_config should pass base_model to Azure config selection.""" + + def test_should_return_gpt5_config_for_custom_deployment_with_base_model(self): + from litellm.types.utils import LlmProviders + + config = ProviderConfigManager.get_provider_chat_config( + model="my-deployment-id", + provider=LlmProviders.AZURE, + base_model="azure/gpt-5", + ) + assert isinstance(config, AzureOpenAIGPT5Config) + + def test_should_return_o_series_config_for_custom_deployment_with_base_model(self): + from litellm.types.utils import LlmProviders + + config = ProviderConfigManager.get_provider_chat_config( + model="my-other-deployment", + provider=LlmProviders.AZURE, + base_model="azure/o3-mini", + ) + assert isinstance(config, AzureOpenAIO1Config) + + +# --------------------------------------------------------------------------- +# get_supported_openai_params — base_model drives Azure param detection +# --------------------------------------------------------------------------- +class TestGetSupportedOpenAIParamsWithBaseModel: + """get_supported_openai_params should use base_model for Azure detection.""" + + def test_should_return_gpt5_params_for_custom_deployment_with_gpt5_base_model( + self, + ): + params = litellm.get_supported_openai_params( + model="my-deployment-id", + custom_llm_provider="azure", + base_model="azure/gpt-5", + ) + assert params is not None + assert "reasoning_effort" in params + # gpt-5 maps max_tokens -> max_completion_tokens, verifying we got GPT-5 config + assert "max_completion_tokens" in params + + def test_should_return_o_series_params_for_custom_deployment_with_o_series_base_model( + self, + ): + params = litellm.get_supported_openai_params( + model="my-other-deployment", + custom_llm_provider="azure", + base_model="azure/o4-mini", + ) + assert params is not None + assert "reasoning_effort" in params + + def test_should_return_regular_params_when_no_base_model(self): + """When base_model is not set and model is non-standard, default Azure config.""" + params = litellm.get_supported_openai_params( + model="my-deployment-id", + custom_llm_provider="azure", + ) + assert params is not None + # Default Azure config supports temperature + assert "temperature" in params + + +# --------------------------------------------------------------------------- +# get_optional_params — base_model drives Azure param mapping +# --------------------------------------------------------------------------- +class TestGetOptionalParamsWithBaseModel: + """get_optional_params should use base_model for Azure model-type detection.""" + + def test_should_map_max_tokens_for_custom_deployment_with_gpt5_base_model(self): + """A non-standard deployment name + gpt-5 base_model should map max_tokens -> max_completion_tokens.""" + params = get_optional_params( + model="my-deployment-id", + custom_llm_provider="azure", + max_tokens=100, + base_model="azure/gpt-5", + ) + assert params.get("max_completion_tokens") == 100 + assert "max_tokens" not in params + + def test_should_keep_max_tokens_for_custom_deployment_without_base_model(self): + """A non-standard deployment name without base_model should use default Azure config.""" + params = get_optional_params( + model="my-deployment-id", + custom_llm_provider="azure", + max_tokens=100, + api_version="2024-05-01-preview", + ) + # Default AzureOpenAIConfig keeps max_tokens as-is (or maps based on api_version) + assert "max_tokens" in params or "max_completion_tokens" in params + + def test_should_support_reasoning_effort_for_custom_deployment_with_o_series_base_model( + self, + ): + """A non-standard deployment name + o-series base_model should accept reasoning_effort.""" + params = get_optional_params( + model="my-other-deployment", + custom_llm_provider="azure", + reasoning_effort="low", + base_model="azure/o4-mini", + ) + assert params.get("reasoning_effort") == "low" + + def test_should_reject_temperature_for_custom_deployment_with_gpt5_base_model( + self, + ): + """A non-standard deployment + gpt-5 base_model should reject temperature.""" + with pytest.raises(litellm.UnsupportedParamsError): + get_optional_params( + model="my-deployment-id", + custom_llm_provider="azure", + temperature=0.5, + base_model="azure/gpt-5", + ) + + +# --------------------------------------------------------------------------- +# Backward compatibility — existing patterns still work +# --------------------------------------------------------------------------- +class TestBackwardCompatibility: + """Existing model-name-based and prefix-based patterns must keep working.""" + + def test_should_detect_gpt5_from_model_name(self): + config = ProviderConfigManager._get_azure_config(model="gpt-5.2") + assert isinstance(config, AzureOpenAIGPT5Config) + + def test_should_detect_gpt5_from_gpt5_series_prefix(self): + config = ProviderConfigManager._get_azure_config( + model="gpt5_series/my-deployment" + ) + assert isinstance(config, AzureOpenAIGPT5Config) + + def test_should_detect_o_series_from_model_name(self): + config = ProviderConfigManager._get_azure_config(model="o4-mini") + assert isinstance(config, AzureOpenAIO1Config) + + def test_should_detect_o_series_from_o_series_prefix(self): + config = ProviderConfigManager._get_azure_config(model="o_series/my-deployment") + assert isinstance(config, AzureOpenAIO1Config) + + def test_should_handle_gpt5_chat_model_correctly(self): + """gpt-5-chat models should NOT be routed to GPT-5 config.""" + config = ProviderConfigManager._get_azure_config(model="gpt-5-chat") + assert type(config).__name__ == "AzureOpenAIConfig" + + def test_base_model_overrides_model_detection(self): + """base_model should take priority over model for type detection.""" + # model looks like o-series, but base_model says gpt-5 + config = ProviderConfigManager._get_azure_config( + model="o3-mini", base_model="azure/gpt-5.2" + ) + assert isinstance(config, AzureOpenAIGPT5Config) + + +# --------------------------------------------------------------------------- +# Deep config method awareness — base_model flows into config internals +# --------------------------------------------------------------------------- +class TestBaseModelFlowsIntoConfigInternals: + """base_model should be used by config internal methods (e.g. is_model_gpt_5_2_model).""" + + def test_should_support_logprobs_for_prefixed_deployment_with_gpt52_base_model( + self, + ): + """Deployment 'my-gpt-5.2' with base_model='azure/gpt-5.2' should support logprobs.""" + params = litellm.get_supported_openai_params( + model="gpt5_series/my-gpt-5.2", + custom_llm_provider="azure", + base_model="azure/gpt-5.2", + ) + assert params is not None + assert "logprobs" in params + assert "top_logprobs" in params + + def test_should_support_logprobs_for_plain_deployment_with_gpt52_base_model(self): + """Deployment 'my-deployment-id' with base_model='azure/gpt-5.2' should support logprobs.""" + params = litellm.get_supported_openai_params( + model="my-deployment-id", + custom_llm_provider="azure", + base_model="azure/gpt-5.2", + ) + assert params is not None + assert "logprobs" in params + assert "top_logprobs" in params + + def test_should_not_support_logprobs_for_gpt5_base_model(self): + """Deployment with base_model='azure/gpt-5' (not 5.2) should NOT support logprobs.""" + params = litellm.get_supported_openai_params( + model="my-deployment-id", + custom_llm_provider="azure", + base_model="azure/gpt-5", + ) + assert params is not None + assert "logprobs" not in params + assert "top_logprobs" not in params + + def test_should_pass_logprobs_through_get_optional_params(self): + """logprobs should pass validation in get_optional_params when base_model is gpt-5.2.""" + params = get_optional_params( + model="gpt5_series/my-gpt-5.2", + custom_llm_provider="azure", + logprobs=True, + top_logprobs=5, + base_model="azure/gpt-5.2", + ) + assert params.get("logprobs") is True + assert params.get("top_logprobs") == 5 + + def test_should_map_max_tokens_for_prefixed_deployment_with_gpt5_base_model(self): + """my-gpt-5.2 with base_model should correctly map max_tokens -> max_completion_tokens.""" + params = get_optional_params( + model="gpt5_series/my-gpt-5.2", + custom_llm_provider="azure", + max_tokens=200, + base_model="azure/gpt-5.2", + ) + assert params.get("max_completion_tokens") == 200 + assert "max_tokens" not in params