diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 5a6debc4af5..71dd2afd779 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -20,6 +20,7 @@ from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomPara from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam from openai.types.responses.tool_param import FunctionToolParam from pydantic import BaseModel +from typing_extensions import ReadOnly import litellm from litellm import ModelResponse @@ -105,6 +106,7 @@ class _BuiltReasoningItem(TypedDict): id: str encrypted_content: str | None summary: Sequence[_ReasoningSummaryText] + content: ReadOnly[Sequence[_ReasoningSummaryText]] def _get_reasoning_items( @@ -133,34 +135,64 @@ def _reasoning_input_items(msg: "AllMessageValues") -> list[dict[str, object]]: return [dict(item) for item in replayed] # mutable-ok: API message payload +def _normalize_reasoning_parts(parts: Iterable[object] | None, default_type: str) -> Sequence[_ReasoningSummaryText]: + normalized: Final[list[_ReasoningSummaryText]] = [] # mutable-ok: local accumulator, returned once + for part in parts or (): + if isinstance(part, dict): + normalized.append( + { # mutable-ok: freshly built per part + "type": part.get("type", default_type), + "text": part.get("text", ""), + } + ) + else: + normalized.append( + { # mutable-ok: freshly built per part + "type": getattr(part, "type", default_type), + "text": getattr(part, "text", ""), + } + ) + return normalized + + def _build_reasoning_item( item_id: str, encrypted_content: str | None, summary_raw: Iterable[object] | None, + content_raw: Iterable[object] | None = None, ) -> _BuiltReasoningItem: """Build a ChatCompletionReasoningItem-shaped dict from raw response data. Handles both pydantic objects (attribute access) and plain dicts. """ - summary: Final[list[_ReasoningSummaryText]] = [] - for s in summary_raw or []: - if isinstance(s, dict): - summary.append({"type": s.get("type", "summary_text"), "text": s.get("text", "")}) - else: - summary.append( - { - "type": getattr(s, "type", "summary_text"), - "text": getattr(s, "text", ""), - } - ) + summary: Final[Sequence[_ReasoningSummaryText]] = _normalize_reasoning_parts( + summary_raw, default_type="summary_text" + ) + content: Final[Sequence[_ReasoningSummaryText]] = _normalize_reasoning_parts( + content_raw, default_type="reasoning_text" + ) return { "id": item_id, "type": "reasoning", "encrypted_content": encrypted_content, "summary": summary, + "content": content, } +def _reasoning_content_from_built_item(reasoning_item: _BuiltReasoningItem) -> str: + """Chat ``reasoning_content`` for a reasoning item. + + Raw reasoning content (e.g. vLLM ``reasoning_text`` parts) is the full chain of + thought and wins when present; the summary is the fallback so the two are never + concatenated into duplicate text. + """ + content_text: Final = " ".join(part["text"] for part in reasoning_item["content"] if part.get("text")) + if content_text: + return content_text + return " ".join(s["text"] for s in reasoning_item["summary"] if s.get("text")) + + def _reasoning_item_from_output_item(item: object) -> _BuiltReasoningItem | None: from openai.types.responses import ResponseReasoningItem @@ -169,13 +201,24 @@ def _reasoning_item_from_output_item(item: object) -> _BuiltReasoningItem | None item_id=item.id, encrypted_content=getattr(item, "encrypted_content", None), summary_raw=item.summary, + content_raw=getattr(item, "content", None), ) if isinstance(item, dict) and item.get("type") == "reasoning": return _build_reasoning_item( item_id=item.get("id", ""), encrypted_content=item.get("encrypted_content"), summary_raw=item.get("summary"), + content_raw=item.get("content"), ) + if isinstance(item, BaseModel): + dumped: Final = item.model_dump() + if dumped.get("type") == "reasoning": + return _build_reasoning_item( + item_id=dumped.get("id", ""), + encrypted_content=dumped.get("encrypted_content"), + summary_raw=dumped.get("summary"), + content_raw=dumped.get("content"), + ) return None @@ -325,9 +368,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): item_type: Final = item.get("type") - # Ignore reasoning items for now - if item_type == "reasoning": - return None, index + # Reasoning dicts are intercepted by _convert_response_output_to_choices + # before this callback runs. # Handle message items with output_text content if item_type == "message": @@ -656,7 +698,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): from openai.types.responses import ( ResponseFunctionToolCall, ResponseOutputMessage, - ResponseReasoningItem, ) try: @@ -679,15 +720,15 @@ 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")) + # Typed ResponseReasoningItem and dict-form reasoning items (from providers + # whose output skips SDK parsing, e.g. GPT-5 Codex raw items) both land here + built_reasoning_item = _reasoning_item_from_output_item(item) # rebind-ok: reassessed per loop item + if built_reasoning_item is not None: + pending_reasoning_item = built_reasoning_item + reasoning_content = _reasoning_content_from_built_item(built_reasoning_item) + continue - elif isinstance(item, ResponseOutputMessage): + if isinstance(item, ResponseOutputMessage): for content in item.content: response_text = getattr(content, "text", "") # Extract annotations from content if present @@ -760,6 +801,11 @@ 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_item is not None: + choice.message.reasoning_content = reasoning_content + choice.message.reasoning_items = [pending_reasoning_item] # mutable-ok: response payload + reasoning_content = None # flush + pending_reasoning_item = None # flush choices.append(choice) else: pass # don't fail request if item in list is not supported @@ -790,10 +836,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): reasoning_items: Final = _reasoning_items_from_output_items(output_items) reasoning_content: Final = " ".join( - summary_block["text"] - for reasoning_item in reasoning_items - for summary_block in reasoning_item["summary"] - if summary_block.get("text") + text + for text in (_reasoning_content_from_built_item(reasoning_item) for reasoning_item in reasoning_items) + if text ) message: Final = Message( content="", @@ -1549,6 +1594,22 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): ) ] ) + elif event_type == "response.reasoning_text.delta": + # Raw (non-summary) reasoning deltas, e.g. from vLLM and other + # Responses-compatible backends that expose the full reasoning content. + content_part = parsed_chunk.get("delta", None) + if content_part: + # One Responses generation maps to one Chat choice, so reasoning + # deltas stream on choice 0 like output_text deltas do; content_index + # is a part index within the reasoning item, not a choice index. + return ModelResponseStream( + choices=[ # mutable-ok: API streaming payload + StreamingChoices( + index=0, + delta=Delta(reasoning_content=content_part), + ) + ] + ) elif event_type in ("response.completed", "response.incomplete"): response_data: Final = parsed_chunk.get("response", {}) output_items: Final = response_data.get("output", []) if response_data else [] diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 9d22a5ddef5..fbc73323052 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -96,6 +96,21 @@ def _redact_function_call(function_call) -> None: function_call.arguments = REDACTED_BY_LITELLM +def _redact_reasoning_items_dict(reasoning_items: object, redacted_str: str) -> None: + """Redact summary and raw content text inside reasoning items (dict form).""" + if not isinstance(reasoning_items, list): + return + for reasoning_item in reasoning_items: + if not isinstance(reasoning_item, dict): + continue + for key in ("summary", "content"): + parts = reasoning_item.get(key) # rebind-ok: reassessed per key + if isinstance(parts, list): + for part in parts: + if isinstance(part, dict) and part.get("text") is not None: + part["text"] = redacted_str + + def _redact_choice_content(choice): """Helper to redact content in a choice (message or delta).""" if isinstance(choice, litellm.Choices): @@ -105,6 +120,7 @@ def _redact_choice_content(choice): choice.message.reasoning_content = REDACTED_BY_LITELLM if hasattr(choice.message, "thinking_blocks"): choice.message.thinking_blocks = None + _redact_reasoning_items_dict(getattr(choice.message, "reasoning_items", None), REDACTED_BY_LITELLM) _redact_tool_calls(getattr(choice.message, "tool_calls", None)) _redact_function_call(getattr(choice.message, "function_call", None)) elif isinstance(choice, litellm.utils.StreamingChoices): @@ -114,6 +130,7 @@ def _redact_choice_content(choice): choice.delta.reasoning_content = REDACTED_BY_LITELLM if hasattr(choice.delta, "thinking_blocks"): choice.delta.thinking_blocks = None + _redact_reasoning_items_dict(getattr(choice.delta, "reasoning_items", None), REDACTED_BY_LITELLM) _redact_tool_calls(getattr(choice.delta, "tool_calls", None)) _redact_function_call(getattr(choice.delta, "function_call", None)) @@ -226,6 +243,7 @@ def _redact_model_response_dict_choices(choices, redacted_str: str): choice["message"]["thinking_blocks"] = None if "audio" in choice["message"]: choice["message"]["audio"] = None + _redact_reasoning_items_dict(choice["message"].get("reasoning_items"), redacted_str) _redact_tool_calls_dict(choice["message"]) elif "delta" in choice and isinstance(choice["delta"], dict): if choice["delta"].get("content") is not None: @@ -236,6 +254,7 @@ def _redact_model_response_dict_choices(choices, redacted_str: str): choice["delta"]["thinking_blocks"] = None if "audio" in choice["delta"]: choice["delta"]["audio"] = None + _redact_reasoning_items_dict(choice["delta"].get("reasoning_items"), redacted_str) _redact_tool_calls_dict(choice["delta"]) else: _redact_choice_content(choice) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index b7c4371f32f..bf98f9169b3 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -626,6 +626,11 @@ class ChatCompletionReasoningSummaryTextBlock(TypedDict, total=False): text: str +class ChatCompletionReasoningTextBlock(TypedDict, total=False): + type: Required[Literal["reasoning_text"]] # writable-ok: Pydantic warns on ReadOnly TypedDict fields + text: str # writable-ok: Pydantic warns on ReadOnly TypedDict fields + + class ChatCompletionReasoningItem(TypedDict, total=False): """Represents an OpenAI Responses API reasoning item for round-tripping in conversation history.""" @@ -633,6 +638,7 @@ class ChatCompletionReasoningItem(TypedDict, total=False): id: str encrypted_content: str | None summary: ReadOnly[list[ChatCompletionReasoningSummaryTextBlock]] + content: ReadOnly[list[ChatCompletionReasoningTextBlock]] class WebSearchOptionsUserLocationApproximate(TypedDict, total=False): diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index e4ada0a9b31..3ab92b2e87f 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -4287,3 +4287,189 @@ def test_system_string_after_a_developer_message_stays_in_input_in_client_order( assert instructions is None assert [item["role"] for item in input_items] == ["developer", "system", "user"] assert input_items[1] == _system_input_item("Be brief.") + +def test_openai_responses_chunk_parser_reasoning_text_delta(): + """Raw reasoning_text deltas map to delta.reasoning_content (issue #40654).""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + iterator = OpenAiResponsesToChatCompletionStreamIterator( + streaming_response=None, sync_stream=True + ) + + chunk = { + "content_index": 0, + "delta": "First, count the bolts.", + "item_id": "rs_a1892a8a9de47516", + "output_index": 0, + "sequence_number": 3, + "type": "response.reasoning_text.delta", + } + + result = iterator.chunk_parser(chunk) + + assert isinstance(result, ModelResponseStream) + assert len(result.choices) == 1 + choice = result.choices[0] + assert isinstance(choice, StreamingChoices) + assert choice.index == 0 + delta = choice.delta + assert isinstance(delta, Delta) + assert delta.content is None + assert delta.reasoning_content == "First, count the bolts." + assert delta.tool_calls is None + + +def _make_raw_reasoning_output(content_text: str | None, summary_text: str | None) -> list: + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + from openai.types.responses.response_reasoning_item import ( + Content, + ResponseReasoningItem, + Summary, + ) + + reasoning_item = ResponseReasoningItem( + id="rs_a1892a8a9de47516", + summary=[Summary(text=summary_text, type="summary_text")] + if summary_text is not None + else [], + type="reasoning", + content=[Content(text=content_text, type="reasoning_text")] + if content_text is not None + else None, + encrypted_content=None, + status=None, + ) + output_message = ResponseOutputMessage( + id="msg_01", + content=[ + ResponseOutputText( + annotations=[], text="303 bolts remain.", type="output_text", logprobs=[] + ) + ], + role="assistant", + status="completed", + type="message", + ) + return [reasoning_item, output_message] + + +def test_convert_response_output_raw_reasoning_content_without_summary(): + """Reasoning items with raw content and an empty summary surface the raw text.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + output_items = _make_raw_reasoning_output( + content_text="raw thinking trace", summary_text=None + ) + + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( + output_items + ) + + assert len(choices) == 1 + message = choices[0].message + assert message.reasoning_content == "raw thinking trace" + assert message.reasoning_items is not None + assert message.reasoning_items[0]["content"][0]["text"] == "raw thinking trace" + assert message.reasoning_items[0]["content"][0]["type"] == "reasoning_text" + + +def test_convert_response_output_mixed_reasoning_content_and_summary_no_duplication(): + """With both raw content and a summary, the raw content wins; nothing is concatenated.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + output_items = _make_raw_reasoning_output( + content_text="full raw trace", summary_text="short summary" + ) + + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( + output_items + ) + + assert len(choices) == 1 + assert choices[0].message.reasoning_content == "full raw trace" + + +def test_convert_response_output_summary_only_reasoning_unchanged(): + """Summary-only reasoning items keep the pre-existing summary behavior.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + output_items = _make_raw_reasoning_output( + content_text=None, summary_text="short summary" + ) + + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( + output_items + ) + + assert len(choices) == 1 + assert choices[0].message.reasoning_content == "short summary" + assert choices[0].message.reasoning_items[0]["content"] == [] + + +def test_convert_response_output_dict_reasoning_carried_to_message_choice(): + """Dict-form reasoning items (SDK parsing skipped) land on the following message choice.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + items = [ + { + "type": "reasoning", + "id": "rs_dict1", + "summary": [], + "content": [{"type": "reasoning_text", "text": "dict raw trace"}], + }, + { + "type": "message", + "id": "msg_dict1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "303 bolts remain.", "annotations": []}], + }, + ] + + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( + items, + handle_raw_dict_callback=handler._handle_raw_dict_response_item, + ) + + assert len(choices) == 1 + message = choices[0].message + assert message.content == "303 bolts remain." + assert message.reasoning_content == "dict raw trace" + assert message.reasoning_items is not None + assert message.reasoning_items[0]["content"][0]["text"] == "dict raw trace" + + +def test_convert_response_output_dict_reasoning_item_with_content(): + """Raw dict reasoning items (non-SDK-parsed payloads) also surface their content.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + _reasoning_content_from_built_item, + _reasoning_items_from_output_items, + ) + + reasoning_items = _reasoning_items_from_output_items( + [ + { + "type": "reasoning", + "id": "rs_dict", + "summary": [], + "content": [{"type": "reasoning_text", "text": "dict raw trace"}], + } + ] + ) + + assert len(reasoning_items) == 1 + assert reasoning_items[0]["content"][0]["text"] == "dict raw trace" + assert _reasoning_content_from_built_item(reasoning_items[0]) == "dict raw trace" + diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index 76092e96307..72146f3235f 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -263,6 +263,91 @@ class TestPerformRedaction: assert delta["thinking_blocks"] is None assert delta["audio"] is None + def test_redacts_reasoning_items_in_model_response_dict_choices(self): + result = { + "choices": [ + { + "message": { + "content": "message content", + "reasoning_items": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "summary text"}], + "content": [{"type": "reasoning_text", "text": "raw reasoning"}], + } + ], + } + }, + { + "delta": { + "content": "delta content", + "reasoning_items": [ + { + "type": "reasoning", + "id": "rs_2", + "summary": [{"type": "summary_text", "text": "delta summary"}], + "content": [{"type": "reasoning_text", "text": "delta raw reasoning"}], + } + ], + } + }, + ] + } + + redacted = perform_redaction({}, result) + + message_item = redacted["choices"][0]["message"]["reasoning_items"][0] + assert message_item["summary"][0]["text"] == "redacted-by-litellm" + assert message_item["content"][0]["text"] == "redacted-by-litellm" + + delta_item = redacted["choices"][1]["delta"]["reasoning_items"][0] + assert delta_item["summary"][0]["text"] == "redacted-by-litellm" + assert delta_item["content"][0]["text"] == "redacted-by-litellm" + + def test_redacts_reasoning_items_on_choice_objects(self): + from litellm.litellm_core_utils.redact_messages import _redact_choice_content + from litellm.types.utils import Choices, Delta, Message, StreamingChoices + + reasoning_items = [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "summary text"}], + "content": [{"type": "reasoning_text", "text": "raw reasoning"}], + } + ] + + message_choice = Choices( + message=Message(role="assistant", content="answer", reasoning_items=reasoning_items), + finish_reason="stop", + index=0, + ) + _redact_choice_content(message_choice) + item = message_choice.message.reasoning_items[0] + assert item["summary"][0]["text"] == "redacted-by-litellm" + assert item["content"][0]["text"] == "redacted-by-litellm" + + stream_choice = StreamingChoices( + delta=Delta( + content="chunk", + reasoning_items=[ + { + "type": "reasoning", + "id": "rs_2", + "summary": [{"type": "summary_text", "text": "delta summary"}], + "content": [{"type": "reasoning_text", "text": "delta raw"}], + } + ], + ), + finish_reason=None, + index=0, + ) + _redact_choice_content(stream_choice) + delta_item = stream_choice.delta.reasoning_items[0] + assert delta_item["summary"][0]["text"] == "redacted-by-litellm" + assert delta_item["content"][0]["text"] == "redacted-by-litellm" + def test_redacts_standard_logging_model_response_dict_choices(self): details = { "standard_logging_object": {