mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(responses): open the reasoning summary part only once reasoning text streams
A reasoning item with no summary text, such as signature-only thinking, streamed one empty summary part and closed with it while response.completed carried an empty summary list. The part is now announced right before the first reasoning delta, the done events are skipped when there is no text, and the item closes with the same empty summary the final response carries, matching OpenAI
This commit is contained in:
parent
37ae1ac75c
commit
964f8aa309
3 changed files with 39 additions and 31 deletions
|
|
@ -177,6 +177,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
self._cached_reasoning_item_id: str | None = None
|
||||
self._sent_reasoning_summary_text_done_event: bool = False
|
||||
self._sent_reasoning_summary_part_done_event: bool = False
|
||||
self._sent_reasoning_summary_part_added_event: bool = False
|
||||
self._reasoning_summary_text: str = ""
|
||||
# -- GENERIC RESPONSE-EVENTS PENDING QUEUE as required by fix --
|
||||
self._pending_response_events: list[BaseLiteLLMOpenAIResponseObject] = []
|
||||
|
|
@ -1035,12 +1036,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
**{
|
||||
"id": reasoning_item_id,
|
||||
"type": "reasoning",
|
||||
"summary": [
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": reasoning_content,
|
||||
}
|
||||
],
|
||||
"summary": [{"type": "summary_text", "text": reasoning_content}] if reasoning_content else [],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
@ -1157,9 +1153,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
),
|
||||
)
|
||||
event.__dict__["sequence_number"] = self._sequence_number
|
||||
self._pending_response_events.extend(
|
||||
[event, self.create_reasoning_summary_part_added_event(self._cached_reasoning_item_id)]
|
||||
)
|
||||
self._pending_response_events.append(event)
|
||||
return
|
||||
|
||||
# Tool-first
|
||||
|
|
@ -1186,25 +1180,27 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
)
|
||||
reasoning_item_id: Final = self._cached_reasoning_item_id
|
||||
self._queue_compaction_item_done()
|
||||
self._sequence_number += 1
|
||||
text_done_event: Final = self.create_reasoning_summary_text_done_event(
|
||||
reasoning_item_id=reasoning_item_id,
|
||||
reasoning_content=reasoning_content,
|
||||
sequence_number=self._sequence_number,
|
||||
)
|
||||
self._sequence_number += 1
|
||||
part_done_event: Final = self.create_reasoning_summary_part_done_event(
|
||||
reasoning_item_id=reasoning_item_id,
|
||||
reasoning_content=reasoning_content,
|
||||
sequence_number=self._sequence_number,
|
||||
)
|
||||
if reasoning_content:
|
||||
self._sequence_number += 1
|
||||
text_done_event: Final = self.create_reasoning_summary_text_done_event(
|
||||
reasoning_item_id=reasoning_item_id,
|
||||
reasoning_content=reasoning_content,
|
||||
sequence_number=self._sequence_number,
|
||||
)
|
||||
self._sequence_number += 1
|
||||
part_done_event: Final = self.create_reasoning_summary_part_done_event(
|
||||
reasoning_item_id=reasoning_item_id,
|
||||
reasoning_content=reasoning_content,
|
||||
sequence_number=self._sequence_number,
|
||||
)
|
||||
self._pending_response_events.extend([text_done_event, part_done_event])
|
||||
self._sequence_number += 1
|
||||
reasoning_output_item_done_event: Final = self.create_reasoning_output_item_done_event(
|
||||
reasoning_item_id=reasoning_item_id,
|
||||
reasoning_content=reasoning_content,
|
||||
sequence_number=self._sequence_number,
|
||||
)
|
||||
self._pending_response_events.extend([text_done_event, part_done_event, reasoning_output_item_done_event])
|
||||
self._pending_response_events.append(reasoning_output_item_done_event)
|
||||
self._reasoning_done_emitted = True
|
||||
self._reasoning_active = False
|
||||
|
||||
|
|
@ -1377,6 +1373,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
|
||||
if self._cached_reasoning_item_id is None:
|
||||
self._cached_reasoning_item_id = mint_reasoning_item_id()
|
||||
if not self._sent_reasoning_summary_part_added_event:
|
||||
self._sent_reasoning_summary_part_added_event = True
|
||||
self._pending_response_events.append(
|
||||
self.create_reasoning_summary_part_added_event(self._cached_reasoning_item_id)
|
||||
)
|
||||
|
||||
return ReasoningSummaryTextDeltaEvent(
|
||||
type=ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA,
|
||||
|
|
|
|||
|
|
@ -4834,9 +4834,9 @@ class TestEnsureOutputItemContentPartAdded:
|
|||
assert completed_event.type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED
|
||||
assert completed_event.response.status == "completed"
|
||||
|
||||
def test_reasoning_item_opens_a_summary_part_instead_of_a_content_part(self):
|
||||
"""Reasoning items announce their summary part, never a content_part.added event."""
|
||||
from litellm.types.llms.openai import OutputItemAddedEvent, ResponsePartAddedEvent
|
||||
def test_reasoning_item_does_not_emit_content_part_added(self):
|
||||
"""Reasoning items should not get a content_part.added event."""
|
||||
from litellm.types.llms.openai import OutputItemAddedEvent
|
||||
|
||||
iterator = self._make_iterator()
|
||||
chunk = self._make_reasoning_chunk()
|
||||
|
|
@ -4844,11 +4844,8 @@ class TestEnsureOutputItemContentPartAdded:
|
|||
iterator._ensure_output_item_for_chunk(chunk)
|
||||
|
||||
events = iterator._pending_response_events
|
||||
assert len(events) == 2
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], OutputItemAddedEvent)
|
||||
assert isinstance(events[1], ResponsePartAddedEvent)
|
||||
assert events[1].item_id == events[0].item.id
|
||||
assert events[1].output_index == events[0].output_index
|
||||
assert iterator.sent_content_part_added_event is False
|
||||
|
||||
def test_only_emits_once(self):
|
||||
|
|
|
|||
|
|
@ -1245,6 +1245,10 @@ def _split_reasoning_then_text_chunks() -> tuple[ModelResponseStream, ...]:
|
|||
return (_reasoning_chunk("let me "), _reasoning_chunk("think"), _chunk("Hello!", finish_reason="stop"))
|
||||
|
||||
|
||||
def _signature_only_thinking_then_text_chunks() -> tuple[ModelResponseStream, ...]:
|
||||
return (_signature_only_thinking_chunk("sig_only"), _chunk("4", finish_reason="stop"))
|
||||
|
||||
|
||||
def _streamed_reasoning_text(chunks: Sequence[ModelResponseStream]) -> str:
|
||||
return "".join(getattr(chunk.choices[0].delta, "reasoning_content", None) or "" for chunk in chunks)
|
||||
|
||||
|
|
@ -1277,11 +1281,17 @@ def _reasoning_item(items: Sequence[Mapping[str, object]]) -> Mapping[str, objec
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
"stream_chunks",
|
||||
[_split_reasoning_then_text_chunks, _reasoning_and_text_in_one_chunk, _signed_thinking_and_text_in_one_chunk],
|
||||
[
|
||||
_split_reasoning_then_text_chunks,
|
||||
_reasoning_and_text_in_one_chunk,
|
||||
_signed_thinking_and_text_in_one_chunk,
|
||||
_signature_only_thinking_then_text_chunks,
|
||||
],
|
||||
ids=[
|
||||
"split_reasoning_then_text_chunks",
|
||||
"reasoning_and_text_in_one_chunk",
|
||||
"signed_thinking_and_text_in_one_chunk",
|
||||
"signature_only_thinking_then_text_chunks",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||||
|
|
@ -1298,7 +1308,7 @@ async def test_sdk_stream_readers_can_place_every_reasoning_summary_delta(stream
|
|||
completed_summary: Final = _reasoning_item(completed_payload["response"]["output"])["summary"]
|
||||
assert "".join(part["text"] for part in accumulated) == _streamed_reasoning_text(chunks)
|
||||
assert _reasoning_item(done_items)["summary"] == accumulated
|
||||
assert "".join(part["text"] for part in completed_summary) == _streamed_reasoning_text(chunks)
|
||||
assert completed_summary == accumulated
|
||||
TypeAdapter(list[OpenAIReasoningSummaryPart]).validate_python([*accumulated, *completed_summary])
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue