diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index 862ff584cf39..717382d55525 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -8,6 +8,9 @@ AnthropicMessagesConfig, ) from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.azure_ai.anthropic.output_params_utils import ( + sanitize_azure_anthropic_output_params, +) from litellm.types.router import GenericLiteLLMParams @@ -162,4 +165,5 @@ def transform_anthropic_messages_request( ) self._normalize_system_role_messages(anthropic_messages_request, model=model) self._remove_scope_from_cache_control(anthropic_messages_request) + sanitize_azure_anthropic_output_params(anthropic_messages_request, model) return anthropic_messages_request diff --git a/litellm/llms/azure_ai/anthropic/output_params_utils.py b/litellm/llms/azure_ai/anthropic/output_params_utils.py new file mode 100644 index 000000000000..12307c56f700 --- /dev/null +++ b/litellm/llms/azure_ai/anthropic/output_params_utils.py @@ -0,0 +1,63 @@ +""" +Shared sanitization for ``output_config`` on Azure AI Foundry Claude requests. +Lives in its own module so both the chat-completion transformation +(``transformation.py``) and the Messages pass-through transformation +(``messages_transformation.py``) can import it without forming a cycle. +""" + +from typing import Final + + +def _model_accepts_output_config_effort(model: str) -> bool: + """Whether ``model`` accepts ``output_config.effort`` on Azure AI Foundry. + + Models that advertise ``supports_output_config`` (or any reasoning effort + level) in the model map accept it; others (e.g. Haiku 4.5) return a 400 + "This model does not support the effort parameter." Imported lazily so + this stays a leaf module. + """ + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + return AnthropicConfig._model_supports_effort_param(model, "azure_ai") + + +def sanitize_azure_anthropic_output_params( + data: dict, # mutable-ok: request dict mutated in place, matches transform_request's contract + model: str, +) -> None: + """Strip Azure-unsupported keys from ``output_config`` in-place. + + Behavior: + * ``output_config.effort`` is dropped for models that do not accept it + (e.g. Haiku 4.5) and forwarded for those that do (Opus/Sonnet 4.6+). + * ``output_config`` left empty after filtering is removed so the request + body carries no empty dict. + * Non-dict values for ``output_config`` are dropped to avoid sending + malformed payloads downstream. + """ + output_config: Final = data.get("output_config") + if output_config is None: + return + if not isinstance(output_config, dict): + data.pop("output_config", None) + return + + drop_effort: Final = "effort" in output_config and not _model_accepts_output_config_effort(model) + if drop_effort: + from litellm._logging import verbose_logger + + verbose_logger.debug( + "Dropping unsupported output_config.effort for azure_ai model=%s " + "(no supports_output_config in the model map)", + model, + ) + + sanitized: Final = ( + {k: v for k, v in output_config.items() if k != "effort"} # mutable-ok: request dict mutated in place + if drop_effort + else output_config + ) + if sanitized: + data["output_config"] = sanitized # rebind-ok: out-param store like siblings + else: + data.pop("output_config", None) diff --git a/litellm/llms/azure_ai/anthropic/transformation.py b/litellm/llms/azure_ai/anthropic/transformation.py index c5053448627d..9ee3a9cf4ed9 100644 --- a/litellm/llms/azure_ai/anthropic/transformation.py +++ b/litellm/llms/azure_ai/anthropic/transformation.py @@ -6,6 +6,9 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.azure_ai.anthropic.output_params_utils import ( + sanitize_azure_anthropic_output_params, +) from litellm.types.llms.openai import AllMessageValues from litellm.types.router import GenericLiteLLMParams @@ -132,4 +135,6 @@ def transform_request( data.pop("max_retries", None) data.pop("stream_options", None) + sanitize_azure_anthropic_output_params(data, model) + return data diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py index f6446b43fab8..f105abc83a2b 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py @@ -271,6 +271,61 @@ def test_transform_anthropic_messages_request_removes_scope_from_cache_control( result["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral" ) + def test_transform_anthropic_messages_request_strips_output_config_effort_for_unsupported_model( + self, + ): + """Regression: output_config.effort forwarded via /anthropic/v1/messages to a model without supports_output_config causes 400 on Azure AI Foundry. + + additional_drop_params does not suppress this because it operates on the + OpenAI->provider translation layer, not on the Anthropic pass-through path. + See: https://github.com/BerriAI/litellm/issues/27168 + """ + config = AzureAnthropicMessagesConfig() + model = "claude-haiku-4-5" + messages = [{"role": "user", "content": "Hello"}] + anthropic_messages_optional_request_params = { + "max_tokens": 1024, + "output_config": {"effort": "medium"}, # Should be stripped + } + litellm_params = GenericLiteLLMParams() + headers = {} + + result = config.transform_anthropic_messages_request( + model=model, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert "output_config" not in result + assert result["max_tokens"] == 1024 + + def test_transform_anthropic_messages_request_preserves_output_config_for_supported_model( + self, + ): + """output_config must be forwarded for models that advertise supports_output_config (e.g. Sonnet 4.6+).""" + config = AzureAnthropicMessagesConfig() + model = "claude-sonnet-4-6" + messages = [{"role": "user", "content": "Hello"}] + anthropic_messages_optional_request_params = { + "max_tokens": 1024, + "output_config": {"effort": "medium"}, + } + litellm_params = GenericLiteLLMParams() + headers = {} + + result = config.transform_anthropic_messages_request( + model=model, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert "output_config" in result + assert result["output_config"] == {"effort": "medium"} + class TestProviderConfigManagerAzureAnthropicMessages: """Test ProviderConfigManager returns correct config for Azure AI Anthropic Messages API""" diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_output_params_utils.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_output_params_utils.py new file mode 100644 index 000000000000..37cf3d4883cd --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_output_params_utils.py @@ -0,0 +1,56 @@ +import os +import sys + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) +) + +import pytest + +from litellm.llms.azure_ai.anthropic.output_params_utils import ( + sanitize_azure_anthropic_output_params, +) + + +class TestSanitizeAzureAnthropicOutputParams: + def test_drops_effort_for_model_without_supports_output_config(self): + """Regression: Haiku 4.5 has no supports_output_config in the model map; effort must be stripped. + + See: https://github.com/BerriAI/litellm/issues/27168 + """ + data = {"output_config": {"effort": "medium"}, "max_tokens": 100} + sanitize_azure_anthropic_output_params(data, "claude-haiku-4-5") + assert "output_config" not in data + + def test_preserves_effort_for_model_with_supports_output_config(self): + """Models with supports_output_config (e.g. Sonnet 4.6) must forward effort unchanged.""" + data = {"output_config": {"effort": "medium"}, "max_tokens": 100} + sanitize_azure_anthropic_output_params(data, "claude-sonnet-4-6") + assert data["output_config"] == {"effort": "medium"} + + def test_preserves_other_output_config_keys_when_effort_dropped(self): + """Only effort is removed; other output_config keys are forwarded.""" + data = {"output_config": {"effort": "medium", "other_key": "value"}} + sanitize_azure_anthropic_output_params(data, "claude-haiku-4-5") + assert data["output_config"] == {"other_key": "value"} + + def test_no_output_config_is_noop(self): + data: dict = {"max_tokens": 100} + sanitize_azure_anthropic_output_params(data, "claude-haiku-4-5") + assert "output_config" not in data + assert data["max_tokens"] == 100 + + def test_non_dict_output_config_is_dropped(self): + data = {"output_config": "bad_value"} + sanitize_azure_anthropic_output_params(data, "claude-haiku-4-5") + assert "output_config" not in data + + def test_empty_output_config_after_effort_drop_is_removed(self): + data = {"output_config": {"effort": "high"}} + sanitize_azure_anthropic_output_params(data, "claude-haiku-4-5") + assert "output_config" not in data + + def test_output_config_without_effort_is_unchanged(self): + data = {"output_config": {"other_key": "value"}} + sanitize_azure_anthropic_output_params(data, "claude-haiku-4-5") + assert data["output_config"] == {"other_key": "value"} diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py index 9dac914ca4db..5897e860cdc2 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py @@ -242,6 +242,74 @@ def test_transform_request_removes_unsupported_params(self): assert result["max_tokens"] == 100 assert "messages" in result + def test_transform_request_strips_output_config_effort_for_unsupported_model(self): + """Regression test: Azure AI Foundry returns 400 when output_config.effort is forwarded to a model without supports_output_config in the model map (e.g. Haiku 4.5). + + See: https://github.com/BerriAI/litellm/issues/27168 + """ + config = AzureAnthropicConfig() + + messages = [{"role": "user", "content": "Hello"}] + optional_params = {"max_tokens": 100} + litellm_params = {"api_key": "test-key"} + headers = {"api-key": "test-key", "anthropic-version": "2023-06-01"} + + with patch.object( + config.__class__.__bases__[0], # AnthropicConfig + "transform_request", + return_value={ + "model": "claude-haiku-4-5", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]} + ], + "max_tokens": 100, + "output_config": {"effort": "medium"}, # Should be stripped + }, + ): + result = config.transform_request( + model="claude-haiku-4-5", + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert "output_config" not in result + assert result["model"] == "claude-haiku-4-5" + assert result["max_tokens"] == 100 + + def test_transform_request_preserves_output_config_for_supported_model(self): + """output_config must be forwarded for models that advertise supports_output_config (e.g. Sonnet 4.6+).""" + config = AzureAnthropicConfig() + + messages = [{"role": "user", "content": "Hello"}] + optional_params = {"max_tokens": 100} + litellm_params = {"api_key": "test-key"} + headers = {"api-key": "test-key", "anthropic-version": "2023-06-01"} + + with patch.object( + config.__class__.__bases__[0], # AnthropicConfig + "transform_request", + return_value={ + "model": "claude-sonnet-4-6", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]} + ], + "max_tokens": 100, + "output_config": {"effort": "medium"}, + }, + ): + result = config.transform_request( + model="claude-sonnet-4-6", + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert "output_config" in result + assert result["output_config"] == {"effort": "medium"} + def test_context_management_compact_beta_header(self): """Test that context_management with compact adds the correct beta header for Azure AI""" config = AzureAnthropicConfig()