diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 219944de521..b1787c6573a 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -1129,7 +1129,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): "id": self._cached_reasoning_item_id, "type": "reasoning", "status": "in_progress", - "summary": None, + "summary": [], } ), ) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index bc92bc9cb3a..8cae403f415 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -3436,24 +3436,29 @@ class LiteLLMCompletionResponsesConfig: encrypted_content = LiteLLMCompletionResponsesConfig._encode_thinking_blocks(message) if reasoning_content or encrypted_content: # Only check the first choice for reasoning content + # summary and encrypted_content are reasoning-only extras the shared model does not declare return [ - GenericResponseOutputItem( - type="reasoning", - id=mint_reasoning_item_id(), - status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( - choice.finish_reason - ), - role="assistant", - content=[ - OutputText( - type="output_text", - text=text, - annotations=[], - ) - for text in (reasoning_content,) - if text - ], - encrypted_content=encrypted_content, + GenericResponseOutputItem.model_validate( + { + "type": "reasoning", + "id": mint_reasoning_item_id(), + "status": LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( + choice.finish_reason + ), + "role": "assistant", + "content": [ + OutputText( + type="output_text", + text=text, + annotations=[], + ) + for text in (reasoning_content,) + if text + ], + # Required by the Responses schema. The reasoning text rides in content, so it stays empty + "summary": [], + "encrypted_content": encrypted_content, + } ) ] return [] diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 3fd4be6f722..e1b04b28f86 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -503,6 +503,39 @@ class TestLiteLLMCompletionResponsesConfig: assert reasoning_items[0].content[0].text == "counting the primes" assert "sig" in reasoning_items[0].encrypted_content + @pytest.mark.parametrize( + "message", + [ + pytest.param( + Message(content="No, 1001 = 7 x 11 x 13", role="assistant", reasoning_content="Trying small primes"), + id="gemini-thought-text", + ), + pytest.param( + Message( + content="No", + role="assistant", + reasoning_content="", + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "sig"}], + ), + id="signature-only-thinking", + ), + ], + ) + def test_reasoning_item_always_carries_a_summary_list(self, message: Message): + """`summary` is required on a Responses reasoning item, and clients that read it + (e.g. OpenAI Agents SDK tracing) fail on the missing field""" + response: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Is 1001 prime?", + responses_api_request={}, + chat_completion_response=ModelResponse( + model="gemini-3.8-flash", choices=[Choices(index=0, finish_reason="stop", message=message)] + ), + ) + + reasoning_items: Final = [item for item in response.output if item.type == "reasoning"] + assert len(reasoning_items) == 1 + assert reasoning_items[0].summary == [] + def test_transform_chat_completion_response_status_with_stop(self): """ Test that transforming a chat completion response with 'stop' finish_reason 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 ed90acbd2fa..31ce6d11bff 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 @@ -1173,6 +1173,30 @@ async def test_signature_only_thinking_streams_a_replayable_reasoning_item(sync_ assert json.loads(reasoning_items[0].encrypted_content)[0]["signature"] == "sig_only" +@pytest.mark.parametrize( + "stream_chunks", + [_reasoning_then_text_chunks, _signed_thinking_and_text_in_one_chunk], + ids=["gemini-thought-text", "signature-only-thinking"], +) +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_streamed_reasoning_item_carries_a_summary_list_from_added_to_completed( + stream_chunks: StreamChunks, sync_mode: bool +): + events: Final = await _collect_events(_build_iterator(stream_chunks()), sync_mode) + + added: Final = next( + event.item + for event in events + if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED and event.item.type == "reasoning" + ) + completed: Final = next( + event for event in events if getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + ) + assert added.summary == [] + assert [item.summary for item in completed.response.output if item.type == "reasoning"] == [[]] + + @REASONING_THEN_TEXT_STREAMS @pytest.mark.parametrize("sync_mode", [True, False]) @pytest.mark.asyncio