fix(responses-bridge): handle ResponseReasoningItemParam (drop) to prevent prompt pollution and DeepSeek V4 400

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)
This commit is contained in:
gumpm5 2026-05-11 15:03:55 +08:00
parent e182a5e0ba
commit c16c2fa433
2 changed files with 100 additions and 0 deletions

View file

@ -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

View file

@ -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) == []