mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
Fix streaming usage chunk to have empty choices per OpenAI spec
When `stream_options: {"include_usage": true}` is set, the final
streaming chunk carrying token usage must have `choices: []` per the
OpenAI API specification. Previously, `model_response_creator()` filled
in a default `StreamingChoices(finish_reason=None)` placeholder, causing
downstream clients (LangChain.js, Dify, etc.) to double-count tokens
or misinterpret the usage-only event as a content chunk.
The fix sets `response.choices = []` on the synthetic usage chunk in
both the sync (`__next__`) and async (`__anext__`) paths, immediately
after `model_response_creator()` builds the response.
Fixes #28735
This commit is contained in:
parent
4148667671
commit
65918b0495
2 changed files with 87 additions and 9 deletions
|
|
@ -1928,6 +1928,10 @@ class CustomStreamWrapper:
|
|||
)
|
||||
|
||||
response = self.model_response_creator()
|
||||
# Per the OpenAI streaming spec, the final usage chunk
|
||||
# must have choices: [] (not a default placeholder choice).
|
||||
# https://platform.openai.com/docs/api-reference/chat/streaming
|
||||
response.choices = []
|
||||
if complete_streaming_response is not None:
|
||||
setattr(
|
||||
response,
|
||||
|
|
@ -2157,6 +2161,10 @@ class CustomStreamWrapper:
|
|||
)
|
||||
|
||||
response = self.model_response_creator()
|
||||
# Per the OpenAI streaming spec, the final usage chunk
|
||||
# must have choices: [] (not a default placeholder choice).
|
||||
# https://platform.openai.com/docs/api-reference/chat/streaming
|
||||
response.choices = []
|
||||
if complete_streaming_response is not None:
|
||||
setattr(
|
||||
response,
|
||||
|
|
|
|||
|
|
@ -2036,23 +2036,19 @@ async def test_azure_streaming_role_preserved_with_include_usage(sync_mode: bool
|
|||
chunks.append(chunk)
|
||||
|
||||
# The prompt_filter chunk should be forwarded with choices=[]
|
||||
assert len(chunks[0].choices) == 0, (
|
||||
f"Expected prompt_filter chunk with choices=[], got {len(chunks[0].choices)} choices"
|
||||
)
|
||||
assert (
|
||||
len(chunks[0].choices) == 0
|
||||
), f"Expected prompt_filter chunk with choices=[], got {len(chunks[0].choices)} choices"
|
||||
|
||||
# At least one chunk must have role='assistant' in its delta
|
||||
has_role = any(
|
||||
len(c.choices) > 0
|
||||
and getattr(c.choices[0].delta, "role", None) == "assistant"
|
||||
len(c.choices) > 0 and getattr(c.choices[0].delta, "role", None) == "assistant"
|
||||
for c in chunks
|
||||
)
|
||||
assert has_role, (
|
||||
"No chunk contained role='assistant' in delta (issue #24221). "
|
||||
"Chunk deltas: "
|
||||
+ str([
|
||||
c.choices[0].delta if c.choices else "no choices"
|
||||
for c in chunks
|
||||
])
|
||||
+ str([c.choices[0].delta if c.choices else "no choices" for c in chunks])
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2124,3 +2120,77 @@ def test_gemini_legacy_vertex_tool_calls_finish_reason_with_stop_enum():
|
|||
f"Expected 'tool_calls' but got {final.choices[0].finish_reason!r}. "
|
||||
"STOP enum was not normalised through map_finish_reason()."
|
||||
)
|
||||
|
||||
|
||||
class TestUsageChunkEmptyChoices:
|
||||
"""The OpenAI streaming spec requires the final usage chunk to have choices: [].
|
||||
|
||||
See: https://platform.openai.com/docs/api-reference/chat/streaming
|
||||
Regression test for https://github.com/BerriAI/litellm/issues/28735
|
||||
"""
|
||||
|
||||
def _build_wrapper(self, logging_obj):
|
||||
"""Build a CustomStreamWrapper that has already sent the last content chunk."""
|
||||
chunks = [
|
||||
ModelResponseStream(
|
||||
id="chatcmpl-test",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=Delta(content="hello", role="assistant"),
|
||||
)
|
||||
],
|
||||
),
|
||||
ModelResponseStream(
|
||||
id="chatcmpl-test",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=Delta(content=None),
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
completion_stream = ModelResponseListIterator(model_responses=chunks)
|
||||
wrapper = CustomStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
model="gpt-4",
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider="openai",
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
return wrapper
|
||||
|
||||
def test_sync_usage_chunk_has_empty_choices(self, logging_obj):
|
||||
"""Sync __next__: the final usage-only chunk must have choices=[]."""
|
||||
wrapper = self._build_wrapper(logging_obj)
|
||||
|
||||
collected = []
|
||||
for chunk in wrapper:
|
||||
collected.append(chunk)
|
||||
|
||||
# The last chunk should be the usage chunk
|
||||
usage_chunk = collected[-1]
|
||||
assert hasattr(usage_chunk, "usage"), "Final chunk should carry usage"
|
||||
assert usage_chunk.choices == [], (
|
||||
f"Usage chunk must have choices=[] per OpenAI spec, "
|
||||
f"got {usage_chunk.choices!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_usage_chunk_has_empty_choices(self, logging_obj):
|
||||
"""Async __anext__: the final usage-only chunk must have choices=[]."""
|
||||
wrapper = self._build_wrapper(logging_obj)
|
||||
|
||||
collected = []
|
||||
async for chunk in wrapper:
|
||||
collected.append(chunk)
|
||||
|
||||
usage_chunk = collected[-1]
|
||||
assert hasattr(usage_chunk, "usage"), "Final chunk should carry usage"
|
||||
assert usage_chunk.choices == [], (
|
||||
f"Usage chunk must have choices=[] per OpenAI spec, "
|
||||
f"got {usage_chunk.choices!r}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue