fix(logging): convert Usage object to dict in non-streaming /v1/responses transform

_transform_usage_objects was setattr-ing a raw Usage Pydantic object
instead of a dict when setting result.usage for non-streaming
ResponsesAPIResponse results. Since ResponsesAPIResponse.usage is typed
as ResponseAPIUsage (which lacks prompt_tokens/completion_tokens),
model_dump() would silently drop those fields, corrupting token usage
reporting for non-streaming /v1/responses calls. The
standard_logging_payload branch already converted to dict correctly;
this applies the same pattern to the result.usage assignment itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TobiMayr 2026-07-08 16:50:23 +02:00
parent 52d5a51fcb
commit a46d18a2e0
2 changed files with 36 additions and 9 deletions

View file

@ -2686,17 +2686,13 @@ class Logging(LiteLLMLoggingBaseClass):
) -> object:
if isinstance(result, ResponsesAPIResponse):
result = result.model_copy()
transformed_usage = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(result.usage)
setattr(result, "usage", transformed_usage)
chat_usage: Final = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(result.usage)
chat_usage_dict: Final = chat_usage.model_dump() if hasattr(chat_usage, "model_dump") else dict(chat_usage)
setattr(result, "usage", chat_usage_dict)
if standard_logging_payload is not None:
response_dict: Final = result.model_dump() if hasattr(result, "model_dump") else dict(result)
# Ensure usage is properly included with transformed chat format
if transformed_usage is not None:
response_dict["usage"] = (
transformed_usage.model_dump()
if hasattr(transformed_usage, "model_dump")
else dict(transformed_usage)
)
if chat_usage is not None:
response_dict["usage"] = chat_usage_dict
standard_logging_payload["response"] = response_dict # rebind-ok: mirrors the transformed usage
elif isinstance(result, TranscriptionResponse):
from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import (

View file

@ -6185,6 +6185,37 @@ def test_success_handler_computes_cost_for_dict_response():
assert logging_obj.model_call_details["response_cost"] == expected_cost
def test_transform_usage_objects_non_streaming_preserves_prompt_and_completion_tokens():
response = ResponsesAPIResponse(
id="resp-test-001",
created_at=1700000000,
output=[],
usage=ResponseAPIUsage(input_tokens=100, output_tokens=50, total_tokens=150),
)
logging_obj = LitellmLogging(
model="gpt-5",
messages=[{"role": "user", "content": "Hello"}],
stream=False,
call_type="responses",
start_time=time.time(),
litellm_call_id="test-usage-preservation",
function_id="test-fn-usage",
)
result = logging_obj._transform_usage_objects(response)
assert isinstance(result.usage, dict)
assert result.usage.get("prompt_tokens") == 100
assert result.usage.get("completion_tokens") == 50
assert result.usage.get("total_tokens") == 150
dumped_usage = result.model_dump().get("usage", {})
assert dumped_usage.get("prompt_tokens") == 100
assert dumped_usage.get("completion_tokens") == 50
assert dumped_usage.get("total_tokens") == 150
def test_success_handler_preserves_precomputed_cost_for_dict_response():
"""Precomputed response_cost on model_call_details must not be overwritten."""
logging_obj = _make_dict_logging_obj()