mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
Merge 1ef9830457 into 5950af8fb5
This commit is contained in:
commit
81819216fd
19 changed files with 1485 additions and 67 deletions
|
|
@ -263,6 +263,7 @@ _TOOL_SEARCH_CALL_TYPE: Final = "tool_search_call"
|
|||
_OUTPUT_TOOL_CALL_ITEM_TYPES: Final = _TOOL_CALL_ITEM_TYPES | {_TOOL_SEARCH_CALL_TYPE}
|
||||
_SCANNED_TOOL_SEARCH_NAME: Final = "tool_search"
|
||||
_TOOL_SEARCH_ARGUMENTS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_STREAM_EVENT_TYPE_ADAPTER: Final = TypeAdapter(str)
|
||||
_TOOL_CALL_PAYLOAD_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{"function_call": "arguments", "custom_tool_call": "input"}
|
||||
)
|
||||
|
|
@ -1936,7 +1937,6 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
new output item under the same response id, and close with a
|
||||
``response.completed`` carrying only the replacement item.
|
||||
|
||||
The proxy's data generator appends ``data: [DONE]`` itself.
|
||||
"""
|
||||
events: Final = (
|
||||
self._block_continuation_events(exc, responses_so_far or ())
|
||||
|
|
@ -1944,6 +1944,7 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
else self._standalone_block_events(exc)
|
||||
)
|
||||
return tuple(
|
||||
f"event: {_STREAM_EVENT_TYPE_ADAPTER.validate_python(stream_item_field(event, 'type'))}\n"
|
||||
f"data: {event.model_dump_json(exclude_none=True, exclude_unset=True, serialize_as_any=True)}\n\n".encode()
|
||||
for event in events
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ from litellm.constants import (
|
|||
STREAM_SSE_KEEPALIVE_PING_BYTES,
|
||||
UNSAFE_PROXY_RESPONSE_HEADERS,
|
||||
)
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException
|
||||
from litellm.integrations.otel.runtime import phase_event
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import get_hidden_params_dict
|
||||
from litellm.litellm_core_utils.bug_report import (
|
||||
|
|
@ -92,6 +92,7 @@ from litellm.litellm_core_utils.streaming_handler import (
|
|||
backfill_missing_cache_usage_fields,
|
||||
)
|
||||
from litellm.litellm_core_utils.tool_fields import DropPolicy, MalformedField, drop_policy, resolve_tool_fields
|
||||
from litellm.llms.openai.responses.guardrail_translation.handler import OpenAIResponsesHandler
|
||||
from litellm.proxy._types import LiteLLMRoutes, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
can_key_call_resolved_model,
|
||||
|
|
@ -119,6 +120,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
|
|||
openai_error_type,
|
||||
relayed_provider_specific_fields,
|
||||
)
|
||||
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState, sse_error_object
|
||||
from litellm.proxy.common_utils.sse_keepalive import (
|
||||
SSE_COMMENT_PING_BYTES,
|
||||
SSE_STREAM_START_TAIL,
|
||||
|
|
@ -1004,11 +1006,32 @@ async def _enforce_guardrail_added_tag_budgets(
|
|||
) from e
|
||||
|
||||
|
||||
async def _parse_event_data_for_error(event_line: str | bytes) -> int | None:
|
||||
SseDataPayload: TypeAlias = Callable[[str | bytes], str | None]
|
||||
|
||||
|
||||
def _sse_text(event_line: str | bytes) -> str:
|
||||
return event_line.decode("utf-8") if isinstance(event_line, bytes) else event_line
|
||||
|
||||
|
||||
def _sse_data_payload(event_line: str | bytes) -> str | None:
|
||||
text: Final = _sse_text(event_line)
|
||||
return text.removeprefix("data: ").strip() if text.startswith("data: ") else None
|
||||
|
||||
|
||||
def _responses_event_data_payload(event_line: str | bytes) -> str | None:
|
||||
match _sse_text(event_line).strip().split("\n"):
|
||||
case ["event: error", data_line] if data_line.startswith("data: "):
|
||||
return data_line.removeprefix("data: ").strip()
|
||||
case _:
|
||||
return _sse_data_payload(event_line)
|
||||
|
||||
|
||||
async def _parse_event_data_for_error(
|
||||
event_line: str | bytes, data_payload: SseDataPayload = _sse_data_payload
|
||||
) -> int | None:
|
||||
"""Parses an event line and returns an error code if present, else None."""
|
||||
event_line = event_line.decode("utf-8") if isinstance(event_line, bytes) else event_line
|
||||
if event_line.startswith("data: "):
|
||||
json_str: Final = event_line[len("data: ") :].strip()
|
||||
json_str: Final = data_payload(event_line)
|
||||
if json_str is not None:
|
||||
if not json_str or json_str == "[DONE]": # handle empty data or [DONE] message
|
||||
return None
|
||||
try:
|
||||
|
|
@ -1039,18 +1062,17 @@ async def _parse_event_data_for_error(event_line: str | bytes) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _extract_error_from_sse_chunk(event_line: str | bytes) -> dict:
|
||||
def _extract_error_from_sse_chunk(event_line: str | bytes, data_payload: SseDataPayload = _sse_data_payload) -> dict:
|
||||
"""
|
||||
Extract error dictionary from SSE format chunk.
|
||||
|
||||
Args:
|
||||
event_line: SSE format event line, e.g. "data: {"error": {...}}\n\n"
|
||||
data_payload: Reads the JSON payload out of the event line
|
||||
|
||||
Returns:
|
||||
Error dictionary in OpenAI API format
|
||||
"""
|
||||
event_line = event_line.decode("utf-8") if isinstance(event_line, bytes) else event_line
|
||||
|
||||
# Default error format
|
||||
default_error: Final = {
|
||||
"message": "Unknown error",
|
||||
|
|
@ -1059,8 +1081,8 @@ def _extract_error_from_sse_chunk(event_line: str | bytes) -> dict:
|
|||
"code": "500",
|
||||
}
|
||||
|
||||
if event_line.startswith("data: "):
|
||||
json_str: Final = event_line[len("data: ") :].strip()
|
||||
json_str: Final = data_payload(event_line)
|
||||
if json_str is not None:
|
||||
if not json_str or json_str == "[DONE]":
|
||||
return default_error
|
||||
|
||||
|
|
@ -1268,6 +1290,70 @@ def _sse_error_frames(error_obj: Mapping[str, object]) -> tuple[str, str]:
|
|||
return f"data: {json.dumps({'error': error_obj}, default=str)}\n\n", "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def _responses_error_frames(error_obj: Mapping[str, object]) -> tuple[str, ...]:
|
||||
return (ResponsesStreamErrorState().format_error(error_obj),)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LateSseFraming:
|
||||
"""How a response the keepalive already opened ends once its upstream fails late."""
|
||||
|
||||
failure: Callable[[Exception], tuple[str, ...]]
|
||||
body: Callable[[Response], tuple[bytes, ...]]
|
||||
|
||||
|
||||
def _openai_late_failure_frames(exc: Exception) -> tuple[str, ...]:
|
||||
_, error_obj = sse_error_payload(exc)
|
||||
return _sse_error_frames(error_obj)
|
||||
|
||||
|
||||
def _openai_late_body_frames(produced: Response) -> tuple[bytes, ...]:
|
||||
return b"data: " + (bytes(produced.body) or b"{}") + b"\n\n", b"data: [DONE]\n\n"
|
||||
|
||||
|
||||
def _responses_late_failure_frames(exc: Exception) -> tuple[str, ...]:
|
||||
if isinstance(exc, ModifyResponseException):
|
||||
return tuple(chunk.decode() for chunk in OpenAIResponsesHandler().build_block_sse_chunks(exc))
|
||||
return _responses_error_frames(sse_error_payload(exc)[1])
|
||||
|
||||
|
||||
def _responses_late_body_frames(produced: Response) -> tuple[bytes, ...]:
|
||||
body: Final = bytes(produced.body)
|
||||
error_obj: Final = sse_error_object(body or b"{}") or {"message": body.decode()}
|
||||
with_status: Final = {**error_obj, "code": error_obj.get("code") or str(produced.status_code)}
|
||||
return tuple(frame.encode() for frame in _responses_error_frames(with_status))
|
||||
|
||||
|
||||
OPENAI_LATE_SSE_FRAMING: Final = LateSseFraming(failure=_openai_late_failure_frames, body=_openai_late_body_frames)
|
||||
RESPONSES_LATE_SSE_FRAMING: Final = LateSseFraming(
|
||||
failure=_responses_late_failure_frames, body=_responses_late_body_frames
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstChunkSseFraming:
|
||||
"""How a stream's first chunk is read for an error, and how the stream ends when reading it fails."""
|
||||
|
||||
data_payload: SseDataPayload
|
||||
error_frames: Callable[[Mapping[str, object]], tuple[str, ...]]
|
||||
|
||||
|
||||
OPENAI_FIRST_CHUNK_FRAMING: Final = FirstChunkSseFraming(data_payload=_sse_data_payload, error_frames=_sse_error_frames)
|
||||
RESPONSES_FIRST_CHUNK_FRAMING: Final = FirstChunkSseFraming(
|
||||
data_payload=_responses_event_data_payload, error_frames=_responses_error_frames
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SseFraming:
|
||||
first_chunk: FirstChunkSseFraming
|
||||
late: LateSseFraming
|
||||
|
||||
|
||||
OPENAI_SSE_FRAMING: Final = SseFraming(first_chunk=OPENAI_FIRST_CHUNK_FRAMING, late=OPENAI_LATE_SSE_FRAMING)
|
||||
RESPONSES_SSE_FRAMING: Final = SseFraming(first_chunk=RESPONSES_FIRST_CHUNK_FRAMING, late=RESPONSES_LATE_SSE_FRAMING)
|
||||
|
||||
|
||||
def _sse_stream_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
|
||||
"""`headers` plus the two that stop reverse proxies from buffering SSE (issue #28384)."""
|
||||
return MappingProxyType({**headers, **_TTFT_KEEPALIVE_HEADERS})
|
||||
|
|
@ -1296,6 +1382,7 @@ async def create_response(
|
|||
request: Request | None = None,
|
||||
refresh_headers: Callable[[], Awaitable[Mapping[str, str]]] | None = None,
|
||||
general_settings: Mapping[str, object] = _NO_GENERAL_SETTINGS,
|
||||
framing: FirstChunkSseFraming = OPENAI_FIRST_CHUNK_FRAMING,
|
||||
) -> StreamingResponse | JSONResponse:
|
||||
"""
|
||||
Create streaming response, checking if the first chunk is an error.
|
||||
|
|
@ -1331,7 +1418,9 @@ async def create_response(
|
|||
|
||||
if first_chunk_value is not None:
|
||||
try:
|
||||
error_code_from_chunk: Final = await _parse_event_data_for_error(first_chunk_value)
|
||||
error_code_from_chunk: Final = await _parse_event_data_for_error(
|
||||
first_chunk_value, framing.data_payload
|
||||
)
|
||||
if error_code_from_chunk is not None:
|
||||
# First chunk is an error, stream hasn't really started yet
|
||||
# Should return standard JSON error response instead of SSE format
|
||||
|
|
@ -1343,7 +1432,9 @@ async def create_response(
|
|||
|
||||
# Parse error content
|
||||
error_dict: Final = with_call_id(
|
||||
JSON_OBJECT.validate_python(_extract_error_from_sse_chunk(first_chunk_value)),
|
||||
JSON_OBJECT.validate_python(
|
||||
_extract_error_from_sse_chunk(first_chunk_value, framing.data_payload)
|
||||
),
|
||||
error_body_call_id(general_settings, resolved_headers.get(LITELLM_CALL_ID_HEADER)),
|
||||
)
|
||||
|
||||
|
|
@ -1385,7 +1476,7 @@ async def create_response(
|
|||
error_status, error_obj = sse_error_payload(e)
|
||||
|
||||
async def error_gen_message() -> AsyncGenerator[str, None]:
|
||||
for frame in _sse_error_frames(error_obj):
|
||||
for frame in framing.error_frames(error_obj):
|
||||
yield frame
|
||||
|
||||
return StreamingResponse(
|
||||
|
|
@ -1468,13 +1559,13 @@ async def _aclose_late_response(produced: Response) -> None:
|
|||
verbose_proxy_logger.debug("error closing relayed streaming generator: %s", exc)
|
||||
|
||||
|
||||
async def _relay_late_response(produced: Response) -> AsyncGenerator[bytes, None]:
|
||||
async def _relay_late_response(produced: Response, framing: LateSseFraming) -> AsyncGenerator[bytes, None]:
|
||||
"""Replay a Response that was built after a keepalive had already opened the wire."""
|
||||
if not isinstance(produced, StreamingResponse):
|
||||
# The status line is already on the wire, so a non-streaming body, an error
|
||||
# body included, can only reach the client as an SSE frame.
|
||||
yield b"data: " + (bytes(produced.body) or b"{}") + b"\n\n"
|
||||
yield b"data: [DONE]\n\n"
|
||||
for frame in framing.body(produced):
|
||||
yield frame
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -1516,6 +1607,7 @@ async def open_sse_before_first_byte(
|
|||
ping_interval_seconds: float | str | None,
|
||||
media_type: str = "text/event-stream",
|
||||
on_late_failure: Callable[[Exception], Awaitable[HTTPException | None]] | None = None,
|
||||
late_framing: LateSseFraming = OPENAI_LATE_SSE_FRAMING,
|
||||
) -> _LateResponseT | StreamingResponse:
|
||||
"""Write SSE keepalive comments while the upstream LLM call is still in flight.
|
||||
|
||||
|
|
@ -1561,11 +1653,10 @@ async def open_sse_before_first_byte(
|
|||
# would never fire and the failure would go unaudited. The hook
|
||||
# also gets to sanitize what reaches the client, by returning or
|
||||
# raising a replacement, so its answer decides the frame.
|
||||
_, error_obj = sse_error_payload(await _sanitized_late_failure(exc, on_late_failure))
|
||||
for frame in _sse_error_frames(error_obj):
|
||||
for frame in late_framing.failure(await _sanitized_late_failure(exc, on_late_failure)):
|
||||
yield frame.encode()
|
||||
return
|
||||
async for chunk in _relay_late_response(produced):
|
||||
async for chunk in _relay_late_response(produced, late_framing):
|
||||
yield chunk
|
||||
finally:
|
||||
if not produce_task.done():
|
||||
|
|
@ -2963,6 +3054,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
is_streaming_request: bool | None = False,
|
||||
contents: list[object] | None = None,
|
||||
skip_pre_call_logic: bool = False,
|
||||
sse_framing: SseFraming = OPENAI_SSE_FRAMING,
|
||||
) -> object:
|
||||
"""Run the request, sending SSE keepalives while the upstream is still silent.
|
||||
|
||||
|
|
@ -3003,9 +3095,11 @@ class ProxyBaseLLMRequestProcessing:
|
|||
is_streaming_request=is_streaming_request,
|
||||
contents=contents,
|
||||
skip_pre_call_logic=skip_pre_call_logic,
|
||||
first_chunk_framing=sse_framing.first_chunk,
|
||||
),
|
||||
ping_interval_seconds=ttft_keepalive_interval(self.data, llm_router),
|
||||
on_late_failure=_audit_late_failure,
|
||||
late_framing=sse_framing.late,
|
||||
)
|
||||
|
||||
async def _process_llm_request(
|
||||
|
|
@ -3029,6 +3123,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
is_streaming_request: bool | None = False,
|
||||
contents: list[object] | None = None, # Add contents parameter
|
||||
skip_pre_call_logic: bool = False,
|
||||
first_chunk_framing: FirstChunkSseFraming = OPENAI_FIRST_CHUNK_FRAMING,
|
||||
) -> Any:
|
||||
"""
|
||||
Common request processing logic for both chat completions and responses API endpoints
|
||||
|
|
@ -3312,6 +3407,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
request=request,
|
||||
refresh_headers=refresh_stream_headers,
|
||||
general_settings=general_settings,
|
||||
framing=first_chunk_framing,
|
||||
)
|
||||
|
||||
### CALL HOOKS ### - modify outgoing data
|
||||
|
|
@ -3551,8 +3647,8 @@ class ProxyBaseLLMRequestProcessing:
|
|||
|
||||
Streaming ``/v1/responses`` short-circuits out of
|
||||
``base_process_llm_request`` before the non-streaming ownership
|
||||
tail runs. The OpenAI SDK closes the connection at ``data: [DONE]``
|
||||
and starlette cancels the body task on disconnect, so a write that
|
||||
tail runs. The OpenAI SDK can close the connection right after the terminal
|
||||
event and starlette cancels the body task on disconnect, so a write that
|
||||
waits for the generator to finish never lands. The iterator sets
|
||||
``completed_response`` before it hands over its terminal chunk, so
|
||||
the ``LiteLLM_ManagedObjectTable`` row is written the moment it
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import json
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
||||
from typing_extensions import TypeIs # noqa: TID251 # the guards narrow hook values to object items, isinstance cannot
|
||||
|
||||
from litellm._logging import redact_internal_details_from_client_message
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -57,6 +61,167 @@ class _FailureDetails(LiteLLMBaseModel):
|
|||
return cls.model_validate({field: getattr(exception, field, None) for field in cls.model_fields})
|
||||
|
||||
|
||||
class _ErrorPayload(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
error: Mapping[str, object] | str
|
||||
|
||||
def error_object(self) -> Mapping[str, object]:
|
||||
return {"message": self.error} if isinstance(self.error, str) else self.error
|
||||
|
||||
|
||||
_DROPPED: Final = object()
|
||||
|
||||
|
||||
class _BaseForm(Enum):
|
||||
PRINTED = "printed"
|
||||
DUMPED = "dumped"
|
||||
|
||||
|
||||
def _prints_every_item(kind: type[object], form: _BaseForm) -> bool:
|
||||
repr_owner: Final = next((owner for owner in kind.__mro__ if "__repr__" in owner.__dict__), object)
|
||||
if form is _BaseForm.DUMPED:
|
||||
return kind.__str__ is object.__str__ and repr_owner in (dict, list, tuple, OrderedDict, defaultdict, Counter)
|
||||
return (
|
||||
kind.__str__ is object.__str__
|
||||
and kind.__format__ is object.__format__
|
||||
and (kind in (OrderedDict, Counter) or repr_owner in (dict, list, tuple, defaultdict))
|
||||
)
|
||||
|
||||
|
||||
def _is_shown_dict(value: object, form: _BaseForm) -> TypeIs[Mapping[object, object]]: # guard-ok: dict items as object
|
||||
kind: Final = type(value)
|
||||
return isinstance(value, dict) and _prints_every_item(kind, form)
|
||||
|
||||
|
||||
def _is_shown_sequence(value: object, form: _BaseForm) -> TypeIs[Sequence[object]]: # guard-ok: items as object
|
||||
kind: Final = type(value)
|
||||
return isinstance(value, (list, tuple)) and _prints_every_item(kind, form)
|
||||
|
||||
|
||||
def _is_proxy(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok: mappingproxy cannot be subclassed
|
||||
return isinstance(value, MappingProxyType)
|
||||
|
||||
|
||||
def _printed_proxy_items(proxy: Mapping[object, object], printed: str) -> Iterable[tuple[object, object]] | None:
|
||||
snapshot: Final = dict(proxy)
|
||||
shown: Final = repr(snapshot)
|
||||
return snapshot.items() if printed in (shown, f"mappingproxy({shown})") else None
|
||||
|
||||
|
||||
def _repr_prints_its_fields(model: BaseModel) -> bool:
|
||||
kind: Final = type(model)
|
||||
return kind.__repr__ is BaseModel.__repr__ and kind.__repr_str__ is BaseModel.__repr_str__
|
||||
|
||||
|
||||
def _plain_scalar(value: str | float) -> str | float:
|
||||
if isinstance(value, str):
|
||||
return str.__str__(value)
|
||||
if isinstance(value, int):
|
||||
return int.__int__(value)
|
||||
return float.__float__(value)
|
||||
|
||||
|
||||
def _repr_shows_its_value(value: str | float) -> bool:
|
||||
plain: Final = _plain_scalar(value)
|
||||
if isinstance(value, Enum):
|
||||
return repr(value) == f"<{type(value).__name__}.{value.name}: {plain!r}>"
|
||||
return repr(value) == repr(plain)
|
||||
|
||||
|
||||
def _text(value: object, form: _BaseForm) -> object:
|
||||
try:
|
||||
if isinstance(value, (str, int, float)) and (form is _BaseForm.DUMPED or _repr_shows_its_value(value)):
|
||||
return _plain_scalar(value)
|
||||
return repr(value) if form is _BaseForm.PRINTED else str(value)
|
||||
except Exception: # noqa: BLE001 # hook values run their own __repr__ and __str__, so a failure drops the value
|
||||
return _DROPPED
|
||||
|
||||
|
||||
def _json_key(key: object, form: _BaseForm) -> object:
|
||||
return key if key is None or type(key) in (str, int, float, bool) else _text(key, form)
|
||||
|
||||
|
||||
def _shown_sequence(value: object, form: _BaseForm) -> Iterator[object] | None:
|
||||
if not _is_shown_sequence(value, form):
|
||||
return None
|
||||
if form is _BaseForm.PRINTED and isinstance(value, list):
|
||||
return list[object].__iter__(value)
|
||||
if form is _BaseForm.PRINTED and isinstance(value, tuple):
|
||||
return tuple[object, ...].__iter__(value)
|
||||
return iter(value)
|
||||
|
||||
|
||||
def _shown_items(value: object, form: _BaseForm) -> Iterable[tuple[object, object]] | None:
|
||||
if not _is_shown_dict(value, form):
|
||||
return None
|
||||
if form is _BaseForm.PRINTED and isinstance(value, dict):
|
||||
return dict[object, object].items(value)
|
||||
return value.items()
|
||||
|
||||
|
||||
def _expansion(value: object, form: _BaseForm) -> tuple[Iterable[tuple[object, object]], _BaseForm] | None:
|
||||
shown_items: Final = _shown_items(value, form)
|
||||
if shown_items is not None:
|
||||
return shown_items, form
|
||||
if _is_proxy(value):
|
||||
proxy_items: Final = _printed_proxy_items(value, repr(value) if form is _BaseForm.PRINTED else str(value))
|
||||
return None if proxy_items is None else (proxy_items, _BaseForm.PRINTED)
|
||||
if form is _BaseForm.PRINTED and isinstance(value, BaseModel) and _repr_prints_its_fields(value):
|
||||
return value.__repr_args__(), _BaseForm.PRINTED
|
||||
return None
|
||||
|
||||
|
||||
def _json_safe(value: object, form: _BaseForm, path: frozenset[int]) -> object:
|
||||
if value is None or type(value) in (str, int, float, bool):
|
||||
return value
|
||||
if id(value) in path:
|
||||
return _text(value, form)
|
||||
inner_path: Final = path | frozenset((id(value),))
|
||||
try:
|
||||
sequence: Final = _shown_sequence(value, form)
|
||||
if sequence is not None:
|
||||
shown_items: Final = [_json_safe(item, form, inner_path) for item in sequence]
|
||||
return [item for item in shown_items if item is not _DROPPED]
|
||||
expansion: Final = _expansion(value, form)
|
||||
if expansion is None:
|
||||
return _text(value, form)
|
||||
items, inner = expansion
|
||||
pairs: Final = [(_json_key(key, inner), _json_safe(item, inner, inner_path)) for key, item in items]
|
||||
return {key: item for key, item in pairs if key is not _DROPPED and item is not _DROPPED}
|
||||
except Exception: # noqa: BLE001 # hook values run their own printers and can nest past the recursion limit
|
||||
return _text(value, form)
|
||||
|
||||
|
||||
def responses_chunk_json(chunk: object) -> str | None:
|
||||
if _is_proxy(chunk) and _printed_proxy_items(chunk, format(chunk, "")) is None:
|
||||
return None
|
||||
if not _is_shown_dict(chunk, _BaseForm.PRINTED) and not _is_proxy(chunk):
|
||||
return None
|
||||
shown: Final = _json_safe(chunk, _BaseForm.PRINTED, frozenset())
|
||||
return None if isinstance(shown, str) or shown is _DROPPED else json.dumps(shown)
|
||||
|
||||
|
||||
def _dumped_detail(error: Mapping[str, object]) -> Mapping[str, object]:
|
||||
path: Final = frozenset((id(error),))
|
||||
shown: Final = [(key, _json_safe(value, _BaseForm.DUMPED, path)) for key, value in error.items()]
|
||||
return {key: value for key, value in shown if value is not _DROPPED}
|
||||
|
||||
|
||||
def sse_error_object(data: str | bytes) -> Mapping[str, object] | None:
|
||||
try:
|
||||
return _ErrorPayload.model_validate_json(data).error_object()
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def event_error_object(event: Mapping[str, object]) -> Mapping[str, object] | None:
|
||||
try:
|
||||
return _ErrorPayload.model_validate(event).error_object() or None
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _original_failure(exception: Exception) -> Exception:
|
||||
current = exception # rebind-ok: the recursion gate requires iterative wrapper traversal
|
||||
while isinstance(current, MidStreamFallbackError) and current.original_exception is not None:
|
||||
|
|
@ -95,6 +260,12 @@ def _status_error_code(status_code: int | None) -> str:
|
|||
return _CLIENT_ERROR_CODES.get(status_code, "invalid_request_error")
|
||||
|
||||
|
||||
def _status_of(code: str | int | None) -> int | None:
|
||||
if isinstance(code, int):
|
||||
return code
|
||||
return int(code) if code is not None and code.isdecimal() else None
|
||||
|
||||
|
||||
def _response_error_code(details: _FailureDetails) -> str:
|
||||
for value in (details.code, details.type):
|
||||
if value == "insufficient_quota":
|
||||
|
|
@ -106,6 +277,34 @@ def _response_error_code(details: _FailureDetails) -> str:
|
|||
return _status_error_code(details.status_code)
|
||||
|
||||
|
||||
_STATUS_PHRASES: Final = MappingProxyType({int(status): status.phrase for status in HTTPStatus})
|
||||
|
||||
|
||||
def _status_phrase(status_code: int | None) -> str:
|
||||
return _STATUS_PHRASES.get(status_code, "Unknown error") if status_code is not None else "Unknown error"
|
||||
|
||||
|
||||
def responses_error_fields(error: Mapping[str, object]) -> Mapping[str, object]:
|
||||
details: Final = _FailureDetails.model_validate(error)
|
||||
status_code: Final = _status_of(details.code)
|
||||
return {
|
||||
"code": _response_error_code(details.model_copy(update={"status_code": status_code})),
|
||||
"message": details.message or _status_phrase(status_code),
|
||||
}
|
||||
|
||||
|
||||
def _error_event(
|
||||
fields: Mapping[str, object], error: Mapping[str, object], sequence_number: int
|
||||
) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": ResponsesAPIStreamEvents.ERROR.value,
|
||||
**fields,
|
||||
"param": error.get("param"),
|
||||
"sequence_number": sequence_number,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
class ResponsesStreamErrorState:
|
||||
def __init__(self) -> None:
|
||||
self.response_id: str | None = None
|
||||
|
|
@ -129,9 +328,22 @@ class ResponsesStreamErrorState:
|
|||
self.model = event.response.model or self.model
|
||||
if event.response.created_at is not None:
|
||||
self.created_at = event.response.created_at
|
||||
if event.type in ("response.completed", "response.failed", "response.incomplete"):
|
||||
if event.type in ("response.completed", "response.failed", "response.incomplete", "error"):
|
||||
self.terminal_emitted = True
|
||||
return frame
|
||||
if not event.type or "\n" in event.type or "\r" in event.type:
|
||||
return frame
|
||||
prefix: Final = f"event: {event.type}\n"
|
||||
return prefix.encode("utf-8") + frame if isinstance(frame, bytes) else prefix + frame
|
||||
|
||||
def format_error(self, error: Mapping[str, object]) -> str:
|
||||
fields: Final = responses_error_fields(error)
|
||||
sequence_number: Final = self.sequence_number + 1
|
||||
payload: Final = json.dumps(_error_event(fields, _dumped_detail(error), sequence_number))
|
||||
return f"event: error\ndata: {payload}\n\n"
|
||||
|
||||
def format_error_message(self, frame: str) -> str:
|
||||
error: Final = sse_error_object(frame.removeprefix("data: "))
|
||||
return frame if error is None else self.format_error(error)
|
||||
|
||||
def format_failure(self, exception: Exception) -> str | None:
|
||||
if self.terminal_emitted:
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@ from litellm.proxy.common_utils.periodic_reload_schedule import (
|
|||
from litellm.proxy.common_utils.proxy_state import ProxyState
|
||||
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
|
||||
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
|
||||
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState
|
||||
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState, responses_chunk_json
|
||||
from litellm.proxy.common_utils.scheduled_job_stagger import (
|
||||
apply_scheduled_job_stagger,
|
||||
attach_job_timing_logger,
|
||||
|
|
@ -10266,6 +10266,11 @@ def _format_streaming_sse_chunk(chunk: object) -> str | bytes:
|
|||
return f"data: {chunk}\n\n"
|
||||
|
||||
|
||||
def _format_responses_sse_chunk(chunk: object) -> str | bytes:
|
||||
data: Final = responses_chunk_json(chunk)
|
||||
return _format_streaming_sse_chunk(chunk=chunk) if data is None else f"data: {data}\n\n"
|
||||
|
||||
|
||||
_SSE_FRAME_DELIMITERS: Final = ("\r\n\r\n", "\n\n", "\r\r")
|
||||
_OPENAI_STREAM_DONE_FRAME: Final = "data: [DONE]\n\n"
|
||||
_MAX_RAW_SSE_BUFFER_CHARS: Final = 8 * 1024 * 1024
|
||||
|
|
@ -10505,7 +10510,9 @@ async def async_data_generator(
|
|||
stream_completed = False
|
||||
client_disconnected = False
|
||||
error_state: Final = ResponsesStreamErrorState() if responses_stream_errors else None
|
||||
ends_with_openai_done: Final = not request_data.get("_litellm_skip_openai_stream_done")
|
||||
ends_with_openai_done: Final = not responses_stream_errors and not request_data.get(
|
||||
"_litellm_skip_openai_stream_done"
|
||||
)
|
||||
needs_iterator_wrap: Final = proxy_logging_obj.needs_iterator_wrap()
|
||||
stream_iterator: Final = _object_stream(
|
||||
proxy_logging_obj.async_post_call_streaming_iterator_hook(
|
||||
|
|
@ -10664,7 +10671,7 @@ async def async_data_generator(
|
|||
if not raw_passthrough:
|
||||
try:
|
||||
if error_state is not None:
|
||||
yield error_state.mark_emitted(_format_streaming_sse_chunk(chunk=chunk))
|
||||
yield error_state.mark_emitted(_format_responses_sse_chunk(chunk=chunk))
|
||||
else:
|
||||
yield _format_streaming_sse_chunk(chunk=chunk)
|
||||
except Exception as e:
|
||||
|
|
@ -10690,8 +10697,8 @@ async def async_data_generator(
|
|||
yield (raw_sse_buffer if raw_sse_buffer.endswith(_SSE_FRAME_DELIMITERS) else raw_sse_buffer + "\n\n")
|
||||
|
||||
if error_message is not None:
|
||||
yield error_message
|
||||
# OpenAI-compatible streams terminate with data: [DONE]; Google GenAI (?alt=sse) does not.
|
||||
yield error_message if error_state is None else error_state.format_error_message(error_message)
|
||||
# Chat Completions streams terminate with data: [DONE]; Responses and Google GenAI (?alt=sse) streams do not.
|
||||
if ends_with_openai_done:
|
||||
yield _OPENAI_STREAM_DONE_FRAME
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from litellm.proxy.auth.user_api_key_auth import (
|
|||
user_api_key_auth_websocket,
|
||||
)
|
||||
from litellm.proxy.common_request_processing import (
|
||||
RESPONSES_SSE_FRAMING,
|
||||
ProxyBaseLLMRequestProcessing,
|
||||
cost_headers,
|
||||
create_response,
|
||||
|
|
@ -482,6 +483,7 @@ async def responses_api(
|
|||
if requests_background(_PROXY_SERVER_REQUEST_ADAPTER.validate_python(data))
|
||||
else native_data_generator
|
||||
),
|
||||
sse_framing=RESPONSES_SSE_FRAMING,
|
||||
model=None,
|
||||
user_model=user_model,
|
||||
user_temperature=user_temperature,
|
||||
|
|
@ -540,7 +542,6 @@ async def responses_api(
|
|||
async def _blocked_stream() -> AsyncGenerator[str, None]:
|
||||
for chunk in block_chunks:
|
||||
yield chunk.decode()
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return await create_response(generator=_blocked_stream(), media_type="text/event-stream", headers={})
|
||||
return build_blocked_response(e)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ https://platform.openai.com/docs/api-reference/responses-streaming
|
|||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Callable, Mapping, Sequence
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
|
@ -20,7 +21,12 @@ from typing_extensions import ReadOnly, TypedDict
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.common_request_processing import RESPONSES_SSE_FRAMING, ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.common_utils.responses_stream_errors import (
|
||||
event_error_object,
|
||||
responses_error_fields,
|
||||
sse_error_object,
|
||||
)
|
||||
from litellm.proxy.response_polling.polling_handler import ResponsePollingHandler
|
||||
from litellm.types.llms.openai import ResponsesAPIStatus
|
||||
|
||||
|
|
@ -69,6 +75,7 @@ class _StreamEvent(TypedDict, total=False):
|
|||
delta: ReadOnly[str]
|
||||
part: ReadOnly[object]
|
||||
response: ReadOnly[_TerminalResponse]
|
||||
error: ReadOnly[_JsonDict]
|
||||
|
||||
|
||||
class _StreamEventParser:
|
||||
|
|
@ -85,6 +92,22 @@ def _sse_frame_data(frame: str) -> str | None:
|
|||
return next((line[6:].strip() for line in split_sse_lines(frame) if line.startswith("data: ")), None)
|
||||
|
||||
|
||||
def _event_error(event: Mapping[str, object]) -> Mapping[str, object]:
|
||||
match event.get("error"):
|
||||
case str() as message if message:
|
||||
return {**event, "message": message}
|
||||
case _:
|
||||
return event_error_object(event) or event
|
||||
|
||||
|
||||
def _first_chunk_error(response: object) -> Mapping[str, object] | None:
|
||||
if isinstance(response, _StreamsBody) or not isinstance(response, Response):
|
||||
return None
|
||||
if response.status_code < HTTPStatus.BAD_REQUEST:
|
||||
return None
|
||||
return responses_error_fields(sse_error_object(bytes(response.body)) or {"code": response.status_code})
|
||||
|
||||
|
||||
async def _never_receive() -> Message:
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
|
|
@ -165,6 +188,7 @@ async def background_streaming_task(
|
|||
user_api_base=user_api_base,
|
||||
version=version,
|
||||
skip_pre_call_logic=True,
|
||||
sse_framing=RESPONSES_SSE_FRAMING,
|
||||
)
|
||||
|
||||
# Process streaming response following OpenAI events format
|
||||
|
|
@ -326,6 +350,10 @@ async def background_streaming_task(
|
|||
status="in_progress",
|
||||
)
|
||||
|
||||
elif event_type == "error":
|
||||
terminal_status = "failed"
|
||||
terminal_error = responses_error_fields(_event_error(event))
|
||||
|
||||
elif event_type in (
|
||||
"response.completed",
|
||||
"response.failed",
|
||||
|
|
@ -382,14 +410,15 @@ async def background_streaming_task(
|
|||
# Final flush to ensure all accumulated state is saved
|
||||
await flush_state_if_needed(force=True)
|
||||
|
||||
# Use the terminal status from the stream, default to "completed"
|
||||
final_status: Final = terminal_status or "completed"
|
||||
early_error: Final = _first_chunk_error(response)
|
||||
final_error: Final = terminal_error if early_error is None else early_error
|
||||
final_status: Final = "failed" if early_error is not None else terminal_status or "completed"
|
||||
|
||||
await polling_handler.update_state(
|
||||
polling_id=polling_id,
|
||||
status=final_status,
|
||||
usage=usage_data,
|
||||
error=terminal_error,
|
||||
error=final_error,
|
||||
reasoning=reasoning_data,
|
||||
tool_choice=tool_choice_data,
|
||||
tools=tools_data,
|
||||
|
|
@ -411,7 +440,7 @@ async def background_streaming_task(
|
|||
"Finished background streaming for %s, status=%s, error=%s, incomplete_details=%s, output_items=%s",
|
||||
polling_id,
|
||||
final_status,
|
||||
terminal_error,
|
||||
final_error,
|
||||
incomplete_details_data,
|
||||
len(output_items),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ class ResponsePollingHandler:
|
|||
polling_id: str,
|
||||
status: ResponsesAPIStatus | None = None,
|
||||
usage: dict | None = None,
|
||||
error: dict | None = None,
|
||||
error: Mapping[str, object] | None = None,
|
||||
incomplete_details: dict | None = None,
|
||||
reasoning: dict | None = None,
|
||||
tool_choice: object | None = None,
|
||||
|
|
|
|||
|
|
@ -1200,9 +1200,7 @@ def test_responses_pre_call_denial_streams_sse_with_typed_message_item(gateway:
|
|||
response.headers["content-type"],
|
||||
response.text,
|
||||
)
|
||||
lines: Final = tuple(line for line in response.text.split("\n") if line.startswith("data: "))
|
||||
assert lines[-1] == "data: [DONE]", response.text
|
||||
events: Final = tuple(json.loads(line.removeprefix("data: ")) for line in lines[:-1])
|
||||
events: Final = _blocked_stream_events(response.text)
|
||||
kinds: Final = tuple(event["type"] for event in events)
|
||||
assert tuple(kind for kind in kinds if kind != "response.output_text.delta") == (
|
||||
"response.created",
|
||||
|
|
@ -1289,9 +1287,13 @@ def _responses_output_denial_config(tmp_path: Path, identity: str, model: str) -
|
|||
|
||||
|
||||
def _blocked_stream_events(text: str) -> tuple[dict[str, object], ...]:
|
||||
lines: Final = tuple(line for line in text.split("\n") if line.startswith("data: "))
|
||||
assert lines[-1] == "data: [DONE]", text
|
||||
return tuple(json.loads(line.removeprefix("data: ")) for line in lines[:-1])
|
||||
assert "data: [DONE]" not in text, text
|
||||
lines: Final = tuple(line for line in text.split("\n") if line.startswith(("event: ", "data: ")))
|
||||
events: Final[tuple[dict[str, object], ...]] = tuple(
|
||||
json.loads(line.removeprefix("data: ")) for line in lines[1::2]
|
||||
)
|
||||
assert lines[::2] == tuple(f"event: {event['type']}" for event in events), text
|
||||
return events
|
||||
|
||||
|
||||
def _dead_api_base() -> str:
|
||||
|
|
@ -2798,7 +2800,7 @@ def test_client_disconnect_after_a_finished_responses_tool_call_scans_the_text_a
|
|||
with _disconnect_rig(gateway, tmp_path, shape="text_then_tool_call") as rig:
|
||||
try:
|
||||
received: Final = _stream_and_close(
|
||||
rig, "/v1/responses", _tool_call_request(rig, "/v1/responses"), "response.output_item.done"
|
||||
rig, "/v1/responses", _tool_call_request(rig, "/v1/responses"), '"type":"response.output_item.done"'
|
||||
)
|
||||
assert rig.token() in received, received
|
||||
scans: Final = eventually(
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ async def test_slow_native_streams_reach_every_caller_whole_and_are_logged_once(
|
|||
assert len(served) == 10
|
||||
for item in served:
|
||||
_assert_answered_with_its_own_marker(item)
|
||||
assert item.text.rstrip().endswith("data: [DONE]"), item.text
|
||||
assert item.text.rstrip().endswith("data: [DONE]") == (item.call.endpoint == "chat"), item.text
|
||||
_assert_native_requests_for(wire.drain(), calls)
|
||||
assert _spend_status_by_call(served) == {item.call_id: "success" for item in served}
|
||||
|
||||
|
|
|
|||
|
|
@ -846,7 +846,12 @@ def test_responses_api_stream_on_a_mantle_claude_id_bridges_to_the_native_stream
|
|||
"POST", "/v1/responses", {"model": model, "input": _prompt(marker), "stream": True}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
events: Final = _sse_objects(response.text)
|
||||
assert "data: [DONE]" not in response.text, response.text
|
||||
events: Final = tuple(
|
||||
_JSON_OBJECT.validate_json(line.removeprefix("data: "))
|
||||
for line in response.text.splitlines()
|
||||
if line.startswith("data: ")
|
||||
)
|
||||
kinds: Final = [event["type"] for event in events]
|
||||
assert (kinds[0], kinds[-1]) == ("response.created", "response.completed"), kinds
|
||||
assert "".join(
|
||||
|
|
|
|||
|
|
@ -482,8 +482,7 @@ def _responses_body(marker: str, *directives: str) -> dict[str, JsonValue]:
|
|||
|
||||
def _failed_response_error(response: httpx.Response) -> dict[str, JsonValue]:
|
||||
assert response.status_code == 200, response.text
|
||||
lines: Final = _data_lines(response.text)
|
||||
assert lines[-1] == "[DONE]" and lines.count("[DONE]") == 1, response.text
|
||||
assert "[DONE]" not in _data_lines(response.text), response.text
|
||||
events: Final = _payloads(response.text)
|
||||
kinds: Final = tuple(string_value(event["type"]) for event in events)
|
||||
assert kinds[0] == "response.created" and kinds[-1] == "response.failed", response.text
|
||||
|
|
|
|||
|
|
@ -474,9 +474,10 @@ def test_native_responses_stream_forwards_the_failed_response_on_both_legs(gatew
|
|||
assert response.status_code == 200, response.text
|
||||
frames: Final = data_frames(response.text)
|
||||
assert [frame["type"] for frame in frames] == ["response.created", "response.failed"], response.text
|
||||
assert [name for name, _ in sse_events(response.text)] == ["response.created", "response.failed"], response.text
|
||||
failed: Final = object_value(frames[-1]["response"])
|
||||
assert failed["status"] == "failed", failed
|
||||
assert object_value(failed["error"])["code"] == "rate_limit_exceeded", failed
|
||||
assert response.text.rstrip().endswith("data: [DONE]"), response.text
|
||||
assert "data: [DONE]" not in response.text, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
assert spend_row(response.headers["x-litellm-call-id"])["status"] == "failure"
|
||||
|
|
|
|||
|
|
@ -256,8 +256,10 @@ def _responses_frames(gateway: Gateway, stream: tuple[bytes, ...]) -> tuple[http
|
|||
)
|
||||
_assert_one_attempt(served.wire, marker)
|
||||
assert served.response.status_code == 200, served.response.text
|
||||
assert served.response.text.rstrip().endswith("data: [DONE]"), served.response.text
|
||||
assert "data: [DONE]" not in served.response.text, served.response.text
|
||||
frames: Final = data_frames(served.response.text)
|
||||
names: Final = tuple(name for name, _ in sse_events(served.response.text))
|
||||
assert names == tuple(item["type"] for item in frames), served.response.text
|
||||
assert frames[-1]["type"] == "response.failed", served.response.text
|
||||
return served.response, object_value(object_value(frames[-1]["response"])["error"])
|
||||
|
||||
|
|
|
|||
|
|
@ -4429,7 +4429,14 @@ class TestBuildBlockSseChunks:
|
|||
def _payloads(self, chunks):
|
||||
import json
|
||||
|
||||
return [json.loads(chunk.decode().removeprefix("data: ").strip()) for chunk in chunks]
|
||||
payloads: Final = [
|
||||
json.loads(next(line[6:] for line in chunk.decode().splitlines() if line.startswith("data: ")))
|
||||
for chunk in chunks
|
||||
]
|
||||
assert [chunk.decode().splitlines()[0] for chunk in chunks] == [
|
||||
f"event: {payload['type']}" for payload in payloads
|
||||
]
|
||||
return payloads
|
||||
|
||||
def test_standalone_block_emits_complete_synthetic_stream(self):
|
||||
handler = OpenAIResponsesHandler()
|
||||
|
|
|
|||
|
|
@ -16,8 +16,14 @@ Pins covered:
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections import Counter, OrderedDict
|
||||
from collections.abc import AsyncIterator, Callable, ItemsView, Iterable, Iterator, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
|
@ -26,7 +32,8 @@ import pytest
|
|||
from fastapi import HTTPException, Response
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from openai import APIError as OpenAIAPIError
|
||||
from pydantic import BaseModel
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
|
@ -34,10 +41,12 @@ from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
|
|||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.common_request_processing import create_response
|
||||
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState
|
||||
from litellm.proxy.proxy_server import (
|
||||
_apply_streaming_chunk_hooks,
|
||||
_fast_serialize_simple_model_response_stream,
|
||||
_format_fallback_metadata_sse_event,
|
||||
_format_responses_sse_chunk,
|
||||
_format_streaming_sse_chunk,
|
||||
_get_client_requested_model_for_streaming,
|
||||
_get_streaming_fallback_metadata,
|
||||
|
|
@ -50,11 +59,16 @@ from litellm.proxy.proxy_server import (
|
|||
select_data_generator,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
ErrorEvent,
|
||||
OutputTextDeltaEvent,
|
||||
ResponseCompletedEvent,
|
||||
ResponseCreatedEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseIncompleteEvent,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamEvents,
|
||||
)
|
||||
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices, Usage
|
||||
from tests._support.clock_loops import AutojumpClockLoop
|
||||
|
||||
|
|
@ -92,6 +106,571 @@ async def _async_iter_raises(exc: Exception):
|
|||
raise exc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("terminal_type", ["response.completed", "response.failed", "response.incomplete", "error"])
|
||||
async def test_responses_stream_dispatches_named_events_without_a_chat_done_sentinel(terminal_type: str) -> None:
|
||||
response: Final = ResponsesAPIResponse(id="resp_framing", created_at=1, model="local-model", output=[])
|
||||
created: Final = ResponseCreatedEvent.model_validate(
|
||||
{"type": "response.created", "sequence_number": 0, "response": response}
|
||||
)
|
||||
delta: Final = OutputTextDeltaEvent.model_validate(
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"sequence_number": 1,
|
||||
"item_id": "msg_framing",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "hello",
|
||||
}
|
||||
)
|
||||
terminal_models: Final = {
|
||||
"response.completed": ResponseCompletedEvent,
|
||||
"response.failed": ResponseFailedEvent,
|
||||
"response.incomplete": ResponseIncompleteEvent,
|
||||
"error": ErrorEvent,
|
||||
}
|
||||
terminal: Final = terminal_models[terminal_type].model_validate(
|
||||
{"type": terminal_type, "sequence_number": 2, "response": response}
|
||||
)
|
||||
|
||||
async def upstream() -> AsyncIterator[BaseModel]:
|
||||
for event in (created, delta, terminal):
|
||||
yield event
|
||||
|
||||
frames: Final = tuple(
|
||||
[
|
||||
frame.decode() if isinstance(frame, bytes) else frame
|
||||
async for frame in select_data_generator(
|
||||
response=upstream(), user_api_key_dict=_user_auth(), request_data={}, responses_stream_errors=True
|
||||
)
|
||||
]
|
||||
)
|
||||
assert len(frames) == 3
|
||||
for frame, event_type in zip(
|
||||
frames, ("response.created", "response.output_text.delta", terminal_type), strict=True
|
||||
):
|
||||
assert frame.startswith(f"event: {event_type}\n")
|
||||
payload: Final = json.loads(
|
||||
next(line.removeprefix("data: ") for line in frame.splitlines() if line.startswith("data: "))
|
||||
)
|
||||
assert payload["type"] == event_type
|
||||
assert json.loads(frames[1].split("data: ", 1)[1])["delta"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"as_chunk",
|
||||
[lambda event: event, lambda event: event.model_dump(), lambda event: MappingProxyType(event.model_dump())],
|
||||
ids=["model", "dict", "mapping_proxy"],
|
||||
)
|
||||
async def test_responses_stream_sends_no_failure_event_after_an_upstream_error_event(
|
||||
as_chunk: Callable[[ErrorEvent], BaseModel | Mapping[str, object]],
|
||||
) -> None:
|
||||
response: Final = ResponsesAPIResponse(id="resp_error_then_raise", created_at=1, model="local-model", output=[])
|
||||
created: Final = ResponseCreatedEvent.model_validate(
|
||||
{"type": "response.created", "sequence_number": 0, "response": response}
|
||||
)
|
||||
error_event: Final = ErrorEvent.model_validate(
|
||||
{"type": "error", "sequence_number": 1, "code": "server_error", "message": "upstream failed", "param": None}
|
||||
)
|
||||
|
||||
async def upstream() -> AsyncIterator[BaseModel | Mapping[str, object]]:
|
||||
yield created
|
||||
yield as_chunk(error_event)
|
||||
raise litellm.APIError(status_code=500, message="Stream close failed", llm_provider="openai", model="local-model")
|
||||
|
||||
frames: Final = tuple(
|
||||
[
|
||||
frame.decode() if isinstance(frame, bytes) else frame
|
||||
async for frame in select_data_generator(
|
||||
response=upstream(), user_api_key_dict=_user_auth(), request_data={}, responses_stream_errors=True
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert tuple(frame.split("\n", 1)[0] for frame in frames) == ("event: response.created", "event: error")
|
||||
assert json.loads(frames[-1].split("data: ", 1)[1])["message"] == "upstream failed"
|
||||
|
||||
|
||||
class _HookOutputItem(BaseModel, frozen=True):
|
||||
id: str
|
||||
type: str
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
MappingProxyType({"id": "msg_nested", "type": "message"}),
|
||||
_HookOutputItem(id="msg_nested", type="message"),
|
||||
],
|
||||
ids=["mapping_proxy", "model"],
|
||||
)
|
||||
def test_responses_sse_chunk_encodes_values_nested_in_a_hook_chunk_as_json_objects(
|
||||
item: Mapping[str, object] | BaseModel,
|
||||
) -> None:
|
||||
frame: Final = _format_responses_sse_chunk(
|
||||
chunk=MappingProxyType({"type": "response.output_item.added", "output_index": 0, "item": item})
|
||||
)
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert json.loads(frame.removeprefix("data: "))["item"] == {"id": "msg_nested", "type": "message"}
|
||||
|
||||
|
||||
def test_responses_error_event_encodes_nested_error_fields_as_json_objects() -> None:
|
||||
frame: Final = ResponsesStreamErrorState().format_error(
|
||||
{
|
||||
"message": "overloaded",
|
||||
"provider_specific_fields": MappingProxyType({"upstream": MappingProxyType({"type": "overloaded_error"})}),
|
||||
}
|
||||
)
|
||||
|
||||
payload: Final = json.loads(frame.split("data: ", 1)[1])
|
||||
assert payload["error"]["provider_specific_fields"] == {"upstream": {"type": "overloaded_error"}}
|
||||
|
||||
|
||||
_UPSTREAM_HEADERS: Final = httpx.Headers({"authorization": "Bearer hook-canary", "x-request-id": "req_1"})
|
||||
|
||||
|
||||
def _responses_frame_payload(nested: object) -> Mapping[str, object]:
|
||||
chunk_frame: Final = _format_responses_sse_chunk(
|
||||
chunk=MappingProxyType({"type": "response.output_text.delta", "nested": nested})
|
||||
)
|
||||
error_frame: Final = ResponsesStreamErrorState().format_error({"message": "blocked", "nested": nested})
|
||||
assert isinstance(chunk_frame, str)
|
||||
chunk_payload: Final = json.loads(chunk_frame.removeprefix("data: "))
|
||||
error_payload: Final = json.loads(error_frame.split("data: ", 1)[1])
|
||||
assert chunk_payload["nested"] == error_payload["error"]["nested"]
|
||||
return chunk_payload
|
||||
|
||||
|
||||
class _HiddenFieldModel(BaseModel, frozen=True):
|
||||
user: str
|
||||
password: str = Field(repr=False)
|
||||
|
||||
|
||||
class _MaskedReprModel(BaseModel, frozen=True):
|
||||
token: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "_MaskedReprModel(token=[secure])"
|
||||
|
||||
|
||||
class _MaskedReprStrModel(BaseModel, frozen=True):
|
||||
token: str
|
||||
|
||||
def __repr_str__(self, join_str: str) -> str:
|
||||
return "token=[secure]"
|
||||
|
||||
|
||||
class _MaskedReprArgsModel(BaseModel, frozen=True):
|
||||
token: str
|
||||
|
||||
def __repr_args__(self) -> Iterable[tuple[str | None, object]]:
|
||||
return (("token", "[secure]"),)
|
||||
|
||||
|
||||
class _MaskedStrModel(BaseModel, frozen=True):
|
||||
token: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "_MaskedStrModel(token=[secure])"
|
||||
|
||||
|
||||
class _MaskedReprString(str):
|
||||
def __repr__(self) -> str:
|
||||
return "_MaskedReprString([secure])"
|
||||
|
||||
|
||||
_REDACTED_HEADERS: Final = RedactedDict({"authorization": "Bearer hook-canary"})
|
||||
|
||||
_MASKED_IN_EVERY_FRAME: Final = (
|
||||
_UPSTREAM_HEADERS,
|
||||
_REDACTED_HEADERS,
|
||||
MappingProxyType({"headers": _REDACTED_HEADERS}),
|
||||
MappingProxyType(_REDACTED_HEADERS),
|
||||
_HiddenFieldModel(user="rig", password="hook-canary"),
|
||||
_MaskedReprStrModel(token="hook-canary"),
|
||||
_MaskedReprArgsModel(token="hook-canary"),
|
||||
)
|
||||
_MASKED_IN_EVERY_FRAME_IDS: Final = (
|
||||
"header_mapping",
|
||||
"redacted_dict",
|
||||
"proxy_over_dict_holding_redacted_dict",
|
||||
"proxy_wrapping_redacted_dict",
|
||||
"repr_false_field",
|
||||
"masked_repr_str_model",
|
||||
"masked_repr_args_model",
|
||||
)
|
||||
|
||||
|
||||
class _UnmaskingDict(dict[str, str]):
|
||||
def items(self) -> ItemsView[str, str]: # pyright: ignore[reportIncompatibleMethodOverride] # rig hides storage
|
||||
return {key: "hook-canary" for key in dict.keys(self)}.items()
|
||||
|
||||
|
||||
class _UnmaskingList(list[str]):
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(("hook-canary",))
|
||||
|
||||
|
||||
class _MostCommonMaskedCounter(Counter[str]):
|
||||
def most_common(self, n: int | None = None) -> list[tuple[str, int]]:
|
||||
return [(key, count) for key, count in super().most_common(n) if key != "hook-canary"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"nested",
|
||||
[
|
||||
*_MASKED_IN_EVERY_FRAME,
|
||||
_MaskedReprModel(token="hook-canary"),
|
||||
_MaskedReprString("hook-canary"),
|
||||
_UnmaskingDict(token="enc:MASKED"),
|
||||
_UnmaskingList(("enc:MASKED",)),
|
||||
_MostCommonMaskedCounter(("shown", "hook-canary")),
|
||||
],
|
||||
ids=[
|
||||
*_MASKED_IN_EVERY_FRAME_IDS,
|
||||
"masked_repr_model",
|
||||
"masked_repr_string",
|
||||
"dict_items_differ_from_storage",
|
||||
"list_iter_differs_from_storage",
|
||||
"counter_most_common_masks",
|
||||
],
|
||||
)
|
||||
def test_responses_sse_chunk_never_shows_what_a_nested_value_hides_from_its_repr(nested: object) -> None:
|
||||
frame: Final = _format_responses_sse_chunk(
|
||||
chunk=MappingProxyType({"type": "response.output_text.delta", "nested": nested})
|
||||
)
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert json.loads(frame.removeprefix("data: "))["type"] == "response.output_text.delta"
|
||||
assert "hook-canary" not in frame
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"nested",
|
||||
[*_MASKED_IN_EVERY_FRAME, _MaskedStrModel(token="hook-canary")],
|
||||
ids=[*_MASKED_IN_EVERY_FRAME_IDS, "masked_str_model"],
|
||||
)
|
||||
def test_responses_error_event_never_shows_what_a_nested_value_hides_from_its_string_form(nested: object) -> None:
|
||||
frame: Final = ResponsesStreamErrorState().format_error(
|
||||
{"message": "blocked", "provider_specific_fields": {"guardrail": "rig-guard", "detail": nested}}
|
||||
)
|
||||
|
||||
payload: Final = json.loads(frame.split("data: ", 1)[1])
|
||||
assert payload["error"]["provider_specific_fields"]["guardrail"] == "rig-guard"
|
||||
assert "hook-canary" not in frame
|
||||
|
||||
|
||||
class _OwnFormatDict(dict[str, str]):
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return json.dumps({"type": "response.output_text.delta", "token": "MASKED"})
|
||||
|
||||
|
||||
class _MaskedStrDict(dict[str, str]):
|
||||
def __str__(self) -> str:
|
||||
return "{'type': 'response.output_text.delta', 'token': 'MASKED'}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chunk",
|
||||
[
|
||||
_OwnFormatDict(type="response.output_text.delta", token="hook-canary"),
|
||||
MappingProxyType(_MaskedStrDict(type="response.output_text.delta", token="hook-canary")),
|
||||
],
|
||||
ids=["own_format", "proxy_over_masked_str"],
|
||||
)
|
||||
def test_responses_sse_chunk_sends_a_whole_chunk_as_the_text_its_printer_gave(chunk: object) -> None:
|
||||
frame: Final = _format_responses_sse_chunk(chunk=chunk)
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert "hook-canary" not in frame
|
||||
|
||||
|
||||
class _RigTuple(tuple[object, ...]):
|
||||
pass
|
||||
|
||||
|
||||
class _RevealingReprModel(BaseModel, frozen=True):
|
||||
a: int
|
||||
_note: str = PrivateAttr(default="hook-canary")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"_RevealingReprModel(note={self._note})"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"detail",
|
||||
[
|
||||
OrderedDict(member=_MaskedStrModel(token="hook-canary")),
|
||||
_RigTuple((_MaskedStrModel(token="hook-canary"),)),
|
||||
_HookOutputItem(id="msg_nested", type="message"),
|
||||
_RevealingReprModel(a=1),
|
||||
],
|
||||
ids=["dict_subclass", "tuple_subclass", "model", "model_with_own_repr"],
|
||||
)
|
||||
def test_responses_error_event_sends_a_detail_the_way_base_json_dumps_did(detail: object) -> None:
|
||||
frame: Final = ResponsesStreamErrorState().format_error(
|
||||
{"message": "blocked", "provider_specific_fields": {"detail": detail}}
|
||||
)
|
||||
|
||||
payload: Final = json.loads(frame.split("data: ", 1)[1])
|
||||
assert payload["error"]["provider_specific_fields"]["detail"] == json.loads(json.dumps(detail, default=str))
|
||||
|
||||
|
||||
class _RigColor(Enum):
|
||||
RED = 1
|
||||
|
||||
|
||||
class _Score(float):
|
||||
pass
|
||||
|
||||
|
||||
class _MaskedCount(int):
|
||||
def __repr__(self) -> str:
|
||||
return "_MaskedCount([secure])"
|
||||
|
||||
|
||||
_WHEN: Final = datetime(2026, 10, 30, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("nested", "printed", "dumped"),
|
||||
[
|
||||
(_WHEN, repr(_WHEN), str(_WHEN)),
|
||||
(_RigColor.RED, repr(_RigColor.RED), str(_RigColor.RED)),
|
||||
(_MaskedCount(7), repr(_MaskedCount(7)), 7),
|
||||
],
|
||||
ids=["datetime", "enum", "number_subclass_with_its_own_repr"],
|
||||
)
|
||||
def test_responses_stream_frames_print_values_json_cannot_take_as_base_printed_them(
|
||||
nested: object, printed: str, dumped: object
|
||||
) -> None:
|
||||
chunk_frame: Final = _format_responses_sse_chunk(
|
||||
chunk=MappingProxyType({"type": "response.output_text.delta", "nested": nested})
|
||||
)
|
||||
error_frame: Final = ResponsesStreamErrorState().format_error({"message": "blocked", "nested": nested})
|
||||
|
||||
assert isinstance(chunk_frame, str)
|
||||
assert json.loads(chunk_frame.removeprefix("data: "))["nested"] == printed
|
||||
assert json.loads(error_frame.split("data: ", 1)[1])["error"]["nested"] == dumped
|
||||
|
||||
|
||||
class _RigCode(str, Enum):
|
||||
BLOCKED = "blocked_by_rig"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("nested", "plain"),
|
||||
[(_RigCode.BLOCKED, "blocked_by_rig"), (HTTPStatus.BAD_REQUEST, 400), (_Score(0.5), 0.5)],
|
||||
ids=["str_enum", "int_enum", "float_subclass"],
|
||||
)
|
||||
def test_responses_stream_frames_send_string_and_number_subclasses_as_their_plain_value(
|
||||
nested: object, plain: object
|
||||
) -> None:
|
||||
assert _responses_frame_payload(nested)["nested"] == plain
|
||||
|
||||
|
||||
_DUMPED_DELTA: Final = OutputTextDeltaEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
|
||||
item_id="msg_1",
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
delta="hi",
|
||||
sequence_number=3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mapping", [dict, MappingProxyType], ids=["dict", "mapping_proxy"])
|
||||
def test_responses_sse_chunk_a_hook_dumped_from_an_event_streams_as_that_event(
|
||||
mapping: Callable[[Mapping[str, object]], Mapping[str, object]],
|
||||
) -> None:
|
||||
frame: Final = _format_responses_sse_chunk(chunk=mapping(_DUMPED_DELTA.model_dump()))
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert json.loads(frame.removeprefix("data: ")) == json.loads(_DUMPED_DELTA.model_dump_json())
|
||||
|
||||
|
||||
_COMPLETED_WITH_SDK_OUTPUT: Final = ResponseCompletedEvent(
|
||||
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
|
||||
sequence_number=4,
|
||||
response=ResponsesAPIResponse(
|
||||
id="resp_1",
|
||||
object="response",
|
||||
created_at=0,
|
||||
model="gpt-x",
|
||||
status="completed",
|
||||
output=[
|
||||
ResponseOutputMessage(
|
||||
id="msg_1",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText(type="output_text", text="hi", annotations=[])],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_responses_sse_chunk_a_hook_copied_from_an_event_streams_as_that_event() -> None:
|
||||
frame: Final = _format_responses_sse_chunk(chunk=dict(_COMPLETED_WITH_SDK_OUTPUT))
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert json.loads(frame.removeprefix("data: ")) == json.loads(_COMPLETED_WITH_SDK_OUTPUT.model_dump_json())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mapping", [dict, MappingProxyType], ids=["dict", "mapping_proxy"])
|
||||
def test_responses_stream_frames_turn_mapping_keys_json_cannot_take_into_strings(
|
||||
mapping: Callable[[Mapping[object, object]], Mapping[object, object]],
|
||||
) -> None:
|
||||
assert _responses_frame_payload(mapping({("a", "b"): 1}))["nested"] == {repr(("a", "b")): 1}
|
||||
|
||||
|
||||
def test_responses_sse_chunk_turns_top_level_keys_json_cannot_take_into_strings() -> None:
|
||||
frame: Final = _format_responses_sse_chunk(
|
||||
chunk=MappingProxyType({"type": "response.output_text.delta", ("a", "b"): "x"})
|
||||
)
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert json.loads(frame.removeprefix("data: ")) == {"type": "response.output_text.delta", repr(("a", "b")): "x"}
|
||||
|
||||
|
||||
def _self_referencing_chunk() -> Mapping[str, object]:
|
||||
chunk: Final[dict[str, object]] = {"type": "response.output_text.delta"}
|
||||
chunk["self"] = chunk # mutable-ok: a self-reference needs the dict to exist before it can hold itself
|
||||
return chunk
|
||||
|
||||
|
||||
def test_responses_stream_frames_print_a_value_that_holds_itself_as_its_text() -> None:
|
||||
nested: Final = _self_referencing_chunk()
|
||||
|
||||
assert _responses_frame_payload(nested)["nested"] == {"type": "response.output_text.delta", "self": repr(nested)}
|
||||
|
||||
|
||||
class _UnprintableValue:
|
||||
def __repr__(self) -> str:
|
||||
raise RuntimeError("no string form")
|
||||
|
||||
|
||||
def test_responses_stream_frames_leave_out_only_the_nested_value_with_no_string_form() -> None:
|
||||
chunk_frame: Final = _format_responses_sse_chunk(
|
||||
chunk={"type": "response.output_text.delta", "meta": {"guardrail": "rig-guard", "bad": _UnprintableValue()}}
|
||||
)
|
||||
error_frame: Final = ResponsesStreamErrorState().format_error(
|
||||
{
|
||||
"message": "blocked",
|
||||
"code": "400",
|
||||
"type": "guardrail_error",
|
||||
"provider_specific_fields": {"guardrail": "rig-guard", "bad": _UnprintableValue()},
|
||||
}
|
||||
)
|
||||
|
||||
assert isinstance(chunk_frame, str)
|
||||
assert json.loads(chunk_frame.removeprefix("data: "))["meta"] == {"guardrail": "rig-guard"}
|
||||
assert json.loads(error_frame.split("data: ", 1)[1])["error"] == {
|
||||
"message": "blocked",
|
||||
"code": "400",
|
||||
"type": "guardrail_error",
|
||||
"provider_specific_fields": {"guardrail": "rig-guard"},
|
||||
}
|
||||
|
||||
|
||||
def test_responses_error_event_keeps_a_detail_nested_past_the_recursion_limit() -> None:
|
||||
nested: Final = functools.reduce(lambda inner, _: {"x": inner}, range(5000), {"leaf": 1})
|
||||
frame: Final = ResponsesStreamErrorState().format_error(
|
||||
{"message": "blocked", "provider_specific_fields": {"guardrail": "rig-guard", "detail": nested}}
|
||||
)
|
||||
|
||||
payload: Final = json.loads(frame.split("data: ", 1)[1])
|
||||
assert payload["error"]["provider_specific_fields"]["guardrail"] == "rig-guard"
|
||||
assert "'leaf': 1" in frame
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chunk",
|
||||
[
|
||||
_UPSTREAM_HEADERS,
|
||||
RedactedDict({"type": "response.output_text.delta", "authorization": "Bearer hook-canary"}),
|
||||
MappingProxyType(RedactedDict({"type": "response.output_text.delta", "authorization": "Bearer hook-canary"})),
|
||||
],
|
||||
ids=["header_mapping_chunk", "redacted_dict_chunk", "proxy_wrapping_redacted_dict_chunk"],
|
||||
)
|
||||
def test_responses_sse_chunk_that_is_not_a_plain_mapping_streams_as_the_plain_data_frame(
|
||||
chunk: Mapping[str, object],
|
||||
) -> None:
|
||||
assert _format_responses_sse_chunk(chunk=chunk) == _format_streaming_sse_chunk(chunk=chunk)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("guardrail_error", "error_object"),
|
||||
[
|
||||
(
|
||||
{"message": "response withheld", "type": "guardrail_scan_error", "code": 500},
|
||||
{"message": "response withheld", "type": "guardrail_scan_error", "code": 500},
|
||||
),
|
||||
("response withheld", {"message": "response withheld"}),
|
||||
],
|
||||
)
|
||||
async def test_a_guardrail_error_string_mid_responses_stream_ends_it_as_a_named_error_event(
|
||||
guardrail_error: Mapping[str, object] | str, error_object: Mapping[str, object]
|
||||
) -> None:
|
||||
response: Final = ResponsesAPIResponse(id="resp_hook_error", created_at=1, model="local-model", output=[])
|
||||
created: Final = ResponseCreatedEvent.model_validate(
|
||||
{"type": "response.created", "sequence_number": 0, "response": response}
|
||||
)
|
||||
|
||||
async def upstream() -> AsyncIterator[object]:
|
||||
yield created
|
||||
yield f"data: {json.dumps({'error': guardrail_error})}\n\n"
|
||||
|
||||
frames: Final = tuple(
|
||||
[
|
||||
frame.decode() if isinstance(frame, bytes) else frame
|
||||
async for frame in select_data_generator(
|
||||
response=upstream(), user_api_key_dict=_user_auth(), request_data={}, responses_stream_errors=True
|
||||
)
|
||||
]
|
||||
)
|
||||
event_line, data_line = frames[-1].strip().split("\n")
|
||||
|
||||
assert "data: [DONE]" not in "".join(frames)
|
||||
assert event_line == "event: error"
|
||||
assert json.loads(data_line.removeprefix("data: ")) == {
|
||||
"type": "error",
|
||||
"code": "server_error",
|
||||
"message": "response withheld",
|
||||
"param": None,
|
||||
"sequence_number": 1,
|
||||
"error": error_object,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("binary", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
("event_type", "event_line"),
|
||||
[
|
||||
(None, ""),
|
||||
("", ""),
|
||||
("response.delta\ninjected", ""),
|
||||
("response.delta\rinjected", ""),
|
||||
("response.delta\r\ninjected", ""),
|
||||
("response.vendor_delta", "event: response.vendor_delta\n"),
|
||||
],
|
||||
)
|
||||
def test_responses_event_names_preserve_payload_without_injecting_sse_lines(
|
||||
event_type: str | None, event_line: str, binary: bool
|
||||
) -> None:
|
||||
payload: Final = {"type": event_type, "sequence_number": 0}
|
||||
data_line: Final = f"data: {json.dumps(payload)}\n\n"
|
||||
frame: Final = data_line.encode("utf-8") if binary else data_line
|
||||
expected: Final = (event_line + data_line).encode("utf-8") if binary else event_line + data_line
|
||||
state: Final = ResponsesStreamErrorState()
|
||||
state.observe_chunk(payload)
|
||||
|
||||
assert state.mark_emitted(frame) == expected
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, chunks, hidden_params=None):
|
||||
self._chunks = chunks
|
||||
|
|
@ -1165,14 +1744,15 @@ async def test_responses_stream_failure_sends_the_failure_hook_replacement(monke
|
|||
]
|
||||
)
|
||||
failure: Final = ResponseFailedEvent.model_validate_json(
|
||||
next(line.removeprefix("data: ") for line in frames[-2].splitlines() if line.startswith("data: "))
|
||||
next(line.removeprefix("data: ") for line in frames[-1].splitlines() if line.startswith("data: "))
|
||||
)
|
||||
|
||||
assert _BLOCK_MESSAGE not in "".join(frames)
|
||||
assert failure.response.error is not None
|
||||
assert failure.response.error["code"] == "permission_error"
|
||||
assert "Request blocked by policy" in failure.response.error["message"]
|
||||
assert frames[-1] == "data: [DONE]\n\n"
|
||||
assert frames[-1].startswith("event: response.failed\n")
|
||||
assert "data: [DONE]\n\n" not in frames
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1368,8 +1948,8 @@ async def test_responses_stream_keeps_tool_deltas_and_only_emits_a_valid_termina
|
|||
for frame in event_frames
|
||||
)
|
||||
|
||||
assert decoded[-1] == "data: [DONE]\n\n"
|
||||
assert len(decoded) == len(event_frames) + 1
|
||||
assert len(decoded) == len(event_frames)
|
||||
assert "data: [DONE]\n\n" not in decoded
|
||||
assert payloads[0]["response"]["id"] == "resp_visible"
|
||||
assert payloads[1] == tool_delta.model_dump()
|
||||
assert len(payloads) == 3
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import datetime
|
|||
import json
|
||||
import re
|
||||
import unittest
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from typing import Any, Final, Literal, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ import litellm
|
|||
from litellm.caching.caching import DualCache
|
||||
from litellm.constants import EMPTY_MAPPING
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
|
@ -35,6 +36,7 @@ from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
|||
from litellm.types.router import RouterErrors
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
from tests._master_key import MASTER_KEY
|
||||
from tests._support.clock_loops import AutojumpClockLoop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -153,8 +155,9 @@ async def test_streaming_upstream_errors_keep_the_client_protocol(
|
|||
assert result.status_code == 200, result.text
|
||||
assert message in result.text
|
||||
if path == "/v1/responses":
|
||||
assert frames[-1] == "data: [DONE]", result.text
|
||||
assert frames[-2].startswith("event: response.failed\n"), result.text
|
||||
assert all("data: [DONE]" not in frame for frame in frames), result.text
|
||||
assert frames[-1].startswith("event: response.failed\n"), result.text
|
||||
assert [frame.splitlines()[0] for frame in frames] == [f"event: {event['type']}" for event in events]
|
||||
if partial:
|
||||
assert [event["type"] for event in events] == [
|
||||
"response.created",
|
||||
|
|
@ -187,6 +190,173 @@ async def test_streaming_upstream_errors_keep_the_client_protocol(
|
|||
assert "error" in events[-1]
|
||||
|
||||
|
||||
class _FirstFrameHook(CustomLogger):
|
||||
def __init__(self, first: str | bytes | Exception) -> None:
|
||||
super().__init__()
|
||||
self.first: Final = first
|
||||
|
||||
async def async_post_call_streaming_iterator_hook(
|
||||
self, user_api_key_dict: object, response: object, request_data: object
|
||||
) -> AsyncIterator[str | bytes]:
|
||||
if isinstance(self.first, Exception):
|
||||
raise self.first
|
||||
yield self.first
|
||||
|
||||
|
||||
_DATA_ERROR_FRAME: Final = 'data: {"error": {"message": "denied", "type": "guardrail_scan_error", "code": 403}}\n\n'
|
||||
_NAMED_ERROR_FRAME: Final = (
|
||||
b'event: error\ndata: {"type": "error", "error": {"type": "overloaded_error", "message": "denied", "code": "529"}}\n\n'
|
||||
)
|
||||
|
||||
|
||||
async def _post_through_first_frame_hook(
|
||||
monkeypatch: pytest.MonkeyPatch, path: str, first: str | bytes | Exception
|
||||
) -> httpx.Response:
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
model: Final = "gpt-6-astra"
|
||||
upstream_url: Final = "https://streaming.example/v1"
|
||||
is_chat: Final = path == "/v1/chat/completions"
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": model,
|
||||
"litellm_params": {"model": "openai/" + model, "api_base": upstream_url, "api_key": "fixture-key"},
|
||||
}
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "callbacks", [_FirstFrameHook(first)])
|
||||
monkeypatch.setitem(app.dependency_overrides, user_api_key_auth, _auth_override)
|
||||
with respx.mock as transport:
|
||||
transport.post(upstream_url + ("/chat/completions" if is_chat else "/responses")).respond(
|
||||
200, content="data: [DONE]\n\n", headers={"Content-Type": "text/event-stream"}
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://testserver") as client:
|
||||
return await client.post(
|
||||
path,
|
||||
json={
|
||||
"model": model,
|
||||
"stream": True,
|
||||
**({"messages": [{"role": "user", "content": "hello"}]} if is_chat else {"input": "hello"}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path,first_frame,expected_status",
|
||||
[
|
||||
("/v1/responses", _DATA_ERROR_FRAME, 403),
|
||||
("/v1/chat/completions", _DATA_ERROR_FRAME, 403),
|
||||
("/v1/chat/completions", _NAMED_ERROR_FRAME, 200),
|
||||
("/cursor/chat/completions", _NAMED_ERROR_FRAME, 200),
|
||||
("/v1/responses", _NAMED_ERROR_FRAME, 529),
|
||||
("/v1/responses", _NAMED_ERROR_FRAME.replace(b"event: error", b"event: response.created"), 200),
|
||||
("/v1/chat/completions", _DATA_ERROR_FRAME + "data: [DONE]\n\n", 200),
|
||||
("/v1/responses", _DATA_ERROR_FRAME + "data: [DONE]\n\n", 200),
|
||||
],
|
||||
)
|
||||
async def test_only_a_lone_first_error_frame_answers_with_its_status(
|
||||
monkeypatch: pytest.MonkeyPatch, path: str, first_frame: str | bytes, expected_status: int
|
||||
) -> None:
|
||||
result: Final = await _post_through_first_frame_hook(monkeypatch, path, first_frame)
|
||||
|
||||
assert result.status_code == expected_status, result.text
|
||||
if expected_status == 200:
|
||||
assert result.headers["content-type"].startswith("text/event-stream"), result.text
|
||||
else:
|
||||
assert result.json()["error"]["message"] == "denied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", ["/cursor/chat/completions", "/v1/chat/completions"])
|
||||
async def test_a_hook_failure_before_the_first_chunk_answers_in_chat_completions_shape(
|
||||
monkeypatch: pytest.MonkeyPatch, path: str
|
||||
) -> None:
|
||||
message: Final = "rate limited before the first chunk"
|
||||
result: Final = await _post_through_first_frame_hook(
|
||||
monkeypatch, path, HTTPException(status_code=429, detail=message)
|
||||
)
|
||||
frames: Final = tuple(frame for frame in result.text.split("\n\n") if frame)
|
||||
|
||||
assert result.status_code == 429, result.text
|
||||
assert frames[-1] == "data: [DONE]", result.text
|
||||
assert message in json.loads(frames[0].removeprefix("data: "))["error"]["message"]
|
||||
assert len(frames) == 2, result.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/responses", "/v1/chat/completions"])
|
||||
def test_a_failure_after_keepalive_pings_keeps_the_client_protocol(monkeypatch: pytest.MonkeyPatch, path: str) -> None:
|
||||
async def request_and_check() -> None:
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
model: Final = "gpt-6-astra"
|
||||
message: Final = "Upstream is rate limiting this key"
|
||||
upstream_url: Final = "https://streaming.example/v1"
|
||||
is_chat: Final = path == "/v1/chat/completions"
|
||||
|
||||
async def slow_rate_limit(_: httpx.Request) -> httpx.Response:
|
||||
await asyncio.sleep(0.3)
|
||||
return httpx.Response(429, json={"error": {"message": message, "type": "rate_limit_error", "code": None}})
|
||||
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": model,
|
||||
"litellm_params": {
|
||||
"model": "openai/" + model,
|
||||
"api_base": upstream_url,
|
||||
"api_key": "fixture-key",
|
||||
"max_retries": 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", 0.05)
|
||||
monkeypatch.setitem(app.dependency_overrides, user_api_key_auth, _auth_override)
|
||||
with respx.mock as transport:
|
||||
transport.post(upstream_url + ("/chat/completions" if is_chat else "/responses")).mock(
|
||||
side_effect=slow_rate_limit
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://testserver") as client:
|
||||
result: Final = await client.post(
|
||||
path,
|
||||
json={
|
||||
"model": model,
|
||||
"stream": True,
|
||||
**({"messages": [{"role": "user", "content": "hello"}]} if is_chat else {"input": "hello"}),
|
||||
},
|
||||
)
|
||||
frames: Final = tuple(frame for frame in result.text.split("\n\n") if frame)
|
||||
|
||||
assert result.status_code == 200, result.text
|
||||
assert frames[0] == ": ping", result.text
|
||||
if is_chat:
|
||||
assert frames[-1] == "data: [DONE]", result.text
|
||||
assert message in json.loads(frames[-2].removeprefix("data: "))["error"]["message"]
|
||||
else:
|
||||
assert "data: [DONE]" not in result.text
|
||||
assert frames[-1].startswith("event: error\ndata: "), result.text
|
||||
error: Final = json.loads(frames[-1].split("data: ", 1)[1])["error"]
|
||||
assert error["code"] == "429"
|
||||
assert message in error["message"]
|
||||
|
||||
loop: Final = AutojumpClockLoop()
|
||||
try:
|
||||
loop.run_until_complete(request_and_check())
|
||||
finally:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
|
||||
|
||||
_COMPACTION_ITEM: Final = {
|
||||
"type": "compaction",
|
||||
"encrypted_content": json.dumps({"type": "compaction", "content": "the cat is named Biscuit"}),
|
||||
|
|
@ -5756,9 +5926,11 @@ class TestGuardrailBlockedResponsesShape:
|
|||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
frames = _sse_data_frames(response.text)
|
||||
assert frames[-1] == "[DONE]"
|
||||
events = [json.loads(frame) for frame in frames[:-1]]
|
||||
assert "[DONE]" not in frames
|
||||
events = [json.loads(frame) for frame in frames]
|
||||
types = [event["type"] for event in events]
|
||||
assert types[-1] == "response.completed"
|
||||
assert [line[7:] for line in response.text.splitlines() if line.startswith("event: ")] == types
|
||||
assert "response.created" in types
|
||||
completed = [event for event in events if event["type"] == "response.completed"]
|
||||
assert len(completed) == 1
|
||||
|
|
@ -5785,7 +5957,8 @@ class TestGuardrailBlockedResponsesShape:
|
|||
|
||||
assert response.status_code == 200, response.text
|
||||
frames = _sse_data_frames(response.text)
|
||||
completed = [json.loads(frame) for frame in frames[:-1] if json.loads(frame)["type"] == "response.completed"]
|
||||
assert "[DONE]" not in frames
|
||||
completed = [json.loads(frame) for frame in frames if json.loads(frame)["type"] == "response.completed"]
|
||||
usage = completed[0]["response"]["usage"]
|
||||
assert usage["input_tokens"] == 14
|
||||
assert usage["output_tokens"] == 20
|
||||
|
|
|
|||
|
|
@ -16,12 +16,13 @@ following the OpenAI Response API format.
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Final, Optional
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy.response_polling.polling_handler import ResponsePollingHandler
|
||||
|
|
@ -1524,6 +1525,145 @@ class TestBackgroundStreamingTerminalEvents:
|
|||
assert final_call.kwargs["status"] == "failed"
|
||||
assert final_call.kwargs["error"] == error_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_late_failure_after_keepalive_pings_sets_failed_status_and_error(self):
|
||||
from litellm.proxy.common_request_processing import RESPONSES_LATE_SSE_FRAMING
|
||||
from litellm.proxy.response_polling.background_streaming import (
|
||||
background_streaming_task,
|
||||
)
|
||||
|
||||
late_frames: Final = RESPONSES_LATE_SSE_FRAMING.failure(HTTPException(status_code=429, detail="rate limited"))
|
||||
|
||||
async def _body_iterator():
|
||||
yield b": ping\n\n"
|
||||
for frame in late_frames:
|
||||
yield frame.encode()
|
||||
|
||||
mock_response: Final = Mock()
|
||||
mock_response.body_iterator = _body_iterator()
|
||||
handler: Final = AsyncMock(spec=ResponsePollingHandler)
|
||||
kwargs: Final = _make_background_streaming_kwargs("poll_late_failure", handler)
|
||||
|
||||
with patch( # test-quality-ok: the processor is built inside the task, same idiom as the sibling tests
|
||||
"litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
|
||||
) as MockProcessor:
|
||||
MockProcessor.return_value.base_process_llm_request = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
await background_streaming_task(**kwargs)
|
||||
|
||||
final_call: Final = handler.update_state.call_args_list[-1]
|
||||
assert final_call.kwargs["status"] == "failed"
|
||||
assert final_call.kwargs["error"] == {"code": "rate_limit_exceeded", "message": "rate limited"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("nested_error", "expected_error"),
|
||||
[
|
||||
(
|
||||
{"type": "guardrail_error", "code": "400", "message": "output blocked by policy"},
|
||||
{"code": "invalid_request_error", "message": "output blocked by policy"},
|
||||
),
|
||||
("output blocked by policy", {"code": "server_error", "message": "output blocked by policy"}),
|
||||
],
|
||||
)
|
||||
async def test_a_nested_only_error_event_keeps_its_reason(
|
||||
self, nested_error: Mapping[str, str] | str, expected_error: Mapping[str, str]
|
||||
):
|
||||
from litellm.proxy.response_polling.background_streaming import (
|
||||
background_streaming_task,
|
||||
)
|
||||
|
||||
error_event: Final = {"type": "error", "sequence_number": 9, "error": nested_error}
|
||||
|
||||
async def _body_iterator():
|
||||
yield b'data: {"type": "response.in_progress"}\n\n'
|
||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
|
||||
|
||||
mock_response: Final = Mock()
|
||||
mock_response.body_iterator = _body_iterator()
|
||||
handler: Final = AsyncMock(spec=ResponsePollingHandler)
|
||||
kwargs: Final = _make_background_streaming_kwargs("poll_nested_error", handler)
|
||||
|
||||
with patch( # test-quality-ok: the processor is built inside the task, same idiom as the sibling tests
|
||||
"litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
|
||||
) as MockProcessor:
|
||||
MockProcessor.return_value.base_process_llm_request = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
await background_streaming_task(**kwargs)
|
||||
|
||||
final_call: Final = handler.update_state.call_args_list[-1]
|
||||
assert final_call.kwargs["status"] == "failed"
|
||||
assert final_call.kwargs["error"] == expected_error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "body", "expected_error"),
|
||||
[
|
||||
(
|
||||
404,
|
||||
json.dumps(
|
||||
{"error": {"message": "model not found", "type": "invalid_request_error", "param": None, "code": "404"}}
|
||||
),
|
||||
{"code": "not_found_error", "message": "model not found"},
|
||||
),
|
||||
(429, "rate limited", {"code": "rate_limit_exceeded", "message": "Too Many Requests"}),
|
||||
(
|
||||
403,
|
||||
json.dumps({"error": {"type": "guardrail_scan_error", "code": 403}}),
|
||||
{"code": "permission_error", "message": "Forbidden"},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_a_first_chunk_error_polls_failed_like_a_late_one(
|
||||
self, status_code: int, body: str, expected_error: Mapping[str, str]
|
||||
):
|
||||
from fastapi import Response
|
||||
|
||||
from litellm.proxy.response_polling.background_streaming import (
|
||||
background_streaming_task,
|
||||
)
|
||||
|
||||
first_chunk_error: Final = Response(status_code=status_code, content=body)
|
||||
handler: Final = AsyncMock(spec=ResponsePollingHandler)
|
||||
kwargs: Final = _make_background_streaming_kwargs("poll_first_chunk_error", handler)
|
||||
|
||||
with patch( # test-quality-ok: the processor is built inside the task, same idiom as the sibling tests
|
||||
"litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
|
||||
) as MockProcessor:
|
||||
MockProcessor.return_value.base_process_llm_request = AsyncMock(
|
||||
return_value=first_chunk_error
|
||||
)
|
||||
await background_streaming_task(**kwargs)
|
||||
|
||||
final_call: Final = handler.update_state.call_args_list[-1]
|
||||
assert final_call.kwargs["status"] == "failed"
|
||||
assert final_call.kwargs["error"] == expected_error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_streamed_success_response_still_polls_completed(self):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm.proxy.response_polling.background_streaming import (
|
||||
background_streaming_task,
|
||||
)
|
||||
|
||||
handler: Final = AsyncMock(spec=ResponsePollingHandler)
|
||||
kwargs: Final = _make_background_streaming_kwargs("poll_non_streamed_success", handler)
|
||||
|
||||
with patch( # test-quality-ok: the processor is built inside the task, same idiom as the sibling tests
|
||||
"litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
|
||||
) as MockProcessor:
|
||||
MockProcessor.return_value.base_process_llm_request = AsyncMock(
|
||||
return_value=JSONResponse(status_code=200, content={"id": "resp_123", "status": "completed"})
|
||||
)
|
||||
await background_streaming_task(**kwargs)
|
||||
|
||||
final_call: Final = handler.update_state.call_args_list[-1]
|
||||
assert final_call.kwargs["status"] == "completed"
|
||||
assert final_call.kwargs["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_incomplete_sets_incomplete_status_and_details(self):
|
||||
"""Test that a response.incomplete stream event results in incomplete status"""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import copy
|
|||
import datetime
|
||||
import gzip
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Mapping
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping
|
||||
from types import MappingProxyType, SimpleNamespace
|
||||
from typing import AsyncGenerator, Callable, Final, Iterator, Literal, Optional, Sequence
|
||||
from urllib.parse import unquote_plus
|
||||
|
|
@ -40,6 +40,7 @@ from litellm.constants import (
|
|||
STREAM_SSE_KEEPALIVE_PING_BYTES,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_guardrail import ModifyResponseException
|
||||
from litellm.integrations.opentelemetry import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.common_request_processing import (
|
||||
|
|
@ -52,11 +53,15 @@ from litellm.proxy.common_request_processing import (
|
|||
_ClientDisconnectedBeforeFirstChunk,
|
||||
attach_guardrail_information,
|
||||
_extract_error_from_sse_chunk,
|
||||
FirstChunkSseFraming,
|
||||
_get_cost_breakdown_from_logging_obj,
|
||||
CostBreakdownHeaderValues,
|
||||
_has_attribute_error_in_chain,
|
||||
OPENAI_FIRST_CHUNK_FRAMING,
|
||||
RESPONSES_FIRST_CHUNK_FRAMING,
|
||||
include_guardrail_response_requested,
|
||||
is_azure_model_router_request,
|
||||
RESPONSES_LATE_SSE_FRAMING,
|
||||
open_sse_before_first_byte,
|
||||
resolve_litellm_call_id,
|
||||
ttft_keepalive_interval,
|
||||
|
|
@ -88,6 +93,7 @@ from litellm.router import Router
|
|||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.utils import ModelResponseListIterator
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import prepare_response_for_header_attachment
|
||||
from tests._support.clock_loops import AutojumpClockLoop
|
||||
|
||||
|
||||
def test_attach_guardrail_information_copies_recorded_entries_onto_model_response():
|
||||
|
|
@ -2256,6 +2262,14 @@ class TestProxyBaseLLMRequestProcessing:
|
|||
)
|
||||
|
||||
|
||||
def _fail_stream_setup() -> str:
|
||||
raise RuntimeError("stream setup failed")
|
||||
|
||||
|
||||
async def _stream_failing_before_its_first_chunk() -> AsyncGenerator[str, None]:
|
||||
yield _fail_stream_setup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCommonRequestProcessingHelpers:
|
||||
async def consume_stream(self, streaming_response: StreamingResponse) -> list:
|
||||
|
|
@ -2334,6 +2348,54 @@ class TestCommonRequestProcessingHelpers:
|
|||
assert body["error"]["code"] == 403
|
||||
assert body["error"]["message"] == "forbidden"
|
||||
|
||||
async def test_a_responses_stream_whose_first_event_is_a_named_error_answers_as_json(self):
|
||||
error: Final = {"type": "guardrail_error", "code": "400", "message": "blocked", "param": None}
|
||||
|
||||
async def responses_stream():
|
||||
yield f"event: error\ndata: {json.dumps({'type': 'error', 'sequence_number': 0, 'error': error})}\n\n"
|
||||
|
||||
response: Final = await create_response(
|
||||
responses_stream(), "text/event-stream", {}, framing=RESPONSES_FIRST_CHUNK_FRAMING
|
||||
)
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body) == {"error": error}
|
||||
|
||||
async def test_a_first_event_error_with_a_unicode_line_separator_in_its_message_answers_as_json(self):
|
||||
error: Final = {"type": "guardrail_error", "code": "400", "message": "blocked\u2028by policy", "param": None}
|
||||
|
||||
async def responses_stream():
|
||||
event: Final = json.dumps({"type": "error", "sequence_number": 0, "error": error}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {event}\n\n"
|
||||
|
||||
response: Final = await create_response(
|
||||
responses_stream(), "text/event-stream", {}, framing=RESPONSES_FIRST_CHUNK_FRAMING
|
||||
)
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body) == {"error": error}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"framing,frame_starts",
|
||||
[
|
||||
(OPENAI_FIRST_CHUNK_FRAMING, ('data: {"error": ', "data: [DONE]")),
|
||||
(RESPONSES_FIRST_CHUNK_FRAMING, ('event: error\ndata: {"type": "error", "code": "server_error"',)),
|
||||
],
|
||||
)
|
||||
async def test_a_stream_that_fails_before_its_first_chunk_ends_in_its_own_api_shape(
|
||||
self, framing: FirstChunkSseFraming, frame_starts: tuple[str, ...]
|
||||
) -> None:
|
||||
response: Final = await create_response(
|
||||
_stream_failing_before_its_first_chunk(), "text/event-stream", {}, framing=framing
|
||||
)
|
||||
frames: Final = await self.consume_stream(response)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert len(frames) == len(frame_starts)
|
||||
assert all(frame.startswith(start) for frame, start in zip(frames, frame_starts, strict=True))
|
||||
|
||||
async def test_create_streaming_response_first_chunk_not_error(self):
|
||||
async def mock_generator():
|
||||
yield 'data: {"content": "first part"}\n\n'
|
||||
|
|
@ -10610,6 +10672,104 @@ async def test_ttft_keepalive_relays_a_late_non_streaming_body_as_an_sse_frame()
|
|||
assert collected[-1] == b"data: [DONE]\n\n"
|
||||
|
||||
|
||||
async def _slow_rate_limit_failure() -> Response:
|
||||
await asyncio.sleep(0.2)
|
||||
raise HTTPException(status_code=429, detail="rate limited")
|
||||
|
||||
|
||||
async def _slow_guardrail_block_body() -> Response:
|
||||
await asyncio.sleep(0.2)
|
||||
return JSONResponse(
|
||||
status_code=400, content={"error": {"message": "blocked", "type": "guardrail_error", "code": "400"}}
|
||||
)
|
||||
|
||||
|
||||
_POLICY_BLOCK_DETAIL: Final = MappingProxyType(
|
||||
{"error": {"message": "blocked by policy", "type": "guardrail_error"}, "guardrail": "content-filter"}
|
||||
)
|
||||
|
||||
|
||||
async def _slow_codeless_rate_limit_body() -> Response:
|
||||
await asyncio.sleep(0.2)
|
||||
return JSONResponse(status_code=429, content={"error": {"message": "slow down", "code": None}})
|
||||
|
||||
|
||||
async def _slow_dict_detail_failure() -> Response:
|
||||
await asyncio.sleep(0.2)
|
||||
raise HTTPException(status_code=400, detail=dict(_POLICY_BLOCK_DETAIL))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"produce,expected_code,expected_error",
|
||||
[
|
||||
(_slow_rate_limit_failure, "rate_limit_exceeded", {"message": "rate limited", "code": "429"}),
|
||||
(_slow_codeless_rate_limit_body, "rate_limit_exceeded", {"message": "slow down", "code": "429"}),
|
||||
(
|
||||
_slow_guardrail_block_body,
|
||||
"invalid_request_error",
|
||||
{"message": "blocked", "type": "guardrail_error", "code": "400"},
|
||||
),
|
||||
(
|
||||
_slow_dict_detail_failure,
|
||||
"invalid_request_error",
|
||||
{"message": "blocked by policy", "code": "400", "provider_specific_fields": dict(_POLICY_BLOCK_DETAIL)},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_ttft_keepalive_ends_a_late_responses_failure_with_an_error_event_and_no_done(
|
||||
produce: Callable[[], Awaitable[Response]], expected_code: str, expected_error: Mapping[str, object]
|
||||
) -> None:
|
||||
async def collect_and_check() -> None:
|
||||
response: Final = await open_sse_before_first_byte(
|
||||
produce(), ping_interval_seconds=0.05, late_framing=RESPONSES_LATE_SSE_FRAMING
|
||||
)
|
||||
collected: Final = await _drain(response)
|
||||
|
||||
assert collected[0] == TTFT_PING
|
||||
assert b"data: [DONE]\n\n" not in collected
|
||||
event_line, data_line = collected[-1].decode().strip().split("\n")
|
||||
event: Final = json.loads(data_line.removeprefix("data: "))
|
||||
assert event_line == "event: error"
|
||||
assert event["type"] == "error"
|
||||
assert (event["code"], event["message"]) == (expected_code, expected_error["message"])
|
||||
assert {key: event["error"].get(key) for key in expected_error} == expected_error
|
||||
|
||||
loop: Final = AutojumpClockLoop()
|
||||
try:
|
||||
loop.run_until_complete(collect_and_check())
|
||||
finally:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _slow_passthrough_block() -> Response:
|
||||
await asyncio.sleep(0.2)
|
||||
raise ModifyResponseException(message="blocked by policy", model="gpt-4o", request_data={})
|
||||
|
||||
|
||||
def test_ttft_keepalive_ends_a_late_responses_passthrough_block_with_the_block_response() -> None:
|
||||
async def collect_and_check() -> None:
|
||||
response: Final = await open_sse_before_first_byte(
|
||||
_slow_passthrough_block(), ping_interval_seconds=0.05, late_framing=RESPONSES_LATE_SSE_FRAMING
|
||||
)
|
||||
collected: Final = await _drain(response)
|
||||
frames: Final = tuple(chunk.decode() for chunk in collected if chunk != TTFT_PING)
|
||||
names: Final = tuple(frame.split("\n", 1)[0] for frame in frames)
|
||||
completed: Final = json.loads(frames[-1].split("\n", 1)[1].removeprefix("data: "))
|
||||
|
||||
assert collected[0] == TTFT_PING
|
||||
assert (names[0], names[-1]) == ("event: response.created", "event: response.completed")
|
||||
assert "event: error" not in names
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "blocked by policy"
|
||||
|
||||
loop: Final = AutojumpClockLoop()
|
||||
try:
|
||||
loop.run_until_complete(collect_and_check())
|
||||
finally:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttft_keepalive_closes_the_upstream_stream_it_relayed():
|
||||
"""Starlette never calls the produced response, so its own cleanup never runs
|
||||
|
|
|
|||
|
|
@ -763,7 +763,10 @@ def test_restamp_streaming_chunk_rewrites_nested_model_in_guardrail_block_frames
|
|||
)[0]
|
||||
for frame in frames
|
||||
]
|
||||
events = [json.loads(frame.decode().removeprefix("data: ")) for frame in client_frames]
|
||||
events = [
|
||||
json.loads(next(line[6:] for line in frame.decode().splitlines() if line.startswith("data: ")))
|
||||
for frame in client_frames
|
||||
]
|
||||
|
||||
assert all(frame.endswith(b"\n\n") for frame in client_frames)
|
||||
assert events[-1]["type"] == "response.completed"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue