mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
refactor(messages): log bridged /v1/messages streams when the client stream ends
This commit is contained in:
parent
0e14eecf34
commit
01292cc271
11 changed files with 550 additions and 31 deletions
|
|
@ -24,6 +24,7 @@ from litellm.llms.anthropic.pass_through.context_management import (
|
|||
apply_context_management,
|
||||
)
|
||||
from litellm.llms.anthropic.pass_through.convertible_blocks import reject_malformed_tool_result_text
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import hold_completion_of
|
||||
from litellm.llms.anthropic.pass_through.safeguards import (
|
||||
SafeguardsEvaluator,
|
||||
build_safeguards_evaluator,
|
||||
|
|
@ -814,6 +815,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
is_async=True,
|
||||
litellm_logging_obj=logging_obj,
|
||||
safeguards_evaluator=safeguards_evaluator,
|
||||
held_completion=None if planned_chat is None else hold_completion_of(completion_response, logging_obj),
|
||||
)
|
||||
if transformed_stream is not None:
|
||||
return transformed_stream
|
||||
|
|
@ -962,6 +964,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
polyfill_result=polyfill_result,
|
||||
is_async=False,
|
||||
litellm_logging_obj=logging_obj,
|
||||
held_completion=None if planned_chat is None else hold_completion_of(completion_response, logging_obj),
|
||||
)
|
||||
if transformed_stream is not None:
|
||||
return transformed_stream
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from litellm.types.utils import AdapterCompletionStreamWrapper, ChatCompletionDe
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import HeldStreamCompletion
|
||||
from litellm.llms.anthropic.pass_through.safeguards import SafeguardsEvaluator
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
|
|
@ -518,6 +519,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
iterations_usage: list[UsageIteration] | None = None,
|
||||
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
|
||||
safeguards_evaluator: "SafeguardsEvaluator | None" = None,
|
||||
held_completion: "HeldStreamCompletion | None" = None,
|
||||
):
|
||||
# Wrap the upstream stream so chunks that carry both content and a
|
||||
# finish_reason (fake-streamed providers) are split into two — see
|
||||
|
|
@ -529,6 +531,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id)
|
||||
self.litellm_logging_obj = litellm_logging_obj
|
||||
self.safeguards_evaluator: Final = safeguards_evaluator
|
||||
self._held_completion: Final = held_completion
|
||||
# Mapping of truncated tool names to original names (for OpenAI's 64-char limit)
|
||||
self.tool_name_mapping = tool_name_mapping or {}
|
||||
# Polyfill applied_edits on final message_delta.
|
||||
|
|
@ -1211,14 +1214,18 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
|
||||
This wrapper ensures dict chunks are SSE formatted with both event and data lines.
|
||||
"""
|
||||
for chunk in self:
|
||||
if isinstance(chunk, dict):
|
||||
event_type: str = str(chunk.get("type", "message"))
|
||||
payload = f"event: {event_type}\ndata: {json.dumps(chunk)}\n\n"
|
||||
yield payload.encode()
|
||||
else:
|
||||
# For non-dict chunks, forward the original value unchanged
|
||||
yield chunk
|
||||
try:
|
||||
for chunk in self:
|
||||
if isinstance(chunk, dict):
|
||||
event_type: str = str(chunk.get("type", "message"))
|
||||
payload = f"event: {event_type}\ndata: {json.dumps(chunk)}\n\n"
|
||||
yield payload.encode()
|
||||
else:
|
||||
# For non-dict chunks, forward the original value unchanged
|
||||
yield chunk
|
||||
finally:
|
||||
if self._held_completion is not None:
|
||||
self._held_completion.log(is_async=False)
|
||||
|
||||
async def async_anthropic_sse_wrapper(self) -> AsyncIterator[bytes]:
|
||||
"""
|
||||
|
|
@ -1237,6 +1244,8 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
else:
|
||||
yield anthropic_sse_frame(await safeguard_results.observe(_SSE_EVENT.validate_python(chunk)))
|
||||
except Exception as e: # noqa: BLE001 # boundary before the socket: any upstream failure becomes an Anthropic error event
|
||||
if self._held_completion is not None:
|
||||
self._held_completion.discard()
|
||||
verbose_logger.exception("Anthropic Adapter - mid-stream error: %s", e)
|
||||
logging_obj: Final = self.litellm_logging_obj
|
||||
provider_error: Final = _provider_error(e)
|
||||
|
|
@ -1257,6 +1266,12 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
failure_handler_error,
|
||||
)
|
||||
yield _mid_stream_error_sse_event(e)
|
||||
else:
|
||||
if self._held_completion is not None:
|
||||
self._held_completion.log(is_async=True)
|
||||
finally:
|
||||
if self._held_completion is not None:
|
||||
self._held_completion.log(is_async=True, client_detached=True)
|
||||
|
||||
def _increment_content_block_index(self):
|
||||
self.current_content_block_index += 1
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ from .streaming_iterator import AnthropicSSEStream, AnthropicStreamWrapper
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import HeldStreamCompletion
|
||||
from litellm.llms.anthropic.pass_through.safeguards import SafeguardsEvaluator
|
||||
from litellm.types.llms.anthropic import ContentBlockContentBlockDict
|
||||
|
||||
|
|
@ -322,6 +323,7 @@ class AnthropicAdapter:
|
|||
is_async: bool = True,
|
||||
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
|
||||
safeguards_evaluator: "SafeguardsEvaluator | None" = None,
|
||||
held_completion: "HeldStreamCompletion | None" = None,
|
||||
) -> AsyncIterator[bytes] | Iterator[bytes] | None:
|
||||
"""
|
||||
Translate OpenAI streaming response to Anthropic format.
|
||||
|
|
@ -350,6 +352,7 @@ class AnthropicAdapter:
|
|||
iterations_usage=iterations_usage,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
safeguards_evaluator=safeguards_evaluator,
|
||||
held_completion=held_completion,
|
||||
)
|
||||
# Return the SSE-wrapped version for proper event formatting.
|
||||
if is_async:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
|
||||
|
||||
from litellm.responses.streaming_iterator import dispatch_stream_success
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _HandsOffCompletion(Protocol):
|
||||
def hand_completion_to(self, on_complete: Callable[[object], None]) -> None: ...
|
||||
|
||||
|
||||
class _NothingHeld:
|
||||
pass
|
||||
|
||||
|
||||
_NOTHING_HELD: Final = _NothingHeld()
|
||||
|
||||
|
||||
class HeldStreamCompletion:
|
||||
"""The executor stream's assembled response, logged by the client-facing Messages stream when it ends.
|
||||
|
||||
A stream that ended with no content hands over ``None``; it is still logged, as the success
|
||||
handler logs an empty stream when it is not held.
|
||||
"""
|
||||
|
||||
def __init__(self, logging_obj: "LiteLLMLoggingObj") -> None:
|
||||
self._logging_obj: Final = logging_obj
|
||||
self._response: object = _NOTHING_HELD
|
||||
|
||||
def take(self, response: object) -> None:
|
||||
self._response = response
|
||||
|
||||
def _release(self) -> object:
|
||||
response: Final = self._response
|
||||
self._response = _NOTHING_HELD
|
||||
return response
|
||||
|
||||
def discard(self) -> None:
|
||||
self._release()
|
||||
|
||||
def log(self, *, is_async: bool, client_detached: bool = False) -> None:
|
||||
response: Final = self._release()
|
||||
if response is _NOTHING_HELD:
|
||||
return
|
||||
dispatch_stream_success(
|
||||
self._logging_obj,
|
||||
response,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
cache_hit=None,
|
||||
is_async=is_async,
|
||||
client_detached=client_detached,
|
||||
)
|
||||
|
||||
|
||||
def hold_completion_of(stream: object, logging_obj: "LiteLLMLoggingObj | None") -> HeldStreamCompletion | None:
|
||||
if logging_obj is None or not isinstance(stream, _HandsOffCompletion):
|
||||
return None
|
||||
held: Final = HeldStreamCompletion(logging_obj)
|
||||
stream.hand_completion_to(held.take)
|
||||
return held
|
||||
|
|
@ -17,6 +17,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
|
|||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import hold_completion_of
|
||||
from litellm.responses.bridge_executor import anative_responses, native_logging_obj, native_responses
|
||||
from litellm.responses.utils import auto_reasoning_summary
|
||||
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
|
||||
|
|
@ -310,6 +311,7 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
model=local_model_name(model, kwargs.get("custom_llm_provider")),
|
||||
litellm_logging_obj=logging_obj,
|
||||
safeguards_evaluator=safeguards_evaluator,
|
||||
held_completion=None if native_logging is None else hold_completion_of(result, native_logging),
|
||||
)
|
||||
return wrapper.async_anthropic_sse_wrapper()
|
||||
|
||||
|
|
@ -399,6 +401,7 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
responses_stream=result,
|
||||
model=local_model_name(model, kwargs.get("custom_llm_provider")),
|
||||
litellm_logging_obj=logging_obj,
|
||||
held_completion=None if native_logging is None else hold_completion_of(result, native_logging),
|
||||
)
|
||||
return wrapper.async_anthropic_sse_wrapper()
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from .transformation import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import HeldStreamCompletion
|
||||
from litellm.llms.anthropic.pass_through.safeguards import SafeguardsEvaluator
|
||||
from litellm.types.llms.openai import ResponseIncompleteEvent
|
||||
|
||||
|
|
@ -127,10 +128,12 @@ class AnthropicResponsesStreamWrapper:
|
|||
model: str,
|
||||
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
|
||||
safeguards_evaluator: "SafeguardsEvaluator | None" = None,
|
||||
held_completion: "HeldStreamCompletion | None" = None,
|
||||
) -> None:
|
||||
self.responses_stream = responses_stream
|
||||
self.model = model
|
||||
self.safeguards_evaluator: Final = safeguards_evaluator
|
||||
self._held_completion: Final = held_completion
|
||||
self._message_id: str = f"msg_{uuid.uuid4()}"
|
||||
if litellm_logging_obj is not None:
|
||||
litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id)
|
||||
|
|
@ -498,8 +501,22 @@ class AnthropicResponsesStreamWrapper:
|
|||
|
||||
async def _sse_frames(self) -> AsyncGenerator[bytes, None]:
|
||||
safeguard_results: Final = StreamedSafeguardResults(self.safeguards_evaluator)
|
||||
async for chunk in self:
|
||||
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
|
||||
held: Final = self._held_completion
|
||||
try:
|
||||
async for chunk in self:
|
||||
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
|
||||
except Exception:
|
||||
if held is not None:
|
||||
held.discard()
|
||||
raise
|
||||
else:
|
||||
if held is not None and self._stream_failed:
|
||||
held.discard()
|
||||
if held is not None:
|
||||
held.log(is_async=True)
|
||||
finally:
|
||||
if held is not None:
|
||||
held.log(is_async=True, client_detached=True)
|
||||
|
||||
def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
|
||||
return partial_responses_incomplete_event_on_disconnect(self.responses_stream)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from litellm.litellm_core_utils.llm_response_utils.get_headers import provider_r
|
|||
from litellm.litellm_core_utils.llm_response_utils.response_metadata import (
|
||||
update_response_metadata,
|
||||
)
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.litellm_core_utils.thread_pool_executor import executor
|
||||
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
|
|
@ -363,6 +364,7 @@ def dispatch_stream_success(
|
|||
end_time: datetime | None,
|
||||
cache_hit: bool | None,
|
||||
is_async: bool,
|
||||
client_detached: bool = False,
|
||||
) -> None:
|
||||
if not is_async:
|
||||
run_async_function(
|
||||
|
|
@ -387,6 +389,9 @@ def dispatch_stream_success(
|
|||
cache_hit=cache_hit,
|
||||
prefer_async_handlers=True,
|
||||
)
|
||||
if client_detached:
|
||||
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine)
|
||||
return
|
||||
if getattr(logging_obj, "_on_deferred_stream_complete", None) is not None:
|
||||
# End-of-stream guardrail scans write guardrail_information after
|
||||
# the terminal event; dispatching now would snapshot metadata early.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final, TypeAlias
|
||||
from typing import Final, TypeAlias, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_prompt_management import CustomPromptManagement
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.anthropic.pass_through.adapters.handler import LiteLLMMessagesToCompletionTransformationHandler
|
||||
from litellm.llms.anthropic.pass_through.adapters.streaming_iterator import AnthropicSSEStream
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.prompts.init_prompts import PromptSpec
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
|
@ -28,10 +31,8 @@ CHAT_RESPONSE: Final = {
|
|||
|
||||
|
||||
class _DeploymentHookCounter(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.pre_call = 0
|
||||
self.post_call = 0
|
||||
pre_call: int = 0
|
||||
post_call: int = 0
|
||||
|
||||
async def async_pre_call_deployment_hook(self, kwargs: Mapping[str, object], call_type: object) -> None:
|
||||
self.pre_call += 1
|
||||
|
|
@ -142,6 +143,18 @@ async def test_bridge_maps_an_upstream_429_to_rate_limit_error(
|
|||
await _ask()
|
||||
|
||||
|
||||
def _sync_bridge(logging_obj: Logging, *, stream: bool) -> object:
|
||||
return LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="hosted_vllm/served-model",
|
||||
stream=stream,
|
||||
api_base=API_BASE,
|
||||
api_key="sk-chat",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
|
||||
def test_sync_bridge_answers_from_one_upstream_call(upstream: respx.Route):
|
||||
logging_obj: Final = Logging(
|
||||
model="hosted_vllm/served-model",
|
||||
|
|
@ -153,14 +166,7 @@ def test_sync_bridge_answers_from_one_upstream_call(upstream: respx.Route):
|
|||
function_id="1234",
|
||||
)
|
||||
|
||||
response: Final = LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="hosted_vllm/served-model",
|
||||
api_base=API_BASE,
|
||||
api_key="sk-chat",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
response: Final = _sync_bridge(logging_obj, stream=False)
|
||||
|
||||
assert isinstance(response, dict)
|
||||
assert response["content"] == [{"type": "text", "text": "from upstream"}]
|
||||
|
|
@ -202,3 +208,173 @@ async def test_bridge_goes_through_a_wrapped_acompletion(upstream: respx.Route,
|
|||
assert response["content"] == [{"type": "text", "text": "from upstream"}]
|
||||
assert seen == ["hosted_vllm/served-model"]
|
||||
assert upstream.call_count == 1
|
||||
|
||||
|
||||
class _SuccessRowRecorder(CustomLogger):
|
||||
call_types: tuple[str, ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.call_types = (*self.call_types, str(kwargs.get("call_type")))
|
||||
|
||||
def log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.call_types = (*self.call_types, str(kwargs.get("call_type")))
|
||||
|
||||
|
||||
_PARKED_COMPLETION: Final = TypeAdapter(tuple[object])
|
||||
|
||||
|
||||
async def _release(parked: Coroutine[object, object, object]) -> None:
|
||||
await parked
|
||||
|
||||
|
||||
async def _settled_rows(recorder: _SuccessRowRecorder) -> tuple[str, ...]:
|
||||
for _ in range(200):
|
||||
if recorder.call_types:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await asyncio.sleep(0.05)
|
||||
return recorder.call_types
|
||||
|
||||
|
||||
STREAM_CHUNK: Final = '{"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"streamed"},"finish_reason":null}]}'
|
||||
STREAM_DONE: Final = '{"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream_recorder(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> _SuccessRowRecorder:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
respx_mock.post(f"{API_BASE}/chat/completions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
content=f"data: {STREAM_CHUNK}\n\ndata: {STREAM_DONE}\n\ndata: [DONE]\n\n".encode(),
|
||||
headers={"content-type": "text/event-stream"},
|
||||
)
|
||||
)
|
||||
recorder: Final = _SuccessRowRecorder()
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
return recorder
|
||||
|
||||
|
||||
async def _read_through_message_stop(stream: AsyncIterator[bytes]) -> None:
|
||||
async for frame in stream:
|
||||
if frame.startswith(b"event: message_stop"):
|
||||
return
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_logs_one_row_when_the_client_stream_ends(stream_recorder: _SuccessRowRecorder):
|
||||
stream: Final = await _ask({"stream": True})
|
||||
assert isinstance(stream, AnthropicSSEStream)
|
||||
|
||||
await _read_through_message_stop(stream)
|
||||
rows_while_the_client_stream_is_open: Final = await _settled_rows(stream_recorder)
|
||||
remaining: Final[list[bytes]] = [frame async for frame in stream]
|
||||
|
||||
assert rows_while_the_client_stream_is_open == ()
|
||||
assert remaining == []
|
||||
assert await _settled_rows(stream_recorder) == ("anthropic_messages",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_still_logs_when_the_client_leaves_after_message_stop(
|
||||
stream_recorder: _SuccessRowRecorder,
|
||||
):
|
||||
stream: Final = await _ask({"stream": True})
|
||||
assert isinstance(stream, AnthropicSSEStream)
|
||||
|
||||
await _read_through_message_stop(stream)
|
||||
await stream.aclose()
|
||||
|
||||
assert await _settled_rows(stream_recorder) == ("anthropic_messages",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_holds_its_row_for_the_proxy_end_of_stream_guardrails(
|
||||
stream_recorder: _SuccessRowRecorder, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [stream_recorder])
|
||||
logging_obj: Final = Logging(
|
||||
model="hosted_vllm/served-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="anthropic_messages",
|
||||
start_time=datetime.datetime.now(datetime.timezone.utc),
|
||||
litellm_call_id="6825beef-0000-4000-8000-000000000007",
|
||||
function_id="1234",
|
||||
)
|
||||
|
||||
async def release(*held: object) -> None:
|
||||
return None
|
||||
|
||||
setattr(logging_obj, "_on_deferred_stream_complete", release) # noqa: B010 # the proxy arms this slot dynamically
|
||||
stream: Final = await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler(
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="hosted_vllm/served-model",
|
||||
stream=True,
|
||||
api_base=API_BASE,
|
||||
api_key="sk-chat",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
assert isinstance(stream, AnthropicSSEStream)
|
||||
frames: Final = [frame async for frame in stream]
|
||||
rows_before_release: Final = await _settled_rows(stream_recorder)
|
||||
held: Final[object] = getattr(logging_obj, "_deferred_stream_complete_args", None)
|
||||
|
||||
assert frames[-1].startswith(b"event: message_stop")
|
||||
assert rows_before_release == ()
|
||||
(parked,) = _PARKED_COMPLETION.validate_python(held)
|
||||
assert asyncio.iscoroutine(parked)
|
||||
await _release(parked)
|
||||
assert stream_recorder.call_types == ("anthropic_messages",)
|
||||
|
||||
|
||||
def test_sync_bridged_stream_logs_one_row(stream_recorder: _SuccessRowRecorder, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "success_callback", [stream_recorder])
|
||||
logging_obj: Final = Logging(
|
||||
model="hosted_vllm/served-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="anthropic_messages",
|
||||
start_time=datetime.datetime.now(datetime.timezone.utc),
|
||||
litellm_call_id="6825beef-0000-4000-8000-000000000006",
|
||||
function_id="1234",
|
||||
)
|
||||
|
||||
stream: Final = _sync_bridge(logging_obj, stream=True)
|
||||
assert isinstance(stream, Iterator)
|
||||
frames: Final = list(cast(Iterator[bytes], stream)) # cast-ok: the sync bridge streams SSE frames as bytes
|
||||
|
||||
assert frames[-1].startswith(b"event: message_stop")
|
||||
assert asyncio.run(_settled_rows(stream_recorder)) == ("anthropic_messages",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_empty_stream_still_logs_one_row(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
respx_mock.post(f"{API_BASE}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, content=b"data: [DONE]\n\n", headers={"content-type": "text/event-stream"})
|
||||
)
|
||||
recorder: Final = _SuccessRowRecorder()
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
|
||||
# deepinfra sends no stream_options, so the empty stream assembles no response to log
|
||||
stream: Final = await litellm.anthropic.messages.acreate(
|
||||
model="deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct",
|
||||
api_base=API_BASE,
|
||||
api_key="sk-chat",
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
)
|
||||
assert isinstance(stream, AnthropicSSEStream)
|
||||
frames: Final = [frame async for frame in stream]
|
||||
|
||||
assert frames[-1].startswith(b"event: message_stop")
|
||||
assert await _settled_rows(recorder) == ("anthropic_messages",)
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import json
|
|||
import os
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
||||
from datetime import datetime
|
||||
from collections.abc import Callable
|
||||
from typing import List, Optional
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -37,11 +37,12 @@ from litellm.llms.anthropic.pass_through.adapters.streaming_iterator import (
|
|||
AnthropicStreamWrapper,
|
||||
_mid_stream_error_sse_event,
|
||||
)
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import HeldStreamCompletion
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.types.utils import Delta, StreamingChoices
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||
|
||||
|
||||
def _make_chunk(delta: Delta, finish_reason: Optional[str] = None) -> MagicMock:
|
||||
def _make_chunk(delta: Delta, finish_reason: str | None = None) -> MagicMock:
|
||||
chunk = MagicMock()
|
||||
chunk.choices = [
|
||||
StreamingChoices(finish_reason=finish_reason, index=0, delta=delta, logprobs=None)
|
||||
|
|
@ -55,7 +56,7 @@ class _AsyncStreamThenRaise:
|
|||
"""Yields the given chunks, then raises ``exc`` (mimics a provider stream
|
||||
that terminates mid-response)."""
|
||||
|
||||
def __init__(self, items: List[MagicMock], exc: BaseException):
|
||||
def __init__(self, items: list[MagicMock], exc: BaseException):
|
||||
self._it = iter(items)
|
||||
self._exc = exc
|
||||
|
||||
|
|
@ -75,7 +76,7 @@ def _parse_sse(raw: bytes) -> tuple[str, dict]:
|
|||
return event_line.removeprefix("event: "), json.loads(data_line.removeprefix("data: "))
|
||||
|
||||
|
||||
async def _drain_sse(wrapper: AnthropicStreamWrapper) -> List[bytes]:
|
||||
async def _drain_sse(wrapper: AnthropicStreamWrapper) -> list[bytes]:
|
||||
return [event async for event in wrapper.async_anthropic_sse_wrapper()]
|
||||
|
||||
|
||||
|
|
@ -318,3 +319,51 @@ async def test_mid_stream_error_emits_error_event_without_logging_obj():
|
|||
events = await _drain_sse(_failing_wrapper(None))
|
||||
|
||||
assert _parse_sse(events[-1])[0] == "error"
|
||||
|
||||
|
||||
class _AsyncSuccessRecorder(CustomLogger):
|
||||
calls: tuple[object, ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.calls = (*self.calls, response_obj)
|
||||
|
||||
|
||||
class _CompletesThenRaises:
|
||||
def __init__(self, exc: Exception, held: HeldStreamCompletion) -> None:
|
||||
self._exc: Final = exc
|
||||
self._held: Final = held
|
||||
|
||||
def __iter__(self) -> Iterator[ModelResponseStream]:
|
||||
return iter(())
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[ModelResponseStream]:
|
||||
return self._chunks()
|
||||
|
||||
async def _chunks(self) -> AsyncIterator[ModelResponseStream]:
|
||||
yield ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content="partial"))])
|
||||
self._held.take(litellm.ModelResponse())
|
||||
raise self._exc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stream_that_fails_after_the_executor_completed_logs_no_success_row():
|
||||
success_recorder: Final = _AsyncSuccessRecorder()
|
||||
async_recorder: Final = _AsyncFailureRecorder()
|
||||
logging_obj: Final = _make_logging_obj("fails-after-complete", async_recorder, _SyncFailureRecorder())
|
||||
logging_obj.dynamic_async_success_callbacks = [success_recorder]
|
||||
held: Final = HeldStreamCompletion(logging_obj)
|
||||
wrapper: Final = AnthropicStreamWrapper(
|
||||
completion_stream=_CompletesThenRaises(_bedrock_drop(), held),
|
||||
model="bedrock-converse-sonnet-4-6",
|
||||
litellm_logging_obj=logging_obj,
|
||||
held_completion=held,
|
||||
)
|
||||
|
||||
events: Final = await _drain_sse(wrapper)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert _parse_sse(events[-1])[0] == "error"
|
||||
assert [str(exc) for exc in async_recorder.exceptions] == ["ConverseStream ended without messageStop"]
|
||||
assert success_recorder.calls == ()
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
from typing import Final, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")))
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_prompt_management import CustomPromptManagement
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
|
|
@ -23,6 +26,7 @@ from litellm.llms.anthropic.pass_through.responses_adapters.handler import (
|
|||
LiteLLMMessagesToResponsesAPIHandler,
|
||||
_build_responses_request,
|
||||
)
|
||||
from litellm.llms.anthropic.pass_through.responses_adapters.streaming_iterator import AnthropicResponsesSSEByteStream
|
||||
from litellm.router import Router
|
||||
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
|
@ -952,3 +956,125 @@ def test_sync_bridge_raises_the_litellm_rate_limit_error_for_an_upstream_429(
|
|||
monkeypatch,
|
||||
httpx.Response(429, json={"error": {"message": "slow down", "type": "rate_limit_error"}}),
|
||||
)
|
||||
|
||||
|
||||
class _SuccessRowRecorder(CustomLogger):
|
||||
call_types: tuple[str, ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.call_types = (*self.call_types, str(kwargs.get("call_type")))
|
||||
|
||||
|
||||
_PARKED_COMPLETION: Final = TypeAdapter(tuple[object])
|
||||
|
||||
|
||||
async def _release(parked: Coroutine[object, object, object]) -> None:
|
||||
await parked
|
||||
|
||||
|
||||
async def _settled_rows(recorder: _SuccessRowRecorder) -> tuple[str, ...]:
|
||||
for _ in range(200):
|
||||
if recorder.call_types:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await asyncio.sleep(0.05)
|
||||
return recorder.call_types
|
||||
|
||||
|
||||
async def _bridged_stream(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, logging_obj: Logging
|
||||
) -> tuple[AnthropicResponsesSSEByteStream, _SuccessRowRecorder]:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-native-test")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
recorder: Final = _SuccessRowRecorder()
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
|
||||
respx_mock.post("https://api.openai.com/v1/responses").mock(
|
||||
return_value=httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=RESPONSES_SSE_BODY)
|
||||
)
|
||||
stream: Final = await _untyped_bridge()(
|
||||
max_tokens=1024,
|
||||
messages=MESSAGES,
|
||||
model="openai/gpt-5.6-luna",
|
||||
stream=True,
|
||||
custom_llm_provider="openai",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
assert isinstance(stream, AnthropicResponsesSSEByteStream)
|
||||
return stream, recorder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_opens_with_the_cost_breakdown_headers(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from litellm.proxy.common_request_processing import cost_headers
|
||||
|
||||
logging_obj: Final = _messages_logging_obj(stream=True)
|
||||
stream, _ = await _bridged_stream(respx_mock, monkeypatch, logging_obj)
|
||||
|
||||
headers: Final = cost_headers(None, logging_obj)
|
||||
await stream.aclose()
|
||||
|
||||
assert headers["x-litellm-response-cost-output"] == "0.0"
|
||||
assert headers["x-litellm-response-cost-discount-amount"] == "0.0"
|
||||
assert headers["x-litellm-response-cost-margin-amount"] == "0.0"
|
||||
|
||||
|
||||
async def _read_through_message_stop(stream: AsyncIterator[bytes]) -> None:
|
||||
async for frame in stream:
|
||||
if frame.startswith(b"event: message_stop"):
|
||||
return
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_logs_one_row_when_the_client_stream_ends(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
stream, recorder = await _bridged_stream(respx_mock, monkeypatch, _messages_logging_obj(stream=True))
|
||||
|
||||
await _read_through_message_stop(stream)
|
||||
rows_while_the_client_stream_is_open: Final = await _settled_rows(recorder)
|
||||
remaining: Final[list[bytes]] = [frame async for frame in stream]
|
||||
|
||||
assert rows_while_the_client_stream_is_open == ()
|
||||
assert remaining == []
|
||||
assert await _settled_rows(recorder) == ("anthropic_messages",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_still_logs_when_the_client_leaves_after_message_stop(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
stream, recorder = await _bridged_stream(respx_mock, monkeypatch, _messages_logging_obj(stream=True))
|
||||
|
||||
await _read_through_message_stop(stream)
|
||||
await stream.aclose()
|
||||
|
||||
assert await _settled_rows(recorder) == ("anthropic_messages",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridged_stream_holds_its_row_for_the_proxy_end_of_stream_guardrails(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
logging_obj: Final = _messages_logging_obj(stream=True)
|
||||
|
||||
async def release(*held: object) -> None:
|
||||
return None
|
||||
|
||||
setattr(logging_obj, "_on_deferred_stream_complete", release) # noqa: B010 # the proxy arms this slot dynamically
|
||||
stream, recorder = await _bridged_stream(respx_mock, monkeypatch, logging_obj)
|
||||
|
||||
frames: Final = [frame async for frame in stream]
|
||||
rows_before_release: Final = await _settled_rows(recorder)
|
||||
held: Final[object] = getattr(logging_obj, "_deferred_stream_complete_args", None)
|
||||
|
||||
assert frames[-1].startswith(b"event: message_stop")
|
||||
assert rows_before_release == ()
|
||||
(parked,) = _PARKED_COMPLETION.validate_python(held)
|
||||
assert asyncio.iscoroutine(parked)
|
||||
await _release(parked)
|
||||
assert recorder.call_types == ("anthropic_messages",)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
|
@ -15,9 +17,12 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.
|
|||
|
||||
import litellm
|
||||
from litellm.exceptions import MidStreamFallbackError
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
encrypted_reasoning_signature,
|
||||
)
|
||||
from litellm.llms.anthropic.pass_through.held_stream_completion import HeldStreamCompletion
|
||||
from litellm.llms.anthropic.pass_through.messages.utils import INCOMPLETE_STREAM_ERROR_MESSAGE
|
||||
from litellm.llms.anthropic.pass_through.responses_adapters.streaming_iterator import (
|
||||
AnthropicResponsesStreamWrapper,
|
||||
|
|
@ -622,3 +627,56 @@ class TestUpstreamFailureEndsStreamWithErrorEvent:
|
|||
chunks = _collect(iter(()))
|
||||
assert [chunk["type"] for chunk in chunks] == ["message_start", "error"]
|
||||
assert chunks[1]["error"] == {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE}
|
||||
|
||||
|
||||
class _AsyncSuccessRecorder(CustomLogger):
|
||||
calls: tuple[object, ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.calls = (*self.calls, response_obj)
|
||||
|
||||
|
||||
def test_a_stream_that_fails_after_the_executor_completed_logs_no_success_row():
|
||||
recorder = _AsyncSuccessRecorder()
|
||||
logging_obj = Logging(
|
||||
model="m",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="anthropic_messages",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="fails-after-complete",
|
||||
function_id="fails-after-complete",
|
||||
dynamic_async_success_callbacks=[recorder],
|
||||
)
|
||||
held = HeldStreamCompletion(logging_obj)
|
||||
completed = ResponsesAPIResponse(
|
||||
id="resp_1",
|
||||
created_at=1,
|
||||
status="completed",
|
||||
model="m",
|
||||
output=[],
|
||||
parallel_tool_calls=True,
|
||||
tool_choice="auto",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
async def _gen() -> AsyncIterator[dict[str, object]]:
|
||||
yield {"type": "response.created"}
|
||||
held.take(completed)
|
||||
yield {"type": "response.completed", "response": completed}
|
||||
raise ConnectionResetError("Response payload is not completed")
|
||||
|
||||
async def _run() -> list[bytes]:
|
||||
wrapper = AnthropicResponsesStreamWrapper(
|
||||
responses_stream=_gen(), model="m", litellm_logging_obj=logging_obj, held_completion=held
|
||||
)
|
||||
frames: list[bytes] = [frame async for frame in wrapper.async_anthropic_sse_wrapper()]
|
||||
await asyncio.sleep(0.2)
|
||||
return frames
|
||||
|
||||
frames = asyncio.run(_run())
|
||||
|
||||
assert frames[-1].startswith(b"event: error")
|
||||
assert recorder.calls == ()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue