mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(anthropic): guard empty choices[] chunks in the messages streaming bridge (#30794)
* fix(anthropic): guard empty choices[] chunks in the messages streaming bridge OpenAI/Azure-compatible backends emit a trailing usage-only chunk with choices=[]. The anthropic /v1/messages streaming adapter assumed every chunk has choices[0], so it crashed mid-stream with IndexError. Guard the choices[0] accesses in the streaming path and route usage-only chunks into the message delta. Fixes #30761. Co-Authored-By: Chenglun Hu <chenglunhu@gmail.com> * style: black-format empty-choices guard + test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
aad4d335de
commit
4d31446b91
3 changed files with 99 additions and 6 deletions
|
|
@ -402,7 +402,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
will_merge_into_held = (
|
||||
self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None
|
||||
)
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
is_final_chunk = (
|
||||
bool(chunk.choices) and chunk.choices[0].finish_reason is not None
|
||||
)
|
||||
processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic(
|
||||
response=chunk,
|
||||
current_content_block_index=self.current_content_block_index,
|
||||
|
|
@ -622,7 +624,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
will_merge_into_held = (
|
||||
self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None
|
||||
)
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
is_final_chunk = (
|
||||
bool(chunk.choices) and chunk.choices[0].finish_reason is not None
|
||||
)
|
||||
processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic(
|
||||
response=chunk,
|
||||
current_content_block_index=self.current_content_block_index,
|
||||
|
|
@ -852,6 +856,10 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
from .transformation import LiteLLMAnthropicMessagesAdapter
|
||||
|
||||
# Example logic - customize based on your needs:
|
||||
# Usage-only / keepalive chunks carry no choices (choices=[]) — they
|
||||
# don't open a new content block. See #30761.
|
||||
if not chunk.choices:
|
||||
return False
|
||||
# If chunk indicates a tool call
|
||||
if chunk.choices[0].finish_reason is not None:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1480,11 +1480,17 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
current_content_block_index: int,
|
||||
applied_edits: Optional[List[AppliedEdit]] = None,
|
||||
) -> Union[ContentBlockDelta, MessageBlockDelta]:
|
||||
## base case - final chunk w/ finish reason
|
||||
if response.choices[0].finish_reason is not None:
|
||||
delta = MessageDelta(
|
||||
stop_reason=self._translate_openai_finish_reason_to_anthropic(response.choices[0].finish_reason),
|
||||
## base case - final chunk w/ finish reason, or a usage-only chunk
|
||||
## (choices=[]) that carries trailing usage. See #30761.
|
||||
if not response.choices or response.choices[0].finish_reason is not None:
|
||||
stop_reason = (
|
||||
self._translate_openai_finish_reason_to_anthropic(
|
||||
response.choices[0].finish_reason
|
||||
)
|
||||
if response.choices
|
||||
else None
|
||||
)
|
||||
delta = MessageDelta(stop_reason=stop_reason)
|
||||
if getattr(response, "usage", None) is not None:
|
||||
litellm_usage_chunk: Optional[Usage] = response.usage # type: ignore
|
||||
elif hasattr(response, "_hidden_params") and "usage" in response._hidden_params:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""Regression test for #30761 — the Anthropic streaming bridge crashed with
|
||||
IndexError when an upstream OpenAI/Azure-compatible chunk had choices=[]
|
||||
(usage-only / keepalive chunk)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../../.."))
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import (
|
||||
AnthropicStreamWrapper,
|
||||
)
|
||||
from litellm.types.utils import Delta, StreamingChoices, Usage
|
||||
|
||||
|
||||
def _content_chunk(text: str, finish_reason: Optional[str] = None) -> MagicMock:
|
||||
chunk = MagicMock()
|
||||
chunk.choices = [
|
||||
StreamingChoices(
|
||||
finish_reason=finish_reason,
|
||||
index=0,
|
||||
delta=Delta(content=text),
|
||||
logprobs=None,
|
||||
)
|
||||
]
|
||||
chunk.usage = None
|
||||
chunk._hidden_params = {}
|
||||
return chunk
|
||||
|
||||
|
||||
def _usage_only_chunk() -> MagicMock:
|
||||
# OpenAI include_usage trailing chunk: empty choices, usage set
|
||||
chunk = MagicMock()
|
||||
chunk.choices = []
|
||||
chunk.usage = Usage(prompt_tokens=5, completion_tokens=3, total_tokens=8)
|
||||
chunk._hidden_params = {}
|
||||
return chunk
|
||||
|
||||
|
||||
def test_sync_stream_survives_empty_choices_usage_chunk():
|
||||
chunks = [
|
||||
_content_chunk("hi"),
|
||||
_content_chunk("", finish_reason="stop"),
|
||||
_usage_only_chunk(),
|
||||
]
|
||||
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
|
||||
events = list(wrapper) # used to raise IndexError mid-stream
|
||||
assert events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_stream_survives_empty_choices_usage_chunk():
|
||||
class _AsyncStream:
|
||||
def __init__(self, items):
|
||||
self._it = iter(items)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._it)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
chunks = [
|
||||
_content_chunk("hi"),
|
||||
_content_chunk("", finish_reason="stop"),
|
||||
_usage_only_chunk(),
|
||||
]
|
||||
wrapper = AnthropicStreamWrapper(
|
||||
completion_stream=_AsyncStream(chunks), model="claude-x"
|
||||
)
|
||||
events = [e async for e in wrapper]
|
||||
assert events
|
||||
Loading…
Add table
Reference in a new issue