fix(responses): include encrypted_content and status in reasoning output_item.done (#44657)

This commit is contained in:
ashishtransfirm 2026-10-18 20:07:45 +05:30
parent 5012083b72
commit 822d5934f3
2 changed files with 151 additions and 19 deletions

View file

@ -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:

View file

@ -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