diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 33f939b4b95..738ada5c3f8 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -324,7 +324,15 @@ class ChunkProcessor: system_fingerprint: Final = chunk.get("system_fingerprint", None) first_chunk_with_choices: Final = next((c for c in chunks if c.get("choices")), chunk) - role: Final = first_chunk_with_choices["choices"][0]["delta"]["role"] + if ( + first_chunk_with_choices.get("choices") + and len(first_chunk_with_choices["choices"]) > 0 + and first_chunk_with_choices["choices"][0].get("delta") + and "role" in first_chunk_with_choices["choices"][0]["delta"] + ): + role: Final = first_chunk_with_choices["choices"][0]["delta"]["role"] + else: + role = "assistant" finish_reason = "stop" for chunk in chunks: if "choices" in chunk and len(chunk["choices"]) > 0: diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils_empty_choices.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils_empty_choices.py new file mode 100644 index 00000000000..f5645d0b16e --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils_empty_choices.py @@ -0,0 +1,66 @@ +import os +import sys + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor + + +def test_build_base_response_all_chunks_empty_choices(): + """ + Regression test for the IndexError in ChunkProcessor.build_base_response + when every chunk has choices=[] (e.g. Anthropic usage-only events). + ``next((c for c in chunks if c.get("choices")), chunk)`` skips every chunk + (empty list is falsy) and falls back to ``chunk``, whose ``choices`` is also + ``[]`` — the old code then did ``choices[0]`` and raised IndexError. + build_base_response should default to role='assistant'. + """ + chunks = [ + { + "id": "chatcmpl-all-empty-choices", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "gpt-4.1-mini", + "choices": [], + }, + { + "id": "chatcmpl-all-empty-choices", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "gpt-4.1-mini", + "choices": [], + }, + ] + + processor = ChunkProcessor(chunks=chunks) + response = processor.build_base_response(chunks) + + assert response.choices[0].message.role == "assistant" + assert response.choices[0].message.content == "" + + +def test_build_base_response_falls_back_to_assistant_when_role_missing(): + """ + When a chunk has non-empty choices but its delta omits the role key, + build_base_response should still produce a valid response and default + to 'assistant'. + """ + chunks = [ + { + "id": "chatcmpl-no-role", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "gpt-4.1-mini", + "choices": [ + {"index": 0, "delta": {"content": "hello"}} + ], + } + ] + + processor = ChunkProcessor(chunks=chunks) + response = processor.build_base_response(chunks) + + assert response.choices[0].message.role == "assistant" + assert response.choices[0].message.content == ""