diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 9a9d2ceda03..c9c7df4d75e 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2414,6 +2414,46 @@ class RealtimeAPITokenUsageProcessor(BaseTokenUsageProcessor): ) +_RESPONSES_WS_BILLABLE_EVENT_TYPES: Final = frozenset({"response.completed", "response.incomplete"}) + + +class _ResponsesWsEventResponse(BaseModel): + usage: Mapping[str, object] | None = None + + +class _ResponsesWsEvent(BaseModel): + type: str = "" + response: _ResponsesWsEventResponse | None = None + + +class ResponsesWebSocketTokenUsageProcessor(BaseTokenUsageProcessor): + @staticmethod + def collect_usage_from_responses_ws_results( + results: Sequence[Mapping[str, object]], + ) -> tuple[Usage, ...]: + events: Final = tuple(_ResponsesWsEvent.model_validate(result) for result in results) + return tuple( + ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( # pyright: ignore[reportPrivateUsage] # same shared transform the realtime processor uses + event.response.usage + ) + for event in events + if event.type in _RESPONSES_WS_BILLABLE_EVENT_TYPES + and event.response is not None + and event.response.usage is not None + ) + + @staticmethod + def collect_and_combine_usage_from_responses_ws_results( + results: Sequence[Mapping[str, object]], + ) -> Usage: + collected_usage_objects: Final = ResponsesWebSocketTokenUsageProcessor.collect_usage_from_responses_ws_results( + results + ) + return ResponsesWebSocketTokenUsageProcessor.combine_usage_objects( + list(collected_usage_objects) # mutable-ok: combine_usage_objects requires a list parameter + ) + + _TRANSCRIPTION_COMPLETED_EVENT_TYPE: Final = "conversation.item.input_audio_transcription.completed" diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index ca2cca5360f..e3c94866985 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -48,6 +48,7 @@ from litellm.constants import ( ) from litellm.cost_calculator import ( RealtimeAPITokenUsageProcessor, + ResponsesWebSocketTokenUsageProcessor, _select_model_name_for_cost_calc, ) from litellm.exceptions import ( @@ -2028,6 +2029,17 @@ class Logging(LiteLLMLoggingBaseClass): results=result, ) + elif self.call_type == CallTypes.aresponses_websocket.value and isinstance(result, list): # pyright: ignore[reportUnknownMemberType] # Logging.call_type is untyped + combined_ws_usage: Final = ( + ResponsesWebSocketTokenUsageProcessor.collect_and_combine_usage_from_responses_ws_results( + results=result # pyright: ignore[reportUnknownArgumentType] # raw event dicts from the WS stream + ) + ) + logging_result = LiteLLMRealtimeStreamLoggingObject( + usage=combined_ws_usage, + results=result, # pyright: ignore[reportUnknownArgumentType] # raw event dicts from the WS stream + ) + elif ( self.call_type == CallTypes.llm_passthrough_route.value or self.call_type == CallTypes.allm_passthrough_route.value diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 9b5fb08a45f..40a6d77482f 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -580,6 +580,7 @@ CallTypesLiteral = Literal[ "search", "asearch", "_arealtime", + "_aresponses_websocket", "create_batch", "acreate_batch", "create_file", diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 0fdca755685..b6366bc803e 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -22,7 +22,13 @@ from litellm.litellm_core_utils.litellm_logging import ( _get_status_fields, set_callbacks, ) -from litellm.types.utils import ModelResponse, TextCompletionResponse +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse +from litellm.types.utils import ( + CallTypes, + LiteLLMRealtimeStreamLoggingObject, + ModelResponse, + TextCompletionResponse, +) @pytest.fixture @@ -6393,6 +6399,90 @@ async def test_prompt_hook_injection_marker_recorded_for_every_surface(logging_o assert pre_choice["metadata"]["litellm_gateway_injected_cache"] == "" +def _responses_ws_logging_obj() -> LitellmLogging: + return LitellmLogging( + model="gpt-4o", + messages=[], + stream=False, + call_type=CallTypes.aresponses_websocket.value, + start_time=time.time(), + litellm_call_id="responses-ws-usage-test", + function_id="responses-ws-usage-test", + ) + + +def test_normalize_logging_result_extracts_usage_for_responses_websocket(monkeypatch): + """LIT-6512: native /v1/responses WebSocket sessions logged $0 spend because the usage + carried by stored response.completed events was never extracted. The session must cost + exactly what the same usage costs over HTTP /v1/responses, discounts included.""" + monkeypatch.setattr(litellm, "cost_discount_config", {"openai": 0.5}) + logging_obj = _responses_ws_logging_obj() + events = [ + {"type": "response.created", "response": {}}, + { + "type": "response.completed", + "response": {"usage": {"input_tokens": 100, "output_tokens": 40, "total_tokens": 140}}, + }, + { + "type": "response.completed", + "response": {"usage": {"input_tokens": 60, "output_tokens": 10, "total_tokens": 70}}, + }, + ] + + normalized = logging_obj.normalize_logging_result(result=events) + + assert isinstance(normalized, LiteLLMRealtimeStreamLoggingObject) + assert normalized.usage.prompt_tokens == 160 + assert normalized.usage.completion_tokens == 50 + + ws_cost = litellm.completion_cost( + completion_response=normalized, + model="gpt-4o", + call_type=CallTypes.aresponses_websocket.value, + custom_llm_provider="openai", + ) + http_cost = litellm.completion_cost( + completion_response=ResponsesAPIResponse( + id="resp-6512", + created_at=1700000000, + output=[], + usage=ResponseAPIUsage(input_tokens=160, output_tokens=50, total_tokens=210), + ), + model="gpt-4o", + call_type=CallTypes.aresponses.value, + custom_llm_provider="openai", + ) + assert ws_cost > 0 + assert ws_cost == http_cost + + +def test_normalize_logging_result_bills_incomplete_responses_websocket_turns(): + """LIT-6512: a turn cut short by max_output_tokens ends in response.incomplete, which + OpenAI bills, so its usage counts toward the session like a completed turn.""" + events = [ + { + "type": "response.created", + "response": {"usage": {"input_tokens": 999, "output_tokens": 999, "total_tokens": 1998}}, + }, + { + "type": "response.incomplete", + "response": {"usage": {"input_tokens": 15, "output_tokens": 16, "total_tokens": 31}}, + }, + { + "type": "response.completed", + "response": {"usage": {"input_tokens": 40, "output_tokens": 4, "total_tokens": 44}}, + }, + {"type": "response.failed", "response": {"usage": None}}, + ] + + normalized = _responses_ws_logging_obj().normalize_logging_result(result=events) + + assert isinstance(normalized, LiteLLMRealtimeStreamLoggingObject) + assert normalized.usage.prompt_tokens == 55 + assert normalized.usage.completion_tokens == 20 + assert normalized.usage.total_tokens == 75 + + def test_get_standard_logging_object_payload_reads_overhead_from_logging_obj_for_dict_results(logging_obj): """LIT-5466: /v1/messages returns a plain dict with no _hidden_params, so the overhead recorded on the logging object must reach hidden_params.litellm_overhead_time_ms (SpendLogs)."""