fix(responses): record spend for native Responses API WebSocket sessions (#38856)

* fix(responses): record spend for native Responses API WebSocket sessions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): bill usage from response.incomplete WebSocket turns

A turn cut short by max_output_tokens ends in response.incomplete, which
OpenAI bills but the processor only read response.completed, so those
sessions still logged zero spend

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(responses): hoist websocket usage test imports to module scope

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): price websocket sessions through the standard cost path

The realtime completion_cost branch skips cost_discount_config and cost_margin_config, so a native
Responses WebSocket session was priced differently from the same usage over HTTP /v1/responses.
Drop the explicit widening so the LiteLLMRealtimeStreamLoggingObject built by
normalize_logging_result flows through the generic usage path, and pin WS == HTTP cost under a
50% provider discount in the regression test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rerun proxy-infra after flaky test_check_migration process tree test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yucheng <yucheng@berri.ai>
This commit is contained in:
devin-ai-integration[bot] 2026-09-08 13:08:26 -07:00 committed by GitHub
parent d36e032241
commit 6a425a5cc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 144 additions and 1 deletions

View file

@ -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"

View file

@ -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

View file

@ -580,6 +580,7 @@ CallTypesLiteral = Literal[
"search",
"asearch",
"_arealtime",
"_aresponses_websocket",
"create_batch",
"acreate_batch",
"create_file",

View file

@ -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)."""