fix(responses)!: bill partial usage on mid-stream failures and report bridged /v1/messages failures to the proxy (#47537)

A Responses API stream that fails partway now bills the usage it already consumed, ends with an error instead of a clean stop, and honors stream_timeout between chunks. Bridged /v1/messages calls report those failures to the proxy's failure hooks, so spend, budgets and logs see them
This commit is contained in:
Mateo Wang 2026-10-28 13:32:09 -07:00 • committed by GitHub
parent a604c89059
commit dfd9ad4103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1155 additions and 47 deletions

View file

@ -842,6 +842,7 @@ class Logging(LiteLLMLoggingBaseClass):
self._async_success_enqueued: bool = False
self._sync_success_scheduled: bool = False
self.on_detached_stream_failure: Callable[[Exception], Awaitable[None]] | None = None
self._stream_failure_claimed: bool = False
self.shadow_eval_request_snapshot: GuardrailRequestSnapshot | None = None
def copy_for_new_call(self, litellm_call_id: str, start_time: datetime.datetime) -> "Logging":
@ -2561,6 +2562,12 @@ class Logging(LiteLLMLoggingBaseClass):
},
)
def claim_stream_failure_report(self) -> bool:
if self._stream_failure_claimed:
return False
self._stream_failure_claimed = True
return True
def record_assembled_response_for_failure(self, assembled: ModelResponse) -> None:
"""Bill a fully streamed response on the failure log when a post-call hook rejects it."""
usage: Final = getattr(assembled, "usage", None)

View file

