Merge pull request #26248 from BerriAI/litellm_anthropic_messages_call_type_fix

fix(proxy): preserve anthropic_messages call type for /v1/messages logging
This commit is contained in:
yuneng-jiang
2026-04-24 09:42:36 -07:00
committed by GitHub
7 changed files with 123 additions and 10 deletions
@@ -1534,12 +1534,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
f"Invalid effort value: {effort}. Must be one of: "
f"'high', 'medium', 'low', 'xhigh', 'max'"
)
# ``max`` is Claude Opus 4.6 only (not Sonnet 4.6, not Opus 4.5/4.7).
# Keep this hardcoded so the error message is specific and stable.
if effort == "max" and not self._is_opus_4_6_model(model):
# ``max`` is for Opus 4.6+ output effort (not Sonnet 4.6, not Opus 4.5).
# Accept known Opus 4.6/4.7 id patterns and/or ``supports_max_reasoning_effort``
# in the model map (same pattern as ``xhigh`` below).
if effort == "max" and not (
self._is_opus_4_6_model(model)
or self._is_opus_4_7_model(model)
or self._supports_effort_level(model, "max")
):
raise ValueError(
f"effort='max' is only supported by Claude Opus 4.6. "
f"Got model: {model}"
f"effort='max' is not supported by this model. Got model: {model}"
)
# ``xhigh`` is data-driven via ``supports_xhigh_reasoning_effort`` so
# enabling it for a new model is a pure model-map change.
@@ -235,7 +235,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
):
from litellm.types.utils import CallTypes
setattr(value, "call_type", CallTypes.completion.value)
setattr(value, "call_type", CallTypes.anthropic_messages.value)
setattr(
value, "stream_options", completion_kwargs.get("stream_options")
)
@@ -105,7 +105,7 @@ def _build_responses_kwargs(
# Reclassify as acompletion so the success handler doesn't try to
# validate the Responses API event as an AnthropicResponse.
# (Mirrors the pattern used in LiteLLMMessagesToCompletionTransformationHandler.)
setattr(value, "call_type", CallTypes.acompletion.value)
setattr(value, "call_type", CallTypes.anthropic_messages.value)
responses_kwargs[key] = value
elif key not in excluded and key not in responses_kwargs and value is not None:
responses_kwargs[key] = value
@@ -9262,6 +9262,7 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
@@ -9296,6 +9297,7 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
+2
View File
@@ -9276,6 +9276,7 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
@@ -9310,6 +9311,7 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
@@ -1654,7 +1654,7 @@ def test_max_effort_rejected_for_opus_45():
messages = [{"role": "user", "content": "Test"}]
with pytest.raises(
ValueError, match="effort='max' is only supported by Claude Opus 4.6"
ValueError, match="effort='max' is not supported by this model"
):
optional_params = {"output_config": {"effort": "max"}}
config.transform_request(
@@ -2213,12 +2213,12 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models():
def test_max_effort_rejected_for_sonnet_46():
"""Test that effort='max' is rejected for Sonnet 4.6 (only Opus 4.6 supports max)."""
"""Test that effort='max' is rejected for Sonnet 4.6 (Opus-only effort level)."""
config = AnthropicConfig()
messages = [{"role": "user", "content": "Test"}]
with pytest.raises(
ValueError, match="effort='max' is only supported by Claude Opus 4.6"
ValueError, match="effort='max' is not supported by this model"
):
config.transform_request(
model="claude-sonnet-4-6-20260219",
@@ -2245,6 +2245,22 @@ def test_max_effort_accepted_for_opus_46():
assert result["output_config"]["effort"] == "max"
def test_max_effort_accepted_for_opus_47():
"""Test that effort='max' works for Opus 4.7."""
config = AnthropicConfig()
messages = [{"role": "user", "content": "Test"}]
result = config.transform_request(
model="claude-opus-4-7",
messages=messages,
optional_params={"output_config": {"effort": "max"}},
litellm_params={},
headers={},
)
assert result["output_config"]["effort"] == "max"
def test_effort_beta_header_not_injected_for_46_models():
"""
Test that is_effort_used returns False for Claude 4.6 models.
@@ -1749,6 +1749,95 @@ async def test_add_litellm_metadata_from_request_headers():
litellm.callbacks = original_callbacks
@pytest.mark.asyncio
async def test_anthropic_messages_standard_logging_object_matches_fixture():
"""
Regression: /v1/messages calls routed to non-Anthropic providers should keep
call_type=anthropic_messages in standard logging payloads.
"""
litellm._turn_on_debug()
test_logger = TestCustomLogger()
original_callbacks = litellm.callbacks
litellm.callbacks = [test_logger]
try:
data = {
"model": "gemini/gemini-2.5-flash",
"messages": [{"role": "user", "content": "Hi."}],
"stream": False,
"mock_response": "Hello! How can I help you today?",
"api_key": "fake-key",
"max_tokens": 4096,
}
mock_request = MagicMock(spec=Request)
mock_request.headers = {"user-agent": "PostmanRuntime/7.53.0"}
mock_request.url.path = "/v1/messages"
mock_request.url = MagicMock()
mock_request.url.__str__.return_value = "http://localhost/v1/messages"
mock_request.method = "POST"
mock_request.query_params = {}
mock_request.client = MagicMock()
mock_request.client.host = "127.0.0.1"
mock_fastapi_response = MagicMock(spec=Response)
mock_user_api_key_dict = UserAPIKeyAuth(
api_key="test-key", user_id="default_user_id"
)
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
async def mock_during_call_hook(*args, **kwargs):
return None
async def mock_pre_call_hook(*args, **kwargs):
return data
async def mock_post_call_success_hook(*args, **kwargs):
return kwargs.get("response", args[2] if len(args) > 2 else None)
mock_proxy_logging_obj.during_call_hook = mock_during_call_hook
mock_proxy_logging_obj.pre_call_hook = mock_pre_call_hook
mock_proxy_logging_obj.post_call_success_hook = mock_post_call_success_hook
processor = ProxyBaseLLMRequestProcessing(data=data)
await processor.base_process_llm_request(
request=mock_request,
fastapi_response=mock_fastapi_response,
user_api_key_dict=mock_user_api_key_dict,
route_type="anthropic_messages",
proxy_logging_obj=mock_proxy_logging_obj,
general_settings={},
proxy_config=MagicMock(),
select_data_generator=None,
llm_router=None,
model="gemini/gemini-2.5-flash",
is_streaming_request=False,
)
await asyncio.sleep(3)
assert test_logger.standard_logging_object is not None
actual = test_logger.standard_logging_object
expected = {
"call_type": "anthropic_messages",
"status": "success",
"model": "gemini/gemini-2.5-flash",
}
# Compare only stable fields from the saved proxy log snapshot.
actual_projection = {
"call_type": actual.get("call_type"),
"status": actual.get("status"),
"model": actual.get("model"),
}
assert actual_projection == expected
assert actual.get("call_type") == "anthropic_messages"
finally:
litellm.callbacks = original_callbacks
def test_add_litellm_metadata_from_request_headers_x_litellm_trace_id_sets_chain_id():
"""x-litellm-trace-id sets both metadata and top-level litellm_session_id/litellm_trace_id for call chaining."""
headers = {"x-litellm-trace-id": "foo"}