fix(streaming): keep SDK chunks with null choices streaming past the abort check

This commit is contained in:
mateo-berri 2026-10-28 02:52:11 -07:00
parent f0382da5db
commit 0ece673ce7
2 changed files with 58 additions and 1 deletions

View file

@ -1557,7 +1557,7 @@ class CustomStreamWrapper:
else: # openai / azure chat model
if isinstance(chunk, ChatCompletionChunk):
self._openai_handler_receives_chat_chunks = True
if any(str(choice.finish_reason) == "abort" for choice in chunk.choices):
if any(str(choice.finish_reason) == "abort" for choice in chunk.choices or ()):
raise litellm.InternalServerError(
message="Upstream generation aborted (finish_reason=abort)",
model=chunk.model,

View file

@ -1,5 +1,6 @@
import asyncio
import datetime
import itertools
import json
import queue
import sys
@ -1445,6 +1446,62 @@ async def test_aborted_stream_raises_instead_of_finishing_successfully(
client.close()
@pytest.mark.parametrize("async_stream", [False, True])
async def test_sdk_stream_with_null_choices_chunk_finishes_normally(async_stream: bool, logging_obj: Logging) -> None:
body: Final = (
"".join(
"data: "
+ json.dumps(
{
"id": "chatcmpl-null-choices",
"object": "chat.completion.chunk",
"created": 1,
"model": "test-model",
"choices": choices,
**extra,
}
)
+ "\n\n"
for choices, extra in (
([{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}], {}),
([{"index": 0, "delta": {}, "finish_reason": "stop"}], {}),
(None, {"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}),
)
)
+ "data: [DONE]\n\n"
)
raw_response: Final = httpx.Response(
200,
content=body,
headers={"content-type": "text/event-stream"},
request=httpx.Request("POST", "https://provider.example/v1/chat/completions"),
)
client: Final = AsyncOpenAI(api_key="unused") if async_stream else OpenAI(api_key="unused")
stream: Final = (
AsyncStream(cast_to=ChatCompletionChunk, response=raw_response, client=client)
if isinstance(client, AsyncOpenAI)
else Stream(cast_to=ChatCompletionChunk, response=raw_response, client=client)
)
response: Final = CustomStreamWrapper(
completion_stream=stream,
model="test-model",
logging_obj=logging_obj,
custom_llm_provider="openai",
)
try:
chunks: Final = [chunk async for chunk in response] if async_stream else list(response)
finally:
if isinstance(client, AsyncOpenAI):
await client.close()
else:
client.close()
streamed_choices: Final = tuple(itertools.chain.from_iterable(chunk.choices for chunk in chunks))
assert "".join(choice.delta.content or "" for choice in streamed_choices) == "hi"
assert [choice.finish_reason for choice in streamed_choices if choice.finish_reason] == ["stop"]
def _hosted_vllm_stream_wrapper(logging_obj: Logging, error_payload: dict) -> CustomStreamWrapper:
"""A CustomStreamWrapper over the real OpenAI-compatible line iterator,
fed an HTTP 200 SSE body that carries an in-body error payload the way