fix(logging): handle dict result.response in streaming /v1/responses spend log

On the streaming /v1/responses spend-log path result.response can arrive as a
dict rather than a ResponsesAPIResponse, so the unified-usage step raised
AttributeError on result.response.usage. Read usage via .get on a dict (attr
otherwise) and write the transformed usage back through the matching branch.
Fixes #29913.
This commit is contained in:
Chenglun Hu 2026-08-27 05:01:46 +08:00
parent 3c24f37502
commit 8253bd7617
2 changed files with 170 additions and 302 deletions

View file

@ -3670,21 +3670,23 @@ class Logging(LiteLLMLoggingBaseClass):
(ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent),
):
## return unified Usage object
if isinstance(result.response.usage, ResponseAPIUsage):
transformed_usage: Final = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
result.response.usage
)
# result.response may be a dict on the streaming /v1/responses spend-log
# path (#29913); a bare `.usage` attribute access would raise there.
resp: Final = result.response
usage: Final = resp.get("usage") if isinstance(resp, dict) else getattr(resp, "usage", None)
if isinstance(usage, ResponseAPIUsage):
transformed_usage: Final = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
# Set as dict instead of Usage object so model_dump() serializes it correctly
setattr(
result.response,
"usage",
(
transformed_usage.model_dump()
if hasattr(transformed_usage, "model_dump")
else dict(transformed_usage)
),
new_usage: Final = (
transformed_usage.model_dump()
if hasattr(transformed_usage, "model_dump")
else dict(transformed_usage)
)
return result.response
if isinstance(resp, dict):
resp["usage"] = new_usage
else:
setattr(resp, "usage", new_usage)
return resp
elif isinstance(result, InteractionsAPIStreamingResponse):
return self._assemble_completed_interaction_response(result)
else:

View file

@ -273,9 +273,7 @@ def test_response_cost_calculator_uses_router_model_id_from_litellm_metadata():
assert cost is not None, "Cost should not be None"
expected_cost = (10 * custom_input_cost) + (5 * custom_output_cost)
assert cost == pytest.approx(
expected_cost
), f"Expected {expected_cost}, got {cost}"
assert cost == pytest.approx(expected_cost), f"Expected {expected_cost}, got {cost}"
finally:
litellm.model_cost.pop(custom_model_id, None)
@ -872,13 +870,8 @@ async def test_datadog_logger_not_shadowed_by_llm_obs(monkeypatch):
# Regression check: we expect a distinct DataDogLogger, not the LLM Obs logger
assert type(datadog_logger) is DataDogLogger
assert any(
isinstance(cb, DataDogLLMObsLogger)
for cb in logging_module._in_memory_loggers
)
assert any(
type(cb) is DataDogLogger for cb in logging_module._in_memory_loggers
)
assert any(isinstance(cb, DataDogLLMObsLogger) for cb in logging_module._in_memory_loggers)
assert any(type(cb) is DataDogLogger for cb in logging_module._in_memory_loggers)
finally:
logging_module._in_memory_loggers.clear()
@ -889,9 +882,7 @@ async def test_logfire_logger_accepts_env_vars_for_base_url(monkeypatch):
# Required env vars for Logfire integration
monkeypatch.setenv("LOGFIRE_TOKEN", "test-token")
monkeypatch.setenv(
"LOGFIRE_BASE_URL", "https://logfire-api-custom.pydantic.dev"
) # no trailing slash on purpose
monkeypatch.setenv("LOGFIRE_BASE_URL", "https://logfire-api-custom.pydantic.dev") # no trailing slash on purpose
# Import after env vars are set (important if module-level caching exists)
from litellm.integrations.opentelemetry import OpenTelemetry # logger class
@ -910,9 +901,7 @@ async def test_logfire_logger_accepts_env_vars_for_base_url(monkeypatch):
# Sanity: we got the right logger type and it is cached
assert type(logger) is OpenTelemetry
assert any(
type(cb) is OpenTelemetry for cb in logging_module._in_memory_loggers
)
assert any(type(cb) is OpenTelemetry for cb in logging_module._in_memory_loggers)
# Core regression check: base URL env var should influence the exporter endpoint.
#
@ -923,9 +912,7 @@ async def test_logfire_logger_accepts_env_vars_for_base_url(monkeypatch):
or getattr(logger, "config", None)
or getattr(logger, "_otel_config", None)
)
assert (
cfg is not None
), "Expected OpenTelemetry logger to keep an otel config on the instance"
assert cfg is not None, "Expected OpenTelemetry logger to keep an otel config on the instance"
endpoint = getattr(cfg, "endpoint", None) or getattr(cfg, "otlp_endpoint", None)
assert endpoint is not None, "Expected otel config to expose the OTLP endpoint"
@ -1083,9 +1070,7 @@ async def test_logging_non_streaming_request():
# Use the filtered call for assertions
call_args = calls_with_expected_input[0]
standard_logging_object = call_args.kwargs["kwargs"][
"standard_logging_object"
]
standard_logging_object = call_args.kwargs["kwargs"]["standard_logging_object"]
assert standard_logging_object["stream"] is not True
finally:
# Restore original callbacks to ensure test isolation
@ -1103,18 +1088,14 @@ async def test_logging_non_streaming_request():
"agenerate_content_stream",
],
)
def test_success_handler_skips_sync_callbacks_for_async_requests(
logging_obj, async_flag
):
def test_success_handler_skips_sync_callbacks_for_async_requests(logging_obj, async_flag):
"""Ensure sync success callbacks are skipped when async call type flags are set."""
from litellm.integrations.custom_logger import CustomLogger
class DummyLogger(CustomLogger):
pass
logging_obj.stream = (
False # simulate non-streaming request where sync callbacks would normally run
)
logging_obj.stream = False # simulate non-streaming request where sync callbacks would normally run
logging_obj.model_call_details["litellm_params"] = {async_flag: True}
logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"]
@ -1190,21 +1171,11 @@ def test_success_handler_runs_sync_callbacks_for_sync_requests(logging_obj, call
def test_is_sync_litellm_request():
assert LitellmLogging._is_sync_litellm_request({}) is True
assert LitellmLogging._is_sync_litellm_request({"acompletion": True}) is False
assert (
LitellmLogging._is_sync_litellm_request({"allm_passthrough_route": True})
is False
)
assert (
LitellmLogging._is_sync_litellm_request({"aanthropic_messages": True}) is False
)
assert LitellmLogging._is_sync_litellm_request({"allm_passthrough_route": True}) is False
assert LitellmLogging._is_sync_litellm_request({"aanthropic_messages": True}) is False
assert LitellmLogging._is_sync_litellm_request({"agenerate_content": True}) is False
assert (
LitellmLogging._is_sync_litellm_request({"agenerate_content_stream": True})
is False
)
assert (
LitellmLogging._is_sync_litellm_request({"aanthropic_messages": False}) is True
)
assert LitellmLogging._is_sync_litellm_request({"agenerate_content_stream": True}) is False
assert LitellmLogging._is_sync_litellm_request({"aanthropic_messages": False}) is True
def test_get_litellm_params_propagates_allm_passthrough_route():
@ -1251,9 +1222,7 @@ async def test_dispatch_success_handlers_invokes_callbacks_once_for_final_stream
logging_obj.model_call_details["litellm_params"] = {"acompletion": True}
with (
patch.object(
mock_callback, "async_log_success_event", new_callable=AsyncMock
) as mock_async_log,
patch.object(mock_callback, "async_log_success_event", new_callable=AsyncMock) as mock_async_log,
patch.object(mock_callback, "log_success_event") as mock_sync_log,
patch.object(
logging_obj,
@ -1314,9 +1283,7 @@ async def test_dispatch_success_handlers_sync_path_invokes_callback_once_for_fin
with (
patch.object(mock_callback, "log_success_event") as mock_sync_log,
patch.object(
mock_callback, "async_log_success_event", new_callable=AsyncMock
) as mock_async_log,
patch.object(mock_callback, "async_log_success_event", new_callable=AsyncMock) as mock_async_log,
patch.object(
logging_obj,
"_success_handler_helper_fn",
@ -1358,20 +1325,14 @@ async def test_dispatch_prefer_async_handlers_runs_legacy_callbacks(
logging_obj.model_call_details["litellm_params"] = {}
with (
patch.object(
logging_obj, "async_success_handler", new_callable=AsyncMock
) as mock_async,
patch.object(
logging_obj, "success_handler", new_callable=MagicMock
) as mock_sync,
patch.object(logging_obj, "async_success_handler", new_callable=AsyncMock) as mock_async,
patch.object(logging_obj, "success_handler", new_callable=MagicMock) as mock_sync,
patch.object(
logging_obj,
"_should_run_sync_callbacks_for_async_calls",
return_value=True,
),
patch(
"litellm.litellm_core_utils.litellm_logging.executor.submit"
) as mock_submit,
patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit,
):
await logging_obj.dispatch_success_handlers(
result=result,
@ -1405,9 +1366,7 @@ async def test_dispatch_success_handlers_invokes_async_callback_for_pass_through
try:
with (
patch.object(
mock_callback, "async_log_success_event", new_callable=AsyncMock
) as mock_async_log,
patch.object(mock_callback, "async_log_success_event", new_callable=AsyncMock) as mock_async_log,
patch.object(mock_callback, "log_success_event") as mock_sync_log,
):
await logging_obj.dispatch_success_handlers(result={"id": "pt-1"})
@ -1434,20 +1393,14 @@ async def test_dispatch_failure_handlers_prefer_async_does_not_submit_sync_handl
logging_obj.model_call_details["litellm_params"] = {}
with (
patch.object(
logging_obj, "async_failure_handler", new_callable=AsyncMock
) as mock_async,
patch.object(
logging_obj, "failure_handler", new_callable=MagicMock
) as mock_sync,
patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock) as mock_async,
patch.object(logging_obj, "failure_handler", new_callable=MagicMock) as mock_sync,
patch.object(
logging_obj,
"_should_run_sync_failure_callbacks_for_async_calls",
return_value=False,
),
patch(
"litellm.litellm_core_utils.litellm_logging.executor.submit"
) as mock_submit,
patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit,
):
await logging_obj.dispatch_failure_handlers(
exception,
@ -1530,12 +1483,8 @@ async def test_dispatch_failure_handlers_submits_sync_handler_for_failure_only_c
patch.object(litellm, "success_callback", []),
patch.object(litellm, "failure_callback", [_sync_failure_callback]),
patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock),
patch.object(
logging_obj, "failure_handler", new_callable=MagicMock
) as mock_sync,
patch(
"litellm.litellm_core_utils.litellm_logging.executor.submit"
) as mock_submit,
patch.object(logging_obj, "failure_handler", new_callable=MagicMock) as mock_sync,
patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit,
):
await logging_obj.dispatch_failure_handlers(
exception,
@ -1562,15 +1511,9 @@ async def test_dispatch_failure_handlers_sync_sdk_shortcut_runs_sync_handler_inl
logging_obj.model_call_details["litellm_params"] = {}
with (
patch.object(
logging_obj, "async_failure_handler", new_callable=AsyncMock
) as mock_async,
patch.object(
logging_obj, "failure_handler", new_callable=MagicMock
) as mock_sync,
patch(
"litellm.litellm_core_utils.litellm_logging.executor.submit"
) as mock_submit,
patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock) as mock_async,
patch.object(logging_obj, "failure_handler", new_callable=MagicMock) as mock_sync,
patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit,
):
await logging_obj.dispatch_failure_handlers(
exception,
@ -1617,14 +1560,10 @@ def test_success_handler_skips_guardrail_logging_hook_when_disabled(logging_obj)
event_hook=GuardrailEventHooks.logging_only,
)
guardrail.should_run_guardrail = MagicMock(return_value=False)
guardrail.logging_hook = MagicMock(
return_value=(logging_obj.model_call_details, model_response)
)
guardrail.logging_hook = MagicMock(return_value=(logging_obj.model_call_details, model_response))
dummy_logger = DummyLogger()
dummy_logger.logging_hook = MagicMock(
return_value=(logging_obj.model_call_details, model_response)
)
dummy_logger.logging_hook = MagicMock(return_value=(logging_obj.model_call_details, model_response))
with patch.object(
logging_obj,
@ -1758,11 +1697,7 @@ def test_get_request_tags_from_metadata_and_litellm_metadata():
# Test case 2: Tags in litellm_metadata only
tags = StandardLoggingPayloadSetup._get_request_tags(
litellm_params={
"litellm_metadata": {
"tags": ["litellm-metadata-tag-1", "litellm-metadata-tag-2"]
}
},
litellm_params={"litellm_metadata": {"tags": ["litellm-metadata-tag-1", "litellm-metadata-tag-2"]}},
proxy_server_request={},
)
assert "litellm-metadata-tag-1" in tags
@ -1867,15 +1802,9 @@ def test_get_request_tags_does_not_mutate_original_tags():
user_agent_count_2 = len([t for t in tags2 if t.startswith("User-Agent:")])
user_agent_count_3 = len([t for t in tags3 if t.startswith("User-Agent:")])
assert (
user_agent_count_1 == 2
), f"Expected 2 User-Agent tags, got {user_agent_count_1}"
assert (
user_agent_count_2 == 2
), f"Expected 2 User-Agent tags, got {user_agent_count_2}"
assert (
user_agent_count_3 == 2
), f"Expected 2 User-Agent tags, got {user_agent_count_3}"
assert user_agent_count_1 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_1}"
assert user_agent_count_2 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_2}"
assert user_agent_count_3 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_3}"
# Verify all returned lists are independent (different objects)
assert tags1 is not tags2
@ -1908,9 +1837,7 @@ def test_get_extra_header_tags():
# Test case 3: Extra headers configured but request has no headers dict
litellm.extra_spend_tag_headers = ["x-custom", "x-tenant"]
result = StandardLoggingPayloadSetup._get_extra_header_tags(
proxy_server_request={"headers": "not-a-dict"}
)
result = StandardLoggingPayloadSetup._get_extra_header_tags(proxy_server_request={"headers": "not-a-dict"})
assert result is None
# Test case 4: Extra headers configured but none match request headers
@ -2211,9 +2138,7 @@ def test_get_masked_values():
"presidio_anonymizer_api_base": None,
"vertex_credentials": "{sensitive_api_key}",
}
masked_values = _get_masked_values(
sensitive_object, unmasked_length=4, number_of_asterisks=4
)
masked_values = _get_masked_values(sensitive_object, unmasked_length=4, number_of_asterisks=4)
assert masked_values["presidio_anonymizer_api_base"] is None
assert masked_values["vertex_credentials"] == "{s****y}"
@ -2238,9 +2163,7 @@ async def test_e2e_generate_cold_storage_object_key_successful():
patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key,
):
# Mock the S3 object key generation to return a predictable result
mock_get_s3_key.return_value = (
"2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
)
mock_get_s3_key.return_value = "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
# Call the function
result = StandardLoggingPayloadSetup._generate_cold_storage_object_key(
@ -2281,16 +2204,12 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path()
with (
patch("litellm.cold_storage_custom_logger", "s3_v2"),
patch(
"litellm.logging_callback_manager.get_active_custom_logger_for_callback_name"
) as mock_get_logger,
patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger,
patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key,
):
# Setup mocks
mock_get_logger.return_value = mock_custom_logger
mock_get_s3_key.return_value = (
"storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
)
mock_get_s3_key.return_value = "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
# Call the function
result = StandardLoggingPayloadSetup._generate_cold_storage_object_key(
@ -2309,9 +2228,7 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path()
)
# Verify the result
assert (
result == "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
)
assert result == "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
@pytest.mark.asyncio
@ -2334,16 +2251,12 @@ async def test_e2e_generate_cold_storage_object_key_with_logger_no_s3_path():
with (
patch("litellm.cold_storage_custom_logger", "s3_v2"),
patch(
"litellm.logging_callback_manager.get_active_custom_logger_for_callback_name"
) as mock_get_logger,
patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger,
patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key,
):
# Setup mocks
mock_get_logger.return_value = mock_custom_logger
mock_get_s3_key.return_value = (
"2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
)
mock_get_s3_key.return_value = "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
# Call the function
result = StandardLoggingPayloadSetup._generate_cold_storage_object_key(
@ -2459,9 +2372,7 @@ def test_get_usage_as_dict():
assert result == {"prompt_tokens": 20, "completion_tokens": 30}
# Test case 5: response_obj with no usage key returns empty
result = StandardLoggingPayloadSetup.get_usage_as_dict(
response_obj={"id": "resp-1", "choices": []}
)
result = StandardLoggingPayloadSetup.get_usage_as_dict(response_obj={"id": "resp-1", "choices": []})
assert result == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
@ -2474,26 +2385,20 @@ def test_append_system_prompt_messages():
# Test case 1: system in kwargs with existing messages
kwargs = {"system": "You are a helpful assistant"}
messages = [{"role": "user", "content": "Hello"}]
result = StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=kwargs, messages=messages
)
result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=messages)
assert len(result) == 2
assert result[0] == {"role": "system", "content": "You are a helpful assistant"}
assert result[1] == {"role": "user", "content": "Hello"}
# Test case 2: system in kwargs with None messages
kwargs = {"system": "You are a helpful assistant"}
result = StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=kwargs, messages=None
)
result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=None)
assert len(result) == 1
assert result[0] == {"role": "system", "content": "You are a helpful assistant"}
# Test case 3: system in kwargs with empty messages list
kwargs = {"system": "You are a helpful assistant"}
result = StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=kwargs, messages=[]
)
result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=[])
assert len(result) == 1
assert result[0] == {"role": "system", "content": "You are a helpful assistant"}
@ -2503,24 +2408,18 @@ def test_append_system_prompt_messages():
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello"},
]
result = StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=kwargs, messages=messages
)
result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=messages)
assert len(result) == 2
assert result[0] == {"role": "system", "content": "You are a helpful assistant"}
# Test case 5: no system in kwargs returns messages unchanged
kwargs = {}
messages = [{"role": "user", "content": "Hello"}]
result = StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=kwargs, messages=messages
)
result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=messages)
assert result == messages
# Test case 6: None kwargs returns messages unchanged
result = StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=None, messages=messages
)
result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=None, messages=messages)
assert result == messages
@ -2581,12 +2480,11 @@ async def test_async_success_handler_sets_standard_logging_object_for_pass_throu
# Verify that standard_logging_object was set
assert "standard_logging_object" in logging_obj.model_call_details, (
"standard_logging_object should be set for pass-through endpoints "
"even when complete_streaming_response is None"
"standard_logging_object should be set for pass-through endpoints even when complete_streaming_response is None"
)
assert logging_obj.model_call_details["standard_logging_object"] is not None, (
"standard_logging_object should not be None for pass-through endpoints"
)
assert (
logging_obj.model_call_details["standard_logging_object"] is not None
), "standard_logging_object should not be None for pass-through endpoints"
# Verify that async_complete_streaming_response was set to prevent re-processing
# This is consistent with the existing code pattern for regular streaming
@ -2594,15 +2492,13 @@ async def test_async_success_handler_sets_standard_logging_object_for_pass_throu
"async_complete_streaming_response should be set to prevent re-processing, "
"consistent with the existing code pattern"
)
assert (
logging_obj.model_call_details["async_complete_streaming_response"] is result
), "async_complete_streaming_response should be set to the result"
assert logging_obj.model_call_details["async_complete_streaming_response"] is result, (
"async_complete_streaming_response should be set to the result"
)
# Verify that response_cost is set to None (cost calculation not possible for pass-through)
# This is consistent with the error handling in the non-pass-through code path
assert (
"response_cost" in logging_obj.model_call_details
), "response_cost should be set for pass-through endpoints"
assert "response_cost" in logging_obj.model_call_details, "response_cost should be set for pass-through endpoints"
assert logging_obj.model_call_details["response_cost"] is None, (
"response_cost should be None for pass-through endpoints since "
"StandardPassThroughResponseObject doesn't have standard usage info"
@ -2661,14 +2557,10 @@ async def test_async_success_handler_prevents_reprocessing_for_pass_through_endp
# Verify first call set the values
assert "standard_logging_object" in logging_obj.model_call_details
assert "async_complete_streaming_response" in logging_obj.model_call_details
first_standard_logging_object = logging_obj.model_call_details[
"standard_logging_object"
]
first_standard_logging_object = logging_obj.model_call_details["standard_logging_object"]
# Second call - should return early due to async_complete_streaming_response guard
with patch.object(
logging_obj, "get_combined_callback_list", return_value=[]
) as mock_callbacks:
with patch.object(logging_obj, "get_combined_callback_list", return_value=[]) as mock_callbacks:
await logging_obj.async_success_handler(
result=result,
start_time=start_time,
@ -2679,10 +2571,9 @@ async def test_async_success_handler_prevents_reprocessing_for_pass_through_endp
mock_callbacks.assert_not_called()
# Verify standard_logging_object wasn't modified by second call
assert (
logging_obj.model_call_details["standard_logging_object"]
is first_standard_logging_object
), "standard_logging_object should not be modified on re-processing"
assert logging_obj.model_call_details["standard_logging_object"] is first_standard_logging_object, (
"standard_logging_object should not be modified on re-processing"
)
@pytest.mark.asyncio
@ -2721,9 +2612,7 @@ async def test_async_success_handler_sets_standard_logging_object_for_streaming_
}
# Create a pass-through response object (simulating unparseable streaming response)
result = StandardPassThroughResponseObject(
response='data: {"chunk": 1}\ndata: {"chunk": 2}\ndata: [DONE]'
)
result = StandardPassThroughResponseObject(response='data: {"chunk": 1}\ndata: {"chunk": 2}\ndata: [DONE]')
start_time = datetime.now()
end_time = datetime.now()
@ -2743,9 +2632,9 @@ async def test_async_success_handler_sets_standard_logging_object_for_streaming_
"standard_logging_object should be set for streaming pass-through endpoints "
"even when the response cannot be parsed into a ModelResponse"
)
assert (
logging_obj.model_call_details["standard_logging_object"] is not None
), "standard_logging_object should not be None for streaming pass-through endpoints"
assert logging_obj.model_call_details["standard_logging_object"] is not None, (
"standard_logging_object should not be None for streaming pass-through endpoints"
)
def test_get_error_information_error_code_priority():
@ -2787,30 +2676,22 @@ def test_get_error_information_error_code_priority():
self.message = message
super().__init__(message)
both_exception = BothAttributesException(
code="400", status_code=500, message="Bad Request"
)
both_exception = BothAttributesException(code="400", status_code=500, message="Bad Request")
result = StandardLoggingPayloadSetup.get_error_information(both_exception)
assert result["error_code"] == "400" # Should prefer 'code' over 'status_code'
# Test case 4: Exception with 'code' as empty string - should fall back to 'status_code'
empty_code_exception = BothAttributesException(
code="", status_code=404, message="Not Found"
)
empty_code_exception = BothAttributesException(code="", status_code=404, message="Not Found")
result = StandardLoggingPayloadSetup.get_error_information(empty_code_exception)
assert result["error_code"] == "404" # Should fall back to status_code
# Test case 5: Exception with 'code' as "None" string - should fall back to 'status_code'
none_string_exception = BothAttributesException(
code="None", status_code=503, message="Service Unavailable"
)
none_string_exception = BothAttributesException(code="None", status_code=503, message="Service Unavailable")
result = StandardLoggingPayloadSetup.get_error_information(none_string_exception)
assert result["error_code"] == "503" # Should fall back to status_code
# Test case 6: Exception with 'code' as None - should fall back to 'status_code'
none_code_exception = BothAttributesException(
code=None, status_code=401, message="Unauthorized"
)
none_code_exception = BothAttributesException(code=None, status_code=401, message="Unauthorized")
result = StandardLoggingPayloadSetup.get_error_information(none_code_exception)
assert result["error_code"] == "401" # Should fall back to status_code
@ -2859,9 +2740,7 @@ def test_get_error_information_prefers_message_attribute_over_str():
)
result = StandardLoggingPayloadSetup.get_error_information(exc)
assert (
result["error_message"] == msg
), f"expected message from .message attribute, got {result['error_message']!r}"
assert result["error_message"] == msg, f"expected message from .message attribute, got {result['error_message']!r}"
assert result["error_code"] == "401"
assert result["error_class"] == "ProxyExceptionLike"
@ -2936,8 +2815,7 @@ def test_get_error_information_preserves_explicit_empty_message():
exc = ProxyExceptionLike(message="", code=500)
result = StandardLoggingPayloadSetup.get_error_information(exc)
assert result["error_message"] == "", (
"explicit empty .message must survive verbatim; got "
f"{result['error_message']!r}"
f"explicit empty .message must survive verbatim; got {result['error_message']!r}"
)
@ -3200,9 +3078,7 @@ def test_process_hidden_params_recalculates_cost_after_failure_handler_zero():
choices=[{"message": {"role": "assistant", "content": "ok"}}],
usage=Usage(prompt_tokens=9698, completion_tokens=30, total_tokens=9728),
)
logging_obj._process_hidden_params_and_response_cost(
result, datetime.now(), datetime.now()
)
logging_obj._process_hidden_params_and_response_cost(result, datetime.now(), datetime.now())
cost = logging_obj.model_call_details.get("response_cost")
assert cost is not None and cost > 0
@ -3226,9 +3102,7 @@ def test_process_hidden_params_preserves_zero_cost_in_hidden_params():
litellm_call_id="test-hidden-zero-cost",
function_id="test-hidden-zero-cost",
)
logging_obj.model_call_details["litellm_params"] = {
"model": "gemini-2.5-flash-lite"
}
logging_obj.model_call_details["litellm_params"] = {"model": "gemini-2.5-flash-lite"}
logging_obj.optional_params = {}
result = ModelResponse(
@ -3238,9 +3112,7 @@ def test_process_hidden_params_preserves_zero_cost_in_hidden_params():
)
result._hidden_params = {"response_cost": 0.0}
logging_obj._process_hidden_params_and_response_cost(
result, datetime.now(), datetime.now()
)
logging_obj._process_hidden_params_and_response_cost(result, datetime.now(), datetime.now())
assert logging_obj.model_call_details.get("response_cost") == 0.0
slo = logging_obj.model_call_details.get("standard_logging_object") or {}
@ -3289,9 +3161,7 @@ def test_process_hidden_params_uses_hidden_params_cost_after_failure_handler_zer
)
result._hidden_params = {"response_cost": passthrough_cost}
logging_obj._process_hidden_params_and_response_cost(
result, datetime.now(), datetime.now()
)
logging_obj._process_hidden_params_and_response_cost(result, datetime.now(), datetime.now())
assert logging_obj.model_call_details.get("response_cost") == passthrough_cost
slo = logging_obj.model_call_details.get("standard_logging_object") or {}
@ -3348,9 +3218,7 @@ def test_function_setup_litellm_metadata_populates_metadata():
assert litellm_metadata.get("user_api_key_hash") == test_api_key_hash
# metadata should be a COPY, not an alias — mutating one must not affect the other
assert (
metadata is not litellm_metadata
), "litellm_params['metadata'] should be a copy, not the same object"
assert metadata is not litellm_metadata, "litellm_params['metadata'] should be a copy, not the same object"
def test_function_setup_litellm_metadata_guardrail_writes_visible_after_setup():
@ -3395,9 +3263,9 @@ def test_function_setup_litellm_metadata_guardrail_writes_visible_after_setup():
litellm_params = logging_obj.model_call_details.get("litellm_params", {})
litellm_metadata = litellm_params.get("litellm_metadata")
assert litellm_metadata is not None
assert litellm_metadata.get("standard_logging_guardrail_information") == [
guardrail_entry
], "guardrail writes after function_setup must be visible to the logging object"
assert litellm_metadata.get("standard_logging_guardrail_information") == [guardrail_entry], (
"guardrail writes after function_setup must be visible to the logging object"
)
assert litellm_metadata.get("applied_guardrails") == ["pam-ethical-request"]
merged = StandardLoggingPayloadSetup.merge_litellm_metadata(litellm_params)
@ -3566,9 +3434,7 @@ def test_failure_handler_skips_sync_callbacks_for_pass_through_requests(logging_
@pytest.mark.parametrize("call_type", ["completion", "acompletion"])
def test_failure_handler_runs_sync_callbacks_for_non_pass_through_requests(
logging_obj, call_type
):
def test_failure_handler_runs_sync_callbacks_for_non_pass_through_requests(logging_obj, call_type):
"""Ensure sync failure callbacks still fire for normal (non-pass-through) requests."""
from litellm.integrations.custom_logger import CustomLogger
@ -3729,9 +3595,7 @@ def test_standard_logging_hidden_params_backfills_response_cost_without_mutating
)
response._hidden_params = {"response_cost": None, "model_id": "mid-test"}
payload = logging_obj._build_standard_logging_payload(
response, datetime.now(), datetime.now()
)
payload = logging_obj._build_standard_logging_payload(response, datetime.now(), datetime.now())
assert payload is not None
assert payload["hidden_params"]["response_cost"] == 0.002
@ -3785,10 +3649,7 @@ def test_merge_hidden_params_from_response_into_metadata_no_op_when_empty():
_hidden_params = {}
logging_obj._merge_hidden_params_from_response_into_metadata(_NoHp())
assert (
"hidden_params"
not in logging_obj.model_call_details["litellm_params"]["metadata"]
)
assert "hidden_params" not in logging_obj.model_call_details["litellm_params"]["metadata"]
# ── StandardLoggingPayloadSetup.get_additional_headers ───────────────────────
@ -3877,9 +3738,7 @@ def _model_router_response(selected_model: str, stamp: bool):
from litellm.types.utils import ModelResponse
response = ModelResponse(model=selected_model)
response._hidden_params = (
{AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model} if stamp else {}
)
response._hidden_params = {AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model} if stamp else {}
return response
@ -3903,9 +3762,7 @@ def test_standard_logging_payload_uses_stamped_model_router_model(logging_obj):
"messages": [],
"litellm_params": {"metadata": {}},
},
init_response_obj=_model_router_response(
"azure_ai/grok-4-1-fast-reasoning", stamp=True
),
init_response_obj=_model_router_response("azure_ai/grok-4-1-fast-reasoning", stamp=True),
start_time=now,
end_time=now,
logging_obj=logging_obj,
@ -3937,9 +3794,7 @@ def test_standard_logging_payload_keeps_requested_model_without_router_stamp(
"messages": [],
"litellm_params": {"metadata": {}},
},
init_response_obj=_model_router_response(
"azure_ai/grok-4-1-fast-reasoning", stamp=False
),
init_response_obj=_model_router_response("azure_ai/grok-4-1-fast-reasoning", stamp=False),
start_time=now,
end_time=now,
logging_obj=logging_obj,
@ -3985,9 +3840,7 @@ def test_success_handler_computes_cost_for_dict_response():
"_build_standard_logging_payload",
return_value={"response_cost": expected_cost},
),
patch(
"litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"
),
patch("litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"),
patch.object(
logging_obj,
"_is_recognized_call_type_for_logging",
@ -4024,9 +3877,7 @@ def test_success_handler_preserves_precomputed_cost_for_dict_response():
"_build_standard_logging_payload",
return_value={"response_cost": precomputed_cost},
),
patch(
"litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"
),
patch("litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"),
patch.object(
logging_obj,
"_is_recognized_call_type_for_logging",
@ -4065,9 +3916,7 @@ def test_success_handler_unified_helper_runs_for_typed_results():
"_build_standard_logging_payload",
return_value={"response_cost": expected_cost},
),
patch(
"litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"
),
patch("litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"),
patch.object(
logging_obj,
"_is_recognized_call_type_for_logging",
@ -4122,9 +3971,7 @@ class TestFirstApiCallStartTimeSetOnce:
assert first == obj.model_call_details["api_call_start_time"]
# Set on the logging object only — user metadata untouched.
assert user_meta == {}
assert (
"first_api_call_start_time" not in obj.model_call_details["litellm_params"]
)
assert "first_api_call_start_time" not in obj.model_call_details["litellm_params"]
time.sleep(0.002) # ensure a distinct retry timestamp
obj.pre_call(input="hi", api_key="sk-test")
@ -4141,18 +3988,16 @@ def test_get_error_information_for_logging_payload_ignores_spoofed_disconnect_wi
baseline = StandardLoggingPayloadSetup.get_error_information(
original_exception=ValueError("provider failure"),
)
error_information, error_str = (
StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={
"error_information": {
"error_code": "499",
"error_message": "Client disconnected the request",
"error_class": "ClientDisconnected",
}
},
original_exception=ValueError("provider failure"),
error_str="provider failure",
)
error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={
"error_information": {
"error_code": "499",
"error_message": "Client disconnected the request",
"error_class": "ClientDisconnected",
}
},
original_exception=ValueError("provider failure"),
error_str="provider failure",
)
assert error_information == baseline
assert error_str == "provider failure"
@ -4166,22 +4011,18 @@ def test_get_error_information_for_logging_payload_client_disconnect():
"error_message": "Client disconnected the request",
"error_class": "ClientDisconnected",
}
error_information, error_str = (
StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={"client_disconnected": True, "error_information": custom_error},
original_exception=None,
error_str=None,
)
error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={"client_disconnected": True, "error_information": custom_error},
original_exception=None,
error_str=None,
)
assert error_information == custom_error
assert error_str == "Client disconnected the request"
error_information, error_str = (
StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={"client_disconnected": True},
original_exception=None,
error_str="existing error",
)
error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={"client_disconnected": True},
original_exception=None,
error_str="existing error",
)
assert error_information["error_code"] == "499"
assert error_str == "existing error"
@ -4189,12 +4030,10 @@ def test_get_error_information_for_logging_payload_client_disconnect():
baseline = StandardLoggingPayloadSetup.get_error_information(
original_exception=None,
)
error_information, error_str = (
StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={},
original_exception=None,
error_str=None,
)
error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata={},
original_exception=None,
error_str=None,
)
assert error_information == baseline
assert error_str is None
@ -4229,9 +4068,7 @@ def test_get_error_information_prefers_message_attribute_over_empty_str():
def __str__(self):
return ""
info = StandardLoggingPayloadSetup.get_error_information(
original_exception=_SilentExc()
)
info = StandardLoggingPayloadSetup.get_error_information(original_exception=_SilentExc())
assert info["error_message"] == "real failure detail"
assert info["error_code"] == "401"
@ -4262,9 +4099,7 @@ def _responses_api_response_with_text(text="hello world"):
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(annotations=[], text=text, type="output_text")
],
content=[ResponseOutputText(annotations=[], text=text, type="output_text")],
)
],
usage=ResponseAPIUsage(input_tokens=11, output_tokens=7, total_tokens=18),
@ -4279,9 +4114,7 @@ def _responses_api_response_with_text(text="hello world"):
("ResponseFailedEvent", "response.failed"),
],
)
def test_handle_anthropic_messages_response_logging_translates_terminal_responses_api_event(
event_cls, event_type
):
def test_handle_anthropic_messages_response_logging_translates_terminal_responses_api_event(event_cls, event_type):
"""Regression for #28595 / #28943. When anthropic_messages routes to the OpenAI
Responses backend and stream=True, success_handler receives a terminal Responses
API event. The handler must translate it to a ModelResponse whose choices carry
@ -4320,10 +4153,7 @@ def test_handle_anthropic_messages_response_logging_passes_model_response_throug
"""Anthropic-native path already yields a ModelResponse; it must be returned unchanged."""
logging_obj = _anthropic_messages_logging_obj()
model_response = ModelResponse()
assert (
logging_obj._handle_anthropic_messages_response_logging(result=model_response)
is model_response
)
assert logging_obj._handle_anthropic_messages_response_logging(result=model_response) is model_response
def test_handle_anthropic_messages_response_logging_degrades_on_unparseable_responses_payload():
@ -4619,9 +4449,7 @@ def test_non_image_response_has_no_output_image_count(logging_obj):
def test_zero_token_video_usage_preserves_duration_seconds(logging_obj):
"""Video usage bills by duration; the payload must keep duration_seconds even with zero tokens."""
payload = _build_payload_for_media_response(
logging_obj, {"id": "video-1", "usage": {"duration_seconds": 4.0}}
)
payload = _build_payload_for_media_response(logging_obj, {"id": "video-1", "usage": {"duration_seconds": 4.0}})
assert payload is not None
assert payload["metadata"]["usage_object"]["duration_seconds"] == 4.0
@ -5600,6 +5428,8 @@ def test_prompt_hooks_skip_prompt_managers_when_no_prompt_id(logging_obj, tmp_pa
)
for hook in [cb for cb in litellm.callbacks if isinstance(cb, VectorStorePreCallHook)]:
litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm.callbacks, hook)
def test_newrelic_dispatch_prefers_otel_v2_when_flag_on(monkeypatch):
"""With LITELLM_OTEL_V2 on, the "newrelic" callback builds the OTel v2
logger (per-team credential routing); with the flag off (default) it keeps
@ -5752,7 +5582,9 @@ def test_get_error_information_skips_traceback_for_budget_rejection_with_provide
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
assert litellm.log_client_error_tracebacks is False
over_budget = _raise_and_catch(litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic"))
over_budget = _raise_and_catch(
litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")
)
result = StandardLoggingPayloadSetup.get_error_information(over_budget)
assert result["error_code"] == "429"
assert result["llm_provider"] == "anthropic"
@ -5782,3 +5614,37 @@ def test_failure_handler_helper_fn_builds_payload_once_per_exception():
other_exc = _raise_and_catch(_ClientError(status_code=429, message="rate limited"))
obj._failure_handler_helper_fn(exception=other_exc, traceback_exception="")
assert obj.model_call_details["standard_logging_object"] is not first_payload
def test_get_assembled_streaming_response_handles_dict_response_usage(logging_obj):
"""#29913: on the streaming /v1/responses spend-log path result.response can
arrive as a dict rather than a ResponsesAPIResponse. The unified-usage step
must not assume a `.usage` attribute (it raised AttributeError before)."""
import datetime as _dt
from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseCompletedEvent,
ResponsesAPIStreamEvents,
)
usage = ResponseAPIUsage(input_tokens=11, output_tokens=7, total_tokens=18)
event = ResponseCompletedEvent.model_construct(
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
response={"usage": usage},
)
now = _dt.datetime.now()
result = logging_obj._get_assembled_streaming_response(
result=event,
start_time=now,
end_time=now,
is_async=False,
streaming_chunks=[],
)
assert isinstance(result, dict)
assert isinstance(result["usage"], dict)
assert result["usage"]["total_tokens"] == 18
assert result["usage"]["prompt_tokens"] == 11
assert result["usage"]["completion_tokens"] == 7