litellm/tests/unit/responses/test_responses_streaming_iterator.py
Kerry Lu f0ae445cc4
fix(proxy): bill partial Responses API streams when the client disconnects (#47611)
* fix(proxy): bill partial Responses API streams when the client disconnects

Streamed /v1/responses requests and the default-route OpenAI and Azure /v1/messages requests behind them logged a 499 with zero spend when the client went away mid-stream, while /v1/chat/completions already billed the chunks it had sent. The Responses API iterator now keeps the response.created payload and can hand the disconnect cleanup a response.incomplete event with counted usage and the served service_tier, and the messages-route byte stream and router wrappers delegate to it.

* fix(proxy): satisfy lint gates for partial Responses API disconnect billing

Make the disconnect helper public so the proxy, router and messages adapter can import it without reportPrivateUsage, type the SSE byte stream as an AsyncGenerator so aclose is known, read litellm_call_id off the logging object, and keep the new tests on Final locals with the recorder injected through dynamic_async_success_callbacks instead of litellm.callbacks.

* fix(router): bill the primary stream's tokens after a mid-stream hop and chat-bridge Responses streams on disconnect

The router's fallback wrapper now folds the primary Responses stream's relayed tokens into the fallback's disconnect event the way _combine_responses_fallback_usage does for a natural end of stream, and LiteLLMCompletionStreamingIterator (chat-only providers served through /v1/responses) builds its disconnect event from the chat completion chunks it collected. stamp_responses_usage_cost is now public so the bridge module can price that event.

* Potential fix for pull request finding 'CodeQL / Log Injection'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-10-20 12:24:38 -07:00

265 lines
8.8 KiB
Python

"""
Regression tests for LIT-4210: the streaming iterators must never run the sync
success_handler on the thread-pool executor concurrently with
async_success_handler. Concurrent mutation of the shared response object /
model_call_details from two threads segfaults pydantic-core (customer pods
crashed with exit 139 whenever any CustomLogger was registered).
"""
import asyncio
import json
import time
from collections.abc import Mapping
from typing import Final
import httpx
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module
from litellm.responses import streaming_iterator as responses_streaming_iterator_module
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator
from litellm.types.llms.openai import ResponsesAPIResponse
class RecordingCustomLogger(CustomLogger):
def __init__(self):
super().__init__()
self.async_hook_started: float | None = None
self.async_hook_finished: float | None = None
async def _record(self):
self.async_hook_started = time.monotonic()
await asyncio.sleep(0.2)
self.async_hook_finished = time.monotonic()
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
await self._record()
async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time):
await self._record()
class RecordingExecutor:
def __init__(self, inner):
self._inner = inner
self.submits: list = []
def submit(self, fn, *args, **kwargs):
self.submits.append((time.monotonic(), fn))
return self._inner.submit(fn, *args, **kwargs)
def submit_times_for(self, logging_obj) -> list:
return [t for t, fn in self.submits if getattr(fn, "__self__", None) is logging_obj]
@pytest.fixture(autouse=True)
def _isolate_callbacks():
saved = (
litellm.callbacks,
litellm.success_callback,
litellm._async_success_callback,
litellm.failure_callback,
litellm._async_failure_callback,
)
yield
(
litellm.callbacks,
litellm.success_callback,
litellm._async_success_callback,
litellm.failure_callback,
litellm._async_failure_callback,
) = saved
@pytest.fixture
def recording_executor(monkeypatch):
recording = RecordingExecutor(thread_pool_executor_module.executor)
monkeypatch.setattr(thread_pool_executor_module, "executor", recording)
monkeypatch.setattr(responses_streaming_iterator_module, "executor", recording)
return recording
def _make_logging_obj() -> LitellmLogging:
logging_obj = LitellmLogging(
model="gpt-5.4-nano",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="aresponses",
start_time=time.time(),
litellm_call_id="lit-4210-test",
function_id="lit-4210-test",
)
logging_obj.model_call_details["litellm_params"] = {"aresponses": True}
return logging_obj
def _make_iterator(logging_obj: LitellmLogging) -> ResponsesAPIStreamingIterator:
iterator = ResponsesAPIStreamingIterator(
response=httpx.Response(200),
model="gpt-5.4-nano",
responses_api_provider_config=None,
logging_obj=logging_obj,
)
iterator.completed_response = ResponsesAPIResponse(
id="resp_lit4210",
created_at=1700000000.0,
model="gpt-5.4-nano",
object="response",
output=[],
parallel_tool_calls=True,
tool_choice="auto",
tools=[],
error=None,
incomplete_details=None,
instructions=None,
metadata={},
temperature=1.0,
top_p=1.0,
)
return iterator
async def _await_tasks_spawned_since(existing: set["asyncio.Task[object]"]) -> None:
spawned = asyncio.all_tasks() - existing
await asyncio.wait_for(asyncio.gather(*spawned, return_exceptions=True), timeout=5)
@pytest.mark.asyncio
async def test_custom_logger_only_never_submits_sync_success_handler(recording_executor):
recorder = RecordingCustomLogger()
litellm.success_callback = [recorder]
litellm._async_success_callback = [recorder]
logging_obj = _make_logging_obj()
iterator = _make_iterator(logging_obj)
existing_tasks = asyncio.all_tasks()
iterator._log_completed_response(is_async=True)
await _await_tasks_spawned_since(existing_tasks)
assert recorder.async_hook_started is not None
assert recording_executor.submit_times_for(logging_obj) == []
@pytest.mark.asyncio
async def test_sync_callbacks_run_only_after_async_handler_completes(recording_executor):
recorder = RecordingCustomLogger()
sync_events: list = []
def sync_callback(kwargs, response_obj, start_time, end_time):
sync_events.append(time.monotonic())
litellm.success_callback = [recorder, sync_callback]
litellm._async_success_callback = [recorder]
logging_obj = _make_logging_obj()
iterator = _make_iterator(logging_obj)
existing_tasks = asyncio.all_tasks()
iterator._log_completed_response(is_async=True)
await _await_tasks_spawned_since(existing_tasks)
assert recorder.async_hook_finished is not None
submit_times = recording_executor.submit_times_for(logging_obj)
assert len(submit_times) == 1
assert submit_times[0] >= recorder.async_hook_finished
_CREATED_RESPONSE_WITH_PRIORITY_TIER: Final[Mapping[str, object]] = {
"id": "resp_partial",
"object": "response",
"created_at": 1700000000.0,
"model": "gpt-5.4-nano",
"status": "in_progress",
"output": [],
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"service_tier": "priority",
}
_CREATED_WITH_PRIORITY_TIER: Final[Mapping[str, object]] = {
"type": "response.created",
"sequence_number": 0,
"response": _CREATED_RESPONSE_WITH_PRIORITY_TIER,
}
def _text_delta(text: str, sequence_number: int) -> Mapping[str, object]:
return {
"type": "response.output_text.delta",
"sequence_number": sequence_number,
"item_id": "msg_partial",
"output_index": 0,
"content_index": 0,
"delta": text,
}
def _completed_after(text: str) -> Mapping[str, object]:
return {
"type": "response.completed",
"sequence_number": 9,
"response": {
**_CREATED_RESPONSE_WITH_PRIORITY_TIER,
"status": "completed",
"usage": {"input_tokens": 5, "output_tokens": 7, "total_tokens": 12},
},
}
def _live_iterator(logging_obj: LitellmLogging) -> ResponsesAPIStreamingIterator:
return ResponsesAPIStreamingIterator(
response=httpx.Response(200),
model="gpt-5.4-nano",
responses_api_provider_config=OpenAIResponsesAPIConfig(),
logging_obj=logging_obj,
request_data={"model": "gpt-5.4-nano", "input": "tell me a story"},
custom_llm_provider="openai",
)
def test_partial_response_on_disconnect_bills_the_relayed_text_at_the_served_service_tier():
iterator: Final = _live_iterator(_make_logging_obj())
for event in (_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time ", 1), _text_delta("a fox slept.", 2)):
iterator._process_chunk(json.dumps(event))
incomplete_event: Final = iterator.partial_response_on_disconnect()
assert incomplete_event is not None
assert incomplete_event.type == "response.incomplete"
partial: Final = incomplete_event.response
assert partial.status == "incomplete"
assert getattr(partial, "service_tier") == "priority"
assert partial.output[0].content[0].text == "Once upon a time a fox slept."
assert partial.usage.output_tokens == litellm.token_counter(
model="gpt-5.4-nano", text="Once upon a time a fox slept."
)
assert partial.usage.input_tokens == litellm.token_counter(
model="gpt-5.4-nano", messages=[{"role": "user", "content": "tell me a story"}]
)
assert partial.usage.total_tokens == partial.usage.input_tokens + partial.usage.output_tokens
@pytest.mark.parametrize(
"events",
[
(),
(_CREATED_WITH_PRIORITY_TIER,),
(_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time", 1), _completed_after("Once upon a time")),
],
ids=["nothing_relayed", "created_only", "already_completed"],
)
@pytest.mark.asyncio
async def test_partial_response_on_disconnect_is_none_when_there_is_nothing_to_bill_or_the_stream_billed_itself(
events: tuple[Mapping[str, object], ...],
):
iterator: Final = _live_iterator(_make_logging_obj())
for event in events:
iterator._process_chunk(json.dumps(event))
assert iterator.partial_response_on_disconnect() is None
await iterator._await_pending_logging()