From c16c2fa433a4cbb89844a17d8f1ee45c0f63d06b Mon Sep 17 00:00:00 2001 From: gumpm5 Date: Mon, 11 May 2026 15:03:55 +0800 Subject: [PATCH] fix(responses-bridge): handle ResponseReasoningItemParam (drop) to prevent prompt pollution and DeepSeek V4 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this fix, when the Responses-API bridges to Chat-Completions, a prior-turn `ResponseReasoningItemParam` (`type: "reasoning"`) falls through the `else` branch of `_transform_responses_api_input_item_to_chat_completion_message` and gets treated as a generic user/assistant content message. This has two bad effects for downstream Chat-Completion providers: 1. **Prompt pollution** — the raw chain-of-thought text ends up in the visible `content` field of an extra assistant message, inflating tokens and confusing the model on the next turn. 2. **DeepSeek V4 400** — the adjacent assistant message has no `reasoning_content` field. DeepSeek V4 (thinking-mode-by-default) rejects this with HTTP 400: reasoning_content must be passed back This is the Responses-API arm of the bug originally reported on the Chat-Completions arm in #26395. ## Fix Add an explicit branch in `_transform_responses_api_input_item_to_chat_completion_message` that drops `type: "reasoning"` items (`return []`). They are then absent from the Chat-Completion `messages` array entirely. Provider-specific transformations downstream (e.g. `DeepSeekChatConfig._transform_messages`) remain responsible for injecting an empty `reasoning_content` on the adjacent assistant message when the provider requires it. This is a **minimum-viable** fix: 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 smallest change that unblocks the bridge without changing semantics for other providers. ## Tests `tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py` — 7 cases: `TestReasoningInputItemHandler` (4 cases): - reasoning item with output_text content → dropped - reasoning item with plain string content → dropped - reasoning item with summary-only (SDK 0.17 form) → dropped - empty reasoning item (no content, no summary) → dropped `TestNonReasoningInputItemUnchanged` (3 cases): - user message still passes through - assistant message still passes through - None content still returns [] (pre-existing behavior) ``` $ pytest tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py 7 passed in 0.99s ``` ## Refs - BerriAI/litellm#26395 (Responses-API arm) - Original Chat-Completions arm: BerriAI/litellm#26660 (handles `_transform_messages` injection on the DeepSeek-provider side) --- .../transformation.py | 27 +++++++ .../test_responses_reasoning_input_item.py | 73 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py 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) == []