mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
fix(responses): include encrypted_content and status in reasoning output_item.done (#44657)
This commit is contained in:
parent
5012083b72
commit
822d5934f3
2 changed files with 151 additions and 19 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue