diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 44c367ee805..3aec95f5c02 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -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 diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 02625605f37..37b8813d499 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_empty_choices.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_empty_choices.py new file mode 100644 index 00000000000..04068534885 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_empty_choices.py @@ -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