@ -2,15 +2,16 @@
## Translates OpenAI call to Anthropic `/v1/messages` format
import asyncio
from collections import deque
from collections.abc import AsyncGenerator, Iterator, Mapping
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Mapping
from typing import TYPE_CHECKING, Any, Final
import anyio
from pydantic import ConfigDict, field_validator
from litellm import verbose_logger
from litellm._logging import redact_internal_details_from_client_message
from litellm._uuid import uuid
from litellm.exceptions import MidStreamFallbackError
from litellm.exceptions import APIError, MidStreamFallbackError
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
@ -22,6 +23,7 @@ from litellm.llms.anthropic.messages.native.utils import (
from litellm.llms.anthropic.messages.safeguards import StreamedSafeguardResults
from litellm.llms.anthropic.messages.utils import anthropic_sse_frame
from litellm.responses.streaming_iterator import (
map_stream_error_to_exception,
partial_responses_incomplete_event_on_disconnect,
stream_error_status_and_message,
)
@ -130,10 +132,12 @@ class AnthropicResponsesStreamWrapper:
) -> None:
self.responses_stream = responses_stream
self.model = model
self.litellm_logging_obj = litellm_logging_obj
self.safeguards_evaluator: Final = safeguards_evaluator
self._message_id: str = f"msg_{uuid.uuid4()}"
if litellm_logging_obj is not None:
litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id)
self._unreported_failure: Exception | None = None
self._current_block_index: int = -1
# Map item_id -> content_block_index so we can stop the right block later
self._item_id_to_block_index: dict[str, int] = {}
@ -377,16 +381,24 @@ class AnthropicResponsesStreamWrapper:
if event_type == "response.failed":
failed: Final = _FailedResponseEvent.model_validate(event)
status_code, message = stream_error_status_and_message(
failed.response.error if failed.response is not None else None
)
upstream_error: Final = failed.response.error if failed.response is not None else None
status_code, message = stream_error_status_and_message(upstream_error)
verbose_logger.error(
"AnthropicResponsesStreamWrapper: upstream Responses stream for %s failed (%s): %s",
self.model,
status_code,
message,
)
self._fail_stream(status_code, message)
self._fail_stream(
status_code,
message,
failure=map_stream_error_to_exception(
upstream_error,
self.model,
getattr(self.responses_stream, "custom_llm_provider", None) or "",
logging_obj=self.litellm_logging_obj,
),
)
return
# ---- response completed -> message_delta + message_stop ----
@ -439,10 +451,36 @@ class AnthropicResponsesStreamWrapper:
self._sent_message_stop = True
return
def _fail_stream(self, status_code: int, message: str) -> None:
def _fail_stream(self, status_code: int, message: str, failure: Exception | None = None) -> None:
self._stream_failed = True
self._unreported_failure = failure or APIError(
status_code=status_code,
message=message,
llm_provider=getattr(self.responses_stream, "custom_llm_provider", None),
model=self.model,
)
self._chunk_queue.append(_anthropic_error_chunk(status_code, message))
async def _report_unreported_failure(self) -> None:
failure: Final = self._unreported_failure
if failure is None:
return
self._unreported_failure = None
on_failure: Final[Callable[[Exception], Awaitable[None]] | None] = getattr(
self.litellm_logging_obj, "_on_detached_stream_failure", None
)
if on_failure is None:
return
try:
with anyio.CancelScope(shield=True):
await on_failure(failure)
except Exception as hook_failure: # noqa: BLE001 # a failing proxy hook must not hide the error event from the client
verbose_logger.warning(
"AnthropicResponsesStreamWrapper: stream failure hook raised: %s(%s)",
type(hook_failure).__name__,
hook_failure,
)
def __aiter__(self) -> "AnthropicResponsesStreamWrapper":
return self
@ -450,6 +488,7 @@ class AnthropicResponsesStreamWrapper:
if self._chunk_queue:
return self._chunk_queue.popleft()
if self._stream_failed:
await self._report_unreported_failure()
raise StopAsyncIteration
if not self._sent_message_start:
@ -478,7 +517,7 @@ class AnthropicResponsesStreamWrapper:
verbose_logger.exception(
"AnthropicResponsesStreamWrapper: upstream Responses stream for %s failed", self.model
)
self._fail_stream(*_failure_status_and_message(e))
self._fail_stream(*_failure_status_and_message(e), failure=_original_failure(e))
if not self._chunk_queue and not self._sent_message_stop and not self._stream_failed:
verbose_logger.error(
@ -498,8 +537,14 @@ class AnthropicResponsesStreamWrapper:
async def _sse_frames(self) -> AsyncGenerator[bytes, None]:
safeguard_results: Final = StreamedSafeguardResults(self.safeguards_evaluator)
async for chunk in self:
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
try:
async for chunk in self:
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
finally:
from litellm.llms.anthropic.messages.native.streaming_iterator import closing_superseded_attempt
if not closing_superseded_attempt():
await self._report_unreported_failure()
def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
return partial_responses_incomplete_event_on_disconnect(self.responses_stream)

View file

@ -4,6 +4,7 @@ import json
import logging
import math
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from functools import lru_cache
from itertools import chain
@ -136,7 +137,7 @@ from litellm.proxy.utils import ( # noqa: F401 # legacy module exports
_check_and_merge_model_level_guardrails, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
check_and_merge_model_level_guardrails,
)
from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect
from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect, spawn_logging_task
from litellm.router import Router
from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict
from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX
@ -2006,6 +2007,49 @@ def _content_text(fields: Mapping[str, object]) -> str:
return str(fields.get("content", ""))
async def _no_stream_failure_to_release() -> None:
return None
@dataclass(slots=True)
class _ClientStreamFailureReport:
logging_obj: LiteLLMLoggingObj
report: Callable[[Exception], Awaitable[None]]
held: Exception | None = None
@classmethod
def hold_while_streaming(cls, logging_obj: LiteLLMLoggingObj | None) -> Callable[[], Awaitable[None]]:
if logging_obj is None or logging_obj.on_detached_stream_failure is None:
return _no_stream_failure_to_release
return cls(logging_obj, logging_obj.on_detached_stream_failure)._start_holding()
def _start_holding(self) -> Callable[[], Awaitable[None]]:
self.logging_obj.on_detached_stream_failure = self.hold
return self.release
async def hold(self, failure: Exception) -> None:
if self.held is None:
self.held = failure
async def release(self) -> None:
self.logging_obj.on_detached_stream_failure = self.report
if self.held is None:
return
await asyncio.shield(
spawn_logging_task(
asyncio.get_running_loop(), self._report_held(self.held), task_name="stream failure report"
)
)
async def _report_held(self, failure: Exception) -> None:
try:
await self.report(failure)
except Exception as hook_failure: # noqa: BLE001 # a failing failure hook must not skip the stream cleanup
verbose_proxy_logger.warning(
"Stream failure hook raised: %s(%s)", type(hook_failure).__name__, sanitize_for_log(hook_failure)
)
class ProxyBaseLLMRequestProcessing:
def __init__(self, data: dict, served_model: str | None = None):
self.data = data
@ -3498,6 +3542,8 @@ class ProxyBaseLLMRequestProcessing:
request_data: Final = self.data
async def _on_detached_stream_failure(exc: Exception) -> None:
if not logging_obj.claim_stream_failure_report():
return
try:
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
@ -4448,11 +4494,15 @@ class ProxyBaseLLMRequestProcessing:
client_disconnected = False
delivered_chunk = False
recent_tail = SSE_STREAM_START_TAIL # rebind-ok: rolling window over the yielded bytes
stream_logging_obj: Final[object] = request_data.get("litellm_logging_obj") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # legacy request data is narrowed to Logging below
guarded_stream: Final[AsyncGenerator[object, None]] = proxy_logging_obj.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=response,
request_data=request_data,
)
release_stream_failure: Final = _ClientStreamFailureReport.hold_while_streaming(
stream_logging_obj if isinstance(stream_logging_obj, LiteLLMLoggingObj) else None
)
try:
str_so_far = ""
async for chunk in guarded_stream:
@ -4523,10 +4573,18 @@ class ProxyBaseLLMRequestProcessing:
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.async_data_generator(): Exception occured - %s", e
)
transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
request_data=request_data,
already_reported: Final = (
isinstance(stream_logging_obj, LiteLLMLoggingObj)
and not stream_logging_obj.claim_stream_failure_report()
)
transformed_exception: Final = (
None
if already_reported
else await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
request_data=request_data,
)
)
if transformed_exception is not None:
e = transformed_exception
@ -4549,15 +4607,18 @@ class ProxyBaseLLMRequestProcessing:
seal: Final = "" if seal_open_frame is None else seal_open_frame(recent_tail)
yield seal + error_frame if seal else error_frame
finally:
await ProxyBaseLLMRequestProcessing.finalize_streaming_generator_cleanup(
request=request,
request_data=request_data,
response=response,
stream_completed=stream_completed,
client_disconnected=client_disconnected,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
try:
await release_stream_failure()
finally:
await ProxyBaseLLMRequestProcessing.finalize_streaming_generator_cleanup(
request=request,
request_data=request_data,
response=response,
stream_completed=stream_completed,
client_disconnected=client_disconnected,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
@staticmethod
def async_sse_data_generator(

View file

@ -41,7 +41,11 @@ from litellm.exceptions import MidStreamFallbackError, RateLimitError
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.exception_mapping_utils import with_deployment_api_base
from litellm.litellm_core_utils.internal_call_metadata import without_budget_reservation
from litellm.litellm_core_utils.internal_call_metadata import (
is_background_response,
requests_background,
without_budget_reservation,
)
from litellm.litellm_core_utils.litellm_logging import BilledRoundsSpend
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.llm_response_utils.get_api_base import get_api_base
@ -65,7 +69,7 @@ from litellm.types.llms.openai import (
ResponsesAPIStreamingResponse,
)
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
from litellm.types.utils import CallTypes
from litellm.types.utils import CallTypes, ModelResponse
from litellm.utils import async_post_call_success_deployment_hook
if TYPE_CHECKING:
@ -288,7 +292,7 @@ def stream_error_status_and_message(error_obj: object) -> tuple[int, str]:
return _status_code_for_error_fields(error_type, error_code), message
def _map_stream_error_to_exception(
def map_stream_error_to_exception(
error_obj: object,
model: str,
custom_llm_provider: str,
@ -578,6 +582,7 @@ class BaseResponsesAPIStreamingIterator:
self.request_data.get("input"),
self.request_data,
self._generated_content + self._generated_tool_arguments,
use_default_image_token_count=False,
)
if _estimate_wanted
else None
@ -731,7 +736,7 @@ class BaseResponsesAPIStreamingIterator:
self._earlier_rounds_spend = earlier
def _map_error_event_exception(self, error_obj: object) -> Exception:
return _map_stream_error_to_exception(
return map_stream_error_to_exception(
error_obj,
self.model or "",
self.custom_llm_provider or "",
@ -981,6 +986,39 @@ class BaseResponsesAPIStreamingIterator:
except Exception:
pass
def _record_partial_usage_for_failure(self) -> None:
generated_text: Final = self._generated_content + self._generated_tool_arguments
if (
not generated_text
or requests_background(self.request_data)
or is_background_response(self._created_response)
or self._completed_response_logged
or self.logging_obj.model_call_details.get("combined_usage_object") is not None
):
return
estimate: Final = _estimate_usage_safely(
self.model or "",
self.request_data.get("input"),
self.request_data,
generated_text,
use_default_image_token_count=True,
)
if estimate is None:
return
usage: Final = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( # pyright: ignore[reportPrivateUsage] # same shared transform the success path above uses
estimate
)
served_tier: Final[object] = getattr(self._created_response, "service_tier", None)
assembled: Final = ModelResponse(
model=self.model, usage=usage, service_tier=served_tier if isinstance(served_tier, str) else None
)
if self._earlier_rounds_spend is None:
self.logging_obj.record_assembled_response_for_failure(assembled)
return
self.logging_obj.record_failure_after_earlier_rounds(
self._earlier_rounds_spend, usage, self.logging_obj.response_cost_calculator(result=assembled) or 0.0
)
def _handle_failure(self, exception: Exception):
"""
Trigger failure handlers before bubbling the exception.
@ -990,6 +1028,7 @@ class BaseResponsesAPIStreamingIterator:
if self._failure_handled:
return
self._failure_handled = True
self._record_partial_usage_for_failure()
traceback_exception: Final = traceback.format_exc()
end_time: Final = datetime.now()
@ -1060,15 +1099,20 @@ class BaseResponsesAPIStreamingIterator:
the same shape the success handler assembles for a natural end of stream.
Its response is the response.created payload (it carries the served
service_tier) plus the text and tool arguments relayed so far, with counted
usage since the provider never sent any. None when nothing was relayed yet
or the stream already reached its terminal event and billed itself.
usage since the provider never sent any. None when nothing was relayed yet,
the stream already reached its terminal event and billed itself, or its failure
log already billed the relayed output.
"""
created: Final = self._created_response
generated: Final = self._generated_content + self._generated_tool_arguments
if self.completed_response is not None or created is None or not generated:
if created is None or not generated or self._billed_without_disconnect():
return None
usage: Final = _estimate_usage_safely(
self.model or "", self.request_data.get("input"), self.request_data, generated
self.model or "",
self.request_data.get("input"),
self.request_data,
generated,
use_default_image_token_count=False,
)
if usage is None:
return None
@ -1082,6 +1126,9 @@ class BaseResponsesAPIStreamingIterator:
stamp_responses_usage_cost(partial, self.logging_obj)
return ResponseIncompleteEvent(type=ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, response=partial)
def _billed_without_disconnect(self) -> bool:
return self.completed_response is not None or self._failure_handled
def _note_yielded_event(self, event: ResponsesAPIStreamingResponse) -> None:
self._yielded_first_chunk = True
if event.type not in PRE_OUTPUT_LIFECYCLE_EVENT_TYPES:
@ -1218,6 +1265,9 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
raise StopAsyncIteration from e
self._raise_for_transport_error(e)
except httpx.TimeoutException as e:
if self.completed_response is not None:
self.finished = True
raise StopAsyncIteration from e
self._raise_for_transport_timeout(e)
except httpx.HTTPError as e:
# Handle HTTP errors
@ -1303,6 +1353,9 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
raise StopIteration from e
self._raise_for_transport_error(e)
except httpx.TimeoutException as e:
if self.completed_response is not None:
self.finished = True
raise StopIteration from e
self._raise_for_transport_timeout(e)
except httpx.HTTPError as e:
# Handle HTTP errors
@ -1727,6 +1780,8 @@ def _estimate_usage_from_text(
request_input: object,
responses_api_request: Mapping[str, object],
generated_text: str,
*,
use_default_image_token_count: bool = False,
) -> ResponseAPIUsage:
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # the transformer's signature is partially untyped
input=request_input, # pyright: ignore[reportArgumentType] # the raw Responses API input is a str or ResponseInputParam list, matching the helper's declared union
@ -1734,7 +1789,7 @@ def _estimate_usage_from_text(
only_since_compaction=True,
)
input_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped
model=model, messages=messages
model=model, messages=messages, use_default_image_token_count=use_default_image_token_count
)
output_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped
model=model, text=generated_text, count_response_tokens=True
@ -1751,6 +1806,8 @@ def _estimate_usage_safely(
request_input: object,
responses_api_request: Mapping[str, object],
generated_text: str,
*,
use_default_image_token_count: bool,
) -> ResponseAPIUsage | None:
try:
return _estimate_usage_from_text(
@ -1758,6 +1815,7 @@ def _estimate_usage_safely(
request_input=request_input,
responses_api_request=responses_api_request,
generated_text=generated_text,
use_default_image_token_count=use_default_image_token_count,
)
except Exception as e:
verbose_logger.debug("Could not estimate usage from stream text, billing $0: %s", e)
@ -2276,7 +2334,7 @@ class _ResponsesWsTurn:
)
if failed_event is None:
return None
return _map_stream_error_to_exception(
return map_stream_error_to_exception(
_ws_event_error(failed_event), model, custom_llm_provider, logging_obj=connection_logging_obj
)

View file

@ -6456,6 +6456,9 @@ class Router:
hop_kwargs: Final = mid_stream_fallback_hop_kwargs(
model=model, original_generic_function=original_generic_function, controls=controls, kwargs=kwargs
)
shared_logging: Final = kwargs.get("litellm_logging_obj")
if isinstance(shared_logging, LiteLLMLogging):
shared_logging.carry_failed_attempt_usage()
response: Final = await self._ageneric_api_call_with_fallbacks_helper(
model=model, original_generic_function=original_generic_function, **kwargs
)

View file

@ -1516,9 +1516,9 @@ def test_bedrock_mantle_openai_envelope_context_overflow_maps_to_context_window_
def test_bedrock_mantle_streamed_context_overflow_event_maps_to_context_window_exceeded():
from litellm.responses.streaming_iterator import _map_stream_error_to_exception
from litellm.responses.streaming_iterator import map_stream_error_to_exception
mapped_exception = _map_stream_error_to_exception(
mapped_exception = map_stream_error_to_exception(
{
"code": "context_length_exceeded",
"message": "Your input exceeds the context window of this model. Please adjust your input and try again.",

View file

@ -4,20 +4,25 @@ Tests for AnthropicResponsesStreamWrapper
"""
import asyncio
import datetime
import json
import os
import sys
from collections.abc import AsyncIterator, Awaitable, Callable
from types import SimpleNamespace
import anyio
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")))
import litellm
from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
from litellm.llms.anthropic.messages.native.streaming_iterator import aclose_superseded_attempt
from litellm.llms.anthropic.messages.native.utils import INCOMPLETE_STREAM_ERROR_MESSAGE
from litellm.llms.anthropic.messages.responses_adapters.streaming_iterator import (
AnthropicResponsesStreamWrapper,
@ -622,3 +627,194 @@ class TestUpstreamFailureEndsStreamWithErrorEvent:
chunks = _collect(iter(()))
assert [chunk["type"] for chunk in chunks] == ["message_start", "error"]
assert chunks[1]["error"] == {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE}
class _FailureHookRecorder:
def __init__(self) -> None:
self.received: tuple[Exception, ...] = ()
async def __call__(self, failure: Exception) -> None:
self.received = (*self.received, failure)
def _logging_obj_with_failure_hook(on_failure: Callable[[Exception], Awaitable[None]]) -> LitellmLogging:
logging_obj = LitellmLogging(
model="m",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="anthropic_messages",
start_time=datetime.datetime(2026, 1, 1),
litellm_call_id="lit-6871-test",
function_id="lit-6871-test",
)
logging_obj._on_detached_stream_failure = on_failure
return logging_obj
def _collect_with_logging(
stream: AsyncIterator[dict[str, object]], logging_obj: LitellmLogging
) -> list[dict[str, object]]:
async def _run() -> list[dict[str, object]]:
wrapper = AnthropicResponsesStreamWrapper(responses_stream=stream, model="m", litellm_logging_obj=logging_obj)
return [chunk async for chunk in wrapper]
return asyncio.run(_run())
class _DeliveryCountingConsumer:
def __init__(self) -> None:
self.delivered: tuple[dict[str, object], ...] = ()
self.delivered_when_hook_fired: tuple[int, ...] = ()
async def hook(self, failure: Exception) -> None:
self.delivered_when_hook_fired = (*self.delivered_when_hook_fired, len(self.delivered))
async def drain(self, wrapper: AnthropicResponsesStreamWrapper) -> None:
async for chunk in wrapper:
self.delivered = (*self.delivered, chunk)
def _failing_after_one_delta() -> AsyncIterator[dict[str, object]]:
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
yield {"type": "response.output_item.added", "item": {"type": "message", "id": "msg_1"}}
yield {"type": "response.output_text.delta", "item_id": "msg_1", "delta": "Hi"}
raise litellm.RateLimitError(message="You have no credits remaining.", llm_provider="openai", model="m")
return _gen()
class TestUpstreamFailureReachesTheProxyFailureHook:
def test_raised_upstream_failure_reaches_the_hook_unwrapped_once_the_error_event_was_consumed(self):
rate_limit = litellm.RateLimitError(message="You have no credits remaining.", llm_provider="openai", model="m")
wrapped = MidStreamFallbackError(
message=str(rate_limit),
model="m",
llm_provider="openai",
original_exception=rate_limit,
is_pre_first_chunk=False,
)
recorder = _FailureHookRecorder()
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
yield {"type": "response.output_item.added", "item": {"type": "message", "id": "msg_1"}}
yield {"type": "response.output_text.delta", "item_id": "msg_1", "delta": "Hi"}
raise wrapped
chunks = _collect_with_logging(_gen(), _logging_obj_with_failure_hook(recorder))
assert [chunk["type"] for chunk in chunks] == [
"message_start",
"content_block_start",
"content_block_delta",
"error",
]
assert recorder.received == (rate_limit,)
def test_response_failed_event_reaches_the_hook_as_an_exception_carrying_the_upstream_status(self):
recorder = _FailureHookRecorder()
failed = SimpleNamespace(
status="failed",
output=[],
usage=None,
error={"code": "rate_limit_exceeded", "message": "Rate limit reached for gpt-5.5, try again in 20s."},
)
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
yield {"type": "response.failed", "response": failed}
chunks = _collect_with_logging(_gen(), _logging_obj_with_failure_hook(recorder))
assert chunks[-1]["type"] == "error"
(failure,) = recorder.received
assert isinstance(failure, litellm.RateLimitError)
assert failure.status_code == 429
assert "Rate limit reached for gpt-5.5, try again in 20s." in failure.message
def test_a_raising_hook_never_hides_the_error_event_from_the_client(self):
async def _exploding_hook(failure: Exception) -> None:
raise RuntimeError("spend writer is down")
logging_obj = _logging_obj_with_failure_hook(_exploding_hook)
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
raise ConnectionResetError("Response payload is not completed")
chunks = _collect_with_logging(_gen(), logging_obj)
assert [chunk["type"] for chunk in chunks] == ["message_start", "error"]
assert chunks[-1]["error"] == {"type": "api_error", "message": "Response payload is not completed"}
def test_the_hook_fires_only_after_the_consumer_took_the_error_event(self):
consumer = _DeliveryCountingConsumer()
async def _run() -> None:
wrapper = AnthropicResponsesStreamWrapper(
responses_stream=_failing_after_one_delta(),
model="m",
litellm_logging_obj=_logging_obj_with_failure_hook(consumer.hook),
)
await consumer.drain(wrapper)
with pytest.raises(StopAsyncIteration):
await wrapper.__anext__()
asyncio.run(_run())
assert [chunk["type"] for chunk in consumer.delivered] == [
"message_start",
"content_block_start",
"content_block_delta",
"error",
]
assert consumer.delivered_when_hook_fired == (len(consumer.delivered),)
@pytest.mark.parametrize(
("close", "expected_reports"),
[
pytest.param(lambda sse: sse.aclose(), 1, id="client_closes"),
pytest.param(aclose_superseded_attempt, 0, id="fallback_takes_over"),
],
)
def test_closing_the_stream_on_the_error_event_reports_unless_a_fallback_takes_over(self, close, expected_reports):
recorder = _FailureHookRecorder()
async def _close_on_the_error_event() -> None:
wrapper = AnthropicResponsesStreamWrapper(
responses_stream=_failing_after_one_delta(), model="m", litellm_logging_obj=_logging_obj_with_failure_hook(recorder)
)
sse = wrapper.async_anthropic_sse_wrapper()
async for frame in sse:
if frame.startswith(b"event: error\n"):
await close(sse)
return
pytest.fail("the stream ended without an error event")
asyncio.run(_close_on_the_error_event())
assert len(recorder.received) == expected_reports
def test_a_client_disconnect_during_the_failure_hook_lets_the_hook_finish(self):
hook_started = anyio.Event()
hook_released = anyio.Event()
finished: list[Exception] = []
async def _held_hook(failure: Exception) -> None:
hook_started.set()
await hook_released.wait()
finished.append(failure)
async def _disconnect_mid_hook() -> None:
wrapper = AnthropicResponsesStreamWrapper(
responses_stream=_failing_after_one_delta(), model="m", litellm_logging_obj=_logging_obj_with_failure_hook(_held_hook)
)
async def _consume() -> None:
async for _ in wrapper.async_anthropic_sse_wrapper():
pass
async with anyio.create_task_group() as task_group:
task_group.start_soon(_consume)
await hook_started.wait()
task_group.cancel_scope.cancel()
hook_released.set()
asyncio.run(_disconnect_mid_hook())
assert len(finished) == 1

View file

@ -10826,12 +10826,19 @@ class TestResolveLitellmCallId:
class _FailureHookRecorder:
"""Stands in for ProxyLogging.post_call_failure_hook, recording what the detached-failure closure hands it."""
def __init__(self, raises: Optional[Exception] = None):
def __init__(self, raises: Optional[Exception] = None, gate: Optional[asyncio.Event] = None):
self.calls = []
self.entered = asyncio.Event()
self.reported = asyncio.Event()
self._raises = raises
self._gate = gate
async def post_call_failure_hook(self, **kwargs):
self.entered.set()
if self._gate is not None:
await self._gate.wait()
self.calls.append(kwargs)
self.reported.set()
if self._raises is not None:
raise self._raises
@ -10932,6 +10939,171 @@ class TestDetachedStreamFailureHook:
assert [call["original_exception"] for call in recorder.calls] == [failure]
@staticmethod
def _armed_stream(recorder: _FailureHookRecorder, iterator_hook_for, response: Optional[MagicMock] = None):
from litellm.proxy._types import UserAPIKeyAuth as RealUserAPIKeyAuth
logging_obj = LiteLLMLoggingObj(
model="gpt-6.1-sol",
messages=[{"role": "user", "content": "Write a story."}],
stream=True,
call_type="anthropic_messages",
start_time=None,
litellm_call_id="call-lit6871",
function_id="function-lit6871",
)
user_api_key_dict = RealUserAPIKeyAuth(api_key="sk-test")
processing_obj = ProxyBaseLLMRequestProcessing(data={"litellm_logging_obj": logging_obj})
ProxyLogging._callback_capabilities_cache.clear()
proxy_logging_obj = ProxyLogging(user_api_key_cache=MagicMock())
proxy_logging_obj.post_call_failure_hook = recorder.post_call_failure_hook
proxy_logging_obj.async_post_call_streaming_iterator_hook = iterator_hook_for(logging_obj)
processing_obj._arm_detached_stream_failure_hook(
logging_obj=logging_obj,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
stream = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=MagicMock() if response is None else response,
user_api_key_dict=user_api_key_dict,
request_data=processing_obj.data,
proxy_logging_obj=proxy_logging_obj,
serialize_chunk=lambda chunk: "data: chunk\n\n",
serialize_error=lambda error: "data: error\n\n",
)
return logging_obj, stream
@staticmethod
def _hook_reporting(upstream_failure: Exception, after_report=None):
def iterator_hook_for(logging_obj):
async def iterator_hook(**_kwargs):
yield b"event: message_start\n\n"
await logging_obj.on_detached_stream_failure(upstream_failure)
yield b"event: error\n\n"
yield b"event: message_stop\n\n"
if after_report is not None:
raise after_report
return iterator_hook
return iterator_hook_for
@pytest.mark.asyncio
async def test_iterator_hook_failure_after_a_bridged_failure_is_the_one_the_failure_hook_gets(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
hook_failure = RuntimeError("iterator hook rejected the stream")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure, after_report=hook_failure))
frames = [frame async for frame in stream]
assert frames == ["data: chunk\n\n"] * 3 + ["data: error\n\n"]
assert [call["original_exception"] for call in recorder.calls] == [hook_failure]
@pytest.mark.asyncio
async def test_failure_hook_replacing_the_iterator_hook_failure_aborts_the_stream(self):
replacement = HTTPException(status_code=418, detail="rejected by a custom failure hook")
recorder = _FailureHookRecorder(raises=replacement)
upstream_failure = RuntimeError("upstream Responses stream failed")
hook_failure = RuntimeError("iterator hook rejected the stream")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure, after_report=hook_failure))
frames = [await stream.__anext__() for _ in range(3)]
with pytest.raises(HTTPException) as raised:
await stream.__anext__()
assert raised.value is replacement
assert frames == ["data: chunk\n\n"] * 3
assert [call["original_exception"] for call in recorder.calls] == [hook_failure]
@pytest.mark.asyncio
async def test_failure_reported_by_the_stream_reaches_the_failure_hook_once_after_the_stream_ends(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure))
frames = []
async for frame in stream:
frames.append(frame)
assert recorder.calls == []
assert frames == ["data: chunk\n\n"] * 3
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_report_and_stream_cleanup_survive_the_client_task_being_cancelled_mid_report(self):
gate = asyncio.Event()
recorder = _FailureHookRecorder(gate=gate)
upstream_failure = RuntimeError("upstream Responses stream failed")
response = MagicMock()
response.aclose = AsyncMock()
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure), response=response)
async def client_reads_the_whole_stream():
return [frame async for frame in stream]
client = asyncio.create_task(client_reads_the_whole_stream())
await asyncio.wait_for(recorder.entered.wait(), timeout=5)
client.cancel()
with pytest.raises(asyncio.CancelledError):
await client
response.aclose.assert_awaited_once()
gate.set()
await asyncio.wait_for(recorder.reported.wait(), timeout=5)
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_reported_by_the_stream_reaches_the_failure_hook_once_when_the_client_leaves_early(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure))
await stream.__anext__()
await stream.__anext__()
await stream.aclose()
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_reported_after_the_stream_ends_still_reaches_the_failure_hook(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed after the client stream closed")
async def iterator_hook(**_kwargs):
yield b"event: message_start\n\n"
logging_obj, stream = self._armed_stream(recorder, lambda _logging_obj: iterator_hook)
frames = [frame async for frame in stream]
await logging_obj.on_detached_stream_failure(upstream_failure)
assert frames == ["data: chunk\n\n"]
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_reported_by_an_iterator_hook_is_not_reported_again_when_the_stream_closes(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
hook_failure = RuntimeError("iterator hook rejected the error frame")
async def iterator_hook(**_kwargs):
yield b"event: message_start\n\n"
raise hook_failure
logging_obj, stream = self._armed_stream(recorder, lambda _logging_obj: iterator_hook)
frames = [frame async for frame in stream]
await logging_obj.on_detached_stream_failure(upstream_failure)
assert frames == ["data: chunk\n\n", "data: error\n\n"]
assert [call["original_exception"] for call in recorder.calls] == [hook_failure]
class TestPostCallMaskedOutputReachesDeferredLogging:
@pytest.mark.asyncio

View file

@ -2668,6 +2668,65 @@ async def test_stream_auto_execute_bills_the_paid_first_round_on_its_success_row
assert _request_failure_row(logging_obj, recorder.logged[0]).get("response_cost", 0.0) == 0.0
class _CutAfterStreamedText(httpx.AsyncByteStream):
async def __aiter__(self):
opened: Final = {**json.loads(json.dumps(_ROUND_TWO_BODY, default=dict)), "status": "in_progress"}
yield _sse(
(
{"type": "response.created", "response": opened, "sequence_number": 0},
{
"type": "response.output_text.delta",
"item_id": "msg_round_two",
"output_index": 0,
"content_index": 0,
"delta": "PING " * 40,
"sequence_number": 1,
},
)
)
raise httpx.ReadError("connection reset mid-stream")
@pytest.mark.asyncio
async def test_stream_auto_execute_bills_a_follow_up_cut_after_streamed_text_with_no_second_fixed_margin(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy.utils import ProxyLogging
monkeypatch.setattr(litellm, "cost_margin_config", {"openai": {"fixed_amount": _FIXED_MARGIN}})
_serve_responses(
respx_mock,
monkeypatch,
(_streamed(_ROUND_ONE_BODY), httpx.Response(200, stream=_CutAfterStreamedText(), headers=SSE_HEADERS)),
)
recorder: Final = _RoundRecorder()
logging_obj: Final = _rounds_logging_obj(recorder, "mcp-stream-follow-up-cut-after-text")
with pytest.raises(httpx.ReadError):
await _stream_auto_execute(logging_obj)
ProxyLogging.fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
await _settle(lambda: len(recorder.logged) > 0 and len(recorder.failed) > 0)
_assert_one_row_bills(recorder.logged, _ROUND_ONE_BODY)
assert len(recorder.failed) == 1
failed: Final = recorder.failed[0]
assert failed["completion_tokens"] > 0
priced_with_its_own_margin: Final = litellm.completion_cost(
completion_response=ModelResponse(
model="gpt-5.6",
usage=Usage(
prompt_tokens=failed["prompt_tokens"],
completion_tokens=failed["completion_tokens"],
total_tokens=failed["prompt_tokens"] + failed["completion_tokens"],
),
),
model="gpt-5.6",
custom_llm_provider="openai",
)
assert failed["response_cost"] == pytest.approx(priced_with_its_own_margin - _FIXED_MARGIN)
_assert_breakdown_agrees_with_the_billed_cost(failed)
@pytest.mark.asyncio
async def test_stream_auto_execute_blocked_by_a_post_call_guardrail_keeps_both_rounds_on_the_success_row(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch

View file

@ -9,20 +9,23 @@ crashed with exit 139 whenever any CustomLogger was registered).
import asyncio
import json
import time
from collections.abc import Mapping
from typing import Final
from collections.abc import AsyncIterator, Mapping
from datetime import datetime
from typing import Final, cast
import httpx
import pytest
import litellm
from litellm.exceptions import MidStreamFallbackError
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 import streaming_iterator as responses_streaming_iterator_module
from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStreamingResponse
from litellm.types.utils import StandardLoggingPayload
class RecordingCustomLogger(CustomLogger):
@ -169,6 +172,240 @@ async def test_sync_callbacks_run_only_after_async_handler_completes(recording_e
assert submit_times[0] >= recorder.async_hook_finished
class FailureRecorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.failure_payloads: tuple[StandardLoggingPayload, ...] = ()
self.success_payloads: tuple[StandardLoggingPayload, ...] = ()
async def async_log_failure_event(
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
self.failure_payloads = (
*self.failure_payloads,
cast(StandardLoggingPayload, kwargs["standard_logging_object"]),
)
async def async_log_success_event(
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
self.success_payloads = (
*self.success_payloads,
cast(StandardLoggingPayload, kwargs["standard_logging_object"]),
)
class _ChunkRecorder:
def __init__(self) -> None:
self.chunks: tuple[ResponsesAPIStreamingResponse, ...] = ()
async def drain(self, iterator: ResponsesAPIStreamingIterator) -> None:
async for chunk in iterator:
self.chunks = (*self.chunks, chunk)
_SEA_DELTAS = ("The sea ", "is wide ", "and deep.")
_SEA_PROMPT = "Write a 300 word story about the sea."
_UNREACHABLE_IMAGE_INPUT: list[dict[str, object]] = [
{
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this picture."},
{"type": "input_image", "image_url": "http://images.invalid/photo.png", "detail": "high"},
],
}
]
_TOOL_ARGUMENT_DELTAS = ('{"city": ', '"Lisbon", ', '"unit": "celsius"}')
class _UpstreamThatTimesOutAfterThreeDeltas:
headers: dict[str, str] = {}
def __init__(self, event_type: str = "response.output_text.delta", deltas: tuple[str, ...] = _SEA_DELTAS) -> None:
self.event_type = event_type
self.deltas = deltas
async def aiter_bytes(self) -> AsyncIterator[bytes]:
for sequence_number, delta in enumerate(self.deltas):
event = {
"type": self.event_type,
"item_id": "item_1",
"output_index": 0,
"content_index": 0,
"delta": delta,
"sequence_number": sequence_number,
}
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
class _UpstreamThatFailsWithUsageAfterThreeDeltas:
headers: dict[str, str] = {}
async def aiter_bytes(self) -> AsyncIterator[bytes]:
for sequence_number, delta in enumerate(_SEA_DELTAS):
event = {
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"delta": delta,
"sequence_number": sequence_number,
}
yield f"data: {json.dumps(event)}\n\n".encode()
failed = {
"type": "response.failed",
"sequence_number": len(_SEA_DELTAS),
"response": {
"id": "resp_1",
"object": "response",
"created_at": 1700000000,
"status": "failed",
"model": "gpt-5.4-nano",
"output": [],
"error": {"code": "server_error", "message": "The model had an internal error"},
"usage": {"input_tokens": 400, "output_tokens": 9, "total_tokens": 409},
},
}
yield f"data: {json.dumps(failed)}\n\n".encode()
async def _failure_payload_after_mid_stream_timeout(
monkeypatch: pytest.MonkeyPatch,
request_input: str | list[dict[str, object]],
upstream: _UpstreamThatTimesOutAfterThreeDeltas | None = None,
background_fields: Mapping[str, object] | None = None,
) -> tuple[FailureRecorder, StandardLoggingPayload]:
upstream_response: Final = upstream or _UpstreamThatTimesOutAfterThreeDeltas()
recorder = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
monkeypatch.setattr(litellm, "success_callback", [recorder])
monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
logging_obj = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator = ResponsesAPIStreamingIterator(
response=cast(httpx.Response, upstream_response),
model="gpt-5.4-nano",
responses_api_provider_config=OpenAIResponsesAPIConfig(),
logging_obj=logging_obj,
custom_llm_provider="openai",
request_data={"model": "gpt-5.4-nano", "input": request_input, "stream": True, **(background_fields or {})},
)
received = _ChunkRecorder()
with pytest.raises(litellm.Timeout, match="Timeout on reading data from socket"):
await received.drain(iterator)
assert [getattr(chunk, "delta", None) for chunk in received.chunks] == list(upstream_response.deltas)
await iterator._await_pending_logging()
(payload,) = recorder.failure_payloads
return recorder, payload
@pytest.mark.asyncio
async def test_mid_stream_read_timeout_logs_failure_with_partial_usage_and_no_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder, payload = await _failure_payload_after_mid_stream_timeout(monkeypatch, _SEA_PROMPT)
assert payload["status"] == "failure"
assert payload["prompt_tokens"] == litellm.token_counter(
model="gpt-5.4-nano", messages=[{"role": "user", "content": _SEA_PROMPT}]
)
assert payload["completion_tokens"] == litellm.token_counter(
model="gpt-5.4-nano", text="".join(_SEA_DELTAS), count_response_tokens=True
)
assert payload["response_cost"] > 0
assert recorder.success_payloads == ()
@pytest.mark.asyncio
async def test_mid_stream_read_timeout_bills_the_tool_call_arguments_already_streamed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
upstream: Final = _UpstreamThatTimesOutAfterThreeDeltas(
event_type="response.function_call_arguments.delta", deltas=_TOOL_ARGUMENT_DELTAS
)
_, payload = await _failure_payload_after_mid_stream_timeout(monkeypatch, _SEA_PROMPT, upstream)
assert payload["status"] == "failure"
assert payload["completion_tokens"] == litellm.token_counter(
model="gpt-5.4-nano", text="".join(_TOOL_ARGUMENT_DELTAS), count_response_tokens=True
)
assert payload["response_cost"] > 0
@pytest.mark.asyncio
async def test_mid_stream_failure_of_a_background_response_bills_nothing_since_retrieving_it_bills_the_whole_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_, payload = await _failure_payload_after_mid_stream_timeout(
monkeypatch, _SEA_PROMPT, background_fields={"background": True}
)
assert payload["status"] == "failure"
assert payload["completion_tokens"] == 0
assert payload["response_cost"] == 0
@pytest.mark.asyncio
async def test_mid_stream_failure_usage_estimate_never_fetches_image_dimensions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_, payload = await _failure_payload_after_mid_stream_timeout(monkeypatch, _UNREACHABLE_IMAGE_INPUT)
assert payload["status"] == "failure"
assert payload["prompt_tokens"] == litellm.token_counter(
model="gpt-5.4-nano",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this picture."},
{"type": "image_url", "image_url": {"url": "http://images.invalid/photo.png", "detail": "high"}},
],
}
],
use_default_image_token_count=True,
)
assert payload["completion_tokens"] > 0
@pytest.mark.asyncio
async def test_mid_stream_failure_keeps_the_usage_the_provider_reported(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
logging_obj = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator = ResponsesAPIStreamingIterator(
response=cast(httpx.Response, _UpstreamThatFailsWithUsageAfterThreeDeltas()),
model="gpt-5.4-nano",
responses_api_provider_config=OpenAIResponsesAPIConfig(),
logging_obj=logging_obj,
custom_llm_provider="openai",
request_data={"model": "gpt-5.4-nano", "input": _SEA_PROMPT, "stream": True},
)
with pytest.raises(MidStreamFallbackError, match="The model had an internal error"):
await _ChunkRecorder().drain(iterator)
await iterator._await_pending_logging()
(payload,) = recorder.failure_payloads
assert (payload["prompt_tokens"], payload["completion_tokens"]) == (400, 9)
_CREATED_RESPONSE_WITH_PRIORITY_TIER: Final[Mapping[str, object]] = {
"id": "resp_partial",
"object": "response",
@ -211,17 +448,135 @@ def _completed_after(text: str) -> Mapping[str, object]:
}
def _live_iterator(logging_obj: LitellmLogging) -> ResponsesAPIStreamingIterator:
def _live_iterator(
logging_obj: LitellmLogging, upstream: object = None, background: bool = False
) -> ResponsesAPIStreamingIterator:
return ResponsesAPIStreamingIterator(
response=httpx.Response(200),
response=cast(httpx.Response, upstream or 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"},
request_data={"model": "gpt-5.4-nano", "input": "tell me a story", "background": background},
custom_llm_provider="openai",
)
class _UpstreamThatTimesOutAfterRelayingText:
headers: dict[str, str] = {}
async def aiter_bytes(self) -> AsyncIterator[bytes]:
for event in (_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time", 1)):
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
class _UpstreamThatCompletesThenTimesOut:
headers: dict[str, str] = {}
async def aiter_bytes(self) -> AsyncIterator[bytes]:
events: Final = (
_CREATED_WITH_PRIORITY_TIER,
*(_text_delta(delta, sequence_number) for sequence_number, delta in enumerate(_SEA_DELTAS, start=1)),
_completed_after("".join(_SEA_DELTAS)),
)
for event in events:
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
@pytest.mark.asyncio
async def test_timeout_after_completed_response_ends_the_stream_and_bills_it_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder: Final = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
monkeypatch.setattr(litellm, "success_callback", [recorder])
monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
logging_obj: Final = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator: Final = _live_iterator(logging_obj, upstream=_UpstreamThatCompletesThenTimesOut())
chunks: Final = _ChunkRecorder()
await chunks.drain(iterator)
await iterator._await_pending_logging()
assert chunks.chunks[-1].type == "response.completed"
assert len(recorder.success_payloads) == 1
success_payload: Final = recorder.success_payloads[0]
response_payload: Final = success_payload["response"]
assert isinstance(response_payload, dict)
usage: Final = cast(dict[str, object], response_payload["usage"])
assert (usage["prompt_tokens"], usage["completion_tokens"], usage["total_tokens"]) == (5, 7, 12)
assert recorder.failure_payloads == ()
assert logging_obj.model_call_details.get("combined_usage_object") is None
class _UpstreamThatTimesOutAfterCreated:
headers: dict[str, str] = {}
def __init__(self, **created_fields: object) -> None:
self.created_fields = created_fields
async def aiter_bytes(self) -> AsyncIterator[bytes]:
created: Final = {
**_CREATED_WITH_PRIORITY_TIER,
"response": {**_CREATED_RESPONSE_WITH_PRIORITY_TIER, **self.created_fields},
}
for event in (created, _text_delta("Once upon a time", 1)):
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
async def _mid_stream_failure_payload(
monkeypatch: pytest.MonkeyPatch, requested_tier: str | None, **created_fields: object
) -> StandardLoggingPayload:
recorder: Final = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
logging_obj: Final = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True}
if requested_tier is None
else {"stream": True, "service_tier": requested_tier},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator: Final = _live_iterator(logging_obj, upstream=_UpstreamThatTimesOutAfterCreated(**created_fields))
with pytest.raises(litellm.Timeout):
await _ChunkRecorder().drain(iterator)
await iterator._await_pending_logging()
(payload,) = recorder.failure_payloads
return payload
@pytest.mark.asyncio
async def test_mid_stream_failure_bills_the_relayed_text_at_the_tier_the_provider_served(
monkeypatch: pytest.MonkeyPatch,
) -> None:
flex_requested: Final = await _mid_stream_failure_payload(monkeypatch, "flex", service_tier="default")
nothing_requested: Final = await _mid_stream_failure_payload(monkeypatch, None, service_tier="default")
assert nothing_requested["response_cost"] > 0
assert flex_requested["response_cost"] == nothing_requested["response_cost"]
@pytest.mark.asyncio
async def test_mid_stream_failure_bills_nothing_when_the_provider_runs_the_response_in_background(
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload: Final = await _mid_stream_failure_payload(monkeypatch, None, background=True)
assert payload["status"] == "failure"
assert (payload["completion_tokens"], payload["response_cost"]) == (0, 0)
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)):
@ -263,3 +618,25 @@ async def test_partial_response_on_disconnect_is_none_when_there_is_nothing_to_b
assert iterator.partial_response_on_disconnect() is None
await iterator._await_pending_logging()
@pytest.mark.asyncio
async def test_partial_response_on_disconnect_is_none_once_the_failure_log_billed_the_relayed_text():
iterator: Final = _live_iterator(_make_logging_obj(), upstream=_UpstreamThatTimesOutAfterRelayingText())
with pytest.raises(litellm.Timeout):
await _ChunkRecorder().drain(iterator)
assert iterator.partial_response_on_disconnect() is None
await iterator._await_pending_logging()
def test_partial_response_on_disconnect_reports_the_relayed_text_of_a_background_response():
iterator: Final = _live_iterator(_make_logging_obj(), background=True)
for event in (_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time", 1)):
iterator._process_chunk(json.dumps(event))
incomplete_event: Final = iterator.partial_response_on_disconnect()
assert incomplete_event is not None
assert incomplete_event.response.output[0].content[0].text == "Once upon a time"

View file

@ -210,9 +210,11 @@ _TRAILING_ERRORS = [
httpx.RemoteProtocolError("peer closed connection without sending complete message body"),
]
_TRAILING_ERRORS_AFTER_COMPLETED = [*_TRAILING_ERRORS, httpx.ReadTimeout("Timeout on reading data from socket")]
@pytest.mark.asyncio
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS, ids=type)
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS_AFTER_COMPLETED, ids=type)
async def test_transport_error_after_completed_event_ends_stream_cleanly(trailing_error):
"""A sloppy connection close after `response.completed` must not turn a
complete stream into an error (regression guard for the transport no longer
@ -243,7 +245,7 @@ async def test_transport_error_before_completed_event_raises():
pass
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS, ids=type)
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS_AFTER_COMPLETED, ids=type)
def test_sync_transport_error_after_completed_event_ends_stream_cleanly(trailing_error):
iterator = _make_sync_iterator(
sse_events=_COMPLETE_STREAM_EVENTS,
@ -1205,7 +1207,9 @@ async def test_completed_event_survives_a_failing_usage_estimate():
falls back to usage None."""
malformed_input: Final = [{"type": "message", "role": "user", "content": 42}]
with pytest.raises(ValueError, match="Invalid content type"):
_estimate_usage_from_text("gpt-4o-mini", malformed_input, {"input": malformed_input}, "hello world")
_estimate_usage_from_text(
"gpt-4o-mini", malformed_input, {"input": malformed_input}, "hello world"
)
response = _responses_api_response_without_usage()
iterator = _make_iterator(

View file

@ -3774,6 +3774,132 @@ async def test_continued_stream_sharing_the_proxy_logging_object_bills_the_cut_t
)
def _responses_sse(*events: Mapping[str, object]) -> str:
return "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events)
def _responses_envelope(response_id: str, model: str, status: str, **fields: object) -> dict[str, object]:
return {
"id": response_id,
"object": "response",
"created_at": 1700000000,
"status": status,
"model": model,
"output": [],
**fields,
}
@pytest.mark.asyncio
async def test_responses_fallback_sharing_the_proxy_logging_object_logs_the_fallback_usage(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
recorder: Final = _SpendRecorder()
monkeypatch.setattr(litellm, "callbacks", [recorder])
respx_mock.post("https://primary.example/v1/responses").mock(
return_value=httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=_responses_sse(
{"type": "response.created", "response": _responses_envelope("resp_primary", "gpt-4o", "in_progress")},
*(
{
"type": "response.output_text.delta",
"item_id": "msg_primary",
"output_index": 0,
"content_index": 0,
"delta": word,
}
for word in ("Once ", "upon ", "a ", "time ", "there")
),
{
"type": "response.failed",
"response": _responses_envelope(
"resp_primary",
"gpt-4o",
"failed",
error={"code": "server_error", "message": "The model had an internal error"},
),
},
),
)
)
respx_mock.post("https://backup.example/v1/responses").mock(
return_value=httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=_responses_sse(
{"type": "response.created", "response": _responses_envelope("resp_backup", "gpt-4o-mini", "in_progress")},
{
"type": "response.completed",
"response": _responses_envelope(
"resp_backup",
"gpt-4o-mini",
"completed",
output=[
{
"type": "message",
"id": "msg_backup",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Once upon a time", "annotations": []}],
}
],
usage={"input_tokens": 1000, "output_tokens": 700, "total_tokens": 1700},
),
},
),
)
)
router: Final = Router(
model_list=[
{
"model_name": "primary",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "k1", "api_base": "https://primary.example/v1"},
},
{
"model_name": "backup",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "k2",
"api_base": "https://backup.example/v1",
},
},
],
fallbacks=[{"primary": ["backup"]}],
num_retries=0,
)
shared_logging, _ = litellm.utils.function_setup(
original_function="aresponses",
rules_obj=litellm.utils.Rules(),
start_time=datetime.now(),
model="primary",
input="Tell me a story",
stream=True,
litellm_call_id="shared-call",
)
response: Final = await router.aresponses(
model="primary",
input="Tell me a story",
stream=True,
litellm_call_id="shared-call",
litellm_logging_obj=shared_logging,
)
_ = [event async for event in response]
await asyncio.wait_for(recorder.succeeded.wait(), timeout=10)
success: Final = recorder.successes[-1]
assert (success["prompt_tokens"], success["completion_tokens"]) == (1000, 700)
spend_row: Final = recorder.spend_rows[-1]
assert (spend_row["prompt_tokens"], spend_row["completion_tokens"], spend_row["spend"]) == (
1000,
700,
success["response_cost"],
)
def test_carry_partial_spend_to_fallback_adds_the_partial_the_cut_stream_inherited():
from litellm.types.utils import Usage