Skip to content
Closed
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
4 changes: 4 additions & 0 deletions litellm/llms/azure_ai/anthropic/messages_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
63 changes: 63 additions & 0 deletions litellm/llms/azure_ai/anthropic/output_params_utils.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions litellm/llms/azure_ai/anthropic/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading