From 822d5934f3ffc2d37451e372e7d50a07f08f7811 Mon Sep 17 00:00:00 2001 From: ashishtransfirm Date: Sun, 18 Oct 2026 20:07:45 +0530 Subject: [PATCH] fix(responses): include encrypted_content and status in reasoning output_item.done (#44657) --- .../streaming_iterator.py | 76 +++++++++++---- .../test_streaming_iterator_transformation.py | 94 +++++++++++++++++++ 2 files changed, 151 insertions(+), 19 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 5c9e5cac5ab..ba2d595c523 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -159,6 +159,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._final_tool_events_queued: bool = False self._sequence_number: int = 0 self._cached_reasoning_item_id: str | None = None + self._cached_encrypted_reasoning_content: str | None = None self._sent_reasoning_summary_text_done_event: bool = False self._sent_reasoning_summary_part_done_event: bool = False self._reasoning_summary_text: str = "" @@ -917,6 +918,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): reasoning_item_id: str, reasoning_content: str, sequence_number: int, + encrypted_content: str | None = None, ) -> OutputItemDoneEvent: """ Create response.output_item.done event for reasoning items. @@ -929,31 +931,35 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): "item": { "id": "rs_0c5dae30e53172980069708ba2f59c8197b71ca9820edad07c", "type": "reasoning", + "status": "completed", "summary": [ { "type": "summary_text", "text": "**Clarifying the first humans**..." } - ] + ], + "encrypted_content": "..." } } """ + item_dict: Final[dict[str, object]] = { + "id": reasoning_item_id, + "type": "reasoning", + "status": "completed", + "summary": [ + { + "type": "summary_text", + "text": reasoning_content, + } + ], + } + if encrypted_content: + item_dict["encrypted_content"] = encrypted_content return OutputItemDoneEvent( type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, output_index=self._reasoning_index(), sequence_number=sequence_number, - item=BaseLiteLLMOpenAIResponseObject( - **{ - "id": reasoning_item_id, - "type": "reasoning", - "summary": [ - { - "type": "summary_text", - "text": reasoning_content, - } - ], - } - ), + item=BaseLiteLLMOpenAIResponseObject(**item_dict), ) def return_default_done_events( @@ -1000,6 +1006,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): return False def common_done_event_logic(self, sync_mode: bool = True) -> BaseLiteLLMOpenAIResponseObject: + if self._reasoning_active and not self._reasoning_done_emitted: + self._flush_pending_reasoning_done_events() + if self._pending_response_events: + return self._pending_response_events.pop(0) + if not self.litellm_model_response or isinstance(self.litellm_model_response, TextCompletionResponse): self.litellm_model_response = self.create_litellm_model_response() if self.litellm_model_response: @@ -1077,14 +1088,29 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._queue_message_item_added_events() return - def _queue_reasoning_done_events_on_reasoning_end(self, chunk: ModelResponseStream) -> None: + def _extract_streamed_thinking_blocks_encrypted(self) -> str | None: + if self._cached_encrypted_reasoning_content is not None: + return self._cached_encrypted_reasoning_content + chunks = [c for c in self.collected_chat_completion_chunks if c.get("choices")] + if not any((c["choices"][0].get("delta") or {}).get("thinking_blocks") for c in chunks): + return None + for c in chunks: + if not c.get("model"): + c["model"] = self.model or "unknown" + try: + built = stream_chunk_builder(chunks=chunks) + if built and built.choices and hasattr(built.choices[0], "message"): + self._cached_encrypted_reasoning_content = ( + LiteLLMCompletionResponsesConfig._encode_thinking_blocks(built.choices[0].message) + ) + return self._cached_encrypted_reasoning_content + except Exception: + return None + return None + + def _flush_pending_reasoning_done_events(self) -> None: if not self._reasoning_active or self._reasoning_done_emitted: return - delta: Final = chunk.choices[0].delta if chunk.choices else None - if delta and getattr(delta, "reasoning_content", None): - self._accumulated_reasoning_content_parts.append(delta.reasoning_content) - if not self._is_reasoning_end(chunk): - return reasoning_content: Final = "".join(self._accumulated_reasoning_content_parts) self._cached_reasoning_item_id = ( self._reasoning_item_id or self._cached_reasoning_item_id or mint_reasoning_item_id() @@ -1103,15 +1129,27 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): sequence_number=self._sequence_number, ) self._sequence_number += 1 + encrypted_content: Final = self._extract_streamed_thinking_blocks_encrypted() 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, + encrypted_content=encrypted_content, ) self._pending_response_events.extend([text_done_event, part_done_event, reasoning_output_item_done_event]) self._reasoning_done_emitted = True self._reasoning_active = False + def _queue_reasoning_done_events_on_reasoning_end(self, chunk: ModelResponseStream) -> None: + if not self._reasoning_active or self._reasoning_done_emitted: + return + delta: Final = chunk.choices[0].delta if chunk.choices else None + if delta and getattr(delta, "reasoning_content", None): + self._accumulated_reasoning_content_parts.append(delta.reasoning_content) + if not self._is_reasoning_end(chunk): + return + self._flush_pending_reasoning_done_events() + async def __anext__( self, ) -> ResponsesAPIStreamingResponse | ResponseCompletedEvent | BaseLiteLLMOpenAIResponseObject: diff --git a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index 1a3430224d1..4ef9d45b4a2 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -1524,3 +1524,97 @@ async def test_caller_function_named_like_the_search_tool_streams_as_function_ca assert [(item["type"], item["name"]) for item in output] == [("function_call", "tool_search_tool_regex")] assert "response.function_call_arguments.done" in [event["type"] for event in events] + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_reasoning_output_item_done_carries_encrypted_content_and_status(sync_mode: bool): + """ + Issue #44657: output_item.done for reasoning must carry encrypted_content and status: completed, + matching response.completed, so replay clients do not lose signatures across tool turns. + """ + chunk: Final = _chunk("Hello!", finish_reason="stop") + signed_block: Final = {"type": "thinking", "thinking": "Let me think", "signature": "sig_abc123"} + delta: Final = Delta(**{**chunk.choices[0].delta.model_dump(), "thinking_blocks": [signed_block]}) + chunks: Final = ( + chunk.model_copy(update={"choices": [StreamingChoices(index=0, delta=delta, finish_reason="stop")]}), + ) + iterator: Final = _build_iterator(chunks) + events: Final = await _collect_events(iterator, sync_mode) + + reasoning_done_events: Final = [ + event + for event in events + if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE + and getattr(event.item, "type", None) == "reasoning" + ] + assert len(reasoning_done_events) == 1 + reasoning_item: Final = reasoning_done_events[0].item + assert getattr(reasoning_item, "status", None) == "completed" + assert getattr(reasoning_item, "encrypted_content", None) == '[{"type":"thinking","thinking":"Let me think","signature":"sig_abc123"}]' + + # Verify response.completed carries the exact same encrypted_content and id + completed_event: Final = next(e for e in events if getattr(e, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED) + completed_reasoning: Final = next(item for item in completed_event.response.output if item.type == "reasoning") + assert completed_reasoning.id == getattr(reasoning_item, "id", None) + assert completed_reasoning.status == "completed" + assert completed_reasoning.encrypted_content == getattr(reasoning_item, "encrypted_content", None) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_reasoning_output_item_done_encrypted_content_incremental_stream(sync_mode: bool): + """ + Verify thinking blocks fragmented across streaming chunks are assembled into complete + encrypted_content on output_item.done. + """ + chunk1: Final = _chunk("", finish_reason=None) + delta1: Final = Delta(**{**chunk1.choices[0].delta.model_dump(), "thinking_blocks": [{"type": "thinking", "thinking": "Step 1 "}]}) + + chunk2: Final = _chunk("", finish_reason=None) + delta2: Final = Delta(**{**chunk2.choices[0].delta.model_dump(), "thinking_blocks": [{"type": "thinking", "thinking": "Step 2"}]}) + + chunk3: Final = _chunk("Hello!", finish_reason="stop") + delta3: Final = Delta(**{**chunk3.choices[0].delta.model_dump(), "thinking_blocks": [{"type": "thinking", "thinking": "", "signature": "sig_done"}]}) + + chunks: Final = ( + chunk1.model_copy(update={"choices": [StreamingChoices(index=0, delta=delta1, finish_reason=None)]}), + chunk2.model_copy(update={"choices": [StreamingChoices(index=0, delta=delta2, finish_reason=None)]}), + chunk3.model_copy(update={"choices": [StreamingChoices(index=0, delta=delta3, finish_reason="stop")]}), + ) + iterator: Final = _build_iterator(chunks) + events: Final = await _collect_events(iterator, sync_mode) + + reasoning_done_events: Final = [ + event + for event in events + if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE + and getattr(event.item, "type", None) == "reasoning" + ] + assert len(reasoning_done_events) == 1 + reasoning_item: Final = reasoning_done_events[0].item + assert getattr(reasoning_item, "status", None) == "completed" + assert getattr(reasoning_item, "encrypted_content", None) == '[{"type":"thinking","thinking":"Step 1 Step 2","signature":"sig_done"}]' + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_reasoning_output_item_done_without_signed_thinking_omits_encrypted_content(sync_mode: bool): + """ + Verify reasoning without signed thinking blocks has status: completed and omits encrypted_content. + """ + chunks: Final = _reasoning_then_text_chunks() + iterator: Final = _build_iterator(chunks) + events: Final = await _collect_events(iterator, sync_mode) + + reasoning_done_events: Final = [ + event + for event in events + if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE + and getattr(event.item, "type", None) == "reasoning" + ] + assert len(reasoning_done_events) == 1 + reasoning_item: Final = reasoning_done_events[0].item + assert getattr(reasoning_item, "status", None) == "completed" + assert getattr(reasoning_item, "encrypted_content", None) is None +