This commit is contained in:
inchang-ing 2026-11-07 06:22:34 +00:00 • committed by GitHub
commit 39411da1d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 173 additions and 5 deletions

View file

@ -775,7 +775,7 @@ class ChunkProcessor:
content = delta.get(delta_key, "")
if content is None:
continue # openai v1.0.0 sets content = None for chunks
content_list.append(content)
content_list.append(delta_content_as_str(content))
# Combine the "content" strings into a single string || combine the 'function' strings into a single string
combined_content: Final = "".join(content_list)
@ -1583,12 +1583,33 @@ def _delta_carries_more_than_text(delta: Mapping[str, object]) -> bool:
return any(delta.get(field) is not None for field in _NON_TEXT_DELTA_FIELDS)
def delta_content_as_str(content: object) -> str:
"""Coerce a streamed delta content value to plain text.
OpenAI-compatible providers are supposed to stream delta.content as a
string, but some stream a list of content parts instead. Join the text
parts into one string and skip every part that carries no usable text, so
the running answer text neither loses the content nor raises when a part
holds a null value or is not a mapping at all.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
texts: Final[list[str]] = [] # mutable-ok: accumulator of streamed delta content text parts
for part in content: # pyright: ignore[reportUnknownVariableType] # delta content parts are dynamically typed
text = part.get("text") if isinstance(part, Mapping) else getattr(part, "text", None) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportUnknownArgumentType] # dynamic delta parts
if isinstance(text, str): # pyright: ignore[reportUnknownArgumentType] # dynamic text value
texts.append(text) # pyright: ignore[reportUnknownArgumentType] # dynamic text value
return "".join(texts)
def _simple_text_part(choices: Sequence[object]) -> str | None:
deltas: Final = tuple(_stream_choice_delta(choice) for choice in choices)
if any(_delta_carries_more_than_text(delta) for delta in deltas):
return None
content: Final = deltas[0].get("content")
return content if isinstance(content, str) else ""
return delta_content_as_str(content)
_NO_TRACE_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})

View file

@ -1102,9 +1102,17 @@ class CustomStreamWrapper:
model_response: ModelResponseStream,
response_obj: Mapping[str, object],
) -> bool:
from litellm.litellm_core_utils.streaming_chunk_builder_utils import (
delta_content_as_str,
)
# Providers may stream delta.content as a list of content parts; fold it to
# text here too, otherwise every chunk after the first is judged empty and
# dropped (only the first survives, via the role clause below).
content_str: Final = delta_content_as_str(completion_obj.get("content"))
if (
"content" in completion_obj
and (isinstance(completion_obj["content"], str) and len(completion_obj["content"]) > 0)
and len(content_str) > 0
or (
"tool_calls" in completion_obj
and completion_obj["tool_calls"] is not None
@ -2840,9 +2848,20 @@ class CustomStreamWrapper:
return None if is_model_response_stream_empty(model_response=usage_free_chunk) else usage_free_chunk
def _accumulate_streamed_delta(self, delta: object) -> None:
from litellm.litellm_core_utils.streaming_chunk_builder_utils import (
delta_content_as_str,
)
get: Final = getattr(delta, "get", None)
content: Final = get("content") if callable(get) else None
self.response_uptil_now += content if isinstance(content, str) else ""
content: Final = get("content") if callable(get) else getattr(delta, "content", None)
if isinstance(content, str):
self.response_uptil_now += content
else:
# Providers that stream delta.content as a list of content parts must
# still accumulate as plain text. delta_content_as_str folds those
# parts and yields "" for anything that is not a content list, which is
# exactly what a non-text delta contributed before.
self.response_uptil_now += delta_content_as_str(content)
if not self._emitted_disqualifying_content and self._delta_disqualifies_continuation(delta):
self._emitted_disqualifying_content = True

View file

@ -2522,6 +2522,71 @@ def test_stream_chunk_builder_rebuilds_each_text_completion_choice_by_index() ->
assert (response.usage.prompt_tokens, response.usage.completion_tokens) == (1, 4)
def test_stream_chunk_builder_joins_content_part_list_delta():
"""A provider streaming delta.content as a content-part list must aggregate as text."""
chunks = [
ModelResponseStream(
id="chatcmpl-123",
created=1,
model="gpt-4.1-mini",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason=None,
index=0,
delta=Delta(content=[{"type": "text", "text": "Hello "}], role="assistant"),
)
],
),
ModelResponseStream(
id="chatcmpl-123",
created=2,
model="gpt-4.1-mini",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason="stop",
index=0,
delta=Delta(content=[{"type": "text", "text": "world"}], role=None),
)
],
),
]
response = stream_chunk_builder(chunks=chunks)
assert response is not None
assert response.choices[0].message.content == "Hello world"
def test_delta_content_as_str_skips_parts_without_usable_text():
"""Parts holding a null value or not being mappings must not abort the join."""
from litellm.litellm_core_utils.streaming_chunk_builder_utils import delta_content_as_str
assert delta_content_as_str([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]) == "ab"
assert delta_content_as_str([{"type": "text", "text": None}]) == ""
assert delta_content_as_str(["bare string", 42, None]) == ""
assert (
delta_content_as_str(
[
{"type": "text", "text": "a"},
{"type": "text", "text": None},
{"type": "text", "text": "b"},
]
)
== "ab"
)
def test_delta_content_as_str_reads_text_from_non_mapping_parts():
"""A part exposing only a ``text`` attribute still contributes its text."""
from litellm.litellm_core_utils.streaming_chunk_builder_utils import delta_content_as_str
class _TextPart:
text = "from attribute"
assert delta_content_as_str([_TextPart()]) == "from attribute"
def test_stream_chunk_builder_keeps_null_text_completion_logprobs_fields_null() -> None:
response: Final = litellm.stream_chunk_builder(
chunks=[

View file

@ -9618,3 +9618,66 @@ def test_chunk_creator_usage_only_chunk_with_dict_usage_keeps_details(
assert usage.prompt_tokens_details.cached_tokens == 80
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 5
def _chunk_with_list_content(text: str, chunk_id: str, created: int) -> ModelResponseStream:
return ModelResponseStream(
id=chunk_id,
object="chat.completion.chunk",
created=created,
model="gpt-5.6",
choices=[
StreamingChoices(
index=0,
delta=Delta(role="assistant", content=[{"type": "text", "text": text}]),
finish_reason=None,
)
],
)
def test_streaming_delta_content_list_accumulates_as_str():
"""A provider streaming delta.content as a content-part list must still accumulate as text."""
chunks: Final = [
_chunk_with_list_content("hello ", "chunk1", 1741037890),
_chunk_with_list_content("world", "chunk2", 1741037891),
]
response: Final = CustomStreamWrapper(
completion_stream=iter(chunks),
model="gpt-5.6",
logging_obj=MagicMock(),
custom_llm_provider="openai",
)
for _ in response:
pass
assert response.response_uptil_now == "hello world"
def test_streaming_accumulates_content_part_with_null_text():
"""A content part carrying a null value must not abort the accumulated stream."""
chunk: Final = ModelResponseStream(
id="chunk-null",
object="chat.completion.chunk",
created=1741037892,
model="gpt-5.6",
choices=[
StreamingChoices(
index=0,
delta=Delta(role="assistant", content=[{"type": "text", "text": None}]),
finish_reason=None,
)
],
)
response: Final = CustomStreamWrapper(
completion_stream=iter([chunk]),
model="gpt-5.6",
logging_obj=MagicMock(),
custom_llm_provider="openai",
)
for _ in response:
pass
assert response.response_uptil_now == ""