diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 31af5a144eb..ebd5bac82bc 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -676,7 +676,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): from openai.types.responses import ( ResponseFunctionToolCall, ResponseOutputMessage, - ResponseReasoningItem, ) try: @@ -691,7 +690,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): choices: Final[list[Choices]] = [] index = 0 reasoning_content: str | None = None - pending_reasoning_item: _BuiltReasoningItem | None = None + pending_reasoning_items: Final[list[_BuiltReasoningItem]] = [] # mutable-ok: accumulator # Collect all tool calls to put them in a single choice # (Chat Completions API expects all tool calls in one message) @@ -699,13 +698,17 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): tool_call_index = 0 for item in output_items: - if isinstance(item, ResponseReasoningItem): - pending_reasoning_item = _build_reasoning_item( - item_id=item.id, - encrypted_content=getattr(item, "encrypted_content", None), - summary_raw=item.summary, - ) - reasoning_content = " ".join(s["text"] for s in pending_reasoning_item["summary"] if s.get("text")) + reasoning_item: Final = _reasoning_item_from_output_item(item) + if reasoning_item is not None: + pending_reasoning_items.append(reasoning_item) + summary_texts: Final = [s["text"] for s in reasoning_item["summary"] if s.get("text")] + if summary_texts: + step_text: Final = " ".join(summary_texts) + reasoning_content = ( + f"{reasoning_content} {step_text}".strip() + if reasoning_content + else step_text + ) elif isinstance(item, ResponseOutputMessage): for content in item.content: @@ -720,10 +723,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): content=response_text if response_text else "", reasoning_content=reasoning_content, annotations=annotations, - reasoning_items=cast( - list[ChatCompletionReasoningItem] | None, - ([pending_reasoning_item] if pending_reasoning_item is not None else None), - ), + reasoning_items=_as_chat_reasoning_items(pending_reasoning_items), ) choices.append( @@ -735,7 +735,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) reasoning_content = None # flush - pending_reasoning_item = None # flush + pending_reasoning_items.clear() # flush index += 1 elif isinstance(item, ResponseFunctionToolCall): @@ -790,14 +790,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): content=None, tool_calls=accumulated_tool_calls, reasoning_content=reasoning_content, - reasoning_items=cast( - list[ChatCompletionReasoningItem] | None, - ([pending_reasoning_item] if pending_reasoning_item is not None else None), - ), + reasoning_items=_as_chat_reasoning_items(pending_reasoning_items), ) choices.append(Choices(message=msg, finish_reason="tool_calls", index=index)) reasoning_content = None - pending_reasoning_item = None + pending_reasoning_items.clear() return choices diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index d0f9bad795d..2e1758aada2 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -4352,3 +4352,111 @@ def test_map_optional_params_verbosity_merges_into_text(): verbosity_only_request, ) assert verbosity_only_request["text"] == {"verbosity": "low"} + + +@pytest.mark.parametrize( + "raw_output_items, expected_reasoning_ids, expected_summary_text, target_type", + [ + pytest.param( + [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "Step 1"}], + "encrypted_content": "enc1", + }, + { + "type": "reasoning", + "id": "rs_2", + "summary": [{"type": "summary_text", "text": "Step 2"}], + "encrypted_content": "enc2", + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "search", + "arguments": "{}", + "status": "completed", + }, + ], + ["rs_1", "rs_2"], + "Step 1 Step 2", + "tool_calls", + id="multiple_reasoning_items_before_tool_call", + ), + pytest.param( + [ + { + "type": "reasoning", + "id": "rs_alpha", + "summary": [{"type": "summary_text", "text": "Analysis"}], + "encrypted_content": "enc_a", + }, + { + "type": "reasoning", + "id": "rs_beta", + "summary": [{"type": "summary_text", "text": "Conclusion"}], + "encrypted_content": "enc_b", + }, + ], + ["rs_alpha", "rs_beta"], + "Analysis Conclusion", + "message", + id="multiple_reasoning_items_before_output_message", + ), + pytest.param( + [ + { + "type": "function_call", + "id": "fc_solo", + "call_id": "call_solo", + "name": "lookup", + "arguments": "{}", + "status": "completed", + } + ], + None, + None, + "tool_calls", + id="zero_reasoning_items_returns_none", + ), + ], +) +def test_convert_response_output_to_choices_preserves_all_reasoning_items( + raw_output_items: list[dict[str, object]], + expected_reasoning_ids: list[str] | None, + expected_summary_text: str | None, + target_type: str, +) -> None: + """Verify non-streaming Responses API bridge retains all reasoning items across message and tool call turns.""" + from openai.types.responses import ResponseOutputMessage + from openai.types.responses.response_output_message import ResponseOutputText + + items_to_pass: Final[list[object]] = list(raw_output_items) # mutable-ok: fixture setup + if target_type == "message": + items_to_pass.append( + ResponseOutputMessage( + id="msg_1", + role="assistant", + status="completed", + type="message", + content=[ResponseOutputText(annotations=[], text="Done", type="output_text", logprobs=[])], + ) + ) + + handler: Final = LiteLLMResponsesTransformationHandler() + choices: Final = handler._convert_response_output_to_choices(items_to_pass) + + assert len(choices) == 1 + msg = choices[0].message + reasoning_items = getattr(msg, "reasoning_items", None) + reasoning_content = getattr(msg, "reasoning_content", None) + if expected_reasoning_ids is None: + assert reasoning_items is None + assert reasoning_content is None + else: + assert reasoning_items is not None + assert [item["id"] for item in reasoning_items] == expected_reasoning_ids + assert reasoning_content == expected_summary_text +