diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 48b12a5fba9..6e3c56e9e1f 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -982,6 +982,33 @@ class LiteLLMCompletionResponsesConfig: return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( function_call=input_item ) + elif input_item.get("type") == "reasoning": + # FIX (BerriAI/litellm#26395 — Responses-API path): + # ``ResponseReasoningItemParam`` carries the prior-turn + # chain-of-thought summary. The fall-through ``else`` branch + # below would treat it as generic user/assistant content and + # place the reasoning text into the regular ``content`` + # field of an extra assistant message. That has two bad effects + # for downstream Chat-Completion providers: + # 1. The reasoning text pollutes the prompt as visible + # content, inflating tokens and confusing the model. + # 2. The adjacent assistant message ends up WITHOUT a + # ``reasoning_content`` field — and DeepSeek V4 rejects + # that with HTTP 400 + # "reasoning_content must be passed back". + # + # Minimum-viable fix: skip the reasoning item entirely (return + # []) so it does not end up in the Chat-Completion ``messages`` + # array at all. Provider-specific transformations downstream + # (e.g. ``DeepSeekChatConfig._transform_messages``) are then + # responsible for injecting an empty ``reasoning_content`` on + # the adjacent assistant message if the provider requires it. + # + # A future improvement could merge the reasoning text into the + # adjacent assistant message's ``reasoning_content`` field for + # full fidelity; this PR deliberately stops at the minimum + # change that unblocks the bridge. + return [] else: content = input_item.get("content") # Handle None content: Responses API allows None content, but GenericChatCompletionMessage requires content diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py b/tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py new file mode 100644 index 00000000000..aff21d7f5f1 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py @@ -0,0 +1,73 @@ +""" +Unit tests for ``_transform_responses_api_input_item_to_chat_completion_message`` +handling of ``ResponseReasoningItemParam`` (``type: "reasoning"``). + +Covers the Responses-API path of BerriAI/litellm#26395 — without the fix, +prior-turn reasoning items pollute the prompt as visible content and leave +the adjacent assistant message without ``reasoning_content``, which DeepSeek +V4 rejects with HTTP 400 ``reasoning_content must be passed back``. +""" + +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) + + +def _transform_item(item): + return LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message( + input_item=item + ) + + +class TestReasoningInputItemHandler: + """Reasoning items are dropped from the Chat-Completion message stream.""" + + def test_reasoning_item_with_output_text_dropped(self): + """Standard Responses-API reasoning item shape → [].""" + item = { + "type": "reasoning", + "id": "rs_abc", + "summary": [], + "content": [{"type": "output_text", "text": "step 1: think about X"}], + } + assert _transform_item(item) == [] + + def test_reasoning_item_with_string_content_dropped(self): + """Variant: reasoning content as a plain string → [].""" + item = {"type": "reasoning", "id": "rs_1", "content": "step 1: ..."} + assert _transform_item(item) == [] + + def test_reasoning_item_with_summary_only_dropped(self): + """SDK 0.17 form: reasoning carried in summary list, no content → [].""" + item = { + "type": "reasoning", + "id": "rs_2", + "summary": [{"type": "summary_text", "text": "..."}], + } + assert _transform_item(item) == [] + + def test_reasoning_item_empty_dropped(self): + """Reasoning item with neither content nor summary still drops cleanly.""" + item = {"type": "reasoning", "id": "rs_3"} + assert _transform_item(item) == [] + + +class TestNonReasoningInputItemUnchanged: + """Non-reasoning items still flow through the existing branches.""" + + def test_user_message_unchanged(self): + item = {"role": "user", "content": "hello"} + out = _transform_item(item) + assert len(out) == 1 + assert out[0].get("role") == "user" + + def test_assistant_message_unchanged(self): + item = {"role": "assistant", "content": "hi"} + out = _transform_item(item) + assert len(out) == 1 + assert out[0].get("role") == "assistant" + + def test_none_content_still_returns_empty(self): + """Pre-existing behavior: None content → [] (unchanged by this fix).""" + item = {"role": "user", "content": None} + assert _transform_item(item) == []