From 353896a6641f08408670c16bde146cb7afa7d46b Mon Sep 17 00:00:00 2001 From: agustin18 Date: Tue, 29 Sep 2026 02:50:38 +0000 Subject: [PATCH 1/4] fix(responses): preserve all reasoning items in non-streaming bridge (#43620) --- .../transformation.py | 35 +++--- ...responses_transformation_transformation.py | 108 ++++++++++++++++++ 2 files changed, 124 insertions(+), 19 deletions(-) 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 + From 3efd070c95a046394390ee722d39be8f4de2c533 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Tue, 29 Sep 2026 03:08:42 +0000 Subject: [PATCH 2/4] fix(responses): attach accumulated reasoning items to raw-dict callback choices --- .../transformation.py | 6 +++++ ...responses_transformation_transformation.py | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index ebd5bac82bc..092adf57ab7 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -780,6 +780,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): elif handle_raw_dict_callback is not None: choice, index = handle_raw_dict_callback(item=raw_item, index=index) if choice is not None: + if pending_reasoning_items: + choice.message.reasoning_items = _as_chat_reasoning_items(pending_reasoning_items) + if reasoning_content: + choice.message.reasoning_content = reasoning_content + pending_reasoning_items.clear() + reasoning_content = None choices.append(choice) else: pass # don't fail request if item in list is not supported 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 2e1758aada2..611d2f88e7b 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 @@ -4405,6 +4405,25 @@ def test_map_optional_params_verbosity_merges_into_text(): "message", id="multiple_reasoning_items_before_output_message", ), + pytest.param( + [ + { + "type": "reasoning", + "id": "rs_raw_1", + "summary": [{"type": "summary_text", "text": "Raw Step 1"}], + "encrypted_content": "enc_raw", + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Raw response text"}], + }, + ], + ["rs_raw_1"], + "Raw Step 1", + "raw_dict_message", + id="reasoning_items_before_raw_dict_message", + ), pytest.param( [ { @@ -4446,7 +4465,10 @@ def test_convert_response_output_to_choices_preserves_all_reasoning_items( ) handler: Final = LiteLLMResponsesTransformationHandler() - choices: Final = handler._convert_response_output_to_choices(items_to_pass) + choices: Final = handler._convert_response_output_to_choices( + items_to_pass, + handle_raw_dict_callback=handler._handle_raw_dict_response_item, + ) assert len(choices) == 1 msg = choices[0].message From 2660b750308f0ab7ac58ae945eb278853771ab33 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Tue, 29 Sep 2026 03:23:36 +0000 Subject: [PATCH 3/4] style(responses): format line length in transformation.py --- .../litellm_responses_transformation/transformation.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 092adf57ab7..f7d464ef629 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -704,11 +704,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): 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 - ) + reasoning_content = f"{reasoning_content} {step_text}".strip() if reasoning_content else step_text elif isinstance(item, ResponseOutputMessage): for content in item.content: From a2f5c85d1f03c703eedf9837c7b211188e9aa27e Mon Sep 17 00:00:00 2001 From: agustin18 Date: Tue, 29 Sep 2026 03:48:37 +0000 Subject: [PATCH 4/4] fix(types): remove invalid Final annotations from loop-scoped variables --- .../litellm_responses_transformation/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index f7d464ef629..a50e0eca90d 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -698,12 +698,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): tool_call_index = 0 for item in output_items: - reasoning_item: Final = _reasoning_item_from_output_item(item) + reasoning_item = _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")] + summary_texts = [s["text"] for s in reasoning_item["summary"] if s.get("text")] if summary_texts: - step_text: Final = " ".join(summary_texts) + step_text = " ".join(summary_texts) reasoning_content = f"{reasoning_content} {step_text}".strip() if reasoning_content else step_text elif isinstance(item, ResponseOutputMessage):