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:
hcl 2026-06-23 21:41:43 +08:00 • committed by Sameer Kankute
parent aad4d335de
commit 4d31446b91
No known key found for this signature in database
3 changed files with 99 additions and 6 deletions

View file

@ -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

View file

@ -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:

View file

@ -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