fix: preserve role='assistant' in Azure streaming with include_usage - #25638
Conversation
When Azure sends stream_options.include_usage=True, it emits an initial chunk with choices=[] (prompt_filter_results) before the first content chunk. Previously, LiteLLM inflated this empty-choices chunk with a default StreamingChoices, which consumed the sent_first_chunk flag and caused strip_role_from_delta to strip role from the real first chunk. Additionally, the first real chunk with role='assistant' and content='' was discarded by is_chunk_non_empty as "empty". This fix: - Forwards chunks with choices=[] faithfully (no inflated default) - Only marks sent_first_chunk for chunks with real choices - Treats chunks with role in delta as non-empty - Guards choices[0] access in __next__/__anext__ and stream_chunk_builder Fixes #24221
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryFixes a bug where Azure streaming with
Confidence Score: 4/5Safe to merge for the reported Azure scenario; one P1 inconsistency in the asyncio.to-thread path should be fixed to prevent future regressions. The primary fix (path 1 of next/anext, stream_chunk_builder, chunk_creator) is correct and covered by a well-written regression test. The P1 issue — missing choices guard in anext path 2 (asyncio.to_thread for sync-only iterators) — doesn't affect Azure today since Azure uses async iterables, but the stated goal of the PR was to guard all choices[0] accesses and this one was missed. litellm/litellm_core_utils/streaming_handler.py — specifically the asyncio.to_thread branch of anext (line 2133) and the stream_options dict access (line 1570).
|
| Filename | Overview |
|---|---|
| litellm/litellm_core_utils/streaming_handler.py | Core fix: chunk_creator now returns model_response with choices=[] instead of None for Azure include_usage empty chunks; next and anext async-for path gain choices guards; but the asyncio.to_thread path (line 2133) still accesses choices[0] without a guard. |
| litellm/litellm_core_utils/streaming_chunk_builder_utils.py | build_base_response correctly uses next(c for c in chunks if c.get('choices'), chunk) to skip empty-choices chunks when extracting role/finish_reason; iteration loop guards with len(chunk['choices']) > 0 — looks safe. |
| litellm/main.py | stream_chunk_builder fast-path and slow-path both guard with len(chunk['choices']) == 0 before accessing choices[0]; the empty-choices chunks are correctly skipped during assembly. |
| tests/test_litellm/litellm_core_utils/test_streaming_handler.py | Adds a dedicated regression test (test_azure_streaming_role_preserved_with_include_usage) that exercises both sync and async modes with real Azure-shaped chunks (choices=[], then role='assistant', then content); existing tests unchanged. |
Sequence Diagram
sequenceDiagram
participant Azure as Azure API
participant CSW as CustomStreamWrapper
participant CC as chunk_creator
participant RPC as return_processed_chunk_logic
Note over Azure,RPC: include_usage=True stream with prompt_filter_results
Azure->>CSW: Chunk 1 choices=[] prompt_filter_results
CSW->>CC: chunk_creator(chunk)
CC->>CC: original_chunk.choices is empty else branch
CC-->>CSW: model_response choices=[] NEW was None before fix
CSW->>CSW: if response.choices False skip sent_first_chunk update GUARDED
CSW-->>Azure: yield chunk with choices=[]
Azure->>CSW: Chunk 2 choices=[delta(role=assistant, content='')]
CSW->>CC: chunk_creator(chunk)
CC->>RPC: return_processed_chunk_logic
RPC->>RPC: is_chunk_non_empty True role present sent_first_chunk=False
RPC->>RPC: strip_role_from_delta sets role=assistant sent_first_chunk=True
CC-->>CSW: model_response role=assistant
CSW-->>Azure: yield chunk with role=assistant
Azure->>CSW: Chunk 3 choices=[delta(content=Hello!)]
CSW->>CC: chunk_creator(chunk)
CC-->>CSW: model_response content=Hello!
CSW-->>Azure: yield content chunk
Comments Outside Diff (2)
-
litellm/litellm_core_utils/streaming_handler.py, line 2133-2138 (link)Missing
choicesguard in__anext__asyncio.to_thread pathPath 1 (the
async forbranch, line 2053) correctly guardschoices[0]withif processed_chunk.choices:, but path 2 (theasyncio.to_threadbranch for sync-only iterators) accessesprocessed_chunk.choices[0]directly. If a provider using a sync-only completion stream (one without__aiter__) emits achoices=[]chunk withinclude_usage=True, this raisesIndexError. -
litellm/litellm_core_utils/streaming_handler.py, line 1568-1573 (link)Direct dict key access can raise
KeyErrorself.stream_options["include_usage"]throwsKeyErrorifstream_optionsis a non-empty dict that doesn't contain theinclude_usagekey (e.g.{"show_all": True}). The surroundingis not Noneguard doesn't protect against this. Prefer.get():
Reviews (1): Last reviewed commit: "fix: preserve role='assistant' in Azure ..." | Re-trigger Greptile
…mpty When logprobs are requested, OpenAI's first streaming chunk has role='assistant' and logprobs=ChoiceLogprobs(content=[]). The Azure streaming fix (PR #25638) made is_chunk_non_empty() treat any first chunk with a non-None role as non-empty, which caused this role-only chunk to be emitted. Callers checking 'logprobs in chunk.choices[0]' then crashed on logprobs.content[0] (IndexError: list index out of range). Guard the role condition: don't emit the first chunk as non-empty when logprobs is present but has no content entries. Azure's use case (no logprobs, empty choices[]) is unaffected.
The Azure streaming fix (PR #25638) made is_chunk_non_empty() treat any first chunk with role!=None as non-empty, for all providers. This broke OpenAI/Mistral tests where the role-only first chunk was expected to be filtered, and test idx==0 assumed the first emitted chunk had actual content (tool_calls, logprobs, etc.). Restrict the condition to Azure, which is the provider that actually needs it: Azure sends prompt_filter_results before the first content chunk, which consumes sent_first_chunk and strips role from the real first chunk. Other providers should retain the original behavior.
Relevant issues
Fixes #24221
Pre-Submission checklist
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unitType
🐛 Bug Fix
Changes
When
stream_options.include_usage=True, Azure sends an initial chunk withchoices=[](prompt_filter_results) before the first content chunk. LiteLLM inflated this empty-choices chunk with a defaultStreamingChoices, which:sent_first_chunkflagstrip_role_from_deltato striprolefrom the real first chunkrole='assistant'andcontent=''was also discarded byis_chunk_non_emptyas "empty"Net result: no chunk ever contained
role='assistant'.Fix (4 files):
streaming_handler.py-chunk_creator: Setmodel_response.choices = []for chunks without choices, forwarding them faithfully instead of inflating with a defaultStreamingChoicesstreaming_handler.py-is_chunk_non_empty: Treat chunks withrolein delta as non-empty (the first chunk withrole='assistant'andcontent=''is a valid OpenAI chunk)streaming_handler.py-__next__/__anext__: Guardchoices[0]access and only marksent_first_chunkfor chunks with real choicesmain.py+streaming_chunk_builder_utils.py: Guardchoices[0]access instream_chunk_builderfor chunks withchoices=[]Originally filed as #24354 (merged to a staging branch, not main).