mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
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:
parent
d36e032241
commit
6a425a5cc5
4 changed files with 144 additions and 1 deletions
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -580,6 +580,7 @@ CallTypesLiteral = Literal[
|
|||
"search",
|
||||
"asearch",
|
||||
"_arealtime",
|
||||
"_aresponses_websocket",
|
||||
"create_batch",
|
||||
"acreate_batch",
|
||||
"create_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)."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue