Skip to content
Merged
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
64 changes: 55 additions & 9 deletions litellm/litellm_core_utils/litellm_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -3507,9 +3507,7 @@ def _get_assembled_streaming_response(
else:
return None

def _handle_anthropic_messages_response_logging(
self, result: Any
) -> Union[ModelResponse, ResponsesAPIResponse]:
def _handle_anthropic_messages_response_logging(self, result: Any) -> ModelResponse:
"""
Handles logging for Anthropic messages responses.

Expand All @@ -3528,15 +3526,14 @@ def _handle_anthropic_messages_response_logging(
return result
elif isinstance(result, ModelResponse):
return result
elif isinstance(

if isinstance(
result,
(ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent),
):
# anthropic_messages() can route to OpenAI Responses API; in that path
# the assembled streaming result is one of these terminal events rather than
# a ModelResponse. Return the inner response so downstream handlers
# (_transform_usage_objects, normalize_logging_result) can process it.
return result.response
result = result.response
if isinstance(result, ResponsesAPIResponse):
return self._translate_responses_api_response_to_model_response(result)

httpx_response = self.model_call_details.get("httpx_response", None)
if httpx_response and isinstance(httpx_response, httpx.Response):
Expand Down Expand Up @@ -3570,6 +3567,55 @@ def _handle_anthropic_messages_response_logging(
)
return result

def _translate_responses_api_response_to_model_response(
self, result: ResponsesAPIResponse
) -> ModelResponse:
"""
Convert a Responses API response into a ModelResponse for spend_logs.

The proxy UI parses spend_log rows expecting chat-completion shape
(response.choices[0].message); a raw ResponsesAPIResponse dump (output[...])
would render as empty in the Logs tab. Translation also yields full
choices/message detail downstream consumers can rely on.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

try:
return LiteLLMResponsesTransformationHandler().transform_response(
model=self.model,
raw_response=result,
model_response=litellm.ModelResponse(),
logging_obj=self,
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=litellm.encoding,
)
except Exception as e:
verbose_logger.debug(
"Responses API -> ModelResponse translation failed for "
"anthropic_messages logging (%s); falling back to minimal "
"usage-only ModelResponse to keep the spend_logs row.",
str(e),
)
model_response = litellm.ModelResponse()
model_response.model = self.model
usage = getattr(result, "usage", None)
if usage is not None and ResponseAPILoggingUtils._is_response_api_usage(
usage
):
setattr(
model_response,
"usage",
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
usage
),
)
return model_response

def _handle_non_streaming_google_genai_generate_content_response_logging(
self, result: Any
) -> ModelResponse:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ def _build_responses_kwargs(
from litellm.types.utils import CallTypes

if isinstance(value, LiteLLMLoggingObject):
# 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.)
# Keep call_type as anthropic_messages so spend_logs are billed
# against /v1/messages; the success handler translates the
# Responses API result back to a ModelResponse for the row.
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:
Expand Down
107 changes: 88 additions & 19 deletions tests/test_litellm/litellm_core_utils/test_litellm_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -3151,6 +3151,41 @@ def __str__(self):
assert info["error_code"] == "401"


def _anthropic_messages_logging_obj():
return LitellmLogging(
model="openai/my-local",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="anthropic_messages",
start_time=time.time(),
litellm_call_id="28595",
function_id="28595",
)


def _responses_api_response_with_text(text="hello world"):
from openai.types.responses import ResponseOutputMessage, ResponseOutputText

from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse

return ResponsesAPIResponse(
id="resp-28595",
created_at=1700000000,
output=[
ResponseOutputMessage(
id="msg-1",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(annotations=[], text=text, type="output_text")
],
)
],
usage=ResponseAPIUsage(input_tokens=11, output_tokens=7, total_tokens=18),
)


@pytest.mark.parametrize(
"event_cls, event_type",
[
Expand All @@ -3159,34 +3194,68 @@ def __str__(self):
("ResponseFailedEvent", "response.failed"),
],
)
def test_handle_anthropic_messages_response_logging_with_terminal_responses_api_events(
def test_handle_anthropic_messages_response_logging_translates_terminal_responses_api_event(
event_cls, event_type
):
"""Regression test for #28943: when anthropic_messages routes to OpenAI Responses
API and stream=True, success_handler receives a terminal ResponsesAPI event instead
of a ModelResponse. The handler must return the inner ResponsesAPIResponse rather
than crashing with AnthropicResponse.model_validate."""
"""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
the assistant text, so the proxy UI Logs tab (which reads response.choices[0])
renders the response content instead of "No response data available"."""
import importlib

openai_types = importlib.import_module("litellm.types.llms.openai")
EventClass = getattr(openai_types, event_cls)
from litellm.types.llms.openai import ResponsesAPIResponse

logging_obj = LitellmLogging(
model="gpt-4o",
messages=[{"role": "user", "content": "hello"}],
stream=True,
call_type="anthropic_messages",
start_time=time.time(),
litellm_call_id="test-rce-123",
function_id="test-fn",
logging_obj = _anthropic_messages_logging_obj()
inner_response = _responses_api_response_with_text("hello world")
event = EventClass(type=event_type, response=inner_response)

result = logging_obj._handle_anthropic_messages_response_logging(result=event)

assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "hello world" # type: ignore[union-attr]
assert result.usage.prompt_tokens == 11 # type: ignore[attr-defined]
assert result.usage.completion_tokens == 7 # type: ignore[attr-defined]


def test_handle_anthropic_messages_response_logging_translates_bare_responses_api_response():
"""Non-streaming bridge path: result is a bare ResponsesAPIResponse (no event wrap)."""
logging_obj = _anthropic_messages_logging_obj()
result = logging_obj._handle_anthropic_messages_response_logging(
result=_responses_api_response_with_text("hi there")
)

inner_response = ResponsesAPIResponse(
id="resp_test", created_at=1700000000, output=[]
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "hi there" # type: ignore[union-attr]
assert result.usage.total_tokens == 18 # type: ignore[attr-defined]


def test_handle_anthropic_messages_response_logging_passes_model_response_through():
"""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
)
event = EventClass(type=event_type, response=inner_response)

result = logging_obj._handle_anthropic_messages_response_logging(result=event)

assert result is inner_response
def test_handle_anthropic_messages_response_logging_degrades_on_unparseable_responses_payload():
"""If the Responses translation raises (eg. empty output on an incomplete response),
the row must still land: a minimal ModelResponse with model + usage is returned."""
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse

logging_obj = _anthropic_messages_logging_obj()
empty = ResponsesAPIResponse(
id="resp-empty",
created_at=1700000000,
output=[],
usage=ResponseAPIUsage(input_tokens=4, output_tokens=0, total_tokens=4),
)

result = logging_obj._handle_anthropic_messages_response_logging(result=empty)

assert isinstance(result, ModelResponse)
assert result.model == "openai/my-local"
assert result.usage.prompt_tokens == 4 # type: ignore[attr-defined]
Loading