From 3bc3be8f928c76a8257d359439ab2364d73c38e2 Mon Sep 17 00:00:00 2001 From: Piyush 100x Date: Mon, 16 Feb 2026 19:48:18 +0530 Subject: [PATCH 1/6] Add full support for in OpenAI Responses API --- .../transformation.py | 133 +++++- .../test_thinking_blocks_transformation.py | 412 ++++++++++++++++++ 2 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index e546a0dbb02..fae89494c90 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -49,6 +49,7 @@ ALL_RESPONSES_API_TOOL_PARAMS, AllMessageValues, ChatCompletionImageObject, + ChatCompletionRedactedThinkingBlock, ChatCompletionThinkingBlock, OpenAIMessageContentListBlock, ) @@ -199,6 +200,18 @@ def convert_chat_completion_messages_to_responses_api( } ) elif role == "assistant" and tool_calls and isinstance(tool_calls, list): + # Handle thinking blocks first if present + thinking_blocks = msg.get("thinking_blocks") + if thinking_blocks and isinstance(thinking_blocks, list): + reasoning_items = self._convert_thinking_blocks_to_reasoning_items( + thinking_blocks + ) + input_items.extend(reasoning_items) + verbose_logger.debug( + f"Responses API: Added {len(reasoning_items)} reasoning items before tool calls" + ) + + # Then handle tool calls for tool_call in tool_calls: function = tool_call.get("function") if function: @@ -214,7 +227,19 @@ def convert_chat_completion_messages_to_responses_api( else: raise ValueError(f"tool call not supported: {tool_call}") elif content is not None: - # Regular user/assistant message + # Handle thinking blocks first if present (for assistant role) + if role == "assistant" and "thinking_blocks" in msg: + thinking_blocks = msg.get("thinking_blocks") + if thinking_blocks and isinstance(thinking_blocks, list): + reasoning_items = self._convert_thinking_blocks_to_reasoning_items( + thinking_blocks + ) + input_items.extend(reasoning_items) + verbose_logger.debug( + f"Responses API: Added {len(reasoning_items)} reasoning items before message" + ) + + # Then regular user/assistant message input_items.append( { "type": "message", @@ -227,6 +252,81 @@ def convert_chat_completion_messages_to_responses_api( return input_items, instructions + def _convert_thinking_blocks_to_reasoning_items( + self, + thinking_blocks: List[ + Union["ChatCompletionThinkingBlock", "ChatCompletionRedactedThinkingBlock"] + ], + ) -> List[Dict[str, Any]]: + """ + Convert thinking_blocks to reasoning items for Responses API. + + Args: + thinking_blocks: List of thinking blocks from Chat Completions format + + Returns: + List of reasoning items in Responses API format + + Handles two types of thinking blocks: + - type: "thinking" -> Creates reasoning item with summary (truncated to 100 chars) + - type: "redacted_thinking" -> Creates reasoning item with encrypted_content preserved + """ + reasoning_items: List[Dict[str, Any]] = [] + + for block in thinking_blocks: + block_type = block.get("type") + + if block_type == "thinking": + # Regular thinking -> reasoning with summary + thinking_text = block.get("thinking", "") + + # Create summary (truncate to 100 chars if needed) + summary_text = thinking_text + if len(thinking_text) > 100: + summary_text = thinking_text[:100] + "..." + + reasoning_item: Dict[str, Any] = { + "type": "reasoning", + "role": "assistant", + } + + # Only add summary if there's actual thinking text + if thinking_text: + reasoning_item["summary"] = [ + {"type": "summary_text", "text": summary_text} + ] + + reasoning_items.append(reasoning_item) + verbose_logger.debug( + f"Responses API: Converted thinking block to reasoning item with summary" + ) + + elif block_type == "redacted_thinking": + # Redacted thinking -> reasoning with encrypted_content + encrypted_data = block.get("data") + + reasoning_item: Dict[str, Any] = { + "type": "reasoning", + "role": "assistant", + } + + # Preserve encrypted_content if present + if encrypted_data is not None: + reasoning_item["encrypted_content"] = encrypted_data + + reasoning_items.append(reasoning_item) + verbose_logger.debug( + f"Responses API: Converted redacted_thinking block, encrypted_content preserved: {encrypted_data is not None}" + ) + + else: + # Unknown type - skip with warning + verbose_logger.warning( + f"Responses API: Unknown thinking block type '{block_type}', skipping" + ) + + return reasoning_items + def _map_optional_params_to_responses_api_request( self, optional_params: dict, @@ -407,6 +507,7 @@ def _convert_response_output_to_choices( choices: List[Choices] = [] index = 0 reasoning_content: Optional[str] = None + thinking_blocks_list: List[Dict[str, Any]] = [] # Collect all tool calls to put them in a single choice # (Chat Completions API expects all tool calls in one message) @@ -415,10 +516,36 @@ def _convert_response_output_to_choices( for item in output_items: if isinstance(item, ResponseReasoningItem): + # Extract reasoning_content from summary for summary_item in item.summary: response_text = getattr(summary_item, "text", "") reasoning_content = response_text if response_text else "" + # Extract encrypted_content and convert to thinking_blocks + encrypted_content = getattr(item, "encrypted_content", None) + if encrypted_content is not None: + # Create redacted_thinking block with encrypted_content + thinking_block: Dict[str, Any] = { + "type": "redacted_thinking", + "data": encrypted_content, + } + thinking_blocks_list.append(thinking_block) + verbose_logger.debug( + f"Responses API: Extracted encrypted_content from ResponseReasoningItem, " + f"converted to thinking_blocks (type: redacted_thinking)" + ) + elif reasoning_content: + # If no encrypted_content but have reasoning text, create regular thinking block + thinking_block = { + "type": "thinking", + "thinking": reasoning_content, + } + thinking_blocks_list.append(thinking_block) + verbose_logger.debug( + f"Responses API: Extracted reasoning text from ResponseReasoningItem, " + f"converted to thinking_blocks (type: thinking)" + ) + elif isinstance(item, ResponseOutputMessage): for content in item.content: response_text = getattr(content, "text", "") @@ -431,6 +558,7 @@ def _convert_response_output_to_choices( role=item.role, content=response_text if response_text else "", reasoning_content=reasoning_content, + thinking_blocks=thinking_blocks_list if thinking_blocks_list else None, annotations=annotations, ) @@ -443,6 +571,7 @@ def _convert_response_output_to_choices( ) reasoning_content = None # flush reasoning content + thinking_blocks_list = [] # flush thinking blocks index += 1 elif isinstance(item, ResponseFunctionToolCall): @@ -471,11 +600,13 @@ def _convert_response_output_to_choices( content=None, tool_calls=accumulated_tool_calls, reasoning_content=reasoning_content, + thinking_blocks=thinking_blocks_list if thinking_blocks_list else None, ) choices.append( Choices(message=msg, finish_reason="tool_calls", index=index) ) reasoning_content = None + thinking_blocks_list = [] return choices diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py new file mode 100644 index 00000000000..5f9eef21eda --- /dev/null +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py @@ -0,0 +1,412 @@ +""" +Test suite for thinking_blocks transformation in OpenAI Responses API. + +Tests the conversion of thinking_blocks from Chat Completion format to +OpenAI Responses API reasoning items format. +""" + +import json +import os +import sys +from typing import List +from unittest.mock import MagicMock + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system-path + +import litellm +from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, +) + + +def test_regular_thinking_block_conversion(): + """ + Test that regular thinking blocks (type: 'thinking') are converted to reasoning items + with summary generated from thinking text. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "content": "The answer is 42", + "thinking_blocks": [ + { + "type": "thinking", + "thinking": "Let me think about this problem step by step...", + "signature": "abc123", + } + ], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Find reasoning item + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 1, "Should have 1 reasoning item" + + reasoning_item = reasoning_items[0] + assert reasoning_item["type"] == "reasoning" + assert reasoning_item["role"] == "assistant" + assert "summary" in reasoning_item + assert len(reasoning_item["summary"]) == 1 + assert reasoning_item["summary"][0]["type"] == "summary_text" + assert ( + "Let me think about this problem" in reasoning_item["summary"][0]["text"] + ), "Summary should contain thinking text" + + # Verify message comes after reasoning + message_items = [item for item in input_items if item.get("type") == "message"] + assert len(message_items) == 1, "Should have 1 message item" + reasoning_index = input_items.index(reasoning_item) + message_index = input_items.index(message_items[0]) + assert ( + reasoning_index < message_index + ), "Reasoning item should come before message" + + +def test_thinking_block_truncation(): + """ + Test that thinking text longer than 100 chars is truncated with '...' in summary. + """ + handler = LiteLLMResponsesTransformationHandler() + + long_thinking = "A" * 150 # 150 character string + + messages = [ + { + "role": "assistant", + "content": "Answer", + "thinking_blocks": [{"type": "thinking", "thinking": long_thinking}], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 1 + + summary_text = reasoning_items[0]["summary"][0]["text"] + assert len(summary_text) == 103, "Should be 100 chars + '...'" + assert summary_text.endswith("..."), "Should end with '...'" + assert summary_text.startswith("A" * 100), "Should start with first 100 chars" + + +def test_redacted_thinking_block_conversion(): + """ + Test that redacted thinking blocks (type: 'redacted_thinking') are converted + to reasoning items with encrypted_content preserved. + """ + handler = LiteLLMResponsesTransformationHandler() + + encrypted_blob = "encrypted_blob_xyz789" + + messages = [ + { + "role": "assistant", + "content": "The answer is 42", + "thinking_blocks": [ + {"type": "redacted_thinking", "data": encrypted_blob} + ], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Find reasoning item + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 1, "Should have 1 reasoning item" + + reasoning_item = reasoning_items[0] + assert reasoning_item["type"] == "reasoning" + assert reasoning_item["role"] == "assistant" + assert "encrypted_content" in reasoning_item + assert ( + reasoning_item["encrypted_content"] == encrypted_blob + ), "encrypted_content should match data field" + assert ( + "summary" not in reasoning_item + ), "Redacted thinking should not have summary" + + +def test_multiple_thinking_blocks(): + """ + Test that multiple thinking blocks (mixed types) are all converted in correct order. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "content": "Final answer", + "thinking_blocks": [ + {"type": "thinking", "thinking": "First thought"}, + {"type": "redacted_thinking", "data": "encrypted_1"}, + {"type": "thinking", "thinking": "Second thought"}, + {"type": "redacted_thinking", "data": "encrypted_2"}, + ], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Find all reasoning items + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 4, "Should have 4 reasoning items" + + # Verify order and content + assert "summary" in reasoning_items[0] + assert "First thought" in reasoning_items[0]["summary"][0]["text"] + + assert "encrypted_content" in reasoning_items[1] + assert reasoning_items[1]["encrypted_content"] == "encrypted_1" + + assert "summary" in reasoning_items[2] + assert "Second thought" in reasoning_items[2]["summary"][0]["text"] + + assert "encrypted_content" in reasoning_items[3] + assert reasoning_items[3]["encrypted_content"] == "encrypted_2" + + # Verify all reasoning items come before message + message_items = [item for item in input_items if item.get("type") == "message"] + assert len(message_items) == 1 + message_index = input_items.index(message_items[0]) + for reasoning_item in reasoning_items: + reasoning_index = input_items.index(reasoning_item) + assert reasoning_index < message_index, "All reasoning before message" + + +def test_thinking_blocks_with_tool_calls(): + """ + Test that thinking blocks are added BEFORE tool_calls in the output. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "thinking_blocks": [ + {"type": "thinking", "thinking": "I need to call a function"} + ], + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "SF"}', + }, + } + ], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Find reasoning and function_call items + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + function_call_items = [ + item for item in input_items if item.get("type") == "function_call" + ] + + assert len(reasoning_items) == 1, "Should have 1 reasoning item" + assert len(function_call_items) == 1, "Should have 1 function_call item" + + # Verify order: reasoning BEFORE function_call + reasoning_index = input_items.index(reasoning_items[0]) + function_call_index = input_items.index(function_call_items[0]) + assert ( + reasoning_index < function_call_index + ), "Reasoning should come before function call" + + # Verify reasoning content + assert "summary" in reasoning_items[0] + assert "I need to call a function" in reasoning_items[0]["summary"][0]["text"] + + # Verify function_call content + assert function_call_items[0]["call_id"] == "call_123" + assert function_call_items[0]["name"] == "get_weather" + + +def test_empty_and_none_thinking_blocks(): + """ + Test that empty or None thinking_blocks are handled gracefully without errors. + """ + handler = LiteLLMResponsesTransformationHandler() + + # Test with None thinking_blocks + messages_none = [ + {"role": "assistant", "content": "Answer", "thinking_blocks": None} + ] + + input_items_none, _ = handler.convert_chat_completion_messages_to_responses_api( + messages_none + ) + reasoning_items_none = [ + item for item in input_items_none if item.get("type") == "reasoning" + ] + assert len(reasoning_items_none) == 0, "Should have no reasoning items for None" + + # Test with empty list thinking_blocks + messages_empty = [{"role": "assistant", "content": "Answer", "thinking_blocks": []}] + + input_items_empty, _ = handler.convert_chat_completion_messages_to_responses_api( + messages_empty + ) + reasoning_items_empty = [ + item for item in input_items_empty if item.get("type") == "reasoning" + ] + assert len(reasoning_items_empty) == 0, "Should have no reasoning items for empty" + + # Test without thinking_blocks key + messages_no_key = [{"role": "assistant", "content": "Answer"}] + + input_items_no_key, _ = handler.convert_chat_completion_messages_to_responses_api( + messages_no_key + ) + reasoning_items_no_key = [ + item for item in input_items_no_key if item.get("type") == "reasoning" + ] + assert ( + len(reasoning_items_no_key) == 0 + ), "Should have no reasoning items without key" + + # All should have message items + assert len([item for item in input_items_none if item.get("type") == "message"]) == 1 + assert ( + len([item for item in input_items_empty if item.get("type") == "message"]) == 1 + ) + assert ( + len([item for item in input_items_no_key if item.get("type") == "message"]) == 1 + ) + + +def test_multi_turn_conversation_with_encrypted_content(): + """ + Test that encrypted_content is preserved across multi-turn conversations. + This simulates passing thinking_blocks from a previous response back in the next request. + """ + handler = LiteLLMResponsesTransformationHandler() + + # Simulate a multi-turn conversation + encrypted_content_from_previous = "encrypted_response_abc123" + + messages = [ + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": "4", + "thinking_blocks": [ + {"type": "redacted_thinking", "data": encrypted_content_from_previous} + ], + }, + {"role": "user", "content": "Now multiply that by 3"}, + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Find reasoning item from assistant message + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 1, "Should have 1 reasoning item" + + # Verify encrypted_content is preserved exactly + assert "encrypted_content" in reasoning_items[0] + assert ( + reasoning_items[0]["encrypted_content"] == encrypted_content_from_previous + ), "encrypted_content must be preserved exactly for multi-turn" + + # Verify message order: user -> reasoning -> assistant message -> user + assert input_items[0]["type"] == "message" + assert input_items[0]["role"] == "user" + assert input_items[1]["type"] == "reasoning" + assert input_items[2]["type"] == "message" + assert input_items[2]["role"] == "assistant" + assert input_items[3]["type"] == "message" + assert input_items[3]["role"] == "user" + + +def test_unknown_thinking_block_type(): + """ + Test that unknown thinking block types are skipped gracefully without errors. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "content": "Answer", + "thinking_blocks": [ + {"type": "unknown_type", "some_field": "some_value"}, + {"type": "thinking", "thinking": "Valid thinking"}, + ], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Should only have 1 reasoning item (the valid one) + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert ( + len(reasoning_items) == 1 + ), "Should skip unknown type and only convert valid one" + assert "Valid thinking" in reasoning_items[0]["summary"][0]["text"] + + +def test_empty_thinking_text(): + """ + Test that thinking blocks with empty thinking text still create reasoning items. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "content": "Answer", + "thinking_blocks": [{"type": "thinking", "thinking": ""}], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 1, "Should create reasoning item even with empty text" + assert reasoning_items[0]["type"] == "reasoning" + assert reasoning_items[0]["role"] == "assistant" + # Empty thinking should not add summary + assert "summary" not in reasoning_items[0], "Empty thinking should not have summary" + + +def test_thinking_blocks_only_no_content(): + """ + Test that assistant messages with only thinking_blocks (no content) work correctly. + The transformation sets content to "" by default, so a message item is created. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "thinking_blocks": [{"type": "thinking", "thinking": "Just thinking..."}], + # Note: no content field (defaults to "") + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Should have reasoning item and message item (with empty content) + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + message_items = [item for item in input_items if item.get("type") == "message"] + + assert len(reasoning_items) == 1, "Should have reasoning item" + assert len(message_items) == 1, "Should have message item (with empty content)" + assert "Just thinking..." in reasoning_items[0]["summary"][0]["text"] + + # Verify reasoning comes before message + reasoning_index = input_items.index(reasoning_items[0]) + message_index = input_items.index(message_items[0]) + assert reasoning_index < message_index, "Reasoning should come before message" From a711547bbc30dbd3c4973d59397fa33c207a0ed1 Mon Sep 17 00:00:00 2001 From: Piyush 100x Date: Mon, 16 Feb 2026 20:10:13 +0530 Subject: [PATCH 2/6] increase test converage - added OUTPUT transformation tests --- .../test_thinking_blocks_transformation.py | 209 +++++++++++++++++- 1 file changed, 204 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py index 5f9eef21eda..dc78a11ba42 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py @@ -5,13 +5,8 @@ OpenAI Responses API reasoning items format. """ -import json import os import sys -from typing import List -from unittest.mock import MagicMock - -import pytest sys.path.insert( 0, os.path.abspath("../../..") @@ -410,3 +405,207 @@ def test_thinking_blocks_only_no_content(): reasoning_index = input_items.index(reasoning_items[0]) message_index = input_items.index(message_items[0]) assert reasoning_index < message_index, "Reasoning should come before message" + + +def test_output_transformation_encrypted_content(): + """ + Test OUTPUT transformation: ResponseReasoningItem with encrypted_content → thinking_blocks. + + This tests the response direction where we extract encrypted_content from + ResponseReasoningItem and convert it to thinking_blocks format. + """ + from openai.types.responses import ResponseReasoningItem, ResponseOutputMessage + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + # Mock ResponseReasoningItem with encrypted_content (what OpenAI returns) + reasoning_item = ResponseReasoningItem.model_construct( + id="rs_test_123", + type="reasoning", + summary=[{"type": "summary_text", "text": "Thinking..."}], + encrypted_content="ENCRYPTED_BLOB_XYZ789", # This should be extracted + status="completed", + ) + + message_item = ResponseOutputMessage.model_construct( + id="msg_test_456", + role="assistant", + content=[{"type": "output_text", "text": "The answer is 42", "annotations": []}], + type="message", + status="completed", + ) + + output_items = [reasoning_item, message_item] + + # Convert to Chat Completion format + choices = handler._convert_response_output_to_choices(output_items) + + assert len(choices) == 1, "Should have 1 choice" + + message = choices[0].message + + # Verify thinking_blocks extracted + assert hasattr(message, "thinking_blocks"), "Message should have thinking_blocks" + assert message.thinking_blocks is not None, "thinking_blocks should not be None" + assert len(message.thinking_blocks) == 1, "Should have 1 thinking block" + + # Verify correct type and encrypted_content + thinking_block = message.thinking_blocks[0] + assert thinking_block["type"] == "redacted_thinking", "Should be redacted_thinking type" + assert ( + thinking_block["data"] == "ENCRYPTED_BLOB_XYZ789" + ), "encrypted_content should be in 'data' field" + + +def test_output_transformation_multiple_reasoning_items(): + """ + Test OUTPUT transformation with multiple ResponseReasoningItems. + + Verify that all encrypted_content blocks are extracted and converted to thinking_blocks. + """ + from openai.types.responses import ResponseReasoningItem, ResponseOutputMessage + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + # Multiple reasoning items with encrypted_content + reasoning_item1 = ResponseReasoningItem.model_construct( + id="rs_1", + type="reasoning", + summary=[{"type": "summary_text", "text": "First thought"}], + encrypted_content="ENCRYPTED_1", + status="completed", + ) + + reasoning_item2 = ResponseReasoningItem.model_construct( + id="rs_2", + type="reasoning", + summary=[{"type": "summary_text", "text": "Second thought"}], + encrypted_content="ENCRYPTED_2", + status="completed", + ) + + message_item = ResponseOutputMessage.model_construct( + id="msg_1", + role="assistant", + content=[{"type": "output_text", "text": "Final answer", "annotations": []}], + type="message", + status="completed", + ) + + output_items = [reasoning_item1, reasoning_item2, message_item] + + # Convert to Chat Completion format + choices = handler._convert_response_output_to_choices(output_items) + + assert len(choices) == 1, "Should have 1 choice" + + message = choices[0].message + + # Verify all thinking_blocks extracted + assert message.thinking_blocks is not None, "thinking_blocks should not be None" + assert len(message.thinking_blocks) == 2, "Should have 2 thinking blocks" + + # Verify order and content + assert message.thinking_blocks[0]["type"] == "redacted_thinking" + assert message.thinking_blocks[0]["data"] == "ENCRYPTED_1" + + assert message.thinking_blocks[1]["type"] == "redacted_thinking" + assert message.thinking_blocks[1]["data"] == "ENCRYPTED_2" + + +def test_output_transformation_reasoning_without_encrypted_content(): + """ + Test OUTPUT transformation: ResponseReasoningItem WITHOUT encrypted_content. + + When there's no encrypted_content, should create a regular "thinking" block + from the summary text. + """ + from openai.types.responses import ResponseReasoningItem, ResponseOutputMessage + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + # Reasoning item WITHOUT encrypted_content (only summary) + reasoning_item = ResponseReasoningItem.model_construct( + id="rs_test", + type="reasoning", + summary=[ + {"type": "summary_text", "text": "Let me think about this problem step by step"} + ], + # No encrypted_content field + status="completed", + ) + + message_item = ResponseOutputMessage.model_construct( + id="msg_test", + role="assistant", + content=[{"type": "output_text", "text": "Answer", "annotations": []}], + type="message", + status="completed", + ) + + output_items = [reasoning_item, message_item] + + # Convert to Chat Completion format + choices = handler._convert_response_output_to_choices(output_items) + + assert len(choices) == 1, "Should have 1 choice" + + message = choices[0].message + + # Verify thinking_blocks extracted from summary + assert message.thinking_blocks is not None, "thinking_blocks should not be None" + assert len(message.thinking_blocks) == 1, "Should have 1 thinking block" + + # Verify it's a regular "thinking" block with text from summary + thinking_block = message.thinking_blocks[0] + assert thinking_block["type"] == "thinking", "Should be 'thinking' type (not redacted)" + assert ( + thinking_block["thinking"] == "Let me think about this problem step by step" + ), "Should extract text from summary" + + +def test_output_transformation_no_reasoning_items(): + """ + Test OUTPUT transformation with no ResponseReasoningItems. + + Verify that messages without reasoning items don't have thinking_blocks. + """ + from openai.types.responses import ResponseOutputMessage + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + # Only message item, no reasoning + message_item = ResponseOutputMessage.model_construct( + id="msg_test", + role="assistant", + content=[{"type": "output_text", "text": "Simple answer", "annotations": []}], + type="message", + status="completed", + ) + + output_items = [message_item] + + # Convert to Chat Completion format + choices = handler._convert_response_output_to_choices(output_items) + + assert len(choices) == 1, "Should have 1 choice" + + message = choices[0].message + + # Verify no thinking_blocks or thinking_blocks is None/empty + if hasattr(message, "thinking_blocks"): + assert ( + message.thinking_blocks is None or len(message.thinking_blocks) == 0 + ), "Should have no thinking_blocks" From babb3641a435badb4417f8f4b525efb7a9b5315f Mon Sep 17 00:00:00 2001 From: Piyush 100x Date: Mon, 16 Feb 2026 20:39:54 +0530 Subject: [PATCH 3/6] fix oepani req schema for response endpoint --- .../litellm_responses_transformation/transformation.py | 2 -- .../test_thinking_blocks_transformation.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index fae89494c90..bbfaa452f98 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -287,7 +287,6 @@ def _convert_thinking_blocks_to_reasoning_items( reasoning_item: Dict[str, Any] = { "type": "reasoning", - "role": "assistant", } # Only add summary if there's actual thinking text @@ -307,7 +306,6 @@ def _convert_thinking_blocks_to_reasoning_items( reasoning_item: Dict[str, Any] = { "type": "reasoning", - "role": "assistant", } # Preserve encrypted_content if present diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py index dc78a11ba42..1f8428f5016 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py @@ -47,7 +47,6 @@ def test_regular_thinking_block_conversion(): reasoning_item = reasoning_items[0] assert reasoning_item["type"] == "reasoning" - assert reasoning_item["role"] == "assistant" assert "summary" in reasoning_item assert len(reasoning_item["summary"]) == 1 assert reasoning_item["summary"][0]["type"] == "summary_text" @@ -119,7 +118,6 @@ def test_redacted_thinking_block_conversion(): reasoning_item = reasoning_items[0] assert reasoning_item["type"] == "reasoning" - assert reasoning_item["role"] == "assistant" assert "encrypted_content" in reasoning_item assert ( reasoning_item["encrypted_content"] == encrypted_blob @@ -371,7 +369,6 @@ def test_empty_thinking_text(): reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] assert len(reasoning_items) == 1, "Should create reasoning item even with empty text" assert reasoning_items[0]["type"] == "reasoning" - assert reasoning_items[0]["role"] == "assistant" # Empty thinking should not add summary assert "summary" not in reasoning_items[0], "Empty thinking should not have summary" From f468998dc94f5162d500b43ee3da2df4556bc522 Mon Sep 17 00:00:00 2001 From: Piyush 100x Date: Mon, 16 Feb 2026 20:43:20 +0530 Subject: [PATCH 4/6] fixes openai schema adherence --- .../litellm_responses_transformation/transformation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index bbfaa452f98..2ff420310db 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -306,6 +306,9 @@ def _convert_thinking_blocks_to_reasoning_items( reasoning_item: Dict[str, Any] = { "type": "reasoning", + # OpenAI requires empty summary array with encrypted_content + # See: https://developers.openai.com/cookbook/examples/responses_api/reasoning_items + "summary": [], } # Preserve encrypted_content if present From 841038b79734042167d8bf6da9df7756ca66257b Mon Sep 17 00:00:00 2001 From: Piyush 100x Date: Mon, 16 Feb 2026 20:58:21 +0530 Subject: [PATCH 5/6] fix tests --- .../transformation.py | 11 +++++------ .../test_thinking_blocks_transformation.py | 9 ++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 2ff420310db..d1114b08531 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -287,14 +287,10 @@ def _convert_thinking_blocks_to_reasoning_items( reasoning_item: Dict[str, Any] = { "type": "reasoning", + # OpenAI requires summary field (can be empty array if no text) + "summary": [{"type": "summary_text", "text": summary_text}] if thinking_text else [], } - # Only add summary if there's actual thinking text - if thinking_text: - reasoning_item["summary"] = [ - {"type": "summary_text", "text": summary_text} - ] - reasoning_items.append(reasoning_item) verbose_logger.debug( f"Responses API: Converted thinking block to reasoning item with summary" @@ -517,6 +513,9 @@ def _convert_response_output_to_choices( for item in output_items: if isinstance(item, ResponseReasoningItem): + # Reset reasoning_content for each reasoning item to avoid stale data + reasoning_content = None + # Extract reasoning_content from summary for summary_item in item.summary: response_text = getattr(summary_item, "text", "") diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py index 1f8428f5016..fb5e5e8297d 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py @@ -122,9 +122,8 @@ def test_redacted_thinking_block_conversion(): assert ( reasoning_item["encrypted_content"] == encrypted_blob ), "encrypted_content should match data field" - assert ( - "summary" not in reasoning_item - ), "Redacted thinking should not have summary" + # OpenAI requires empty summary array with encrypted_content (per OpenAI spec) + assert reasoning_item["summary"] == [], "Redacted thinking should have empty summary" def test_multiple_thinking_blocks(): @@ -369,8 +368,8 @@ def test_empty_thinking_text(): reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] assert len(reasoning_items) == 1, "Should create reasoning item even with empty text" assert reasoning_items[0]["type"] == "reasoning" - # Empty thinking should not add summary - assert "summary" not in reasoning_items[0], "Empty thinking should not have summary" + # OpenAI requires summary field (even if empty) - use empty array for empty thinking + assert reasoning_items[0]["summary"] == [], "Empty thinking should have empty summary array" def test_thinking_blocks_only_no_content(): From e82318934a3fa43a77d7c160d941104bd9fb528b Mon Sep 17 00:00:00 2001 From: Piyush 100x Date: Mon, 16 Feb 2026 21:51:02 +0530 Subject: [PATCH 6/6] cleanup --- .../transformation.py | 91 ++++++++----------- .../test_thinking_blocks_transformation.py | 35 ++++++- 2 files changed, 70 insertions(+), 56 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index d1114b08531..1ac398180e1 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -199,35 +199,9 @@ def convert_chat_completion_messages_to_responses_api( "output": tool_output, } ) - elif role == "assistant" and tool_calls and isinstance(tool_calls, list): - # Handle thinking blocks first if present - thinking_blocks = msg.get("thinking_blocks") - if thinking_blocks and isinstance(thinking_blocks, list): - reasoning_items = self._convert_thinking_blocks_to_reasoning_items( - thinking_blocks - ) - input_items.extend(reasoning_items) - verbose_logger.debug( - f"Responses API: Added {len(reasoning_items)} reasoning items before tool calls" - ) - - # Then handle tool calls - for tool_call in tool_calls: - function = tool_call.get("function") - if function: - input_tool_call = { - "type": "function_call", - "call_id": tool_call["id"], - } - if "name" in function: - input_tool_call["name"] = function["name"] - if "arguments" in function: - input_tool_call["arguments"] = function["arguments"] - input_items.append(input_tool_call) - else: - raise ValueError(f"tool call not supported: {tool_call}") - elif content is not None: - # Handle thinking blocks first if present (for assistant role) + else: + # Handle assistant and user messages + # For assistant: extract thinking_blocks FIRST (before content/tool_calls) if role == "assistant" and "thinking_blocks" in msg: thinking_blocks = msg.get("thinking_blocks") if thinking_blocks and isinstance(thinking_blocks, list): @@ -236,19 +210,37 @@ def convert_chat_completion_messages_to_responses_api( ) input_items.extend(reasoning_items) verbose_logger.debug( - f"Responses API: Added {len(reasoning_items)} reasoning items before message" + f"Responses API: Added {len(reasoning_items)} reasoning items" ) - # Then regular user/assistant message - input_items.append( - { - "type": "message", - "role": role, - "content": self._convert_content_to_responses_format( - content, cast(str, role) - ), - } - ) + # THEN handle message content: tool_calls OR regular content + if role == "assistant" and tool_calls and isinstance(tool_calls, list): + # Handle tool calls + for tool_call in tool_calls: + function = tool_call.get("function") + if function: + input_tool_call = { + "type": "function_call", + "call_id": tool_call["id"], + } + if "name" in function: + input_tool_call["name"] = function["name"] + if "arguments" in function: + input_tool_call["arguments"] = function["arguments"] + input_items.append(input_tool_call) + else: + raise ValueError(f"tool call not supported: {tool_call}") + elif content is not None: + # Handle regular message content (user or assistant) + input_items.append( + { + "type": "message", + "role": role, + "content": self._convert_content_to_responses_format( + content, cast(str, role) + ), + } + ) return input_items, instructions @@ -268,8 +260,11 @@ def _convert_thinking_blocks_to_reasoning_items( List of reasoning items in Responses API format Handles two types of thinking blocks: - - type: "thinking" -> Creates reasoning item with summary (truncated to 100 chars) + - type: "thinking" -> Creates reasoning item with full summary text preserved - type: "redacted_thinking" -> Creates reasoning item with encrypted_content preserved + + Per OpenAI docs, reasoning items should be passed back "untouched" between turns. + See: https://developers.openai.com/api/docs/guides/reasoning """ reasoning_items: List[Dict[str, Any]] = [] @@ -277,30 +272,24 @@ def _convert_thinking_blocks_to_reasoning_items( block_type = block.get("type") if block_type == "thinking": - # Regular thinking -> reasoning with summary + # Regular thinking -> reasoning with summary (full text, no truncation) thinking_text = block.get("thinking", "") - # Create summary (truncate to 100 chars if needed) - summary_text = thinking_text - if len(thinking_text) > 100: - summary_text = thinking_text[:100] + "..." - reasoning_item: Dict[str, Any] = { "type": "reasoning", - # OpenAI requires summary field (can be empty array if no text) - "summary": [{"type": "summary_text", "text": summary_text}] if thinking_text else [], + "summary": [{"type": "summary_text", "text": thinking_text}] if thinking_text else [], } reasoning_items.append(reasoning_item) verbose_logger.debug( - f"Responses API: Converted thinking block to reasoning item with summary" + "Responses API: Converted thinking block to reasoning item with summary" ) elif block_type == "redacted_thinking": # Redacted thinking -> reasoning with encrypted_content encrypted_data = block.get("data") - reasoning_item: Dict[str, Any] = { + reasoning_item = { "type": "reasoning", # OpenAI requires empty summary array with encrypted_content # See: https://developers.openai.com/cookbook/examples/responses_api/reasoning_items diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py index fb5e5e8297d..25c88b5347d 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_thinking_blocks_transformation.py @@ -64,9 +64,11 @@ def test_regular_thinking_block_conversion(): ), "Reasoning item should come before message" -def test_thinking_block_truncation(): +def test_thinking_block_full_text_preserved(): """ - Test that thinking text longer than 100 chars is truncated with '...' in summary. + Test that thinking text is passed through in full without any truncation. + + Per OpenAI docs, reasoning items should be passed back "untouched" between turns. """ handler = LiteLLMResponsesTransformationHandler() @@ -86,9 +88,8 @@ def test_thinking_block_truncation(): assert len(reasoning_items) == 1 summary_text = reasoning_items[0]["summary"][0]["text"] - assert len(summary_text) == 103, "Should be 100 chars + '...'" - assert summary_text.endswith("..."), "Should end with '...'" - assert summary_text.startswith("A" * 100), "Should start with first 100 chars" + assert len(summary_text) == 150, "Full text should be preserved without truncation" + assert summary_text == long_thinking, "Text should be passed through exactly as-is" def test_redacted_thinking_block_conversion(): @@ -372,6 +373,30 @@ def test_empty_thinking_text(): assert reasoning_items[0]["summary"] == [], "Empty thinking should have empty summary array" +def test_thinking_blocks_with_explicit_none_content(): + """ + Test edge case: assistant message with thinking_blocks, content=None, no tool_calls. + + This should NOT silently drop thinking_blocks - they should still be converted. + """ + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "content": None, # Explicitly None + "thinking_blocks": [{"type": "thinking", "thinking": "Processing..."}], + } + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + # Should have reasoning item even with content=None + reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] + assert len(reasoning_items) == 1, "Should have reasoning item despite content=None" + assert "Processing..." in reasoning_items[0]["summary"][0]["text"] + + def test_thinking_blocks_only_no_content(): """ Test that assistant messages with only thinking_blocks (no content) work correctly.